From 24ef69cf7a36d8d8e3e005632ce95e05eee532fa Mon Sep 17 00:00:00 2001 From: Stuart Taylor Date: Fri, 16 Feb 2018 23:18:53 +0000 Subject: [PATCH] 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 --- client/less/flexgrid.less | 4 +- common/app/Toasts/redux/index.js | 2 +- common/app/app.less | 2 +- common/app/entities/index.js | 131 ++++++- common/app/helperComponents/ButtonSpacer.jsx | 11 + common/app/helperComponents/FullWidthRow.jsx | 20 ++ common/app/helperComponents/Loader.jsx | 23 ++ common/app/helperComponents/Spacer.jsx | 11 + common/app/helperComponents/index.js | 4 + common/app/redux/fetch-challenges-epic.js | 7 +- common/app/redux/index.js | 4 +- common/app/routes/Settings/Email-Setting.jsx | 126 ------- common/app/routes/Settings/Job-Settings.jsx | 39 -- .../app/routes/Settings/Locked-Settings.jsx | 42 --- .../app/routes/Settings/Settings-Skeleton.jsx | 99 ------ common/app/routes/Settings/Settings.jsx | 278 +++------------ .../app/routes/Settings/Social-Settings.jsx | 76 ---- .../Settings/components/About-Settings.jsx | 236 ++++++++++++ .../app/routes/Settings/components/Camper.jsx | 58 +++ .../Settings/components/Cert-Settings.jsx | 247 +++++++++++++ .../routes/Settings/components/DangerZone.jsx | 106 ++++++ .../Settings/components/DeleteModal.jsx | 71 ++++ .../Settings/components/Email-Settings.jsx | 168 +++++++++ .../routes/Settings/components/EmailForm.jsx | 160 +++++++++ .../routes/Settings/components/Honesty.jsx | 95 +++++ .../Settings/components/Internet-Settings.jsx | 109 ++++++ .../Settings/components/JSAlgoAndDSForm.jsx | 120 +++++++ .../{ => components}/Language-Settings.jsx | 57 ++- .../Settings/components/Locked-Settings.jsx | 42 +++ .../components/Portfolio-Settings.jsx | 178 ++++++++++ .../routes/Settings/components/ResetModal.jsx | 65 ++++ .../Settings/components/SectionHeader.jsx | 25 ++ .../Settings/components/SocialIcons.jsx | 134 +++++++ .../Settings/components/SolutionViewer.jsx | 53 +++ .../Settings/components/ThemeSettings.jsx | 38 ++ .../Settings/components/UsernameSettings.jsx | 187 ++++++++++ .../Settings/formHelpers/BlockSaveButton.jsx | 24 ++ .../Settings/formHelpers/BlockSaveWrapper.jsx | 23 ++ .../app/routes/Settings/formHelpers/Form.jsx | 88 +++++ .../Settings/formHelpers/FormFields.jsx | 97 +++++ .../app/routes/Settings/formHelpers/index.js | 35 ++ .../routes/Settings/redux/certificate-epic.js | 42 +++ .../routes/Settings/redux/danger-zone-epic.js | 53 +++ common/app/routes/Settings/redux/index.js | 130 ++++++- .../Settings/redux/new-username-epic.js | 30 ++ .../routes/Settings/redux/update-user-epic.js | 210 ++++++++--- common/app/routes/Settings/settings.less | 96 +++-- .../Settings/utils/buildUserProjectsMap.js | 32 ++ common/models/user.js | 335 ++++++++++++++---- common/models/user.json | 19 +- common/resource/academicPolicy.js | 27 ++ package-lock.json | 94 ++++- package.json | 5 +- public/css/loader.css | 74 ++++ public/css/prism.css | 140 ++++++++ ...our-responsive-web-design-certificate.json | 137 ------- ...-your-front-end-libraries-certificate.json | 137 ------- ...m-your-data-visualization-certificate.json | 106 ------ ...ur-apis-and-microservices-certificate.json | 108 ------ ...ity-and-quality-assurance-certificate.json | 108 ------ .../apis-and-microservices-certificate.json | 37 ++ .../data-visualization-certificate.json | 37 ++ .../front-end-libraries-certificate.json | 37 ++ ...ity-and-quality-assurance-certificate.json | 37 ++ ...ithms-and-data-structures-certificate.json | 37 ++ .../responsive-web-design-certificate.json | 37 ++ seed/index.js | 15 +- server/boot/authentication.js | 31 +- server/boot/certificate.js | 174 ++++----- server/boot/settings.js | 169 ++++++--- server/boot/user.js | 137 +++---- server/services/user.js | 71 ++-- server/utils/certTypes.json | 5 +- server/utils/constantStrings.json | 5 +- server/utils/index.js | 4 + server/utils/publicUserProps.js | 85 +++++ server/utils/superBlockCertTypeMap.js | 19 + .../emails/user-request-update-email.ejs | 4 +- 78 files changed, 4395 insertions(+), 1724 deletions(-) create mode 100644 common/app/helperComponents/ButtonSpacer.jsx create mode 100644 common/app/helperComponents/FullWidthRow.jsx create mode 100644 common/app/helperComponents/Loader.jsx create mode 100644 common/app/helperComponents/Spacer.jsx create mode 100644 common/app/helperComponents/index.js delete mode 100644 common/app/routes/Settings/Email-Setting.jsx delete mode 100644 common/app/routes/Settings/Job-Settings.jsx delete mode 100644 common/app/routes/Settings/Locked-Settings.jsx delete mode 100644 common/app/routes/Settings/Settings-Skeleton.jsx delete mode 100644 common/app/routes/Settings/Social-Settings.jsx create mode 100644 common/app/routes/Settings/components/About-Settings.jsx create mode 100644 common/app/routes/Settings/components/Camper.jsx create mode 100644 common/app/routes/Settings/components/Cert-Settings.jsx create mode 100644 common/app/routes/Settings/components/DangerZone.jsx create mode 100644 common/app/routes/Settings/components/DeleteModal.jsx create mode 100644 common/app/routes/Settings/components/Email-Settings.jsx create mode 100644 common/app/routes/Settings/components/EmailForm.jsx create mode 100644 common/app/routes/Settings/components/Honesty.jsx create mode 100644 common/app/routes/Settings/components/Internet-Settings.jsx create mode 100644 common/app/routes/Settings/components/JSAlgoAndDSForm.jsx rename common/app/routes/Settings/{ => components}/Language-Settings.jsx (63%) create mode 100644 common/app/routes/Settings/components/Locked-Settings.jsx create mode 100644 common/app/routes/Settings/components/Portfolio-Settings.jsx create mode 100644 common/app/routes/Settings/components/ResetModal.jsx create mode 100644 common/app/routes/Settings/components/SectionHeader.jsx create mode 100644 common/app/routes/Settings/components/SocialIcons.jsx create mode 100644 common/app/routes/Settings/components/SolutionViewer.jsx create mode 100644 common/app/routes/Settings/components/ThemeSettings.jsx create mode 100644 common/app/routes/Settings/components/UsernameSettings.jsx create mode 100644 common/app/routes/Settings/formHelpers/BlockSaveButton.jsx create mode 100644 common/app/routes/Settings/formHelpers/BlockSaveWrapper.jsx create mode 100644 common/app/routes/Settings/formHelpers/Form.jsx create mode 100644 common/app/routes/Settings/formHelpers/FormFields.jsx create mode 100644 common/app/routes/Settings/formHelpers/index.js create mode 100644 common/app/routes/Settings/redux/certificate-epic.js create mode 100644 common/app/routes/Settings/redux/danger-zone-epic.js create mode 100644 common/app/routes/Settings/redux/new-username-epic.js create mode 100644 common/app/routes/Settings/utils/buildUserProjectsMap.js create mode 100644 common/resource/academicPolicy.js create mode 100644 public/css/loader.css create mode 100644 public/css/prism.css delete mode 100644 seed/challenges/01-responsive-web-design/claim-your-responsive-web-design-certificate.json delete mode 100644 seed/challenges/03-front-end-libraries/claim-your-front-end-libraries-certificate.json delete mode 100644 seed/challenges/04-data-visualization/claim-your-data-visualization-certificate.json delete mode 100644 seed/challenges/05-apis-and-microservices/claim-your-apis-and-microservices-certificate.json delete mode 100644 seed/challenges/06-information-security-and-quality-assurance/claim-your-information-security-and-quality-assurance-certificate.json create mode 100644 seed/challenges/09-certificates/apis-and-microservices-certificate.json create mode 100644 seed/challenges/09-certificates/data-visualization-certificate.json create mode 100644 seed/challenges/09-certificates/front-end-libraries-certificate.json create mode 100644 seed/challenges/09-certificates/information-security-and-quality-assurance-certificate.json create mode 100644 seed/challenges/09-certificates/javascript-algorithms-and-data-structures-certificate.json create mode 100644 seed/challenges/09-certificates/responsive-web-design-certificate.json create mode 100644 server/utils/publicUserProps.js create mode 100644 server/utils/superBlockCertTypeMap.js diff --git a/client/less/flexgrid.less b/client/less/flexgrid.less index b04dcafbf6..00aad1ecad 100644 --- a/client/less/flexgrid.less +++ b/client/less/flexgrid.less @@ -26,9 +26,9 @@ .content-mixin(baseline) { align-content: baseline; } .content-mixin(@_) {} -.grid(@direction: row; @items: none; @justify: none; @content: none) { +.grid(@direction: row; @items: none; @justify: none; @content: none; @wrap: wrap) { display: flex; - flex-wrap: wrap; + flex-wrap: @wrap; flex-direction: @direction; .justify-mixin(@justify); diff --git a/common/app/Toasts/redux/index.js b/common/app/Toasts/redux/index.js index e8cdccec87..f39d37cdb2 100644 --- a/common/app/Toasts/redux/index.js +++ b/common/app/Toasts/redux/index.js @@ -37,7 +37,7 @@ export default handleActions( [types.makeToast]: (state, { payload: toast }) => [ ...state, toast - ], + ].filter(toast => !!toast.message), [types.removeToast]: (state, { payload: key }) => state.filter( toast => toast.key !== key ) diff --git a/common/app/app.less b/common/app/app.less index 88d45ba25e..1f955787f7 100644 --- a/common/app/app.less +++ b/common/app/app.less @@ -7,7 +7,7 @@ // Here we invert the order in which // they are painted using css so the // nav is on top again - .grid(@direction: column); + .grid(@direction: column; @wrap: nowrap); height: 100%; width: 100%; } diff --git a/common/app/entities/index.js b/common/app/entities/index.js index 0223edb0d8..455ab1a8ac 100644 --- a/common/app/entities/index.js +++ b/common/app/entities/index.js @@ -1,4 +1,5 @@ -import _ from 'lodash'; +import { findIndex, invert, pick, property } from 'lodash'; +import uuid from 'uuid/v4'; import { composeReducers, createAction, @@ -8,11 +9,16 @@ import { import { themes } from '../../utils/themes'; import { types as challenges } from '../routes/Challenges/redux'; +import { usernameSelector } from '../redux'; export const ns = 'entities'; export const getNS = state => state[ns]; export const entitiesSelector = getNS; export const types = createTypes([ + 'addPortfolioItem', + 'optoUpdatePortfolio', + 'regresPortfolio', + 'updateMultipleUserFlags', 'updateTheme', 'updateUserFlag', 'updateUserEmail', @@ -20,6 +26,18 @@ export const types = createTypes([ 'updateUserCurrentChallenge' ], ns); +// addPortfolioItem(...PortfolioItem) => Action +export const addPortfolioItem = createAction(types.addPortfolioItem); +// optoUpdatePortfolio(...PortfolioItem) => Action +export const optoUpdatePortfolio = createAction(types.optoUpdatePortfolio); +// regresPortfolio(id: String) => Action +export const regresPortfolio = createAction(types.regresPortfolio); + +// updateMultipleUserFlags({ username: String, flags: { String }) => Action +export const updateMultipleUserFlags = createAction( + types.updateMultipleUserFlags +); + // updateUserFlag(username: String, flag: String) => Action export const updateUserFlag = createAction( types.updateUserFlag, @@ -41,7 +59,8 @@ export const updateUserCurrentChallenge = createAction( ); // entity meta creators -const getEntityAction = _.property('meta.entitiesAction'); +const getEntityAction = property('meta.entitiesAction'); + export const updateThemeMetacreator = (username, theme) => ({ entitiesAction: { type: types.updateTheme, @@ -52,6 +71,16 @@ export const updateThemeMetacreator = (username, theme) => ({ } }); +export function emptyPortfolio() { + return { + id: uuid(), + title: '', + description: '', + url: '', + image: '' + }; +} + const defaultState = { superBlock: {}, block: {}, @@ -59,13 +88,56 @@ const defaultState = { user: {} }; +export function selectiveChallengeTitleSelector(state, dashedName) { + return getNS(state).challenge[dashedName].title; +} + +export function portfolioSelector(state, props) { + const username = usernameSelector(state); + const { portfolio } = getNS(state).user[username]; + const pIndex = findIndex(portfolio, p => p.id === props.id); + return portfolio[pIndex]; +} + +export function projectsSelector(state) { + const blocks = getNS(state).block; + const challengeNameToIdMap = invert(challengeIdToNameMapSelector(state)); + return Object.keys(blocks) + .filter(key => + key.includes('projects') && !key.includes('coding-interview') + ) + .map(key => blocks[key]) + .map(({ name, challenges, superBlock }) => { + const projectChallengeDashNames = challenges + // remove any project intros + .filter(chal => !chal.includes('get-set-for')); + const projectChallenges = projectChallengeDashNames + .map(dashedName => selectiveChallengeTitleSelector(state, dashedName)); + return { + projectBlockName: name, + superBlock, + challenges: projectChallenges, + challengeNameIdMap: pick( + challengeNameToIdMap, + projectChallengeDashNames + ) + }; + }); +} + +export function challengeIdToNameMapSelector(state) { + return getNS(state).challengeIdToName || {}; +} + export const challengeMapSelector = state => getNS(state).challenge || {}; + export function makeBlockSelector(block) { return state => { const blockMap = getNS(state).block || {}; return blockMap[block] || {}; }; } + export function makeSuperBlockSelector(name) { return state => { const superBlock = getNS(state).superBlock || {}; @@ -121,6 +193,61 @@ export default composeReducers( } } }), + [types.addPortfolioItem]: (state, { payload: username }) => ({ + ...state, + user: { + ...state.user, + [username]: { + ...state.user[username], + portfolio: [ + ...state.user[username].portfolio, + emptyPortfolio() + ] + } + } + }), + [types.optoUpdatePortfolio]: ( + state, + { payload: { username, portfolio }} + ) => { + const currentPortfolio = state.user[username].portfolio.slice(0); + const pIndex = findIndex(currentPortfolio, p => p.id === portfolio.id); + const updatedPortfolio = currentPortfolio; + updatedPortfolio[pIndex] = portfolio; + return { + ...state, + user: { + ...state.user, + [username]: { + ...state.user[username], + portfolio: updatedPortfolio + } + } + }; + }, + [types.regresPortfolio]: (state, { payload: { username, id } }) => ({ + ...state, + user: { + ...state.user, + [username]: { + ...state.user[username], + portfolio: state.user[username].portfolio.filter(p => p.id !== id) + } + } + }), + [types.updateMultipleUserFlags]: ( + state, + { payload: { username, flags }} + ) => ({ + ...state, + user: { + ...state.user, + [username]: { + ...state.user[username], + ...flags + } + } + }), [types.updateUserFlag]: (state, { payload: { username, flag } }) => ({ ...state, user: { diff --git a/common/app/helperComponents/ButtonSpacer.jsx b/common/app/helperComponents/ButtonSpacer.jsx new file mode 100644 index 0000000000..80bd095413 --- /dev/null +++ b/common/app/helperComponents/ButtonSpacer.jsx @@ -0,0 +1,11 @@ +import React from 'react'; + +function ButtonSpacer() { + return ( +
+ ); +} + +ButtonSpacer.displayName = 'ButtonSpacer'; + +export default ButtonSpacer; diff --git a/common/app/helperComponents/FullWidthRow.jsx b/common/app/helperComponents/FullWidthRow.jsx new file mode 100644 index 0000000000..e6f85e90ab --- /dev/null +++ b/common/app/helperComponents/FullWidthRow.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Row, Col } from 'react-bootstrap'; + +function FullWidthRow({ children }) { + return ( + + + { children } + + + ); +} + +FullWidthRow.displayName = 'FullWidthRow'; +FullWidthRow.propTypes = { + children: PropTypes.any +}; + +export default FullWidthRow; diff --git a/common/app/helperComponents/Loader.jsx b/common/app/helperComponents/Loader.jsx new file mode 100644 index 0000000000..5d955457ea --- /dev/null +++ b/common/app/helperComponents/Loader.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import Helmet from 'react-helmet'; + +function Loader() { + return ( +
+ + + +
+
+
+
+
+
+
+
+ ); +} + +Loader.displayName = 'Loader'; + +export default Loader; diff --git a/common/app/helperComponents/Spacer.jsx b/common/app/helperComponents/Spacer.jsx new file mode 100644 index 0000000000..a099f6caa6 --- /dev/null +++ b/common/app/helperComponents/Spacer.jsx @@ -0,0 +1,11 @@ +import React from 'react'; + +function Spacer() { + return ( +
+ ); +} + +Spacer.displayName = 'Spacer'; + +export default Spacer; diff --git a/common/app/helperComponents/index.js b/common/app/helperComponents/index.js new file mode 100644 index 0000000000..b632f684ad --- /dev/null +++ b/common/app/helperComponents/index.js @@ -0,0 +1,4 @@ +export { default as FullWidthRow } from './FullWidthRow.jsx'; +export { default as Loader } from './Loader.jsx'; +export { default as Spacer } from './Spacer.jsx'; +export { default as ButtonSpacer } from './ButtonSpacer.jsx'; diff --git a/common/app/redux/fetch-challenges-epic.js b/common/app/redux/fetch-challenges-epic.js index 6c4dece21f..6873507cc6 100644 --- a/common/app/redux/fetch-challenges-epic.js +++ b/common/app/redux/fetch-challenges-epic.js @@ -54,11 +54,14 @@ export function fetchChallengesEpic( { getState }, { services } ) { - return actions::ofType(types.appMounted) + return actions::ofType( + types.appMounted, + types.updateChallenges + ) .flatMapLatest(() => { const lang = langSelector(getState()); const options = { - lang, + params: { lang }, service: 'map' }; return services.readService$(options) diff --git a/common/app/redux/index.js b/common/app/redux/index.js index 6b53c6b413..ef5159b4c9 100644 --- a/common/app/redux/index.js +++ b/common/app/redux/index.js @@ -40,7 +40,7 @@ export const types = createTypes([ createAsyncTypes('fetchChallenge'), createAsyncTypes('fetchChallenges'), - + 'updateChallenges', createAsyncTypes('fetchUser'), 'showSignIn', @@ -108,7 +108,7 @@ export const fetchChallengesCompleted = createAction( (entities, result) => ({ entities, result }), entities => ({ entities }) ); - +export const updateChallenges = createAction(types.updateChallenges); // updateTitle(title: String) => Action export const updateTitle = createAction(types.updateTitle); diff --git a/common/app/routes/Settings/Email-Setting.jsx b/common/app/routes/Settings/Email-Setting.jsx deleted file mode 100644 index ced3cd62cc..0000000000 --- a/common/app/routes/Settings/Email-Setting.jsx +++ /dev/null @@ -1,126 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { Button, Row, Col } from 'react-bootstrap'; -import TB from './Toggle-Button'; -import FA from 'react-fontawesome'; - -import ns from './ns.json'; -import { onRouteUpdateEmail } from './redux'; -import { Link } from '../../Router'; - -const propTypes = { - email: PropTypes.string, - sendMonthlyEmail: PropTypes.bool, - sendNotificationEmail: PropTypes.bool, - sendQuincyEmail: PropTypes.bool, - toggleMonthlyEmail: PropTypes.func.isRequired, - toggleNotificationEmail: PropTypes.func.isRequired, - toggleQuincyEmail: PropTypes.func.isRequired -}; - -export function UpdateEmailButton() { - return ( - - - - ); -} - -export default function EmailSettings({ - email, - sendMonthlyEmail, - sendNotificationEmail, - sendQuincyEmail, - toggleMonthlyEmail, - toggleNotificationEmail, - toggleQuincyEmail -}) { - if (!email) { - return ( -
- -

- You don't have an email id associated to this account. -

-
- - - -
- ); - } - return ( -
- -

- { email } -

-
- - - - - -

- Send me announcement emails -
- (we'll send you these every Thursday) -

- - - - -
- - -

- Send me notification emails -
- (these will pertain to your account) -

- - - - -
- - -

- Send me Quincy's weekly email -
- (with new articles every Tuesday) -

- - - - -
-
- ); -} - -EmailSettings.displayName = 'EmailSettings'; -EmailSettings.propTypes = propTypes; diff --git a/common/app/routes/Settings/Job-Settings.jsx b/common/app/routes/Settings/Job-Settings.jsx deleted file mode 100644 index 5b2f9d9017..0000000000 --- a/common/app/routes/Settings/Job-Settings.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { Button, Row, Col } from 'react-bootstrap'; -import classnames from 'classnames'; - -const propTypes = { - isAvailableForHire: PropTypes.bool, - toggle: PropTypes.func.isRequired -}; - -export default function JobSettings({ isAvailableForHire, toggle }) { - const className = classnames({ - active: isAvailableForHire, - 'btn-toggle': true - }); - return ( - - -

- Available for hire? -

- - - - -
- ); -} - -JobSettings.displayName = 'JobSettings'; -JobSettings.propTypes = propTypes; diff --git a/common/app/routes/Settings/Locked-Settings.jsx b/common/app/routes/Settings/Locked-Settings.jsx deleted file mode 100644 index 9f083e0217..0000000000 --- a/common/app/routes/Settings/Locked-Settings.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { Button, Row, Col } from 'react-bootstrap'; -import classnames from 'classnames'; - -const propTypes = { - isLocked: PropTypes.bool, - toggle: PropTypes.func.isRequired -}; - -export default function LockSettings({ isLocked, toggle }) { - const className = classnames({ - 'positive-20': true, - active: isLocked, - 'btn-toggle': true - }); - return ( - - -

- Make all of my solutions private -
- (this disables your certificates) -

- - - - -
- ); -} - -LockSettings.displayName = 'LockSettings'; -LockSettings.propTypes = propTypes; diff --git a/common/app/routes/Settings/Settings-Skeleton.jsx b/common/app/routes/Settings/Settings-Skeleton.jsx deleted file mode 100644 index c50223ddde..0000000000 --- a/common/app/routes/Settings/Settings-Skeleton.jsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from 'react'; -import { Button, Row, Col } from 'react-bootstrap'; - -import ns from './ns.json'; - -// actual chars required to give buttons some height -// whitespace alone is no good -const placeholderString = ( - - placeholder text of 28 chars - -); -const shortString = ( - - placeholder - -); - - -export default function SettingsSkeleton() { - return ( -
- - - - - - - -

{ placeholderString }

-

{ shortString }

- - - - - - - - -
-

{ placeholderString }

- - - - - -
-

{ placeholderString }

-
- ); -} diff --git a/common/app/routes/Settings/Settings.jsx b/common/app/routes/Settings/Settings.jsx index f02b667013..6954a701d1 100644 --- a/common/app/routes/Settings/Settings.jsx +++ b/common/app/routes/Settings/Settings.jsx @@ -3,58 +3,39 @@ import React from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { Button, Row, Col } from 'react-bootstrap'; +import { Button } from 'react-bootstrap'; import FA from 'react-fontawesome'; import ns from './ns.json'; -import LockedSettings from './Locked-Settings.jsx'; -import JobSettings from './Job-Settings.jsx'; -import SocialSettings from './Social-Settings.jsx'; -import EmailSettings from './Email-Setting.jsx'; -import LanguageSettings from './Language-Settings.jsx'; -import SettingsSkeleton from './Settings-Skeleton.jsx'; +import { FullWidthRow, Spacer, Loader } from '../../helperComponents'; +import AboutSettings from './components/About-Settings.jsx'; +import InternetSettings from './components/Internet-Settings.jsx'; +import EmailSettings from './components/Email-Settings.jsx'; +import DangerZone from './components/DangerZone.jsx'; +import LanguageSettings from './components/Language-Settings.jsx'; +import CertificationSettings from './components/Cert-Settings.jsx'; +import PortfolioSettings from './components/Portfolio-Settings.jsx'; +import Honesty from './components/Honesty.jsx'; -import { toggleUserFlag } from './redux'; import { toggleNightMode, updateTitle, signInLoadingSelector, - userSelector, + usernameSelector, themeSelector, hardGoTo } from '../../redux'; const mapStateToProps = createSelector( - userSelector, + usernameSelector, themeSelector, signInLoadingSelector, ( - { - username, - email, - isAvailableForHire, - isLocked, - isGithubCool, - isTwitter, - isLinkedIn, - sendMonthlyEmail, - sendNotificationEmail, - sendQuincyEmail - }, + username, theme, showLoading, ) => ({ - currentTheme: theme, - email, - isAvailableForHire, - isGithubCool, - isLinkedIn, - isLocked, - isTwitter, - sendMonthlyEmail, - sendNotificationEmail, - sendQuincyEmail, showLoading, username }) @@ -62,37 +43,13 @@ const mapStateToProps = createSelector( const mapDispatchToProps = { hardGoTo, - toggleIsAvailableForHire: () => toggleUserFlag('isAvailableForHire'), - toggleIsLocked: () => toggleUserFlag('isLocked'), - toggleMonthlyEmail: () => toggleUserFlag('sendMonthlyEmail'), toggleNightMode, - toggleNotificationEmail: () => toggleUserFlag('sendNotificationEmail'), - toggleQuincyEmail: () => toggleUserFlag('sendQuincyEmail'), updateTitle }; const propTypes = { - children: PropTypes.element, - currentTheme: PropTypes.string, - email: PropTypes.string, hardGoTo: PropTypes.func.isRequired, - initialLang: PropTypes.string, - isAvailableForHire: PropTypes.bool, - isGithubCool: PropTypes.bool, - isLinkedIn: PropTypes.bool, - isLocked: PropTypes.bool, - isTwitter: PropTypes.bool, - lang: PropTypes.string, - sendMonthlyEmail: PropTypes.bool, - sendNotificationEmail: PropTypes.bool, - sendQuincyEmail: PropTypes.bool, showLoading: PropTypes.bool, - toggleIsAvailableForHire: PropTypes.func.isRequired, - toggleIsLocked: PropTypes.func.isRequired, - toggleMonthlyEmail: PropTypes.func.isRequired, - toggleNightMode: PropTypes.func.isRequired, - toggleNotificationEmail: PropTypes.func.isRequired, - toggleQuincyEmail: PropTypes.func.isRequired, updateMyLang: PropTypes.func, updateTitle: PropTypes.func.isRequired, username: PropTypes.string @@ -121,188 +78,51 @@ export class Settings extends React.Component { render() { const { - currentTheme, - email, - isAvailableForHire, - isGithubCool, - isLinkedIn, - isLocked, - isTwitter, - sendMonthlyEmail, - sendNotificationEmail, - sendQuincyEmail, showLoading, - toggleIsAvailableForHire, - toggleIsLocked, - toggleMonthlyEmail, - toggleNightMode, - toggleNotificationEmail, - toggleQuincyEmail, username } = this.props; if (!username && showLoading) { - return ; + return ; } return (
- - - - - - -

Settings for your Account

-

Actions

- - - - - - - - - - -
-

Account Settings

- - - - - -
-

Privacy Settings

- - + + - - - + Sign me out of freeCodeCamp + + +

{ `Account Settings for ${username}` }

+ + + + + + + + + + + + + + +
); } diff --git a/common/app/routes/Settings/Social-Settings.jsx b/common/app/routes/Settings/Social-Settings.jsx deleted file mode 100644 index 2a2704961d..0000000000 --- a/common/app/routes/Settings/Social-Settings.jsx +++ /dev/null @@ -1,76 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { Button } from 'react-bootstrap'; -import FA from 'react-fontawesome'; -import classnames from 'classnames'; - -const propTypes = { - isGithubCool: PropTypes.bool, - isLinkedIn: PropTypes.bool, - isTwitter: PropTypes.bool -}; - -export default function SocialSettings({ - isGithubCool, - isTwitter, - isLinkedIn -}) { - const githubCopy = isGithubCool ? - 'Update my profile from GitHub' : - 'Link my GitHub to enable my public profile'; - const buttons = [ - - ]; - const socials = [ - { - isActive: isTwitter, - identifier: 'twitter', - text: 'Twitter' - }, - { - isActive: isLinkedIn, - identifier: 'linkedin', - text: 'LinkedIn' - } - ]; - if (isGithubCool) { - socials.forEach(({ isActive, identifier, text }) => { - const socialClass = classnames( - 'btn-link-social', - `btn-${identifier}`, - { active: isActive } - ); - const socialLink = isActive ? - `/account/unlink/${identifier}` : - `/link/${identifier}`; - const socialText = isTwitter ? - `Remove my ${text} from my portfolio` : - `Add my ${text} to my portfolio`; - buttons.push(( - - )); - }); - } - return (
{ buttons }
); -} - -SocialSettings.displayName = 'SocialSettings'; -SocialSettings.propTypes = propTypes; diff --git a/common/app/routes/Settings/components/About-Settings.jsx b/common/app/routes/Settings/components/About-Settings.jsx new file mode 100644 index 0000000000..f9ee1d0881 --- /dev/null +++ b/common/app/routes/Settings/components/About-Settings.jsx @@ -0,0 +1,236 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { createSelector } from 'reselect'; +import { bindActionCreators } from 'redux'; +import { reduxForm } from 'redux-form'; +import { + Nav, + NavItem +} from 'react-bootstrap'; + +import { FullWidthRow, Spacer } from '../../../helperComponents'; +import LockedSettings from './Locked-Settings.jsx'; +import ThemeSettings from './ThemeSettings.jsx'; +import Camper from './Camper.jsx'; +import UsernameSettings from './UsernameSettings.jsx'; +import SectionHeader from './SectionHeader.jsx'; +import { userSelector, toggleNightMode } from '../../../redux'; +import { + updateUserBackend +} from '../redux'; +import { + BlockSaveButton, + BlockSaveWrapper, + FormFields, + maxLength, + validURL +} from '../formHelpers'; + +const max288Char = maxLength(288); + +const mapStateToProps = createSelector( + userSelector, + ( + { + about, + isLocked, + location, + name, + picture, + points, + theme, + username + }, + ) => ({ + about, + currentTheme: theme, + initialValues: { name, location, about, picture }, + isLocked, + location, + name, + picture, + points, + username + }) +); + +const formFields = [ 'name', 'location', 'picture', 'about' ]; + +function validator(values) { + const errors = {}; + const { + about, + picture + } = values; + errors.about = max288Char(about); + errors.picutre = validURL(picture); + +} + +function mapDispatchToProps(dispatch) { + return bindActionCreators({ + toggleNightMode, + updateUserBackend + }, dispatch); +} + +const propTypes = { + about: PropTypes.string, + currentTheme: PropTypes.string, + fields: PropTypes.object, + handleSubmit: PropTypes.func.isRequired, + isLocked: PropTypes.bool, + location: PropTypes.string, + name: PropTypes.string, + picture: PropTypes.string, + points: PropTypes.number, + toggleNightMode: PropTypes.func.isRequired, + updateUserBackend: PropTypes.func.isRequired, + username: PropTypes.string +}; + +class AboutSettings extends PureComponent { + constructor(props) { + super(props); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleTabSelect = this.handleTabSelect.bind(this); + this.renderEdit = this.renderEdit.bind(this); + this.renderPreview = this.renderPreview.bind(this); + + this.state = { + view: 'edit' + }; + this.show = { + edit: this.renderEdit, + preview: this.renderPreview + }; + } + + handleSubmit(values) { + this.props.updateUserBackend(values); + } + + handleTabSelect(key) { + this.setState(state => ({ + ...state, + view: key + })); + } + + renderEdit() { + const { fields } = this.props; + const options = { + types: { + about: 'textarea', + picture: 'url' + } + }; + return ( +
+ +
+ ); + } + + renderPreview() { + const { + fields: { + picture: { value: picture }, + name: { value: name }, + location: { value: location }, + about: { value: about } + }, + points, + username + } = this.props; + return ( + + ); + } + + render() { + const { + currentTheme, + fields: { _meta: { allPristine } }, + handleSubmit, + isLocked, + toggleNightMode, + updateUserBackend, + username + } = this.props; + const { view } = this.state; + + const toggleIsLocked = () => updateUserBackend({ isLocked: !isLocked }); + const toggleTheme = () => toggleNightMode(username, currentTheme); + return ( +
+ + About Settings + + + + + +
+ +
+ { + this.show[view]() + } + + + +
+
+ + + + + + + +
+ ); + } +} + +AboutSettings.displayName = 'AboutSettings'; +AboutSettings.propTypes = propTypes; + +export default reduxForm( + { + form: 'account-settings', + fields: formFields, + validate: validator + }, + mapStateToProps, + mapDispatchToProps +)(AboutSettings); diff --git a/common/app/routes/Settings/components/Camper.jsx b/common/app/routes/Settings/components/Camper.jsx new file mode 100644 index 0000000000..ce254f328d --- /dev/null +++ b/common/app/routes/Settings/components/Camper.jsx @@ -0,0 +1,58 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Col, Row } from 'react-bootstrap'; + +import SocialIcons from './SocialIcons.jsx'; + +const propTypes = { + about: PropTypes.string, + location: PropTypes.string, + name: PropTypes.string, + picture: PropTypes.string, + points: PropTypes.number, + username: PropTypes.string +}; + +function pluralise(word, condition) { + return condition ? word + 's' : word; +} + +function Camper({ + name, + username, + location, + points, + picture, + about +}) { + + return ( +
+ + + { + + +
+ +
+

@{ username }

+ { name &&

{ name }

} + { location &&

{ location }

} + { about &&

{ about }

} +

+ { `${points} ${pluralise('point', points > 1)}` } +

+
+
+ ); +} + +Camper.displayName = 'Camper'; +Camper.propTypes = propTypes; + +export default Camper; diff --git a/common/app/routes/Settings/components/Cert-Settings.jsx b/common/app/routes/Settings/components/Cert-Settings.jsx new file mode 100644 index 0000000000..f2214250ca --- /dev/null +++ b/common/app/routes/Settings/components/Cert-Settings.jsx @@ -0,0 +1,247 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { Button } from 'react-bootstrap'; + +import { FullWidthRow } from '../../../helperComponents'; +import { Form } from '../formHelpers'; +import JSAlgoAndDSForm from './JSAlgoAndDSForm.jsx'; +import SectionHeader from './SectionHeader.jsx'; +import { projectsSelector } from '../../../entities'; +import { claimCert, updateUserBackend } from '../redux'; +import { fetchChallenges, userSelector, hardGoTo } from '../../../redux'; +import { + buildUserProjectsMap, + jsProjectSuperBlock +} from '../utils/buildUserProjectsMap'; + +const mapStateToProps = createSelector( + userSelector, + projectsSelector, + ( + { + challengeMap, + isRespWebDesignCert, + is2018DataVisCert, + isFrontEndLibsCert, + isJsAlgoDataStructCert, + isApisMicroservicesCert, + isInfosecQaCert, + username + }, + projects + ) => ({ + projects, + userProjects: projects + .map(block => buildUserProjectsMap(block, challengeMap)) + .reduce((projects, current) => ({ + ...projects, + ...current + }), {}), + blockNameIsCertMap: { + 'Applied Responsive Web Design Projects': isRespWebDesignCert, + /* eslint-disable max-len */ + 'JavaScript Algorithms and Data Structures Projects': isJsAlgoDataStructCert, + /* eslint-enable max-len */ + 'Front End Libraries Projects': isFrontEndLibsCert, + 'Data Visualization Projects': is2018DataVisCert, + 'API and Microservice Projects': isApisMicroservicesCert, + 'Information Security and Quality Assurance Projects': isInfosecQaCert + }, + username + }) +); + +function mapDispatchToProps(dispatch) { + return bindActionCreators({ + claimCert, + fetchChallenges, + hardGoTo, + updateUserBackend + }, dispatch); +} + +const propTypes = { + blockNameIsCertMap: PropTypes.objectOf(PropTypes.bool), + claimCert: PropTypes.func.isRequired, + fetchChallenges: PropTypes.func.isRequired, + hardGoTo: PropTypes.func.isRequired, + projects: PropTypes.arrayOf( + PropTypes.shape({ + projectBlockName: PropTypes.string, + challenges: PropTypes.arrayOf(PropTypes.string) + }) + ), + superBlock: PropTypes.string, + updateUserBackend: PropTypes.func.isRequired, + userProjects: PropTypes.objectOf( + PropTypes.objectOf(PropTypes.oneOfType( + [ + PropTypes.string, + PropTypes.object + ] + )) + ), + username: PropTypes.string +}; + +class CertificationSettings extends PureComponent { + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + } + + componentDidMount() { + const { projects } = this.props; + if (!projects.length) { + this.props.fetchChallenges(); + } + } + + handleSubmit(values) { + const { id } = values; + const fullForm = _.values(values) + .filter(Boolean) + .filter(_.isString) + // 5 projects + 1 id prop + .length === 6; + const valuesSaved = _.values(this.props.userProjects[id]) + .filter(Boolean) + .filter(_.isString) + .length === 6; + if (fullForm && valuesSaved) { + return this.props.claimCert(id); + } + const { projects } = this.props; + const pIndex = _.findIndex(projects, p => p.superBlock === id); + values.nameToIdMap = projects[pIndex].challengeNameIdMap; + return this.props.updateUserBackend({ + projects: { + [id]: values + } + }); + } + + render() { + const { + blockNameIsCertMap, + claimCert, + hardGoTo, + projects, + userProjects, + username + } = this.props; + if (!projects.length) { + return null; + } + return ( +
+ + Certification Settings + + +

+ Add links to the live demos of your projects as you finish them. + Then, once you have added all 5 projects required for a certificate, + you can claim it. +

+
+ { + projects.map(({ + projectBlockName, + challenges, + superBlock + }) => { + const isCertClaimed = blockNameIsCertMap[projectBlockName]; + if (superBlock === jsProjectSuperBlock) { + return ( + + ); + } + const options = challenges + .reduce((options, current) => { + options.types[current] = 'url'; + return options; + }, { types: {} }); + + options.types.id = 'hidden'; + options.placeholder = false; + + const userValues = userProjects[superBlock] || {}; + + if (!userValues.id) { + userValues.id = superBlock; + } + + const initialValues = challenges + .reduce((accu, current) => ({ + ...accu, + [current]: '' + }), {}); + + const completedProjects = _.values(userValues) + .filter(Boolean) + .filter(_.isString) + // minus 1 to account for the id + .length - 1; + + const fullForm = completedProjects === challenges.length; + return ( + +

{ projectBlockName }

+
+ { + isCertClaimed ? + : + null + } +
+ + ); + }) + } +
+ ); + } +} + +CertificationSettings.displayName = 'CertificationSettings'; +CertificationSettings.propTypes = propTypes; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(CertificationSettings); diff --git a/common/app/routes/Settings/components/DangerZone.jsx b/common/app/routes/Settings/components/DangerZone.jsx new file mode 100644 index 0000000000..12800aeb36 --- /dev/null +++ b/common/app/routes/Settings/components/DangerZone.jsx @@ -0,0 +1,106 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { Panel, Alert, Button } from 'react-bootstrap'; +import { connect } from 'react-redux'; + +import { ButtonSpacer, FullWidthRow } from '../../../helperComponents'; +import ResetModal from './ResetModal.jsx'; +import DeleteModal from './DeleteModal.jsx'; +import { resetProgress, deleteAccount } from '../redux'; + +const propTypes = { + deleteAccount: PropTypes.func.isRequired, + resetProgress: PropTypes.func.isRequired +}; + +const mapStateToProps = () => ({}); +const mapDispatchToProps = { + deleteAccount, + resetProgress +}; + +class DangerZone extends PureComponent { + constructor(props) { + super(props); + + this.state = { + delete: false, + reset: false + }; + + this.toggleDeleteModal = this.toggleDeleteModal.bind(this); + this.toggleResetModal = this.toggleResetModal.bind(this); + } + + toggleDeleteModal() { + return this.setState(state => ({ + ...state, + delete: !state.delete + })); + } + + toggleResetModal() { + return this.setState(state => ({ + ...state, + reset: !state.reset + })); + } + + render() { + const { resetProgress, deleteAccount } = this.props; + return ( +
+ + Danger Zone} + > + +

+ Tread carefully, changes made in this area are permanent. + They cannot be undone. +

+
+ + + + + +
+ + +
+
+ ); + } +} + +DangerZone.displayName = 'DangerZone'; +DangerZone.propTypes = propTypes; + +export default connect(mapStateToProps, mapDispatchToProps)(DangerZone); diff --git a/common/app/routes/Settings/components/DeleteModal.jsx b/common/app/routes/Settings/components/DeleteModal.jsx new file mode 100644 index 0000000000..9b77378a75 --- /dev/null +++ b/common/app/routes/Settings/components/DeleteModal.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Modal, Button } from 'react-bootstrap'; + +const propTypes = { + delete: PropTypes.func.isRequired, + onHide: PropTypes.func.isRequired, + show: PropTypes.bool +}; + +function DeleteModal(props) { + const { show, onHide } = props; + return ( + + + Delete My Account + + +

+ This will really delete all your data, including all your progress + and account information. +

+

+ We won't be able to recover any of it for you later, + even if you change your mind. +

+

+ If there's something we could do better, send us an email instead and + we'll do our best:   + + team@freecodecamp.org + +

+
+ +
+ + + + + + + ); +} + +DeleteModal.displayName = 'DeleteModal'; +DeleteModal.propTypes = propTypes; + +export default DeleteModal; diff --git a/common/app/routes/Settings/components/Email-Settings.jsx b/common/app/routes/Settings/components/Email-Settings.jsx new file mode 100644 index 0000000000..babe95c9ba --- /dev/null +++ b/common/app/routes/Settings/components/Email-Settings.jsx @@ -0,0 +1,168 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { createSelector } from 'reselect'; +import { bindActionCreators } from 'redux'; +import { reduxForm } from 'redux-form'; +import { + Alert, + Button, + Col, + ControlLabel, + HelpBlock, + Row +} from 'react-bootstrap'; + +import TB from '../Toggle-Button'; +import EmailForm from './EmailForm.jsx'; +import { Link } from '../../../Router'; +import { FullWidthRow, Spacer } from '../../../helperComponents'; +import SectionHeader from './SectionHeader.jsx'; +import { userSelector } from '../../../redux'; +import { onRouteUpdateEmail, updateMyEmail, updateUserBackend } from '../redux'; + +const mapStateToProps = createSelector( + userSelector, + ({ + email, + isEmailVerified, + sendQuincyEmail + }) => ({ + email, + initialValues: { email }, + isEmailVerified, + sendQuincyEmail + }) +); + +function mapDispatchToProps(dispatch) { + return bindActionCreators({ + updateMyEmail, + updateUserBackend + }, dispatch); +} + +const propTypes = { + email: PropTypes.string, + isEmailVerified: PropTypes.bool, + options: PropTypes.arrayOf( + PropTypes.shape({ + flag: PropTypes.string, + label: PropTypes.string, + bool: PropTypes.bool + }) + ), + sendQuincyEmail: PropTypes.bool, + updateMyEmail: PropTypes.func.isRequired, + updateUserBackend: PropTypes.func.isRequired +}; + +export function UpdateEmailButton() { + return ( + + + + ); +} + +class EmailSettings extends PureComponent { + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + } + + handleSubmit({ email }) { + + this.props.updateMyEmail(email); + } + + render() { + const { + email, + isEmailVerified, + sendQuincyEmail + } = this.props; + if (!email) { + return ( +
+ +

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

+
+ + + +
+ ); + } + return ( +
+ + Email Settings + + { + isEmailVerified ? null : + + + + A change of email adress has not been verified. + To use your new email, you must verify it first using the link + we sent you. + + + + } + + + + + + + + + Send me Quincy's weekly email + + + + updateUserBackend({ + sendQuincyEmail: !sendQuincyEmail + }) + } + value={ sendQuincyEmail } + /> + + + +
+ ); + } +} + +EmailSettings.displayName = 'EmailSettings'; +EmailSettings.propTypes = propTypes; + +export default reduxForm( + { + form: 'email-settings', + fields: [ 'email' ] + }, + mapStateToProps, + mapDispatchToProps +)(EmailSettings); diff --git a/common/app/routes/Settings/components/EmailForm.jsx b/common/app/routes/Settings/components/EmailForm.jsx new file mode 100644 index 0000000000..4d795ba37c --- /dev/null +++ b/common/app/routes/Settings/components/EmailForm.jsx @@ -0,0 +1,160 @@ +import React, { PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import { reduxForm } from 'redux-form'; +import { + Row, + Col, + ControlLabel, + FormControl, + HelpBlock, + Alert +} from 'react-bootstrap'; +import { updateUserBackend } from '../redux'; +import { FullWidthRow, Spacer } from '../../../helperComponents'; +import { BlockSaveButton, BlockSaveWrapper, validEmail } from '../formHelpers'; + +const propTypes = { + email: PropTypes.string, + errors: PropTypes.object, + fields: PropTypes.objectOf( + PropTypes.shape({ + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + value: PropTypes.string.isRequired + }) + ), + handleSubmit: PropTypes.func.isRequired, + updateUserBackend: PropTypes.func.isRequired +}; + +function mapStateToProps() { + return {}; +} + +const mapDispatchtoProps = { updateUserBackend }; + +function validator(values) { + const errors = {}; + const { email = '', confirmEmail = '' } = values; + + errors.email = validEmail(email); + if (errors.email || errors.confirmEmail) { + return errors; + } + errors.confirmEmail = email.toLowerCase() === confirmEmail.toLowerCase() ? + null : + 'Emails should be the same'; + + return errors; +} + +class EmailForm extends PureComponent { + constructor(props) { + super(props); + + this.options = { + required: [ 'confirmEmail', 'email' ], + types: { confirmemail: 'email', email: 'email' } + }; + this.handleSubmit = this.handleSubmit.bind(this); + } + + handleSubmit(values) { + const { updateUserBackend } = this.props; + const update = { + email: values.email + }; + updateUserBackend(update); + } + + render() { + + const { + fields: { email, confirmEmail }, + handleSubmit + } = this.props; + + const disableForm = (email.pristine && confirmEmail.pristine) || + (!!email.error || !!confirmEmail.error); + + return ( + + + + + Email + + + + + + + + { + !email.pristine && email.error ? + + + { email.error } + + : + null + } + + + + + Confirm Email + + + + + + + + { + !confirmEmail.pristine && confirmEmail.error ? + + + { confirmEmail.error } + + : + null + } + + + + + + + ); + } +} + +EmailForm.displayName = 'EmailForm'; +EmailForm.propTypes = propTypes; + +export default reduxForm( + { + form: 'email-form', + fields: [ 'confirmEmail', 'email' ], + validate: validator + }, + mapStateToProps, + mapDispatchtoProps +)(EmailForm); diff --git a/common/app/routes/Settings/components/Honesty.jsx b/common/app/routes/Settings/components/Honesty.jsx new file mode 100644 index 0000000000..ae095ef8ca --- /dev/null +++ b/common/app/routes/Settings/components/Honesty.jsx @@ -0,0 +1,95 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { Button, Panel } from 'react-bootstrap'; +import { createSelector } from 'reselect'; +import { connect } from 'react-redux'; + +import { FullWidthRow } from '../../../helperComponents'; +import SectionHeader from './SectionHeader.jsx'; +import { userSelector } from '../../../redux'; +import academicPolicy from '../../../../resource/academicPolicy'; +import { updateUserBackend } from '../redux'; + +const propTypes = { + isHonest: PropTypes.bool, + policy: PropTypes.arrayOf(PropTypes.string), + updateUserBackend: PropTypes.func.isRequired +}; + +const mapStateToProps = createSelector( + userSelector, + ({ isHonest }) => ({ + policy: academicPolicy, + isHonest + }) +); + +const mapDispatchToProps = { updateUserBackend }; + +class Honesty extends PureComponent { + constructor(props) { + super(props); + this.state = { + showHonesty: false + }; + + this.handleAgreeClick = this.handleAgreeClick.bind(this); + } + + handleAgreeClick() { + this.props.updateUserBackend({ isHonest: true }); + } + + render() { + const { policy, isHonest } = this.props; + const isHonestAgreed = ( + +

+ You have already accepted our Academic Honesty Policy +

+
+ ); + const agreeButton = ( + + ); + return ( +
+ + Academic Honesty Policy + + + + { + policy.map( + (line, i) => ( +

+ ) + ) + } +
+ { + isHonest ? + isHonestAgreed : + agreeButton + } + + +

+ ); + } +} + + +Honesty.displayName = 'Honesty'; +Honesty.propTypes = propTypes; + +export default connect(mapStateToProps, mapDispatchToProps)(Honesty); diff --git a/common/app/routes/Settings/components/Internet-Settings.jsx b/common/app/routes/Settings/components/Internet-Settings.jsx new file mode 100644 index 0000000000..72e5ab6574 --- /dev/null +++ b/common/app/routes/Settings/components/Internet-Settings.jsx @@ -0,0 +1,109 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { createSelector } from 'reselect'; +import { bindActionCreators } from 'redux'; +import { reduxForm } from 'redux-form'; + +import { FullWidthRow, Spacer } from '../../../helperComponents'; +import { BlockSaveButton, BlockSaveWrapper, FormFields } from '../formHelpers'; +import SectionHeader from './SectionHeader.jsx'; +import { userSelector } from '../../../redux'; +import { updateUserBackend } from '../redux'; + +const mapStateToProps = createSelector( + userSelector, + ({ + githubURL = '', + linkedin = '', + twitter = '', + website = '' + }) => ({ + initialValues: { + githubURL, + linkedin, + twitter, + website + } + }) +); + +const formFields = [ 'githubURL', 'linkedin', 'twitter', 'website' ]; + +function mapDispatchToProps(dispatch) { + return bindActionCreators({ + updateUserBackend + }, dispatch); +} + +const propTypes = { + fields: PropTypes.object, + githubURL: PropTypes.string, + handleSubmit: PropTypes.func.isRequired, + linkedin: PropTypes.string, + twitter: PropTypes.string, + updateUserBackend: PropTypes.func.isRequired, + username: PropTypes.string, + website: PropTypes.string +}; + +class InternetSettings extends PureComponent { + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + } + + handleSubmit(values) { + console.log(values); + this.props.updateUserBackend(values); + } + + render() { + const { + fields, + fields: { _meta: { allPristine } }, + handleSubmit + } = this.props; + const options = { + types: formFields.reduce( + (all, current) => ({ ...all, [current]: 'url' }), + {} + ), + placeholder: false + }; + return ( +
+ + Your Internet Presence + + +
+ + + + + + +
+
+ ); + } +} + +InternetSettings.displayName = 'InternetSettings'; +InternetSettings.propTypes = propTypes; + +export default reduxForm( + { + form: 'internet-settings', + fields: formFields + }, + mapStateToProps, + mapDispatchToProps +)(InternetSettings); diff --git a/common/app/routes/Settings/components/JSAlgoAndDSForm.jsx b/common/app/routes/Settings/components/JSAlgoAndDSForm.jsx new file mode 100644 index 0000000000..8cc19d2bd4 --- /dev/null +++ b/common/app/routes/Settings/components/JSAlgoAndDSForm.jsx @@ -0,0 +1,120 @@ +import React, { PureComponent } from 'react'; +import { kebabCase } from 'lodash'; +import PropTypes from 'prop-types'; +import { Button } from 'react-bootstrap'; + +import { FullWidthRow } from '../../../helperComponents'; +import { BlockSaveButton } from '../formHelpers'; +import { Link } from '../../../Router'; +import SolutionViewer from './SolutionViewer.jsx'; + +const jsFormPropTypes = { + challenges: PropTypes.arrayOf(PropTypes.string), + claimCert: PropTypes.func.isRequired, + hardGoTo: PropTypes.func.isRequired, + isCertClaimed: PropTypes.bool, + jsProjects: PropTypes.objectOf(PropTypes.object), + projectBlockName: PropTypes.string, + superBlock: PropTypes.string, + username: PropTypes.string +}; + +const jsProjectPath = '/challenges/javascript-algorithms-and-data-structures-' + + 'projects/'; + +class JSAlgoAndDSForm extends PureComponent { + constructor(props) { + super(props); + + this.state = {}; + this.handleSolutionToggle = this.handleSolutionToggle.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + } + + handleSolutionToggle(e) { + e.persist(); + return this.setState(state => ({ + ...state, + [e.target.id]: !state[e.target.id] + })); + } + + handleSubmit(e) { + e.preventDefault(); + const { username, superBlock, isCertClaimed } = this.props; + if (isCertClaimed) { + return this.props.hardGoTo(`/c/${username}/${superBlock}`); + } + return this.props.claimCert(superBlock); + } + + render() { + const { + projectBlockName, + challenges = [], + jsProjects = {}, + isCertClaimed + } = this.props; + return ( + +

{ projectBlockName }

+

+ To complete this certification, you must first complete the + JavaScript Algorithms and Data Structures project challenges +

+
    + { + challenges.map(challenge => ( +
    +
  • +

    { challenge }

    + { + Object.keys(jsProjects[challenge]).length ? +
    + +
    : + + + + } +
  • + { + this.state[challenge] ? + : + null + } +
    + )) + } +
+ { + Object.keys(jsProjects).length === 6 ? +
+ + { isCertClaimed ? 'Show' : 'Claim'} Certificate + +
: + null + } +
+
+ ); + } +} + +JSAlgoAndDSForm.displayName = 'JSAlgoAndDSForm'; +JSAlgoAndDSForm.propTypes = jsFormPropTypes; + +export default JSAlgoAndDSForm; diff --git a/common/app/routes/Settings/Language-Settings.jsx b/common/app/routes/Settings/components/Language-Settings.jsx similarity index 63% rename from common/app/routes/Settings/Language-Settings.jsx rename to common/app/routes/Settings/components/Language-Settings.jsx index 61bd554b71..bf4caf18c2 100644 --- a/common/app/routes/Settings/Language-Settings.jsx +++ b/common/app/routes/Settings/components/Language-Settings.jsx @@ -2,11 +2,19 @@ import PropTypes from 'prop-types'; import React from 'react'; import { createSelector } from 'reselect'; import { reduxForm } from 'redux-form'; -import { FormControl, FormGroup } from 'react-bootstrap'; +import { + FormControl, + FormGroup, + ControlLabel, + Row, + Col +} from 'react-bootstrap'; -import { updateMyLang } from './redux'; -import { userSelector } from '../../redux'; -import langs from '../../../utils/supported-languages'; +import { updateMyLang } from '../redux'; +import { userSelector } from '../../../redux'; +import langs from '../../../../utils/supported-languages'; +import { FullWidthRow } from '../../../helperComponents'; +import SectionHeader from './SectionHeader.jsx'; const propTypes = { fields: PropTypes.object, @@ -85,18 +93,35 @@ export class LanguageSettings extends React.Component { fields: { lang: { name, value } } } = this.props; return ( - - - { options } - - +
+ + Language Settings + + + + + + + Prefered Language for Challenges + + + + + { options } + + + + + +
); } } diff --git a/common/app/routes/Settings/components/Locked-Settings.jsx b/common/app/routes/Settings/components/Locked-Settings.jsx new file mode 100644 index 0000000000..ea9bdb6d5e --- /dev/null +++ b/common/app/routes/Settings/components/Locked-Settings.jsx @@ -0,0 +1,42 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { + Row, + Col, + ControlLabel +} from 'react-bootstrap'; + +import TB from '../Toggle-Button'; + +const propTypes = { + isLocked: PropTypes.bool, + toggleIsLocked: PropTypes.func.isRequired +}; + +export default function LockSettings({ isLocked, toggleIsLocked }) { + return ( + + + +

+ + Make all of my solutions private +
+ (this disables your certificates) +
+

+
+ + + + +
+ ); +} + +LockSettings.displayName = 'LockSettings'; +LockSettings.propTypes = propTypes; diff --git a/common/app/routes/Settings/components/Portfolio-Settings.jsx b/common/app/routes/Settings/components/Portfolio-Settings.jsx new file mode 100644 index 0000000000..257ec0b321 --- /dev/null +++ b/common/app/routes/Settings/components/Portfolio-Settings.jsx @@ -0,0 +1,178 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { createSelector } from 'reselect'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { Button } from 'react-bootstrap'; + +import { FullWidthRow, ButtonSpacer } from '../../../helperComponents'; +import SectionHeader from './SectionHeader.jsx'; +import { userSelector } from '../../../redux'; +import { addPortfolioItem } from '../../../entities'; +import { updateMyPortfolio, deletePortfolio } from '../redux'; +import { + Form, + maxLength, + minLength, + validURL +} from '../formHelpers'; + +const minTwoChar = minLength(2); +const max288Char = maxLength(288); + +const propTypes = { + addPortfolioItem: PropTypes.func.isRequired, + deletePortfolio: PropTypes.func.isRequired, + picture: PropTypes.string, + portfolio: PropTypes.arrayOf( + PropTypes.shape({ + description: PropTypes.string, + image: PropTypes.string, + title: PropTypes.string, + url: PropTypes.string + }) + ), + updateMyPortfolio: PropTypes.func.isRequired, + username: PropTypes.string +}; + +const mapStateToProps = createSelector( + userSelector, + ({ portfolio, username }) => ({ + portfolio, + username + }) +); + +function mapDispatchToProps(dispatch) { + return bindActionCreators({ + addPortfolioItem, + deletePortfolio, + updateMyPortfolio + }, dispatch); +} + +const formFields = [ 'title', 'url', 'image', 'description', 'id' ]; +const options = { + types: { + id: 'hidden', + url: 'url', + image: 'url', + description: 'textarea' + }, + required: [ 'url', 'title', 'id' ] +}; + +function validator(values) { + const errors = {}; + const { + title = '', + url = '', + description = '', + image = '' + } = values; + errors.title = minTwoChar(title); + errors.description = max288Char(description); + errors.url = url && validURL(url); + errors.image = image && validURL(image); + return errors; +} + +class PortfolioSettings extends PureComponent { + constructor(props) { + super(props); + + this.handleAdd = this.handleAdd.bind(this); + this.handleDelete = this.handleDelete.bind(this); + this.handleSave = this.handleSave.bind(this); + this.renderPortfolio = this.renderPortfolio.bind(this); + } + + handleAdd() { + this.props.addPortfolioItem(this.props.username); + } + + handleDelete(id) { + const { deletePortfolio } = this.props; + deletePortfolio({ portfolio: { id } }); + } + + handleSave(portfolio) { + const { updateMyPortfolio } = this.props; + updateMyPortfolio(portfolio); + } + + renderPortfolio(portfolio, index, arr) { + const { + id + } = portfolio; + return ( +
+ +
+ + + { + index + 1 !== arr.length &&
+ } + +
+ ); + } + + render() { + const { portfolio = [] } = this.props; + return ( +
+ + Portfolio Settings + + +
+

+ Share your non-FreeCodeCamp projects, articles or accepted + pull requests. +

+
+
+ { + portfolio.length ? portfolio.map(this.renderPortfolio) : null + } + + + + +
+ ); + } +} + +PortfolioSettings.displayName = 'PortfolioSettings'; +PortfolioSettings.propTypes = propTypes; + +export default connect(mapStateToProps, mapDispatchToProps)(PortfolioSettings); diff --git a/common/app/routes/Settings/components/ResetModal.jsx b/common/app/routes/Settings/components/ResetModal.jsx new file mode 100644 index 0000000000..456702ec2d --- /dev/null +++ b/common/app/routes/Settings/components/ResetModal.jsx @@ -0,0 +1,65 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Modal, Button } from 'react-bootstrap'; + +const propTypes = { + onHide: PropTypes.func.isRequired, + reset: PropTypes.func.isRequired, + show: PropTypes.bool +}; + +function ResetModal(props) { + const { show, onHide } = props; + return ( + + + Reset My Progress + + +

+ This will really delete all of your progress, points, completed + challenges, our records of your projects, any certificates you have, + everything. +

+

+ We won't be able to recover any of it for you later, even if you + change your mind. +

+
+ +
+ + + + + + + ); +} + +ResetModal.displayName = 'ResetModal'; +ResetModal.propTypes = propTypes; + +export default ResetModal; diff --git a/common/app/routes/Settings/components/SectionHeader.jsx b/common/app/routes/Settings/components/SectionHeader.jsx new file mode 100644 index 0000000000..ce9a97fbc0 --- /dev/null +++ b/common/app/routes/Settings/components/SectionHeader.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FullWidthRow } from '../../../helperComponents'; + +const propTypes = { + children: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.element, + PropTypes.node + ]) +}; + +function SectionHeader({ children }) { + return ( + +

{ children }

+
+
+ ); +} + +SectionHeader.displayName = 'SectionHeader'; +SectionHeader.propTypes = propTypes; + +export default SectionHeader; diff --git a/common/app/routes/Settings/components/SocialIcons.jsx b/common/app/routes/Settings/components/SocialIcons.jsx new file mode 100644 index 0000000000..1d85c83bcf --- /dev/null +++ b/common/app/routes/Settings/components/SocialIcons.jsx @@ -0,0 +1,134 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { createSelector } from 'reselect'; +import { connect } from 'react-redux'; +import { + Row, + Col +} from 'react-bootstrap'; +import FontAwesome from 'react-fontawesome'; + +import { userSelector } from '../../../redux'; + +const propTypes = { + email: PropTypes.string, + githubURL: PropTypes.string, + isGithub: PropTypes.bool, + isLinkedIn: PropTypes.bool, + isTwitter: PropTypes.bool, + isWebsite: PropTypes.bool, + linkedIn: PropTypes.string, + twitter: PropTypes.string, + website: PropTypes.string +}; + +const mapStateToProps = createSelector( + userSelector, + ({ + githubURL, + isLinkedIn, + isGithub, + isTwitter, + isWebsite, + linkedIn, + twitter, + website + }) => ({ + githubURL, + isLinkedIn, + isGithub, + isTwitter, + isWebsite, + linkedIn, + twitter, + website + }) +); + +function mapDispatchToProps() { + return {}; +} + +function LinkedInIcon(linkedIn) { + return ( + + + + ); +} + +function githubIcon(ghURL) { + return ( + + + + ); +} + +function WebsiteIcon(website) { + return ( + + + + ); +} + +function TwitterIcon(handle) { + return ( + + + + ); +} + +function SocialIcons(props) { + const { + githubURL, + isLinkedIn, + isGithub, + isTwitter, + isWebsite, + linkedIn, + twitter, + website + } = props; + return ( + + + { + isLinkedIn ? LinkedInIcon(linkedIn) : null + } + { + isGithub ? githubIcon(githubURL) : null + } + { + isWebsite ? WebsiteIcon(website) : null + } + { + isTwitter ? TwitterIcon(twitter) : null + } + + + ); +} + +SocialIcons.displayName = 'SocialIcons'; +SocialIcons.propTypes = propTypes; + +export default connect(mapStateToProps, mapDispatchToProps)(SocialIcons); diff --git a/common/app/routes/Settings/components/SolutionViewer.jsx b/common/app/routes/Settings/components/SolutionViewer.jsx new file mode 100644 index 0000000000..c93aefa0d8 --- /dev/null +++ b/common/app/routes/Settings/components/SolutionViewer.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Panel } from 'react-bootstrap'; +import Prism from 'prismjs'; +import Helmet from 'react-helmet'; + +const prismLang = { + css: 'css', + js: 'javascript', + jsx: 'javascript', + html: 'markup' +}; + +function SolutionViewer({ files }) { + return ( +
+ + + + { + Object.keys(files) + .map(key => files[key]) + .map(file => ( + +
+                
+              
+
+ )) + } +
+ ); +} + +SolutionViewer.displayName = 'SolutionViewer'; +SolutionViewer.propTypes = { + files: PropTypes.object +}; + +export default SolutionViewer; diff --git a/common/app/routes/Settings/components/ThemeSettings.jsx b/common/app/routes/Settings/components/ThemeSettings.jsx new file mode 100644 index 0000000000..314350562b --- /dev/null +++ b/common/app/routes/Settings/components/ThemeSettings.jsx @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { + Row, + Col, + ControlLabel +} from 'react-bootstrap'; + +import TB from '../Toggle-Button'; + +const propTypes = { + currentTheme: PropTypes.string.isRequired, + toggleNightMode: PropTypes.func.isRequired +}; + +export default function ThemeSettings({ currentTheme, toggleNightMode }) { + return ( + + + +

+ Night Mode +

+
+ + + toggleNightMode(currentTheme) } + value={ currentTheme === 'night' } + /> + +
+ ); +} + +ThemeSettings.displayName = 'ThemeSettings'; +ThemeSettings.propTypes = propTypes; diff --git a/common/app/routes/Settings/components/UsernameSettings.jsx b/common/app/routes/Settings/components/UsernameSettings.jsx new file mode 100644 index 0000000000..b244d7e77a --- /dev/null +++ b/common/app/routes/Settings/components/UsernameSettings.jsx @@ -0,0 +1,187 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Col, + ControlLabel, + FormControl, + Alert +} from 'react-bootstrap'; +import { reduxForm } from 'redux-form'; +import { createSelector } from 'reselect'; + +import { + settingsSelector, + updateUserBackend, + validateUsername +} from '../redux'; +import { userSelector } from '../../../redux'; +import { BlockSaveButton, minLength } from '../formHelpers'; +import { FullWidthRow } from '../../../helperComponents'; + +const minTwoChar = minLength(2); + +const propTypes = { + fields: PropTypes.objectOf( + PropTypes.shape({ + error: PropTypes.string, + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + pristine: PropTypes.bool.isRequired, + value: PropTypes.string.isRequired + }) + ), + handleSubmit: PropTypes.func.isRequired, + isValidUsername: PropTypes.bool, + submitAction: PropTypes.func.isRequired, + username: PropTypes.string, + validateUsername: PropTypes.func.isRequired, + validating: PropTypes.bool +}; + +const mapStateToProps = createSelector( + userSelector, + settingsSelector, + ({ username }, { isValidUsername, validating }) => ({ + initialValues: { username }, + isValidUsername, + validate: validator, + validating + }) +); + +const mapDispatchToProps = { + validateUsername, + submitAction: updateUserBackend +}; +function normalise(str = '') { + return str.toLowerCase().trim(); +} + +function makeHandleChange(changeFn, validationAction, valid) { + return function handleChange(e) { + const { value } = e.target; + e.target.value = normalise(value); + if (e.target.value && valid) { + validationAction(value); + } + return changeFn(e); + }; +} + +function validator(values) { + const errors = {}; + const { username } = values; + const minWarn = minTwoChar(username); + if (minWarn) { + errors.username = minWarn; + return errors; + } + if (username.length === 0) { + errors.username = 'Username cannot be empty'; + } + return errors; +} + +function renderAlerts(validating, error, isValidUsername) { + if (!validating && error) { + return ( + + + { error } + + + ); + } + if (!validating && !isValidUsername) { + return ( + + + Username not available + + + ); + } + if (validating) { + return ( + + + Validating username + + + ); + } + if (!validating && isValidUsername) { + return ( + + + Username is available + + + ); + } + return null; +} + +function UsernameSettings(props) { + const { + fields: { + username: { + value, + onChange, + error, + pristine, + valid + } + }, + handleSubmit, + isValidUsername, + submitAction, + validateUsername, + validating + } = props; + return ( +
+ { + !pristine && renderAlerts(validating, error, isValidUsername) + } + + + + + Username + + + + + + + + + + +
+ ); +} + +UsernameSettings.displayName = 'UsernameSettings'; +UsernameSettings.propTypes = propTypes; + +export default reduxForm( + { + form: 'usernameSettings', + fields: [ 'username' ] + }, + mapStateToProps, + mapDispatchToProps +)(UsernameSettings); diff --git a/common/app/routes/Settings/formHelpers/BlockSaveButton.jsx b/common/app/routes/Settings/formHelpers/BlockSaveButton.jsx new file mode 100644 index 0000000000..c551c87de5 --- /dev/null +++ b/common/app/routes/Settings/formHelpers/BlockSaveButton.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button } from 'react-bootstrap'; + +function BlockSaveButton(props) { + return ( + + ); +} + +BlockSaveButton.displayName = 'BlockSaveButton'; +BlockSaveButton.propTypes = { + children: PropTypes.any +}; + +export default BlockSaveButton; diff --git a/common/app/routes/Settings/formHelpers/BlockSaveWrapper.jsx b/common/app/routes/Settings/formHelpers/BlockSaveWrapper.jsx new file mode 100644 index 0000000000..d560793e64 --- /dev/null +++ b/common/app/routes/Settings/formHelpers/BlockSaveWrapper.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const propTypes = { + children: PropTypes.node +}; + +const style = { + padding: '0 15px' +}; + +function BlockSaveWrapper({ children }) { + return ( +
+ { children } +
+ ); +} + +BlockSaveWrapper.displayName = 'BlockSaveWrapper'; +BlockSaveWrapper.propTypes = propTypes; + +export default BlockSaveWrapper; diff --git a/common/app/routes/Settings/formHelpers/Form.jsx b/common/app/routes/Settings/formHelpers/Form.jsx new file mode 100644 index 0000000000..f9461c2172 --- /dev/null +++ b/common/app/routes/Settings/formHelpers/Form.jsx @@ -0,0 +1,88 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { reduxForm } from 'redux-form'; + +import { FormFields, BlockSaveButton, BlockSaveWrapper } from './'; + +const propTypes = { + buttonText: PropTypes.string, + enableSubmit: PropTypes.bool, + errors: PropTypes.object, + fields: PropTypes.objectOf( + PropTypes.shape({ + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + value: PropTypes.string.isRequired + }) + ), + formFields: PropTypes.arrayOf(PropTypes.string).isRequired, + handleSubmit: PropTypes.func, + hideButton: PropTypes.bool, + id: PropTypes.string.isRequired, + initialValues: PropTypes.object, + options: PropTypes.shape({ + ignored: PropTypes.arrayOf(PropTypes.string), + required: PropTypes.arrayOf(PropTypes.string), + types: PropTypes.objectOf(PropTypes.string) + }), + submit: PropTypes.func.isRequired +}; + +function DynamicForm({ + // redux-form + errors, + fields, + handleSubmit, + fields: { _meta: { allPristine }}, + + // HOC + buttonText, + enableSubmit, + hideButton, + id, + options, + submit +}) { + return ( +
+ + + { + hideButton ? + null : + errors[key]).length) + } + > + { + buttonText ? buttonText : null + } + + } + + + ); +} + +DynamicForm.displayName = 'DynamicForm'; +DynamicForm.propTypes = propTypes; + +const DynamicFormWithRedux = reduxForm()(DynamicForm); + +export default function Form(props) { + return ( + + ); +} + +Form.propTypes = propTypes; diff --git a/common/app/routes/Settings/formHelpers/FormFields.jsx b/common/app/routes/Settings/formHelpers/FormFields.jsx new file mode 100644 index 0000000000..d3c2c84927 --- /dev/null +++ b/common/app/routes/Settings/formHelpers/FormFields.jsx @@ -0,0 +1,97 @@ +import React from 'react'; +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import { + Alert, + Col, + ControlLabel, + FormControl, + HelpBlock, + Row +} from 'react-bootstrap'; + +const propTypes = { + errors: PropTypes.objectOf(PropTypes.string), + fields: PropTypes.objectOf( + PropTypes.shape({ + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + value: PropTypes.string.isRequired + }) + ).isRequired, + options: PropTypes.shape({ + errors: PropTypes.objectOf( + PropTypes.oneOfType([ + PropTypes.string, + PropTypes.instanceOf(null) + ]) + ), + ignored: PropTypes.arrayOf(PropTypes.string), + placeholder: PropTypes.bool, + required: PropTypes.arrayOf(PropTypes.string), + types: PropTypes.objectOf(PropTypes.string) + }) +}; + +function FormFields(props) { + const { errors = {}, fields, options = {} } = props; + const { + ignored = [], + placeholder = true, + required = [], + types = {} + } = options; + return ( +
+ { + Object.keys(fields) + .filter(field => !ignored.includes(field)) + .map(key => fields[key]) + .map(({ name, onChange, value, pristine }) => { + const key = _.kebabCase(name); + const type = name in types ? types[name] : 'text'; + return ( + + + { type === 'hidden' ? + null : + + { _.startCase(name) } + + } + + + + { + name in errors && !pristine ? + + + { errors[name] } + + : + null + } + + + ); + }) + } +
+ ); +} + +FormFields.displayName = 'FormFields'; +FormFields.propTypes = propTypes; + +export default FormFields; diff --git a/common/app/routes/Settings/formHelpers/index.js b/common/app/routes/Settings/formHelpers/index.js new file mode 100644 index 0000000000..29360ee1c6 --- /dev/null +++ b/common/app/routes/Settings/formHelpers/index.js @@ -0,0 +1,35 @@ +import { isEmail, isURL } from 'validator'; + +/** Components **/ + +export { default as BlockSaveButton } from './BlockSaveButton.jsx'; +export { default as BlockSaveWrapper } from './BlockSaveWrapper.jsx'; +export { default as Form } from './Form.jsx'; +export { default as FormFields } from './FormFields.jsx'; + +/** Normalise **/ + +export function lowerAndTrim(str = '') { + return str.toLowerCase().trim(); +} + +/** Validation **/ + +export function maxLength(max) { + return value => value && value.length > max ? + `Must be ${max} characters or less` : + null; +} + +export function minLength(min) { + return value => value && value.length < min ? + `Must be ${min} characters or more` : + null; +} +export function validEmail(email) { + return isEmail(email) ? null : 'Must be a valid email'; +} + +export function validURL(str) { + return isURL(str) ? null : 'Must be a valid URL'; +} diff --git a/common/app/routes/Settings/redux/certificate-epic.js b/common/app/routes/Settings/redux/certificate-epic.js new file mode 100644 index 0000000000..afa1725a3c --- /dev/null +++ b/common/app/routes/Settings/redux/certificate-epic.js @@ -0,0 +1,42 @@ +import { Observable } from 'rx'; +import { ofType } from 'redux-epic'; + +import { doActionOnError, fetchUser } from '../../../redux'; +import { makeToast } from '../../../Toasts/redux'; +import { postJSON$ } from '../../../../utils/ajax-stream'; +import { + types, + claimCertComplete, + claimCertError +} from '../redux'; + +function certificateEpic(actions$, { getState }) { + const start = actions$::ofType(types.claimCert.start) + .flatMap(({ payload: superBlock }) => { + const { + app: { csrfToken: _csrf } + } = getState(); + return postJSON$('/certificate/verify', { _csrf, superBlock }); + }) + .map(claimCertComplete) + .catch(doActionOnError(error => claimCertError(error))); + + const complete = actions$::ofType(types.claimCert.complete) + .flatMap(({ meta: { message, success }}) => Observable.if( + () => success, + Observable.of(fetchUser(), makeToast({ message })), + Observable.of(makeToast({ message })) + )); + + const error = actions$::ofType(types.claimCert.error) + .flatMap(error => { + return Observable.of( + makeToast({ message: 'Something went wrong updating your account' }), + { type: 'error', error} + ); + }); + + return Observable.merge(start, complete, error); +} + +export default certificateEpic; diff --git a/common/app/routes/Settings/redux/danger-zone-epic.js b/common/app/routes/Settings/redux/danger-zone-epic.js new file mode 100644 index 0000000000..0d5a0c8138 --- /dev/null +++ b/common/app/routes/Settings/redux/danger-zone-epic.js @@ -0,0 +1,53 @@ +import { Observable } from 'rx'; +import { ofType } from 'redux-epic'; + +import { + types, + resetProgressError, + deleteAccountError, + deleteAccountComplete +} from './'; +import { postJSON$ } from '../../../../utils/ajax-stream'; +import { + doActionOnError, + hardGoTo, + createErrorObservable +} from '../../../redux'; + +function dangerZoneEpic(actions$, { getState }) { + /** Reset Progress **/ + const resetStart = actions$::ofType(types.resetProgress.start) + .flatMap(() => { + const { csrfToken: _csrf } = getState().app; + return postJSON$('/account/reset-progress', { _csrf }) + .map(() => hardGoTo('/')) + .catch(doActionOnError(error => resetProgressError(error))); + }); + const resetError = actions$::ofType(types.resetProgress.error) + .flatMap(createErrorObservable); + + /** Delete Account **/ + const deleteStart = actions$::ofType(types.deleteAccount.start) + .flatMap(() => { + const { csrfToken: _csrf } = getState().app; + return postJSON$('/account/delete', { _csrf }) + .map(deleteAccountComplete) + .catch(doActionOnError(error => deleteAccountError(error))); + }); + + const deleteComplete = actions$::ofType(types.deleteAccount.complete) + .map(() => hardGoTo('/')); + + const deleteError = actions$::ofType(types.deleteAccount.error) + .flatMap(createErrorObservable); + + return Observable.merge( + resetStart, + resetError, + deleteStart, + deleteComplete, + deleteError + ).filter(Boolean); +} + +export default dangerZoneEpic; diff --git a/common/app/routes/Settings/redux/index.js b/common/app/routes/Settings/redux/index.js index c244aacce0..5814a6d10e 100644 --- a/common/app/routes/Settings/redux/index.js +++ b/common/app/routes/Settings/redux/index.js @@ -1,23 +1,46 @@ import { isLocationAction } from 'redux-first-router'; import { - addNS, + composeReducers, createAction, createAsyncTypes, - createTypes + createTypes, + handleActions } from 'berkeleys-redux-utils'; +import { identity } from 'lodash'; -import userUpdateEpic from './update-user-epic.js'; +import certificateEpic from './certificate-epic'; +import dangerZoneEpic from './danger-zone-epic'; +import userUpdateEpic from './update-user-epic'; +import newUsernameEpic from './new-username-epic'; import ns from '../ns.json'; import { utils } from '../../../Flash/redux'; export const epics = [ + certificateEpic, + dangerZoneEpic, + newUsernameEpic, userUpdateEpic ]; +const createActionWithFlash = type => createAction( + type, + null, + utils.createFlashMetaAction +); + export const types = createTypes([ - 'toggleUserFlag', + createAsyncTypes('claimCert'), createAsyncTypes('updateMyEmail'), + createAsyncTypes('updateUserBackend'), + createAsyncTypes('deletePortfolio'), + createAsyncTypes('updateMyPortfolio'), 'updateMyLang', + 'updateNewUsernameValidity', + createAsyncTypes('validateUsername'), + createAsyncTypes('refetchChallengeMap'), + createAsyncTypes('deleteAccount'), + createAsyncTypes('resetProgress'), + 'onRouteSettings', 'onRouteUpdateEmail' ], 'settings'); @@ -25,34 +48,92 @@ export const types = createTypes([ export const onRouteSettings = createAction(types.onRouteSettings); export const onRouteUpdateEmail = createAction(types.onRouteUpdateEmail); -export const toggleUserFlag = createAction(types.toggleUserFlag); + +export const claimCert = createAction(types.claimCert.start); +export const claimCertComplete = createAction( + types.claimCert.complete, + ({ result }) => result, + identity +); +export const claimCertError = createAction( + types.claimCert.error, + identity +); + +export const updateUserBackend = createAction(types.updateUserBackend.start); +export const updateUserBackendComplete = createActionWithFlash( + types.updateUserBackend.complete +); +export const updateUserBackendError = createActionWithFlash( + types.updateUserBackend.error +); + export const updateMyEmail = createAction(types.updateMyEmail.start); -export const updateMyEmailComplete = createAction( - types.updateMyEmail.complete, - null, - utils.createFlashMetaAction +export const updateMyEmailComplete = createActionWithFlash( + types.updateMyEmail.complete +); +export const updateMyEmailError = createActionWithFlash( + types.updateMyEmail.error ); -export const updateMyEmailError = createAction( - types.updateMyEmail.error, - null, - utils.createFlashMetaAction +export const updateMyPortfolio = createAction(types.updateMyPortfolio.start); +export const updateMyPortfolioComplete = createAction( + types.updateMyPortfolio.complete ); - +export const updateMyPortfolioError = createAction( + types.updateMyPortfolio.error +); +export const deletePortfolio = createAction(types.deletePortfolio.start); +export const deletePortfolioError = createAction(types.deletePortfolio.error); export const updateMyLang = createAction( types.updateMyLang, (values) => values.lang ); +export const resetProgress = createAction(types.resetProgress.start); +export const resetProgressComplete = createAction(types.resetProgress.complete); +export const resetProgressError = createAction( + types.resetProgress.error, + identity +); + +export const deleteAccount = createAction(types.deleteAccount.start); +export const deleteAccountComplete = createAction(types.deleteAccount.complete); +export const deleteAccountError = createAction( + types.deleteAccount.error, + identity +); + +export const updateNewUsernameValidity = createAction( + types.updateNewUsernameValidity +); + +export const refetchChallengeMap = createAction( + types.refetchChallengeMap.start +); + +export const validateUsername = createAction(types.validateUsername.start); +export const validateUsernameError = createAction( + types.validateUsername.error, + identity +); + const defaultState = { - showUpdateEmailView: false + showUpdateEmailView: false, + isValidUsername: false, + validating: false }; const getNS = state => state[ns]; + +export function settingsSelector(state) { + return getNS(state); +} + export const showUpdateEmailViewSelector = state => getNS(state).showUpdateEmailView; -export default addNS( +export default composeReducers( ns, function settingsRouteReducer(state = defaultState, action) { if (isLocationAction(action)) { @@ -71,5 +152,20 @@ export default addNS( } } return state; - } + }, + handleActions(() => ({ + [types.updateNewUsernameValidity]: (state, { payload }) => ({ + ...state, + isValidUsername: payload, + validating: false + }), + [types.validateUsername.start]: state => ({ + ...state, + isValidUsername: false, + validating: true + }), + [types.validateUsername.error]: state => ({ ...state, validating: false }) + }), + defaultState +) ); diff --git a/common/app/routes/Settings/redux/new-username-epic.js b/common/app/routes/Settings/redux/new-username-epic.js new file mode 100644 index 0000000000..b6a7055711 --- /dev/null +++ b/common/app/routes/Settings/redux/new-username-epic.js @@ -0,0 +1,30 @@ +import { Observable } from 'rx'; +import { ofType } from 'redux-epic'; + +import { + types, + updateNewUsernameValidity, + validateUsernameError +} from './'; +import { getJSON$ } from '../../../../utils/ajax-stream'; +import { + doActionOnError, + createErrorObservable +} from '../../../redux'; + +function validateUsernameEpic(actions$) { + const start = actions$::ofType(types.validateUsername.start) + .debounce(500) + .flatMap(({ payload }) => + getJSON$(`/api/users/exists?username=${payload}`) + .map(({ exists }) => updateNewUsernameValidity(!exists)) + .catch(error => doActionOnError(() => validateUsernameError(error))) + ); + + const error = actions$::ofType(types.validateUsername.error) + .flatMap(createErrorObservable); + + return Observable.merge(start, error); +} + +export default validateUsernameEpic; diff --git a/common/app/routes/Settings/redux/update-user-epic.js b/common/app/routes/Settings/redux/update-user-epic.js index 41cece6c40..d95779801f 100644 --- a/common/app/routes/Settings/redux/update-user-epic.js +++ b/common/app/routes/Settings/redux/update-user-epic.js @@ -1,31 +1,176 @@ import { Observable } from 'rx'; import { combineEpics, ofType } from 'redux-epic'; - -import { types, onRouteSettings } from './'; +import { pick } from 'lodash'; +import { + types, + onRouteSettings, + refetchChallengeMap, + updateUserBackendComplete, + updateMyPortfolioComplete +} from './'; import { makeToast } from '../../../Toasts/redux'; import { - fetchChallenges, + updateChallenges, doActionOnError, - userSelector + usernameSelector, + userSelector, + createErrorObservable } from '../../../redux'; import { - updateUserFlag, updateUserEmail, - updateUserLang + updateUserLang, + updateMultipleUserFlags, + regresPortfolio, + optoUpdatePortfolio } from '../../../entities'; import { postJSON$ } from '../../../../utils/ajax-stream'; import langs from '../../../../utils/supported-languages'; -const urlMap = { - isLocked: 'lockdown', - isAvailableForHire: 'available-for-hire', - sendQuincyEmail: 'quincy-email', - sendNotificationEmail: 'notification-email', - sendMonthlyEmail: 'announcement-email' +const endpoints = { + email: '/update-my-email', + projects: '/update-my-projects', + username: '/update-my-username' }; -export function updateUserEmailEpic(actions, { getState }) { +function backendUserUpdateEpic(actions$, { getState }) { + const start = actions$::ofType(types.updateUserBackend.start); + const server = start + .flatMap(({ payload }) => { + const userMap = userSelector(getState()); + const { username } = userMap; + const flagsToCheck = Object.keys(payload); + const valuesToCheck = pick(userMap, flagsToCheck); + const oldValues = { + ...flagsToCheck.reduce((accu, current) => ({ ...accu, [current]: '' })), + ...valuesToCheck + }; + const valuesToUpdate = flagsToCheck.reduce((accu, current) => { + if (payload[current] !== valuesToCheck[current]) { + return { ...accu, [current]: payload[current] }; + } + return accu; + }, {}); + if (!Object.keys(valuesToUpdate).length) { + return Observable.of( + makeToast({ message: 'No changes in settings detected' }) + ); + } + const { + app: { csrfToken: _csrf } + } = getState(); + let body = { _csrf }; + let endpoint = '/update-flags'; + const updateKeys = Object.keys(valuesToUpdate); + if (updateKeys.length === 1 && updateKeys[0] in endpoints) { + // there is a specific route for this update + const flag = updateKeys[0]; + endpoint = endpoints[flag]; + body = { + ...body, + [flag]: valuesToUpdate[flag] + }; + } else { + body = { + ...body, + values: valuesToUpdate + }; + } + return postJSON$(endpoint, body) + .map(updateUserBackendComplete) + .catch( + doActionOnError( + () => updateMultipleUserFlags({ username, flags: oldValues }) + ) + ); + }); + const optimistic = start + .flatMap(({ payload }) => { + const username = usernameSelector(getState()); + return Observable.of( + updateMultipleUserFlags({ username, flags: payload }) + ); + }); + const complete = actions$::ofType(types.updateUserBackend.complete) + .flatMap(({ payload: { message } }) => Observable.if( + () => message.includes('project'), + Observable.of(refetchChallengeMap(), makeToast({ message })), + Observable.of(makeToast({ message })) + ) + ); + + return Observable.merge(server, optimistic, complete); +} + +function refetchChallengeMapEpic(actions$, { getState }) { + return actions$::ofType(types.refetchChallengeMap.start) + .flatMap(() => { + const { + app: { csrfToken: _csrf } + } = getState(); + const username = usernameSelector(getState()); + return postJSON$('/refetch-user-challenge-map', { _csrf }) + .map(({ challengeMap }) => + updateMultipleUserFlags({ username, flags: { challengeMap } }) + ) + .catch(createErrorObservable); + }); +} + +function updateMyPortfolioEpic(actions$, { getState }) { + const edit = actions$::ofType(types.updateMyPortfolio.start); + const remove = actions$::ofType(types.deletePortfolio.start); + const serverEdit = edit + .flatMap(({ payload }) => { + const { id } = payload; + const { + app: { csrfToken: _csrf, username } + } = getState(); + return postJSON$('/update-my-portfolio', { _csrf, portfolio: payload }) + .map(updateMyPortfolioComplete) + .catch(doActionOnError(() => regresPortfolio({ username, id }))); + }); + const optimisticEdit = edit + .map(({ payload }) => { + const username = usernameSelector(getState()); + return optoUpdatePortfolio({ username, portfolio: payload }); + }); + const complete = actions$::ofType(types.updateMyPortfolio.complete) + .flatMap(({ payload: { message } }) => + Observable.of(makeToast({ message })) + ); + + const serverRemove = remove + .flatMap(({ payload: { portfolio } }) => { + const { + app: { csrfToken: _csrf } + } = getState(); + return postJSON$('/update-my-portfolio', { _csrf, portfolio }) + .map(updateMyPortfolioComplete) + .catch( + doActionOnError( + () => makeToast({ + message: 'Something went wrong removing a portfolio item.' + }) + ) + ); + }); + const optimisticRemove = remove + .flatMap(({ payload: { portfolio: { id } } }) => { + const username = usernameSelector(getState()); + return Observable.of(regresPortfolio({ username, id })); + }); + + return Observable.merge( + serverEdit, + optimisticEdit, + complete, + serverRemove, + optimisticRemove + ); +} + +function updateUserEmailEpic(actions, { getState }) { return actions::ofType(types.updateMyEmail) .flatMap(({ payload: email }) => { const { @@ -38,7 +183,6 @@ export function updateUserEmailEpic(actions, { getState }) { updateUserEmail(username, email) ); const ajaxUpdate = postJSON$('/update-my-email', body) - .map(({ message }) => makeToast({ message })) .catch(doActionOnError(() => oldEmail ? updateUserEmail(username, oldEmail) : null @@ -71,7 +215,7 @@ export function updateUserLangEpic(actions, { getState }) { // update url to reflect change onRouteSettings({ lang }), // refetch challenges in new language - fetchChallenges() + updateChallenges() ); }) .catch(doActionOnError(() => { @@ -85,41 +229,11 @@ export function updateUserLangEpic(actions, { getState }) { }); return Observable.merge(ajaxUpdate, optimistic); } -export function updateUserFlagEpic(actions, { getState }) { - const toggleFlag = actions - .filter(({ type, payload }) => type === types.toggleUserFlag && payload) - .map(({ payload }) => payload); - const optimistic = toggleFlag.map(flag => { - const { app: { user: username } } = getState(); - return updateUserFlag(username, flag); - }); - const serverUpdate = toggleFlag - .debounce(500) - .flatMap(flag => { - const url = `/toggle-${urlMap[ flag ]}`; - const { - app: { user: username, csrfToken: _csrf }, - entities: { user: userMap } - } = getState(); - const user = userMap[username]; - const currentValue = user[ flag ]; - return postJSON$(url, { _csrf }) - .map(({ flag, value }) => { - if (currentValue === value) { - return null; - } - return updateUserFlag(username, flag); - }) - .filter(Boolean) - .catch(doActionOnError(() => { - return updateUserFlag(username, currentValue); - })); - }); - return Observable.merge(optimistic, serverUpdate); -} export default combineEpics( - updateUserFlagEpic, + backendUserUpdateEpic, + refetchChallengeMapEpic, + updateMyPortfolioEpic, updateUserEmailEpic, updateUserLangEpic ); diff --git a/common/app/routes/Settings/settings.less b/common/app/routes/Settings/settings.less index 41607d07a0..8fc27c8ebd 100644 --- a/common/app/routes/Settings/settings.less +++ b/common/app/routes/Settings/settings.less @@ -3,20 +3,46 @@ @skeleton-gray: #b0bdb7; -@keyframes pulsingOverlay { - 0% { - opacity: 0.5; - } - 50% { - opacity: 0.8; - } - 100% { - opacity: 0.5; +.night .@{ns}-container { + + .btn-group { + + label:disabled, label[disabled] { + border: 1px solid #999; + background-color: #999; + color: #333; + } } } .@{ns}-container { .center(@value: @container-xl, @padding: @grid-gutter-width); + + button:disabled, button[disabled] { + border: 1px solid #999; + background-color: #999; + color: #333; + } + + .panel { + background-color: #fff; + } + + .solution-list { + padding-left: 0px; + } + + .solution-list-item { + list-style: none; + display: flex; + justify-content: space-between; + align-items: center; + + p { + font-weight: bold; + margin-bottom: 0px; + } + } } .@{ns}-email-container { @@ -25,23 +51,41 @@ }) } -.@{ns}-skeleton { - background-color: #fff; - z-index: 10; - animation-name: pulsingOverlay; - animation-duration: 2.5s; - animation-timing-function: linear; - animation-iteration-count: infinite; - animation-direction: normal; +.inline-form-field { + display: flex; + align-items: center; + margin: 5px 0; - .placeholder-string { - background-color: @skeleton-gray; - box-shadow: 0px 0px 12px 6px @skeleton-gray; - color: @skeleton-gray; - } - .btn-link-social { - background-color: @skeleton-gray; - border-color: @skeleton-gray; - box-shadow: 0px 0px 12px 6px @skeleton-gray; + input, textarea { + background-color: #fff; + } +} +.edit-preview-tabs { + + li { + padding-bottom: 0px; + } +} + +.avatar-container { + display: flex; + justify-content: center; +} + +.portfolio-settings-intro { + display: flex; + justify-content: space-around; + + .p-intro { + font-size: 18px; + } +} + +.danger-zone-panel { + background-color: #fff; + + .panel-heading { + background-color: #880000; + color: #fff; } } diff --git a/common/app/routes/Settings/utils/buildUserProjectsMap.js b/common/app/routes/Settings/utils/buildUserProjectsMap.js new file mode 100644 index 0000000000..770099af38 --- /dev/null +++ b/common/app/routes/Settings/utils/buildUserProjectsMap.js @@ -0,0 +1,32 @@ +import { dasherize } from '../../../../../server/utils/index'; + +export const jsProjectSuperBlock = 'javascript-algorithms-and-data-structures'; + +export function buildUserProjectsMap(projectBlock, challengeMap) { + const { + challengeNameIdMap, + challenges, + superBlock + } = projectBlock; + return { + [superBlock]: challenges.reduce((solutions, current) => { + const dashedName = dasherize(current) + .replace('java-script', 'javascript') + .replace('metric-imperial', 'metricimperial'); + const completed = challengeMap[challengeNameIdMap[dashedName]]; + let solution = ''; + if (superBlock === jsProjectSuperBlock) { + solution = {}; + } + if (completed) { + solution = 'solution' in completed ? + completed.solution : + completed.files; + } + return { + ...solutions, + [current]: solution + }; + }, {}) + }; +} diff --git a/common/models/user.js b/common/models/user.js index 12b057eb72..135129c536 100644 --- a/common/models/user.js +++ b/common/models/user.js @@ -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'); diff --git a/common/models/user.json b/common/models/user.json index 6a7779c299..2a5ee0cd59 100644 --- a/common/models/user.json +++ b/common/models/user.json @@ -89,6 +89,10 @@ "type": "string", "default": "" }, + "about": { + "type": "string", + "default": "" + }, "name": { "type": "string", "default": "" @@ -139,11 +143,6 @@ "description": "Campers profile does not show challenges/certificates to the public", "default": false }, - "isAvailableForHire": { - "type": "boolean", - "description": "Camper is available for hire", - "default": false - }, "currentChallengeId": { "type": "string", "description": "The challenge last visited by the user", @@ -185,12 +184,12 @@ }, "isRespWebDesignCert": { "type": "boolean", - "description": "Camper is data visualization certified", + "description": "Camper is responsive web design certified", "default": false }, - "isNewDataVisCert": { + "is2018DataVisCert": { "type": "boolean", - "description": "Camper is responsive web design certified", + "description": "Camper is data visualization certified (2018)", "default": false }, "isFrontEndLibsCert": { @@ -243,6 +242,10 @@ ], "default": [] }, + "portfolio": { + "type": "array", + "default": [] + }, "rand": { "type": "number", "index": true diff --git a/common/resource/academicPolicy.js b/common/resource/academicPolicy.js new file mode 100644 index 0000000000..4efc832ee2 --- /dev/null +++ b/common/resource/academicPolicy.js @@ -0,0 +1,27 @@ +const policy = [ + 'Before you can claim a verified certificate, you must accept the ' + + 'Academic Honesty Policy below.', + 'I understand that plagiarism means copying someone else’s work and ' + + 'presenting the work as if it were my own, without clearly attributing ' + + 'the original author.', + 'I understand that plagiarism is an act of intellectual dishonesty, and ' + + 'that people usually get kicked out of university or fired from their ' + + 'jobs if they get caught plagiarizing.', + 'Aside from using open source libraries such as jQuery and Bootstrap, ' + + 'and short snippets of code which are clearly attributed to their ' + + 'original author, 100% of the code in my projects was written by me, or ' + + 'along with another camper with whom I was pair programming in real time.', + 'I pledge that I did not plagiarize any of my freeCodeCamp work. ' + + 'I understand that freeCodeCamp’s team will audit my projects ' + + 'to confirm this.', + 'In the situations where we discover instances of unambiguous plagiarism, ' + + 'we will replace the camper in question’s certification with a message ' + + 'that "Upon review, this account has been flagged for academic dishonesty."', + 'As an academic institution that grants achievement-based certifications, ' + + 'we take academic honesty very seriously. If you have any questions about ' + + 'this policy, or suspect that someone has violated it, you can email ' + + 'team@freecodecamp.org and we ' + + 'will investigate.' +]; + +export default policy; diff --git a/package-lock.json b/package-lock.json index 1abea10315..51d39364a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2914,6 +2914,17 @@ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz", "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=" }, + "clipboard": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-1.7.1.tgz", + "integrity": "sha1-Ng1taUbpmnof7zleQrqStem1oWs=", + "optional": true, + "requires": { + "good-listener": "1.2.2", + "select": "1.1.2", + "tiny-emitter": "2.0.2" + } + }, "clite": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/clite/-/clite-0.3.0.tgz", @@ -4028,6 +4039,11 @@ "resolved": "https://registry.npmjs.org/dashify/-/dashify-0.2.2.tgz", "integrity": "sha1-agdBWgHJH69KMuONnfunH2HLIP4=" }, + "date-fns": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.29.0.tgz", + "integrity": "sha512-lbTXWZ6M20cWH8N9S6afb0SBm6tMk+uUg6z3MqHPKE9atmsY3kJkTm8vKe93izJ2B2+q5MV990sM2CHgtAZaOw==" + }, "date-now": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", @@ -4185,6 +4201,12 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, + "delegate": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", + "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", + "optional": true + }, "denodeify": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/denodeify/-/denodeify-1.2.1.tgz", @@ -5434,6 +5456,11 @@ "strip-eof": "1.0.0" } }, + "exenv": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", + "integrity": "sha1-KueOhdmJQVhnCwPUe+wfA72Ru50=" + }, "exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", @@ -5575,6 +5602,13 @@ "@types/express": "4.0.39", "lodash": "4.17.4", "validator": "8.2.0" + }, + "dependencies": { + "validator": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-8.2.0.tgz", + "integrity": "sha512-Yw5wW34fSv5spzTXNkokD6S6/Oq92d8q/t14TqsS3fAiA1RYnxSFSIZ+CY3n6PGGRCq5HhJTSepQvFUS2QUDxA==" + } } }, "extend": { @@ -7488,6 +7522,15 @@ "sparkles": "1.0.0" } }, + "good-listener": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", + "integrity": "sha1-1TswzfkxPf+33JoNR3CWqm0UXFA=", + "optional": true, + "requires": { + "delegate": "3.2.0" + } + }, "google-auth-library": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-0.10.0.tgz", @@ -13543,6 +13586,14 @@ "plur": "1.0.0" } }, + "prismjs": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.11.0.tgz", + "integrity": "sha1-KXrvM+t5Qhv9sZJzpQkspRWXDSk=", + "requires": { + "clipboard": "1.7.1" + } + }, "private": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz", @@ -13980,6 +14031,17 @@ } } }, + "react-helmet": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-5.2.0.tgz", + "integrity": "sha1-qBgR3yExOm1VxfBYxK66XW89l6c=", + "requires": { + "deep-equal": "1.0.1", + "object-assign": "4.1.1", + "prop-types": "15.6.0", + "react-side-effect": "1.1.3" + } + }, "react-hot-api": { "version": "0.4.7", "resolved": "https://registry.npmjs.org/react-hot-api/-/react-hot-api-0.4.7.tgz", @@ -14089,6 +14151,15 @@ "prop-types": "15.6.0" } }, + "react-side-effect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-1.1.3.tgz", + "integrity": "sha1-USwlq+DewXKDTEAB7FxR4E1BvFw=", + "requires": { + "exenv": "1.2.2", + "shallowequal": "1.0.2" + } + }, "react-test-renderer": { "version": "15.6.2", "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-15.6.2.tgz", @@ -15001,6 +15072,12 @@ } } }, + "select": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", + "integrity": "sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0=", + "optional": true + }, "semver": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz", @@ -15214,6 +15291,11 @@ } } }, + "shallowequal": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.0.2.tgz", + "integrity": "sha512-zlVXeVUKvo+HEv1e2KQF/csyeMKx2oHvatQ9l6XjCUj3agvC8XGf6R9HvIPDSmp8FNPvx7b5kaEJTRi7CqxtEw==" + }, "shebang-command": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", @@ -17066,6 +17148,12 @@ "next-tick": "1.0.0" } }, + "tiny-emitter": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.0.2.tgz", + "integrity": "sha512-2NM0auVBGft5tee/OxP4PI3d8WItkDM+fPnaRAVo6xTDI2knbz9eC5ArWGqtGlYqiH3RU5yMpdyTTO7MguC4ow==", + "optional": true + }, "tmp": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.29.tgz", @@ -17844,9 +17932,9 @@ } }, "validator": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-8.2.0.tgz", - "integrity": "sha512-Yw5wW34fSv5spzTXNkokD6S6/Oq92d8q/t14TqsS3fAiA1RYnxSFSIZ+CY3n6PGGRCq5HhJTSepQvFUS2QUDxA==" + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-9.4.0.tgz", + "integrity": "sha512-ftkCYp/7HrGdybVCuwSje07POAd93ksZJpb5GVDBzm8SLKIm3QMJcZugb5dOJsONBoWhIXl0jtoGHTyou3DAgA==" }, "value-equal": { "version": "0.4.0", diff --git a/package.json b/package.json index 0777fed921..16dae80e16 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "cors": "^2.8.4", "csurf": "^1.8.3", "d3": "~3.5.17", + "date-fns": "^1.29.0", "debug": "^2.2.0", "dedent": "~0.7.0", "dotenv": "^4.0.0", @@ -105,6 +106,7 @@ "passport-oauth": "^1.0.0", "passport-twitter": "^1.0.3", "pmx": "~0.6.2", + "prismjs": "^1.11.0", "prop-types": "^15.5.10", "react": "^15.6.2", "react-addons-css-transition-group": "~15.4.2", @@ -114,6 +116,7 @@ "react-dom": "^15.6.2", "react-fontawesome": "^1.2.0", "react-freecodecamp-search": "^1.4.1", + "react-helmet": "^5.2.0", "react-images": "^0.5.1", "react-motion": "~0.4.2", "react-no-ssr": "^1.0.1", @@ -135,7 +138,7 @@ "snyk": "^1.68.1", "store": "git+https://github.com/berkeleytrue/store.js.git#feature/noop-server", "uuid": "^3.0.1", - "validator": "^8.2.0" + "validator": "^9.4.0" }, "devDependencies": { "adler32": "~0.1.7", diff --git a/public/css/loader.css b/public/css/loader.css new file mode 100644 index 0000000000..244aabf740 --- /dev/null +++ b/public/css/loader.css @@ -0,0 +1,74 @@ +.loader { + box-sizing: border-box; + display: flex; + flex: 0 1 auto; + flex-direction: column; + flex-grow: 1; + flex-shrink: 0; + flex-basis: 25%; + align-items: center; + justify-content: center; +} +@-webkit-keyframes ball-scale-ripple-multiple { + 0% { + -webkit-transform: scale(0.1); + transform: scale(0.1); + opacity: 1; + } + 70% { + -webkit-transform: scale(1); + transform: scale(1); + opacity: 0.7; + } + 100% { + opacity: 0.0; + } +} + +@keyframes ball-scale-ripple-multiple { + 0% { + -webkit-transform: scale(0.1); + transform: scale(0.1); + opacity: 1; + } + 70% { + -webkit-transform: scale(1); + transform: scale(1); + opacity: 0.7; + } + 100% { + opacity: 0.0; + } +} + +.ball-scale-ripple-multiple { + position: relative; + -webkit-transform: translateY(-25px); + transform: translateY(-25px); +} +.ball-scale-ripple-multiple > div:nth-child(0) { + -webkit-animation-delay: -0.8s; + animation-delay: -0.8s; +} +.ball-scale-ripple-multiple > div:nth-child(1) { + -webkit-animation-delay: -0.6s; + animation-delay: -0.6s; } +.ball-scale-ripple-multiple > div:nth-child(2) { + -webkit-animation-delay: -0.4s; + animation-delay: -0.4s; } +.ball-scale-ripple-multiple > div:nth-child(3) { + -webkit-animation-delay: -0.2s; + animation-delay: -0.2s; } +.ball-scale-ripple-multiple > div { + -webkit-animation-fill-mode: both; + animation-fill-mode: both; + position: absolute; + top: -42px; + left: -26px; + width: 100px; + height: 100px; + border-radius: 100%; + border: 2px solid #006400; + -webkit-animation: ball-scale-ripple-multiple 1.25s 0s infinite cubic-bezier(0.21, 0.53, 0.56, 0.8); + animation: ball-scale-ripple-multiple 1.25s 0s infinite cubic-bezier(0.21, 0.53, 0.56, 0.8); +} \ No newline at end of file diff --git a/public/css/prism.css b/public/css/prism.css new file mode 100644 index 0000000000..7671e44a1a --- /dev/null +++ b/public/css/prism.css @@ -0,0 +1,140 @@ +/* PrismJS 1.10.0 +http://prismjs.com/download.html?themes=prism&languages=markup+css+clike+javascript+jsx */ +/** + * prism.js default theme for JavaScript, CSS and HTML + * Based on dabblet (http://dabblet.com) + * @author Lea Verou + */ + +code[class*="language-"], +pre[class*="language-"] { + color: black; + background: none; + text-shadow: 0 1px white; + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection, +code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection { + text-shadow: none; + background: #b3d4fc; +} + +pre[class*="language-"]::selection, pre[class*="language-"] ::selection, +code[class*="language-"]::selection, code[class*="language-"] ::selection { + text-shadow: none; + background: #b3d4fc; +} + +@media print { + code[class*="language-"], + pre[class*="language-"] { + text-shadow: none; + } +} + +/* Code blocks */ +pre[class*="language-"] { + padding: 1em; + margin: .5em 0; + overflow: auto; +} + +:not(pre) > code[class*="language-"], +pre[class*="language-"] { + background: #f5f2f0; +} + +/* Inline code */ +:not(pre) > code[class*="language-"] { + padding: .1em; + border-radius: .3em; + white-space: normal; +} + +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: slategray; +} + +.token.punctuation { + color: #999; +} + +.namespace { + opacity: .7; +} + +.token.property, +.token.tag, +.token.boolean, +.token.number, +.token.constant, +.token.symbol, +.token.deleted { + color: #905; +} + +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: #690; +} + +.token.operator, +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string { + color: #a67f59; + background: hsla(0, 0%, 100%, .5); +} + +.token.atrule, +.token.attr-value, +.token.keyword { + color: #07a; +} + +.token.function { + color: #DD4A68; +} + +.token.regex, +.token.important, +.token.variable { + color: #e90; +} + +.token.important, +.token.bold { + font-weight: bold; +} +.token.italic { + font-style: italic; +} + +.token.entity { + cursor: help; +} + diff --git a/seed/challenges/01-responsive-web-design/claim-your-responsive-web-design-certificate.json b/seed/challenges/01-responsive-web-design/claim-your-responsive-web-design-certificate.json deleted file mode 100644 index 14b8182062..0000000000 --- a/seed/challenges/01-responsive-web-design/claim-your-responsive-web-design-certificate.json +++ /dev/null @@ -1,137 +0,0 @@ -{ - "name": "Claim Your Responsive Web Design Certificate", - "order": 13, - "time": "5 minutes", - "challenges": [ - { - "id": "587d78aa367417b2b2512aee", - "title": "Claim Your Responsive Web Design Certificate", - "description": [ - [ - "https://i.imgur.com/GjTPLxI.jpg", - "An image of our Responsive Web Design Certificate", - "This challenge will give you your verified Responsive Web Design Certificate. Before we issue your certificate, we must verify that you have completed all of our basic and intermediate algorithm scripting challenges, and all our responsive web design projects. You must also accept our Academic Honesty Pledge. Click the button below to start this process.", - "" - ], - [ - "https://i.imgur.com/uLPsUko.jpg", - "The definition of plagiarism: Plagiarism (noun) - copying someone else’s work and presenting it as your own without crediting them", - "By clicking below, you pledge that all of your submitted code A) is code you or your pair personally wrote, or B) comes from open source libraries like jQuery, or C) has been clearly attributed to its original authors. You also give us permission to audit your challenge solutions and revoke your certificate if we discover evidence of plagiarism.", - "#" - ], - [ - "https://i.imgur.com/cyRVnUa.jpg", - "An image of the text \"Responsive Web Design requirements\"", - "Let's confirm that you have completed all of our responsive web design projects. Click the button below to verify this.", - "#" - ], - [ - "https://i.imgur.com/Q5Za9U6.jpg", - "An image of the word \"Congratulations\"", - "Congratulations! We've added your Responsive Web Design Certificate to your portfolio page. Unless you choose to hide your solutions, this certificate will remain publicly visible and verifiable.", - "" - ] - ], - "challengeSeed": [ - { - "properties": [ - "isHonest", - "isRespWebDesignCert" - ], - "apis": [ - "/certificate/honest", - "/certificate/verify/responsive-web-design" - ], - "stepIndex": [ - 1, - 2 - ] - } - ], - "tests": [ - { - "id": "587d78af367417b2b2512b03", - "title": "Build a Survey Form" - }, - { - "id": "bd7158d8c442eddfaeb5bd18", - "title": "Build a Tribute Page" - }, - { - "id": "587d78af367417b2b2512b04", - "title": "Build a Product Landing Page" - }, - { - "id": "587d78b0367417b2b2512b05", - "title": "Build a Technical Documentation Page" - }, - { - "id": "bd7158d8c242eddfaeb5bd13", - "title": "Build a Personal Portfolio Webpage" - } - ], - "type": "Waypoint", - "challengeType": 7, - "translations": { - "es": { - "title": "Reclama tu certificado de Desarrollo de interfaces", - "description": [ - [ - "https://i.imgur.com/GjTPLxI.jpg", - "An image of our Responsive Web Design Certificate", - "This challenge will give you your verified Responsive Web Design Certificate. Before we issue your certificate, we must verify that you have completed all of our responsive web design projects. You must also accept our Academic Honesty Pledge. Click the button below to start this process.", - "" - ], - [ - "https://i.imgur.com/HArFfMN.jpg", - "Plagio (nombre): acción y efecto de plagiar. Plagiar (verbo) - copiar en lo sustancial obras ajenas, dándolas como propias.", - "Al pulsar el botón siguiente, juras que todo el código en tus soluciones a los desafíos A) es código que tú o tu compañero escribieron personalmente, o B) proviene de librerías de código abierto como jQuery, o C) ha sido claramente atribuido a sus autores originales. También nos otorgas el permiso para auditar tus soluciones a los desafíos y revocar tu certificado si encontramos evidencia de plagio.", - "#" - ], - [ - "https://i.imgur.com/cyRVnUa.jpg", - "An image of the text \"Responsive Web Design requirements\"", - "Let's confirm that you have completed all of our responsive web design projects. Click the button below to verify this.", - "#" - ], - [ - "https://i.imgur.com/16SIhHO.jpg", - "Una imagen de la palabra \"Congratulations\"", - "¡Felicitaciones! Hemos agregado tu certificado de Desarrollo de interfaces a tu portafolio. A menos que elijas no mostrar tus soluciones, este certificado será públicamente visible y verificable.", - "" - ] - ] - }, - "pt-br": { - "title": "Solicite seu Certificado de Design Responsivo", - "description": [ - [ - "https://i.imgur.com/k8btNUB.jpg", - "Uma Imagem do Nosso Certificado de Design Responsivo", - "Este desafio lhe dará seu certificado verificado de Design Responsivo. Antes de emitir o seu certificado, devemos verificar se você completou todos os nossos desafios básicos e intermediários de algoritmos e todos os nossos projetos básicos, intermediários e avançados de desenvolvimento. Você também deve aceitar nosso compromisso de honestidade acadêmica. Clique no botão abaixo para iniciar este processo.", - "" - ], - [ - "https://i.imgur.com/uLPsUko.jpg", - "A definição de plágio: Plágio (substantivo) - copiar o trabalho de outra pessoa e apresentá-lo como seu sem creditar o autor", - "Ao clicar abaixo, você promete que todo o seu código enviado A) foi escrito por você ou o seu par pessoalmente, ou B) vem de bibliotecas de código aberto como o jQuery ou C) foi claramente atribuído a seus autores originais. Você também nos dá permissão para auditar suas soluções de desafios e revogar o seu certificado se descobrirmos evidências de plágio.", - "#" - ], - [ - "https://i.imgur.com/UedoV2G.jpg", - "Uma imagem do texto \"Requisitos do Certificado de Desenvolvimento Front-End\"", - "Vamos confirmar que você completou todos os nossos desafios de algoritmos básicos e intermediários e todos os nossos projetos básicos, intermediários e avançados de desenvolvimento. Clique no botão abaixo para verificar.", - "#" - ], - [ - "https://i.imgur.com/Q5Za9U6.jpg", - "Uma imagem da palavra \"Parabéns\"", - "Parabéns! Adicionamos o seu Certificado de Design Responsivo à sua página de portfólio. A menos que você opte por esconder suas soluções, este certificado permanecerá publicamente visível e verificável", - "" - ] - ] - } - } - } - ] -} \ No newline at end of file diff --git a/seed/challenges/03-front-end-libraries/claim-your-front-end-libraries-certificate.json b/seed/challenges/03-front-end-libraries/claim-your-front-end-libraries-certificate.json deleted file mode 100644 index bb7f027e75..0000000000 --- a/seed/challenges/03-front-end-libraries/claim-your-front-end-libraries-certificate.json +++ /dev/null @@ -1,137 +0,0 @@ -{ - "name": "Claim Your Front End Libraries Certificate", - "order": 13, - "time": "5 minutes", - "challenges": [ - { - "id": "587d7dbb367417b2b2512bad", - "title": "Claim Your Front End Libraries Certificate", - "description": [ - [ - "https://i.imgur.com/vOtZumH.jpg", - "An image of our Front End Libraries Certificate", - "This challenge will give you your verified Front End Libraries Certificate. Before we issue your certificate, we must verify that you have completed all of our front end libraries projects. You must also accept our Academic Honesty Pledge. Click the button below to start this process.", - "" - ], - [ - "https://i.imgur.com/uLPsUko.jpg", - "The definition of plagiarism: Plagiarism (noun) - copying someone else’s work and presenting it as your own without crediting them", - "By clicking below, you pledge that all of your submitted code A) is code you or your pair personally wrote, or B) comes from open source libraries like jQuery, or C) has been clearly attributed to its original authors. You also give us permission to audit your challenge solutions and revoke your certificate if we discover evidence of plagiarism.", - "#" - ], - [ - "https://i.imgur.com/GJeTCMS.jpg", - "An image of the text \"Front End Libraries Certificate requirements\"", - "Let's confirm that you have completed all of our front end libraries projects. Click the button below to verify this.", - "#" - ], - [ - "https://i.imgur.com/Q5Za9U6.jpg", - "An image of the word \"Congratulations\"", - "Congratulations! We've added your Front End Libraries Certificate to your portfolio page. Unless you choose to hide your solutions, this certificate will remain publicly visible and verifiable.", - "" - ] - ], - "challengeSeed": [ - { - "properties": [ - "isHonest", - "isFrontEndLibsCert" - ], - "apis": [ - "/certificate/honest", - "/certificate/verify/front-end-libraries" - ], - "stepIndex": [ - 1, - 2 - ] - } - ], - "tests": [ - { - "id": "bd7158d8c442eddfaeb5bd0f", - "title": "Build a Pomodoro Clock" - }, - { - "id": "bd7158d8c442eddfaeb5bd17", - "title": "Build a JavaScript Calculator" - }, - { - "id": "587d7dbc367417b2b2512bae", - "title": "Build a Drum Machine" - }, - { - "id": "bd7157d8c242eddfaeb5bd13", - "title": "Build a Markdown Previewer" - }, - { - "id": "bd7158d8c442eddfaeb5bd13", - "title": "Build a Random Quote Machine" - } - ], - "type": "Waypoint", - "challengeType": 7, - "translations": { - "es": { - "title": "Reclama tu certificado de Desarrollo de interfaces", - "description": [ - [ - "https://i.imgur.com/k8btNUB.jpg", - "Una imagen que muestra nuestro certificado de Desarrollo de interfaces", - "Este desafío te otorga tu certificado autenticado de Desarrollo de interfaces. Antes de que podamos emitir tu certificado, debemos verificar que has completado todos los desafíos básicos e intermedios de diseño de algoritmos, y todos los proyectos básicos e intermedios de desarrollo de interfaces. También debes aceptar nuestro Juramento de honestidad académica. Pulsa el botón siguiente para iniciar este proceso.", - "" - ], - [ - "https://i.imgur.com/HArFfMN.jpg", - "Plagio (nombre): acción y efecto de plagiar. Plagiar (verbo) - copiar en lo sustancial obras ajenas, dándolas como propias.", - "Al pulsar el botón siguiente, juras que todo el código en tus soluciones a los desafíos A) es código que tú o tu compañero escribieron personalmente, o B) proviene de librerías de código abierto como jQuery, o C) ha sido claramente atribuido a sus autores originales. También nos otorgas el permiso para auditar tus soluciones a los desafíos y revocar tu certificado si encontramos evidencia de plagio.", - "#" - ], - [ - "https://i.imgur.com/14F2Van.jpg", - "Una imagen del texto \"Front End Development Certificate requirements\"", - "Confirmemos que has completado todos nuestros desafíos básicos e intermedios de diseño de algoritmos, y todos nuestros proyectos básicos e intermedios de desarrollo de interfaces. Pulsa el botón siguiente para hacer la verificación.", - "#" - ], - [ - "https://i.imgur.com/16SIhHO.jpg", - "Una imagen de la palabra \"Congratulations\"", - "¡Felicitaciones! Hemos agregado tu certificado de Desarrollo de interfaces a tu portafolio. A menos que elijas no mostrar tus soluciones, este certificado será públicamente visible y verificable.", - "" - ] - ] - }, - "pt-br": { - "title": "Solicite seu Certificado de Bibliotecas Front End", - "description": [ - [ - "https://i.imgur.com/vOtZumH.jpg", - "Uma imagem do nosso Certificado de Bibliotecas Front End", - "This challenge will give you your verified Front End Libraries Certificate. Before we issue your certificate, we must verify that you have completed all of our front end libraries projects. You must also accept our Academic Honesty Pledge. Click the button below to start this process.", - "" - ], - [ - "https://i.imgur.com/uLPsUko.jpg", - "A definição de plágio: Plágio (substantivo) - copiar o trabalho de outra pessoa e apresentá-lo como seu sem creditar o autor", - "Ao clicar abaixo, você declara que todo o seu código enviado A) foi escrito por você ou pelo seu par pessoalmente, ou B) vem de bibliotecas de código aberto como o jQuery ou C) foi claramente atribuído a seus autores originais. Você também nos dá permissão para auditar suas soluções de desafios e revogar o seu certificado se descobrirmos evidências de plágio.", - "#" - ], - [ - "https://i.imgur.com/UedoV2G.jpg", - "Uma imagem do texto \"Requisitos do Certificado de Desenvolvimento Front-End\"", - "Vamos confirmar que você completou todos os nossos desafios de algoritmos básicos e intermediários e todos os nossos projetos básicos, intermediários e avançados de desenvolvimento. Clique no botão abaixo para verificar.", - "#" - ], - [ - "https://i.imgur.com/Q5Za9U6.jpg", - "Uma imagem da palavra \"Parabéns\"", - "Parabéns! Adicionamos o seu Certificado de Bibliotecas Front End à sua página de portfólio. A menos que você opte por esconder suas soluções, este certificado permanecerá publicamente visível e verificável.", - "" - ] - ] - } - } - } - ] -} \ No newline at end of file diff --git a/seed/challenges/04-data-visualization/claim-your-data-visualization-certificate.json b/seed/challenges/04-data-visualization/claim-your-data-visualization-certificate.json deleted file mode 100644 index 8ad9520289..0000000000 --- a/seed/challenges/04-data-visualization/claim-your-data-visualization-certificate.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "name": "Claim Your Data Visualization Certificate", - "order": 13, - "time": "5 minutes", - "challenges": [ - { - "id": "587d7fa5367417b2b2512bbe", - "title": "Claim Your Data Visualization Certificate", - "description": [ - [ - "https://i.imgur.com/N8drT4I.jpg", - "An image of our Data Visualization Certificate", - "This challenge will give you your verified Data Visualization Certificate. Before we issue your certificate, we must verify that you have completed all of our data visualisation projects. You must also accept our Academic Honesty Pledge. Click the button below to start this process.", - "" - ], - [ - "https://i.imgur.com/uLPsUko.jpg", - "The definition of plagiarism: Plagiarism (noun) - copying someone else’s work and presenting it as your own without crediting them", - "By clicking below, you pledge that all of your submitted code A) is code you or your pair personally wrote, or B) comes from open source libraries like jQuery, or C) has been clearly attributed to its original authors. You also give us permission to audit your challenge solutions and revoke your certificate if we discover evidence of plagiarism.", - "#" - ], - [ - "https://i.imgur.com/BUaEvDo.jpg", - "An image of the text \"Data Visualization Certificate requirements\"", - "Let's confirm that you have completed data visualisation projects. Click the button below to verify this.", "#" - ], - [ - "https://i.imgur.com/Q5Za9U6.jpg", - "An image of the word \"Congratulations\"", - "Congratulations! We've added your Data Visualization Certificate to your portfolio page. Unless you choose to hide your solutions, this certificate will remain publicly visible and verifiable.", - "" - ] - ], - "challengeSeed": [ - { - "properties": [ - "isHonest", - "isDataVisCert" - ], - "apis": [ - "/certificate/honest", - "/certificate/verify/data-visualization" - ], - "stepIndex": [ - 1, - 2 - ] - } - ], - "tests": [ - { - "id": "587d7fa6367417b2b2512bc0", - "title": "Visualize Data with a Treemap Diagram" - }, - { - "id": "587d7fa6367417b2b2512bbf", - "title": "Visualize Data with a Choropleth Map" - }, - { - "id": "bd7188d8c242eddfaeb5bd13", - "title": "Visualize Data with a Heat Map" - }, - { - "id": "bd7178d8c242eddfaeb5bd13", - "title": "Visualize Data with a Scatterplot Graph" - }, - { - "id": "bd7168d8c242eddfaeb5bd13", - "title": "Visualize Data with a Bar Chart" - } - ], - "type": "Waypoint", - "challengeType": 7, - "translations": { - "es": { - "title": "Reclama tu certificado de Desarrollo de interfaces", - "description": [ - [ - "https://i.imgur.com/N8drT4I.jpg", - "Una imagen que muestra nuestro certificado de Desarrollo de interfaces", - "This challenge will give you your verified Data Visualization Certificate. Before we issue your certificate, we must verify that you have completed all of our data visualisation projects. You must also accept our Academic Honesty Pledge. Click the button below to start this process.", "" - ], - [ - "https://i.imgur.com/HArFfMN.jpg", - "Plagio (nombre): acción y efecto de plagiar. Plagiar (verbo) - copiar en lo sustancial obras ajenas, dándolas como propias.", - "Al pulsar el botón siguiente, juras que todo el código en tus soluciones a los desafíos A) es código que tú o tu compañero escribieron personalmente, o B) proviene de librerías de código abierto como jQuery, o C) ha sido claramente atribuido a sus autores originales. También nos otorgas el permiso para auditar tus soluciones a los desafíos y revocar tu certificado si encontramos evidencia de plagio.", - "#" - ], - [ - "https://i.imgur.com/BUaEvDo.jpg", - "An image of the text \"Data Visualization Certificate requirements\"", - "Let's confirm that you have completed data visualisation projects. Click the button below to verify this.", "Confirmemos que has completado todos nuestros desafíos básicos e intermedios de diseño de algoritmos, y todos nuestros proyectos básicos e intermedios de desarrollo de interfaces. Pulsa el botón siguiente para hacer la verificación.", - "#" - ], - [ - "https://i.imgur.com/16SIhHO.jpg", - "Una imagen de la palabra \"Congratulations\"", - "¡Felicitaciones! Hemos agregado tu certificado de Desarrollo de interfaces a tu portafolio. A menos que elijas no mostrar tus soluciones, este certificado será públicamente visible y verificable.", - "" - ] - ] - } - } - } - ] -} \ No newline at end of file diff --git a/seed/challenges/05-apis-and-microservices/claim-your-apis-and-microservices-certificate.json b/seed/challenges/05-apis-and-microservices/claim-your-apis-and-microservices-certificate.json deleted file mode 100644 index c9541e727a..0000000000 --- a/seed/challenges/05-apis-and-microservices/claim-your-apis-and-microservices-certificate.json +++ /dev/null @@ -1,108 +0,0 @@ -{ - "name": "Claim Your APIs and Microservices Certificate", - "order": 13, - "time": "5 minutes", - "challenges": [ - { - "id": "587d7fb3367417b2b2512bf9", - "title": "Claim Your APIs and Microservices Certificate", - "description": [ - [ - "https://i.imgur.com/gfH7j5B.jpg", - "An image of our APIs and Microservices Certificate", - "This challenge will give you your verified APIs and Microservices Certificate. Before we issue your certificate, we must verify that you have completed all of our apis and microservices projects. You must also accept our Academic Honesty Pledge. Click the button below to start this process.", - "" - ], - [ - "https://i.imgur.com/uLPsUko.jpg", - "The definition of plagiarism: Plagiarism (noun) - copying someone else’s work and presenting it as your own without crediting them", - "By clicking below, you pledge that all of your submitted code A) is code you or your pair personally wrote, or B) comes from open source libraries like jQuery, or C) has been clearly attributed to its original authors. You also give us permission to audit your challenge solutions and revoke your certificate if we discover evidence of plagiarism.", - "#" - ], - [ - "https://i.imgur.com/IBTfUzO.jpg", - "An image of the text \"APIs and Microservices Certificate requirements\"", - "Let's confirm that you have completed all of our apis and microservices projects. Click the button below to verify this.", - "#" - ], - [ - "https://i.imgur.com/Q5Za9U6.jpg", - "An image of the word \"Congratulations\"", - "Congratulations! We've added your APIs and Microservices Certificate to your portfolio page. Unless you choose to hide your solutions, this certificate will remain publicly visible and verifiable.", - "" - ] - ], - "challengeSeed": [ - { - "properties": [ - "isHonest", - "isApisMicroservicesCert" - ], - "apis": [ - "/certificate/honest", - "/certificate/verify/apis-microservices" - ], - "stepIndex": [ - 1, - 2 - ] - } - ], - "tests": [ - { - "id": "bd7158d8c443edefaeb5bdef", - "title": "Timestamp Microservice" - }, - { - "id": "bd7158d8c443edefaeb5bdff", - "title": "Request Header Parser Microservice" - }, - { - "id": "bd7158d8c443edefaeb5bd0e", - "title": "URL Shortener Microservice" - }, - { - "id": "bd7158d8c443edefaeb5bd0f", - "title": "File Metadata Microservice" - }, - { - "id": "bd7158d8c443edefaeb5bdee", - "title": "Exercise Tracker" - } - ], - "type": "Waypoint", - "challengeType": 7, - "translations": { - "es": { - "title": "Reclama tu certificado de Desarrollo de interfaces", - "description": [ - [ - "https://i.imgur.com/gfH7j5B.jpg", - "Una imagen que muestra nuestro certificado de Desarrollo de interfaces", - "This challenge will give you your verified APIs and Microservices Certificate. Before we issue your certificate, we must verify that you have completed all of our apis and microservices projects. You must also accept our Academic Honesty Pledge. Click the button below to start this process.", - "" - ], - [ - "https://i.imgur.com/HArFfMN.jpg", - "Plagio (nombre): acción y efecto de plagiar. Plagiar (verbo) - copiar en lo sustancial obras ajenas, dándolas como propias.", - "Al pulsar el botón siguiente, juras que todo el código en tus soluciones a los desafíos A) es código que tú o tu compañero escribieron personalmente, o B) proviene de librerías de código abierto como jQuery, o C) ha sido claramente atribuido a sus autores originales. También nos otorgas el permiso para auditar tus soluciones a los desafíos y revocar tu certificado si encontramos evidencia de plagio.", - "#" - ], - [ - "https://i.imgur.com/IBTfUzO.jpg", - "An image of the text \"APIs and Microservices Certificate requirements\"", - "Let's confirm that you have completed all of our apis and microservices projects. Click the button below to verify this.", - "#" - ], - [ - "https://i.imgur.com/16SIhHO.jpg", - "Una imagen de la palabra \"Congratulations\"", - "¡Felicitaciones! Hemos agregado tu certificado de Desarrollo de interfaces a tu portafolio. A menos que elijas no mostrar tus soluciones, este certificado será públicamente visible y verificable.", - "" - ] - ] - } - } - } - ] -} \ No newline at end of file diff --git a/seed/challenges/06-information-security-and-quality-assurance/claim-your-information-security-and-quality-assurance-certificate.json b/seed/challenges/06-information-security-and-quality-assurance/claim-your-information-security-and-quality-assurance-certificate.json deleted file mode 100644 index ba3526796c..0000000000 --- a/seed/challenges/06-information-security-and-quality-assurance/claim-your-information-security-and-quality-assurance-certificate.json +++ /dev/null @@ -1,108 +0,0 @@ -{ - "name": "Claim Your Information Security and Quality Assurance Certificate", - "order": 13, - "time": "5 minutes", - "challenges": [ - { - "id": "587d8247367417b2b2512c35", - "title": "Claim Your Information Security and Quality Assurance Certificate", - "description": [ - [ - "https://i.imgur.com/YhKzGLb.jpg", - "An image of our Information Security and Quality Assurance Certificate", - "This challenge will give you your verified Information Security and Quality Assurance Certificate. Before we issue your certificate, we must verify that you have completed all of our information security and quality assurance projects. You must also accept our Academic Honesty Pledge. Click the button below to start this process.", - "" - ], - [ - "https://i.imgur.com/uLPsUko.jpg", - "The definition of plagiarism: Plagiarism (noun) - copying someone else’s work and presenting it as your own without crediting them", - "By clicking below, you pledge that all of your submitted code A) is code you or your pair personally wrote, or B) comes from open source libraries like jQuery, or C) has been clearly attributed to its original authors. You also give us permission to audit your challenge solutions and revoke your certificate if we discover evidence of plagiarism.", - "#" - ], - [ - "https://i.imgur.com/TM4KGfb.jpg", - "An image of the text \"Information Security and Quality Assurance Certificate requirements\"", - "Let's confirm that you have completed all of our information security and quality assurance projects. Click the button below to verify this.", - "#" - ], - [ - "https://i.imgur.com/Q5Za9U6.jpg", - "An image of the word \"Congratulations\"", - "Congratulations! We've added your Information Security and Quality Assurance Certificate to your portfolio page. Unless you choose to hide your solutions, this certificate will remain publicly visible and verifiable.", - "" - ] - ], - "challengeSeed": [ - { - "properties": [ - "isHonest", - "isInfosecQaCert" - ], - "apis": [ - "/certificate/honest", - "/certificate/verify/information-security-quality-assurance" - ], - "stepIndex": [ - 1, - 2 - ] - } - ], - "tests": [ - { - "id": "587d8249367417b2b2512c42", - "title": "Issue Tracker" - }, - { - "id": "587d8249367417b2b2512c41", - "title": "Metric-Imperial Converter" - }, - { - "id": "587d824a367417b2b2512c43", - "title": "Personal Library" - }, - { - "id": "587d824a367417b2b2512c44", - "title": "Stock Price Checker" - }, - { - "id": "587d824a367417b2b2512c45", - "title": "Anonymous Message Board" - } - ], - "type": "Waypoint", - "challengeType": 7, - "translations": { - "es": { - "title": "Reclama tu certificado de Desarrollo de interfaces", - "description": [ - [ - "https://i.imgur.com/YhKzGLb.jpg", - "Una imagen que muestra nuestro certificado de Desarrollo de interfaces", - "This challenge will give you your verified Information Security and Quality Assurance Certificate. Before we issue your certificate, we must verify that you have completed all of our information security and quality assurance projects. You must also accept our Academic Honesty Pledge. Click the button below to start this process.", - "" - ], - [ - "https://i.imgur.com/HArFfMN.jpg", - "Plagio (nombre): acción y efecto de plagiar. Plagiar (verbo) - copiar en lo sustancial obras ajenas, dándolas como propias.", - "Al pulsar el botón siguiente, juras que todo el código en tus soluciones a los desafíos A) es código que tú o tu compañero escribieron personalmente, o B) proviene de librerías de código abierto como jQuery, o C) ha sido claramente atribuido a sus autores originales. También nos otorgas el permiso para auditar tus soluciones a los desafíos y revocar tu certificado si encontramos evidencia de plagio.", - "#" - ], - [ - "https://i.imgur.com/TM4KGfb.jpg", - "An image of the text \"Information Security and Quality Assurance Certificate requirements\"", - "Let's confirm that you have completed all of our information security and quality assurance projects. Click the button below to verify this.", - "#" - ], - [ - "https://i.imgur.com/16SIhHO.jpg", - "Una imagen de la palabra \"Congratulations\"", - "¡Felicitaciones! Hemos agregado tu certificado de Desarrollo de interfaces a tu portafolio. A menos que elijas no mostrar tus soluciones, este certificado será públicamente visible y verificable.", - "" - ] - ] - } - } - } - ] -} \ No newline at end of file diff --git a/seed/challenges/09-certificates/apis-and-microservices-certificate.json b/seed/challenges/09-certificates/apis-and-microservices-certificate.json new file mode 100644 index 0000000000..a881f98ed0 --- /dev/null +++ b/seed/challenges/09-certificates/apis-and-microservices-certificate.json @@ -0,0 +1,37 @@ +{ + "name": "API's and Microservices Certificate", + "order": 5, + "isPrivate": true, + "challenges": [ + { + "id": "561add10cb82ac38a17523bc", + "title": "API's and Microservices Certificate", + "challengeType": 7, + "description": [], + "challengeSeed": [], + "isPrivate": true, + "tests": [ + { + "id": "bd7158d8c443edefaeb5bdef", + "title": "Timestamp Microservice" + }, + { + "id": "bd7158d8c443edefaeb5bdff", + "title": "Request Header Parser Microservice" + }, + { + "id": "bd7158d8c443edefaeb5bd0e", + "title": "URL Shortener Microservice" + }, + { + "id": "bd7158d8c443edefaeb5bdee", + "title": "Exercise Tracker" + }, + { + "id": "bd7158d8c443edefaeb5bd0f", + "title": "File Metadata Microservice" + } + ] + } + ] +} \ No newline at end of file diff --git a/seed/challenges/09-certificates/data-visualization-certificate.json b/seed/challenges/09-certificates/data-visualization-certificate.json new file mode 100644 index 0000000000..d67f2e4559 --- /dev/null +++ b/seed/challenges/09-certificates/data-visualization-certificate.json @@ -0,0 +1,37 @@ +{ + "name": "Data Visualization Certificate", + "order": 4, + "isPrivate": true, + "challenges": [ + { + "id": "5a553ca864b52e1d8bceea14", + "title": "Data Visualization Certificate", + "challengeType": 7, + "description": [], + "challengeSeed": [], + "isPrivate": true, + "tests": [ + { + "id": "bd7168d8c242eddfaeb5bd13", + "title": "Visualize Data with a Bar Chart" + }, + { + "id": "bd7178d8c242eddfaeb5bd13", + "title": "Visualize Data with a Scatterplot Graph" + }, + { + "id": "bd7188d8c242eddfaeb5bd13", + "title": "Visualize Data with a Heat Map" + }, + { + "id": "587d7fa6367417b2b2512bbf", + "title": "Visualize Data with a Choropleth Map" + }, + { + "id": "587d7fa6367417b2b2512bc0", + "title": "Visualize Data with a Treemap Diagram" + } + ] + } + ] +} \ No newline at end of file diff --git a/seed/challenges/09-certificates/front-end-libraries-certificate.json b/seed/challenges/09-certificates/front-end-libraries-certificate.json new file mode 100644 index 0000000000..064bfa9b8f --- /dev/null +++ b/seed/challenges/09-certificates/front-end-libraries-certificate.json @@ -0,0 +1,37 @@ +{ + "name": "Front End Libraries Certificate", + "order": 3, + "isPrivate": true, + "challenges": [ + { + "id": "561acd10cb82ac38a17513bc", + "title": "Front End Libraries Certificate", + "challengeType": 7, + "description": [], + "challengeSeed": [], + "isPrivate": true, + "tests": [ + { + "id": "bd7158d8c442eddfaeb5bd13", + "title": "Build a Random Quote Machine" + }, + { + "id": "bd7157d8c242eddfaeb5bd13", + "title": "Build a Markdown Previewer" + }, + { + "id": "587d7dbc367417b2b2512bae", + "title": "Build a Drum Machine" + }, + { + "id": "bd7158d8c442eddfaeb5bd17", + "title": "Build a JavaScript Calculator" + }, + { + "id": "bd7158d8c442eddfaeb5bd0f", + "title": "Build a Pomodoro Clock" + } + ] + } + ] +} \ No newline at end of file diff --git a/seed/challenges/09-certificates/information-security-and-quality-assurance-certificate.json b/seed/challenges/09-certificates/information-security-and-quality-assurance-certificate.json new file mode 100644 index 0000000000..8bf0bfc457 --- /dev/null +++ b/seed/challenges/09-certificates/information-security-and-quality-assurance-certificate.json @@ -0,0 +1,37 @@ +{ + "name": "Information, Securtiy and Quality Assurance Certificate", + "order": 6, + "isPrivate": true, + "challenges": [ + { + "id": "561add10cb82ac38a17213bc", + "title": "Information, Securtiy and Quality Assurance Certificate", + "challengeType": 7, + "description": [], + "challengeSeed": [], + "isPrivate": true, + "tests": [ + { + "id": "587d8249367417b2b2512c42", + "title": "Issue Tracker" + }, + { + "id": "587d8249367417b2b2512c41", + "title": "Metric-Imperial Converter" + }, + { + "id": "587d824a367417b2b2512c43", + "title": "Personal Library" + }, + { + "id": "587d824a367417b2b2512c44", + "title": "Stock Price Checker" + }, + { + "id": "587d824a367417b2b2512c45", + "title": "Anonymous Message Board" + } + ] + } + ] +} \ No newline at end of file diff --git a/seed/challenges/09-certificates/javascript-algorithms-and-data-structures-certificate.json b/seed/challenges/09-certificates/javascript-algorithms-and-data-structures-certificate.json new file mode 100644 index 0000000000..59fb6a78cd --- /dev/null +++ b/seed/challenges/09-certificates/javascript-algorithms-and-data-structures-certificate.json @@ -0,0 +1,37 @@ +{ + "name": "JavaScript Algorithms and Data Structures Certificate", + "order": 2, + "isPrivate": true, + "challenges": [ + { + "id": "561abd10cb81ac38a17513bc", + "title": "JavaScript Algorithms and Data Structures Certificate", + "challengeType": 7, + "description": [], + "challengeSeed": [], + "isPrivate": true, + "tests": [ + { + "id": "aaa48de84e1ecc7c742e1124", + "title": "Palindrome Checker" + }, + { + "id": "a7f4d8f2483413a6ce226cac", + "title": "Roman Numeral Converter" + }, + { + "id": "56533eb9ac21ba0edf2244e2", + "title": "Caesars Cipher" + }, + { + "id": "aff0395860f5d3034dc0bfc9", + "title": "Telephone Number Validator" + }, + { + "id": "aa2e6f85cab2ab736c9a9b24", + "title": "Cash Register" + } + ] + } + ] +} \ No newline at end of file diff --git a/seed/challenges/09-certificates/responsive-web-design-certificate.json b/seed/challenges/09-certificates/responsive-web-design-certificate.json new file mode 100644 index 0000000000..bfb8a1d8d2 --- /dev/null +++ b/seed/challenges/09-certificates/responsive-web-design-certificate.json @@ -0,0 +1,37 @@ +{ + "name": "Responsive Web Design Certificate", + "order": 1, + "isPrivate": true, + "challenges": [ + { + "id": "561add10cb82ac38a17513bc", + "title": "Responsive Web Design Certificate", + "challengeType": 7, + "description": [], + "challengeSeed": [], + "isPrivate": true, + "tests": [ + { + "id": "bd7158d8c442eddfaeb5bd18", + "title": "Build a Tribute Page" + }, + { + "id": "587d78af367417b2b2512b03", + "title": "Build a Survey Form" + }, + { + "id": "587d78af367417b2b2512b04", + "title": "Build a Product Landing Page" + }, + { + "id": "587d78b0367417b2b2512b05", + "title": "Build a Technical Documentation Page" + }, + { + "id": "587d78b0367417b2b2512b06", + "title": "Build a Personal Portfolio Webpage" + } + ] + } + ] +} \ No newline at end of file diff --git a/seed/index.js b/seed/index.js index 24da38df1c..3932935d79 100644 --- a/seed/index.js +++ b/seed/index.js @@ -8,8 +8,8 @@ const _ = require('lodash'); const utils = require('../server/utils'); const getChallenges = require('./getChallenges'); const app = require('../server/server'); - const createDebugger = require('debug'); + const log = createDebugger('fcc:seed'); // force logger to always output // this may be brittle @@ -51,6 +51,7 @@ Observable.combineLatest( const message = challengeSpec.message; const required = challengeSpec.required || []; const template = challengeSpec.template; + const isPrivate = !!challengeSpec.isPrivate; log('parsed %s successfully', blockName); @@ -63,12 +64,13 @@ Observable.combineLatest( title: blockName, name: nameify(blockName), dashedName: dasherize(blockName), - superOrder: superOrder, - superBlock: superBlock, + superOrder, + superBlock, superBlockMessage: message, - order: order, - time: time, - isLocked: isLocked + order, + time, + isLocked, + isPrivate }; return createBlocks(block) @@ -110,6 +112,7 @@ Observable.combineLatest( challenge.isBeta = challenge.isBeta || isBeta; challenge.isComingSoon = challenge.isComingSoon || isComingSoon; challenge.isLocked = challenge.isLocked || isLocked; + challenge.isPrivate = challenge.isPrivate || isPrivate; challenge.time = challengeSpec.time; challenge.superOrder = superOrder; challenge.superBlock = superBlock diff --git a/server/boot/authentication.js b/server/boot/authentication.js index c40c864345..8409f824e0 100644 --- a/server/boot/authentication.js +++ b/server/boot/authentication.js @@ -7,9 +7,11 @@ import { check } from 'express-validator/check'; import { ifUserRedirectTo, + ifNoUserRedirectTo, createValidatorErrorHandler } from '../utils/middleware'; import { wrapHandledError } from '../utils/create-handled-error.js'; +import { homeURL } from '../../common/utils/constantStrings.json'; const isSignUpDisabled = !!process.env.DISABLE_SIGNUP; // const debug = debugFactory('fcc:boot:auth'); @@ -22,6 +24,7 @@ module.exports = function enableAuthentication(app) { // loopback.io/doc/en/lb2/Authentication-authorization-and-permissions.html app.enableAuth(); const ifUserRedirect = ifUserRedirectTo(); + const ifNoUserRedirectHome = ifNoUserRedirectTo(homeURL); const router = app.loopback.Router(); const api = app.loopback.Router(); const { AuthToken, User } = app.models; @@ -79,7 +82,8 @@ module.exports = function enableAuthentication(app) { const { query: { email: encodedEmail, - token: authTokenId + token: authTokenId, + emailChange } = {} } = req; @@ -122,14 +126,16 @@ module.exports = function enableAuthentication(app) { ); } if (user.email !== email) { - throw wrapHandledError( - new Error('user email does not match'), - { - type: 'info', - message: defaultErrorMsg, - redirectTo: '/email-signin' - } - ); + if (!emailChange || (emailChange && user.newEmail !== email)) { + throw wrapHandledError( + new Error('user email does not match'), + { + type: 'info', + message: defaultErrorMsg, + redirectTo: '/email-signin' + } + ); + } } return authToken.validate$() .map(isValid => { @@ -185,6 +191,13 @@ module.exports = function enableAuthentication(app) { getPasswordlessAuth ); + router.get( + '/passwordless-change', + ifNoUserRedirectHome, + passwordlessGetValidators, + getPasswordlessAuth + ); + const passwordlessPostValidators = [ check('email') .isEmail() diff --git a/server/boot/certificate.js b/server/boot/certificate.js index 0bb7f9169b..ce4162f614 100644 --- a/server/boot/certificate.js +++ b/server/boot/certificate.js @@ -7,28 +7,31 @@ import debug from 'debug'; import { isEmail } from 'validator'; import { - ifNoUser401, - ifNoUserSend + ifNoUser401 } from '../utils/middleware'; import { observeQuery } from '../utils/rx'; import { + // legacy + frontEndChallengeId, + backEndChallengeId, + dataVisId, + + // modern respWebDesignId, frontEndLibsId, + dataVis2018Id, jsAlgoDataStructId, - frontEndChallengeId, - dataVisId, apisMicroservicesId, - backEndChallengeId, infosecQaId } from '../utils/constantStrings.json'; - import { completeCommitment$ } from '../utils/commit'; import certTypes from '../utils/certTypes.json'; +import superBlockCertTypeMap from '../utils/superBlockCertTypeMap'; const log = debug('fcc:certification'); const renderCertifedEmail = loopback.template(path.join( @@ -38,12 +41,9 @@ const renderCertifedEmail = loopback.template(path.join( 'emails', 'certified.ejs' )); -const sendMessageToNonUser = ifNoUserSend( - 'must be logged in to complete.' -); function isCertified(ids, challengeMap = {}) { - return _.every(ids, ({ id }) => challengeMap[id]); + return _.every(ids, ({ id }) => _.has(challengeMap, id)); } function getIdsForCert$(id, Challenge) { @@ -120,12 +120,16 @@ export default function certificate(app) { const { Email, Challenge } = app.models; const certTypeIds = { + // legacy [certTypes.frontEnd]: getIdsForCert$(frontEndChallengeId, Challenge), [certTypes.backEnd]: getIdsForCert$(backEndChallengeId, Challenge), + [certTypes.dataVis]: getIdsForCert$(dataVisId, Challenge), + + // modern [certTypes.respWebDesign]: getIdsForCert$(respWebDesignId, Challenge), [certTypes.frontEndLibs]: getIdsForCert$(frontEndLibsId, Challenge), + [certTypes.dataVis2018]: getIdsForCert$(dataVis2018Id, Challenge), [certTypes.jsAlgoDataStruct]: getIdsForCert$(jsAlgoDataStructId, Challenge), - [certTypes.dataVis]: getIdsForCert$(dataVisId, Challenge), [certTypes.apisMicroservices]: getIdsForCert$( apisMicroservicesId, Challenge @@ -133,78 +137,65 @@ export default function certificate(app) { [certTypes.infosecQa]: getIdsForCert$(infosecQaId, Challenge) }; - router.post( - '/certificate/verify/front-end', - ifNoUser401, - verifyCert.bind(null, certTypes.frontEnd) - ); + const superBlocks = Object.keys(superBlockCertTypeMap); router.post( - '/certificate/verify/back-end', + '/certificate/verify', ifNoUser401, - verifyCert.bind(null, certTypes.backEnd) - ); - - router.post( - '/certificate/verify/responsive-web-design', - ifNoUser401, - verifyCert.bind(null, certTypes.respWebDesign) - ); - - router.post( - '/certificate/verify/front-end-libraries', - ifNoUser401, - verifyCert.bind(null, certTypes.frontEndLibs) - ); - - router.post( - '/certificate/verify/javascript-algorithms-data-structures', - ifNoUser401, - verifyCert.bind(null, certTypes.jsAlgoDataStruct) - ); - - router.post( - '/certificate/verify/data-visualization', - ifNoUser401, - verifyCert.bind(null, certTypes.dataVis) - ); - - router.post( - '/certificate/verify/apis-microservices', - ifNoUser401, - verifyCert.bind(null, certTypes.apisMicroservices) - ); - - router.post( - '/certificate/verify/information-security-quality-assurance', - ifNoUser401, - verifyCert.bind(null, certTypes.infosecQa) - ); - - router.post( - '/certificate/honest', - sendMessageToNonUser, - postHonest + ifNoSuperBlock404, + verifyCert ); app.use(router); - function verifyCert(certType, req, res, next) { - const { user } = req; + const noNameMessage = dedent` + We need your name so we can put it on your certificate. + Add your name to your account settings and click the save button. + Then we can issue your certificate. + `; + + const notCertifiedMessage = name => dedent` + it looks like you have not completed the neccessary steps. + Please complete the required challenges to claim the + ${name} + `; + + const alreadyClaimedMessage = name => dedent` + It looks like you already have claimed the ${name} + `; + + const successMessage = (username, name) => dedent` + @${username}, you have sucessfully claimed + the ${name}! + Congratulations on behalf of the freeCodeCamp team! + `; + + function verifyCert(req, res, next) { + const { body: { superBlock }, user } = req; + + let certType = superBlockCertTypeMap[superBlock]; + log(certType); + if (certType === 'isDataVisCert') { + certType = 'is2018DataVisCert'; + log(certType); + } return user.getChallengeMap$() - .flatMap(() => certTypeIds[certType]) - .flatMap(challenge => { + .flatMap(() => certTypeIds[certType]) + .flatMap(challenge => { const { id, tests, name, challengeType } = challenge; - if ( - user[certType] || - !isCertified(tests, user.challengeMap) - ) { - return Observable.just(false); + if (user[certType]) { + return Observable.just(alreadyClaimedMessage(name)); + } + if (!user[certType] && !isCertified(tests, user.challengeMap)) { + return Observable.just(notCertifiedMessage(name)); + } + if (!user.name) { + return Observable.just(noNameMessage); } const updateData = { $set: { @@ -232,49 +223,32 @@ export default function certificate(app) { sendCertifiedEmail(user, Email.send$), ({ count }, pledgeOrMessage) => ({ count, pledgeOrMessage }) ) - .map( + .map( ({ count, pledgeOrMessage }) => { if (typeof pledgeOrMessage === 'string') { log(pledgeOrMessage); } log(`${count} documents updated`); - return true; + return successMessage(user.username, name); } ); - }) + }) .subscribe( - (didCertify) => { - if (didCertify) { - // Check if they have a name set - if (user.name === '') { - return res.status(200).send( - dedent` - We need your name so we can put it on your certificate. - Add your - name to your GitHub account, then go to your - settings - page and click the "update my portfolio from GitHub" - button. Then we can issue your certificate. - ` - ); - } - return res.status(200).send(true); - } - return res.status(200).send( - dedent` - Looks like you have not completed the neccessary steps. - Please return to the challenge map. - ` - ); + (message) => { + return res.status(200).json({ + message, + success: message.includes('Congratulations') + }); }, next ); } - function postHonest(req, res, next) { - return req.user.update$({ $set: { isHonest: true } }).subscribe( - () => res.status(200).send(true), - next - ); + function ifNoSuperBlock404(req, res, next) { + const { superBlock } = req.body; + if (superBlock && superBlocks.includes(superBlock)) { + return next(); + } + return res.status(404).end(); } } diff --git a/server/boot/settings.js b/server/boot/settings.js index fb4a12467a..f1056f48a8 100644 --- a/server/boot/settings.js +++ b/server/boot/settings.js @@ -10,7 +10,7 @@ import { alertTypes } from '../../common/utils/flash.js'; export default function settingsController(app) { const api = app.loopback.Router(); - const toggleUserFlag = flag => (req, res, next) => { + const toggleUserFlag = (flag, req, res, next) => { const { user } = req; const currentValue = user[ flag ]; return user @@ -24,6 +24,15 @@ export default function settingsController(app) { ); }; + function refetchChallengeMap(req, res, next) { + const { user } = req; + return user.requestChallengeMap() + .subscribe( + challengeMap => res.json({ challengeMap }), + next + ); + } + const updateMyEmailValidators = [ check('email') .isEmail() @@ -39,14 +48,6 @@ export default function settingsController(app) { ); } - api.post( - '/update-my-email', - ifNoUser401, - updateMyEmailValidators, - createValidatorErrorHandler(alertTypes.danger), - updateMyEmail - ); - function updateMyLang(req, res, next) { const { user, body: { lang } = {} } = req; const langName = supportedLanguages[lang]; @@ -87,6 +88,94 @@ export default function settingsController(app) { ); } + const updateMyThemeValidators = [ + check('theme') + .isIn(Object.keys(themes)) + .withMessage('Theme is invalid.') + ]; + + function updateMyTheme(req, res, next) { + const { body: { theme } } = req; + if (req.user.theme === theme) { + return res.sendFlash(alertTypes.info, 'Theme already set'); + } + return req.user.updateTheme(theme) + .then( + () => res.sendFlash(alertTypes.info, 'Your theme has been updated'), + next + ); + } + + function updateFlags(req, res, next) { + const { user, body: { values } } = req; + const keys = Object.keys(values); + if ( + keys.length === 1 && + typeof keys[0] === 'boolean' + ) { + return toggleUserFlag(keys[0], req, res, next); + } + return user.requestUpdateFlags(values) + .subscribe( + message => res.json({ message }), + next + ); + } + + function updateMyPortfolio(req, res, next) { + const { + user, + body: { portfolio } + } = req; + // if we only have one key, it should be the id + // user cannot send only one key to this route + // other than to remove a portfolio item + const requestDelete = Object.keys(portfolio).length === 1; + return user.updateMyPortfolio(portfolio, requestDelete) + .subscribe( + message => res.json({ message }), + next + ); + } + + function updateMyProjects(req, res, next) { + const { + user, + body: { projects: project } + } = req; + return user.updateMyProjects(project) + .subscribe( + message => res.json({ message }), + next + ); + } + + function updateMyUsername(req, res, next) { + const { user, body: { username } } = req; + return user.updateMyUsername(username) + .subscribe( + message => res.json({ message }), + next + ); + } + + api.post( + '/refetch-user-challenge-map', + ifNoUser401, + refetchChallengeMap + ); + api.post( + '/update-flags', + ifNoUser401, + updateFlags + ); + api.post( + '/update-my-email', + ifNoUser401, + updateMyEmailValidators, + createValidatorErrorHandler(alertTypes.danger), + updateMyEmail + ); api.post( '/update-my-current-challenge', ifNoUser401, @@ -94,23 +183,21 @@ export default function settingsController(app) { createValidatorErrorHandler(alertTypes.danger), updateMyCurrentChallenge ); - - const updateMyThemeValidators = [ - check('theme') - .isIn(Object.keys(themes)) - .withMessage('Theme is invalid.') - ]; - function updateMyTheme(req, res, next) { - const { body: { theme } } = req; - if (req.user.theme === theme) { - return res.sendFlash(alertTypes.info, 'Theme already set'); - } - return req.user.updateTheme(theme) - .then( - () => res.sendFlash(alertTypes.info, 'Your theme has been updated'), - next - ); - } + api.post( + '/update-my-lang', + ifNoUser401, + updateMyLang + ); + api.post( + '/update-my-portfolio', + ifNoUser401, + updateMyPortfolio + ); + api.post( + '/update-my-projects', + ifNoUser401, + updateMyProjects + ); api.post( '/update-my-theme', ifNoUser401, @@ -118,36 +205,10 @@ export default function settingsController(app) { createValidatorErrorHandler(alertTypes.danger), updateMyTheme ); - api.post( - '/toggle-available-for-hire', + '/update-my-username', ifNoUser401, - toggleUserFlag('isAvailableForHire') - ); - api.post( - '/toggle-lockdown', - ifNoUser401, - toggleUserFlag('isLocked') - ); - api.post( - '/toggle-announcement-email', - ifNoUser401, - toggleUserFlag('sendMonthlyEmail') - ); - api.post( - '/toggle-notification-email', - ifNoUser401, - toggleUserFlag('sendNotificationEmail') - ); - api.post( - '/toggle-quincy-email', - ifNoUser401, - toggleUserFlag('sendQuincyEmail') - ); - api.post( - '/update-my-lang', - ifNoUser401, - updateMyLang + updateMyUsername ); app.use(api); diff --git a/server/boot/user.js b/server/boot/user.js index 5df361b91c..ecddb568b8 100644 --- a/server/boot/user.js +++ b/server/boot/user.js @@ -2,6 +2,7 @@ import dedent from 'dedent'; import moment from 'moment-timezone'; import { Observable } from 'rx'; import debugFactory from 'debug'; +// import { curry } from 'lodash'; import emoji from 'node-emoji'; import { @@ -11,10 +12,12 @@ import { frontEndLibsId, jsAlgoDataStructId, dataVisId, + dataVis2018Id, apisMicroservicesId, infosecQaId } from '../utils/constantStrings.json'; import certTypes from '../utils/certTypes.json'; +import superBlockCertTypeMap from '../utils/superBlockCertTypeMap'; import { ifNoUser401, ifNoUserRedirectTo, @@ -32,6 +35,7 @@ import { getChallengeInfo, cachedMap } from '../utils/map'; const debug = debugFactory('fcc:boot:user'); const sendNonUserToMap = ifNoUserRedirectTo('/map'); +// const sendNonUserToMapWithMessage = curry(ifNoUserRedirectTo, 2)('/map'); const certIds = { [certTypes.frontEnd]: frontEndChallengeId, [certTypes.backEnd]: backEndChallengeId, @@ -39,6 +43,7 @@ const certIds = { [certTypes.frontEndLibs]: frontEndLibsId, [certTypes.jsAlgoDataStruct]: jsAlgoDataStructId, [certTypes.dataVis]: dataVisId, + [certTypes.dataVis2018]: dataVis2018Id, [certTypes.apisMicroservices]: apisMicroservicesId, [certTypes.infosecQa]: infosecQaId }; @@ -52,6 +57,7 @@ const certViews = { [certTypes.jsAlgoDataStruct]: 'certificate/javascript-algorithms-and-data-structures.jade', [certTypes.dataVis]: 'certificate/data-visualization.jade', + [certTypes.dataVis2018]: 'certificate/data-visualization-2018.jade', [certTypes.apisMicroservices]: 'certificate/apis-and-microservices.jade', [certTypes.infosecQa]: 'certificate/information-security-and-quality-assurance.jade' @@ -66,6 +72,7 @@ const certText = { [certTypes.jsAlgoDataStruct]: 'JavaScript Algorithms and Data Structures Certified', [certTypes.dataVis]: 'Data Visualization Certified', + [certTypes.dataVis2018]: 'Data Visualization Certified', [certTypes.apisMicroservices]: 'APIs and Microservices Certified', [certTypes.infosecQa]: 'Information Security and Quality Assurance Certified' }; @@ -160,11 +167,6 @@ module.exports = function(app) { ); } - router.get( - '/delete-my-account', - sendNonUserToMap, - showDelete - ); api.post( '/account/delete', ifNoUser401, @@ -175,17 +177,11 @@ module.exports = function(app) { sendNonUserToMap, getAccount ); - router.get( - '/reset-my-progress', - sendNonUserToMap, - showResetProgress - ); api.post( - '/account/resetprogress', + '/account/reset-progress', ifNoUser401, postResetProgress ); - api.get( '/account/unlink/:social', sendNonUserToMap, @@ -194,48 +190,8 @@ module.exports = function(app) { // Ensure these are the last routes! api.get( - '/:username/front-end-certification', - showCert.bind(null, certTypes.frontEnd) - ); - - api.get( - '/:username/back-end-certification', - showCert.bind(null, certTypes.backEnd) - ); - - api.get( - '/:username/full-stack-certification', - (req, res) => res.redirect(req.url.replace('full-stack', 'back-end')) - ); - - api.get( - '/:username/responsive-web-design-certification', - showCert.bind(null, certTypes.respWebDesign) - ); - - api.get( - '/:username/front-end-libraries-certification', - showCert.bind(null, certTypes.frontEndLibs) - ); - - api.get( - '/:username/javascript-algorithms-data-structures-certification', - showCert.bind(null, certTypes.jsAlgoDataStruct) - ); - - api.get( - '/:username/data-visualization-certification', - showCert.bind(null, certTypes.dataVis) - ); - - api.get( - '/:username/apis-microservices-certification', - showCert.bind(null, certTypes.apisMicroservices) - ); - - api.get( - '/:username/information-security-quality-assurance-certification', - showCert.bind(null, certTypes.infosecQa) + '/c/:username/:cert', + showCert ); router.get('/:username', showUserProfile); @@ -410,14 +366,14 @@ module.exports = function(app) { ); } - function showCert(certType, req, res, next) { - const username = req.params.username.toLowerCase(); + function showCert(req, res, next) { + let { username, cert } = req.params; + username = username.toLowerCase(); + const certType = superBlockCertTypeMap[cert]; const certId = certIds[certType]; return findUserByUsername$(username, { - isGithubCool: true, isCheater: true, isLocked: true, - isAvailableForHire: true, isFrontEndCert: true, isBackEndCert: true, isFullStackCert: true, @@ -425,6 +381,7 @@ module.exports = function(app) { isFrontEndLibsCert: true, isJsAlgoDataStructCert: true, isDataVisCert: true, + is2018DataVisCert: true, isApisMicroservicesCert: true, isInfosecQaCert: true, isHonest: true, @@ -434,6 +391,7 @@ module.exports = function(app) { }) .subscribe( user => { + const profile = `/${user.username}`; if (!user) { req.flash( 'danger', @@ -441,15 +399,16 @@ module.exports = function(app) { ); return res.redirect('/'); } - if (!user.isGithubCool) { + + if (!user.name) { req.flash( 'danger', dedent` - This user needs to link GitHub with their account + This user needs to add their name to their account in order for others to be able to view their certificate. ` ); - return res.redirect('back'); + return res.redirect(profile); } if (user.isCheater) { @@ -465,20 +424,20 @@ module.exports = function(app) { in order for others to be able to view their certificate. ` ); - return res.redirect('back'); + return res.redirect('/'); } + if (!user.isHonest) { req.flash( 'danger', - dedent` + dedent` ${username} has not yet agreed to our Academic Honesty Pledge. ` ); - return res.redirect('back'); + return res.redirect(profile); } if (user[certType]) { - const { challengeMap = {} } = user; const { completedDate = new Date() } = challengeMap[certId] || {}; @@ -495,51 +454,49 @@ module.exports = function(app) { 'danger', `Looks like user ${username} is not ${certText[certType]}` ); - return res.redirect('back'); + return res.redirect(profile); }, next ); } - function showDelete(req, res) { - return res.render('account/delete', { title: 'Delete My Account!' }); - } - function postDeleteAccount(req, res, next) { User.destroyById(req.user.id, function(err) { if (err) { return next(err); } req.logout(); - req.flash('info', 'You\'ve successfully deleted your account.'); - return res.redirect('/'); - }); - } - - function showResetProgress(req, res) { - return res.render('account/reset-progress', { title: 'Reset My Progress!' + req.flash('success', 'You have successfully deleted your account.'); + return res.status(200).end(); }); } function postResetProgress(req, res, next) { User.findById(req.user.id, function(err, user) { if (err) { return next(err); } - return user.updateAttributes({ + return user.update$({ progressTimestamps: [{ timestamp: Date.now() }], - currentStreak: 0, - longestStreak: 0, currentChallengeId: '', - isBackEndCert: false, - isFullStackCert: false, - isDataVisCert: false, + isRespWebDesignCert: false, + is2018DataVisCert: false, + isFrontEndLibsCert: false, + isJsAlgoDataStructCert: false, + isApisMicroservicesCert: false, + isInfosecQaCert: false, + is2018FullStackCert: false, isFrontEndCert: false, - challengeMap: {}, - challegesCompleted: [] - }, function(err) { - if (err) { return next(err); } - req.flash('info', 'You\'ve successfully reset your progress.'); - return res.redirect('/'); - }); + isBackEndCert: false, + isDataVisCert: false, + isFullStackCert: false, + challengeMap: {} + }) + .subscribe( + () => { + req.flash('success', 'You have successfully reset your progress.'); + return res.status(200).end(); + }, + next + ); }); } diff --git a/server/services/user.js b/server/services/user.js index 727ec32223..e191f74984 100644 --- a/server/services/user.js +++ b/server/services/user.js @@ -1,44 +1,10 @@ -import _ from 'lodash'; -// import debug from 'debug'; -// use old rxjs import { Observable } from 'rx'; +import _ from 'lodash'; -const publicUserProps = [ - 'id', - 'name', - 'username', - 'bio', - 'theme', - 'picture', - 'points', - 'email', - 'languageTag', - - 'isCheater', - 'isGithubCool', - - 'isLocked', - 'isAvailableForHire', - 'isFrontEndCert', - 'isBackEndCert', - 'isDataVisCert', - 'isFullStackCert', - 'isRespWebDesignCert', - 'isFrontEndLibsCert', - 'isJsAlgoDataStructCert', - 'isApisMicroservicesCert', - 'isInfosecQaCert', - - 'githubURL', - 'sendMonthlyEmail', - 'sendNotificationEmail', - 'sendQuincyEmail', - - 'currentChallengeId', - 'challengeMap' -]; - -// const log = debug('fcc:services:user'); +import { + userPropsForSession, + normaliseUserFields +} from '../utils/publicUserProps'; export default function userServices() { return { @@ -51,18 +17,23 @@ export default function userServices() { Observable.defer(() => user.getChallengeMap$()) .map(challengeMap => ({ ...user.toJSON(), challengeMap })) .map(user => ({ - entities: { - user: { - [user.username]: { - ..._.pick(user, publicUserProps), - isTwitter: !!user.twitter, - isLinkedIn: !!user.linkedIn + entities: { + user: { + [user.username]: { + ..._.pick(user, userPropsForSession), + isEmailVerified: !!user.emailVerified, + isGithub: !!user.githubURL, + isLinkedIn: !!user.linkedIn, + isTwitter: !!user.twitter, + isWebsite: !!user.website, + ...normaliseUserFields(user) + } } - } - }, - result: user.username - })) - ) + }, + result: user.username + }) + ) + ) .subscribe( user => cb(null, user), cb diff --git a/server/utils/certTypes.json b/server/utils/certTypes.json index 90cf58d1ff..9f29be3a27 100644 --- a/server/utils/certTypes.json +++ b/server/utils/certTypes.json @@ -1,11 +1,12 @@ { "frontEnd": "isFrontEndCert", "backEnd": "isBackEndCert", + "dataVis": "isDataVisCert", "fullStack": "isFullStackCert", "respWebDesign": "isRespWebDesignCert", "frontEndLibs": "isFrontEndLibsCert", + "dataVis2018": "is2018DataVisCert", "jsAlgoDataStruct": "isJsAlgoDataStructCert", - "dataVis": "isDataVisCert", "apisMicroservices": "isApisMicroservicesCert", "infosecQa": "isInfosecQaCert" -} +} \ No newline at end of file diff --git a/server/utils/constantStrings.json b/server/utils/constantStrings.json index 9163a1a3b5..78d72a5a01 100644 --- a/server/utils/constantStrings.json +++ b/server/utils/constantStrings.json @@ -1,11 +1,14 @@ { "gitHubUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1521.3 Safari/537.36", + "frontEndChallengeId": "561add10cb82ac38a17513be", "backEndChallengeId": "660add10cb82ac38a17513be", + "dataVisId": "561add10cb82ac39a17513bc", + "respWebDesignId": "561add10cb82ac38a17513bc", "frontEndLibsId": "561acd10cb82ac38a17513bc", + "dataVis2018Id": "5a553ca864b52e1d8bceea14", "jsAlgoDataStructId": "561abd10cb81ac38a17513bc", - "dataVisId": "561add10cb82ac39a17513bc", "apisMicroservicesId": "561add10cb82ac38a17523bc", "infosecQaId": "561add10cb82ac38a17213bc" } diff --git a/server/utils/index.js b/server/utils/index.js index 4b10716908..8591bfead8 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -20,3 +20,7 @@ export function unDasherize(name) { .replace(/[^a-zA-Z\d\s]/g, '') .trim(); } + +export function addPlaceholderImage(name) { + return `https://identicon.org?t=${name}&s=256`; +} diff --git a/server/utils/publicUserProps.js b/server/utils/publicUserProps.js new file mode 100644 index 0000000000..def0a6bbf8 --- /dev/null +++ b/server/utils/publicUserProps.js @@ -0,0 +1,85 @@ +import { isURL } from 'validator'; + +import { addPlaceholderImage } from '../utils'; +import { + prepUniqueDaysByHours, + calcCurrentStreak, + calcLongestStreak +} from '../utils/user-stats'; + +export const publicUserProps = [ + 'about', + 'calendar', + 'challengeMap', + 'githubURL', + 'isApisMicroservicesCert', + 'isBackEndCert', + 'isCheater', + 'isDataVisCert', + 'isFrontEndCert', + 'isFullStackCert', + 'isFrontEndLibsCert', + 'isGithubCool', + 'isHonest', + 'isInfosecQaCert', + 'isJsAlgoDataStructCert', + 'isLocked', + 'isRespWebDesignCert', + 'linkedin', + 'location', + 'name', + 'points', + 'portfolio', + 'projects', + 'streak', + 'twitter', + 'username', + 'website' +]; + +export const userPropsForSession = [ + ...publicUserProps, + 'currentChallengeId', + 'email', + 'id', + 'languageTag', + 'sendQuincyEmail', + 'theme' +]; + +export function normaliseUserFields(user) { + const about = user.bio && !user.about ? user.bio : user.about; + const picture = user.picture || addPlaceholderImage(user.username); + const twitter = user.twitter && isURL(user.twitter) ? + user.twitter : + user.twitter && `https://www.twitter.com/${user.twitter.replace(/^@/, '')}`; + return { about, picture, twitter }; +} + +export function getProgress(progressTimestamps, timezone = 'EST') { + const calendar = progressTimestamps + .map((objOrNum) => { + return typeof objOrNum === 'number' ? + objOrNum : + objOrNum.timestamp; + }) + .filter((timestamp) => { + return !!timestamp; + }) + .reduce((data, timeStamp) => { + data[Math.floor(timeStamp / 1000)] = 1; + return data; + }, {}); + const timestamps = progressTimestamps + .map(objOrNum => { + return typeof objOrNum === 'number' ? + objOrNum : + objOrNum.timestamp; + }); + const uniqueHours = prepUniqueDaysByHours(timestamps, timezone); + const streak = { + longest: calcLongestStreak(uniqueHours, timezone), + current: calcCurrentStreak(uniqueHours, timezone) + }; + return { calendar, streak }; +} diff --git a/server/utils/superBlockCertTypeMap.js b/server/utils/superBlockCertTypeMap.js new file mode 100644 index 0000000000..49c91e1aed --- /dev/null +++ b/server/utils/superBlockCertTypeMap.js @@ -0,0 +1,19 @@ +import certTypes from './certTypes.json'; + +const superBlockCertTypeMap = { + // legacy + 'front-end': certTypes.frontEnd, + 'back-end': certTypes.backEnd, + 'data-visualization': certTypes.dataVis, + 'full-stack': certTypes.fullStack, + + // modern + 'responsive-web-design': certTypes.respWebDesign, + 'javascript-algorithms-and-data-structures': certTypes.jsAlgoDataStruct, + 'front-end-libraries': certTypes.frontEndLibs, + 'data-visualization-2018': certTypes.dataVis2018, + 'apis-and-microservices': certTypes.apisMicroservices, + 'information-security-and-quality-assurance': certTypes.infosecQa +}; + +export default superBlockCertTypeMap; diff --git a/server/views/emails/user-request-update-email.ejs b/server/views/emails/user-request-update-email.ejs index c917fe46ae..d24a47c74f 100644 --- a/server/views/emails/user-request-update-email.ejs +++ b/server/views/emails/user-request-update-email.ejs @@ -1,8 +1,8 @@ Thank you for updating your contact details. -Please verify your email by following the link below: +Please verify your new email by following the link below: -<%= verifyHref %> +<%= host %>/passwordless-change?email=<%= loginEmail %>&token=<%= loginToken %>&emailChange=<%= emailChange %> Happy coding!