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

* fix(layout): Fix Settings layout in firefox

* chore(availableForHire): Remove available for hire setting

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

* fix(map): Fix undefined lang requested

* feat(settings): Expand Settings page functionality

* chore(pledge): Remove pledge from Settings

* fix(about): Adjust AboutSettings layout

* fix(portfolio): Improve PortfolioSettings layout

* fix(email): Improve EmailSettings layout

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

* fix(AHP): Format AHP

* fix(DangerZone): Adjust DangerZone layout

* fix(projectSettings): Change Button Copy

* fix(CertSettings): Fix certificate claim logic

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

View File

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

View File

@@ -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%;
}

View File

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

View File

@@ -0,0 +1,11 @@
import React from 'react';
function ButtonSpacer() {
return (
<div className='button-spacer' />
);
}
ButtonSpacer.displayName = 'ButtonSpacer';
export default ButtonSpacer;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Row, Col } from 'react-bootstrap';
function FullWidthRow({ children }) {
return (
<Row>
<Col sm={ 8 } smOffset={ 2 } xs={ 12 }>
{ children }
</Col>
</Row>
);
}
FullWidthRow.displayName = 'FullWidthRow';
FullWidthRow.propTypes = {
children: PropTypes.any
};
export default FullWidthRow;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import Helmet from 'react-helmet';
function Loader() {
return (
<div className='full-size'>
<Helmet>
<link href='/css/loader.css' rel='stylesheet' />
</Helmet>
<div className='loader full-size'>
<div className='ball-scale-ripple-multiple'>
<div/>
<div/>
<div/>
</div>
</div>
</div>
);
}
Loader.displayName = 'Loader';
export default Loader;

View File

@@ -0,0 +1,11 @@
import React from 'react';
function Spacer() {
return (
<div className='spacer' />
);
}
Spacer.displayName = 'Spacer';
export default Spacer;

View File

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

View File

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

View File

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

View File

@@ -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 (
<Link
style={{ textDecoration: 'none' }}
to={ onRouteUpdateEmail() }
>
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
className='btn-link-social'
>
<FA name='envelope' />
Update my Email
</Button>
</Link>
);
}
export default function EmailSettings({
email,
sendMonthlyEmail,
sendNotificationEmail,
sendQuincyEmail,
toggleMonthlyEmail,
toggleNotificationEmail,
toggleQuincyEmail
}) {
if (!email) {
return (
<div>
<Row>
<p className='large-p text-center'>
You don't have an email id associated to this account.
</p>
</Row>
<Row>
<UpdateEmailButton />
</Row>
</div>
);
}
return (
<div className={ `${ns}-email-container` }>
<Row>
<p className='large-p text-center'>
<em>{ email }</em>
</p>
</Row>
<Row>
<UpdateEmailButton />
</Row>
<Row>
<Col sm={ 8 }>
<p className='large-p'>
Send me announcement emails
<br />
(we'll send you these every Thursday)
</p>
</Col>
<Col sm={ 4 }>
<TB
name='monthly-email'
onChange={ toggleMonthlyEmail }
value={ sendMonthlyEmail }
/>
</Col>
</Row>
<Row>
<Col sm={ 8 }>
<p className='large-p'>
Send me notification emails
<br />
(these will pertain to your account)
</p>
</Col>
<Col sm={ 4 }>
<TB
name='notifications-email'
onChange={ toggleNotificationEmail }
value={ sendNotificationEmail }
/>
</Col>
</Row>
<Row>
<Col sm={ 8 }>
<p className='large-p'>
Send me Quincy's weekly email
<br />
(with new articles every Tuesday)
</p>
</Col>
<Col sm={ 4 }>
<TB
name='quincy-email'
onChange={ toggleQuincyEmail }
value={ sendQuincyEmail }
/>
</Col>
</Row>
</div>
);
}
EmailSettings.displayName = 'EmailSettings';
EmailSettings.propTypes = propTypes;

View File

@@ -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 (
<Row>
<Col xs={ 9 }>
<p className='large-p'>
Available for hire?
</p>
</Col>
<Col xs={ 3 }>
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
className={ className }
onClick={ toggle }
>
{ isAvailableForHire ? 'Yes' : 'No' }
</Button>
</Col>
</Row>
);
}
JobSettings.displayName = 'JobSettings';
JobSettings.propTypes = propTypes;

View File

@@ -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 (
<Row>
<Col xs={ 9 }>
<p className='large-p'>
Make all of my solutions private
<br />
(this disables your certificates)
</p>
</Col>
<Col xs={ 3 }>
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
className={ className }
onClick={ toggle }
>
{ isLocked ? 'On' : 'Off' }
</Button>
</Col>
</Row>
);
}
LockSettings.displayName = 'LockSettings';
LockSettings.propTypes = propTypes;

View File

@@ -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 = (
<span className='placeholder-string'>
placeholder text of 28 chars
</span>
);
const shortString = (
<span className='placeholder-string'>
placeholder
</span>
);
export default function SettingsSkeleton() {
return (
<div className={ `${ns}-container ${ns}-skeleton` }>
<Row>
<Col xs={ 12 }>
<Button
block={ true }
bsSize='lg'
className='btn-link-social'
>
{ placeholderString }
</Button>
<Button
block={ true }
bsSize='lg'
className='btn-link-social'
>
{ placeholderString }
</Button>
<Button
block={ true }
bsSize='lg'
className='btn-link-social'
>
{ placeholderString }
</Button>
</Col>
</Row>
<h1 className='text-center'>{ placeholderString }</h1>
<h2 className='text-center'>{ shortString }</h2>
<Row>
<Col xs={ 12 }>
<Button
block={ true }
bsSize='lg'
className='btn-link-social'
>
{ placeholderString }
</Button>
<Button
block={ true }
bsSize='lg'
className='btn-link-social'
>
{ placeholderString }
</Button>
<Button
block={ true }
bsSize='lg'
className='btn-link-social'
>
{ placeholderString }
</Button>
<Button
block={ true }
bsSize='lg'
className='btn-link-social'
>
{ placeholderString }
</Button>
</Col>
</Row>
<div className='spacer' />
<h2 className='text-center'>{ placeholderString }</h2>
<Row>
<Col xs={ 12 }>
<Button
block={ true }
bsSize='lg'
className='btn-link-social'
>
{ placeholderString }
</Button>
</Col>
</Row>
<div className='spacer' />
<h2 className='text-center'>{ placeholderString }</h2>
</div>
);
}

View File

@@ -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 <SettingsSkeleton />;
return <Loader />;
}
return (
<div className={ `${ns}-container` }>
<Row>
<Col xs={ 12 }>
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
className='btn-link-social'
href={ `/${username}` }
>
<FA name='user' />
Show me my public profile
</Button>
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
className='btn-link-social'
href={ '/signout' }
>
Sign me out of freeCodeCamp
</Button>
</Col>
</Row>
<h1 className='text-center'>Settings for your Account</h1>
<h2 className='text-center'>Actions</h2>
<Row>
<Col xs={ 12 }>
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
className='btn-link-social'
onClick={ () => toggleNightMode(username, currentTheme) }
>
Toggle Night Mode
</Button>
</Col>
</Row>
<Row>
<Col xs={ 12 }>
<SocialSettings
isGithubCool={ isGithubCool }
isLinkedIn={ isLinkedIn }
isTwitter={ isTwitter }
/>
</Col>
</Row>
<div className='spacer' />
<h2 className='text-center'>Account Settings</h2>
<Row>
<Col xs={ 12 }>
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
className='btn-link-social'
href='/commit'
>
Edit my pledge
</Button>
</Col>
</Row>
<div className='spacer' />
<h2 className='text-center'>Privacy Settings</h2>
<Row>
<Col
md={ 6 }
mdOffset={ 3 }
sm={ 8 }
smOffset={ 2 }
xs={ 12 }
<FullWidthRow>
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
className='btn-link-social'
href={ `/${username}` }
>
<LockedSettings
isLocked={ isLocked }
toggle={ toggleIsLocked }
/>
</Col>
</Row>
<div className='spacer' />
<h2 className='text-center'>Job Search Settings</h2>
<Row>
<Col
md={ 6 }
mdOffset={ 3 }
sm={ 8 }
smOffset={ 2 }
xs={ 12 }
<FA name='user' />
Show me my public profile
</Button>
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
className='btn-link-social'
href={ '/signout' }
>
<JobSettings
isAvailableForHire={ isAvailableForHire }
toggle={ toggleIsAvailableForHire }
/>
</Col>
</Row>
<div className='spacer' />
<h2 className='text-center'>Email Settings</h2>
<Row>
<Col
md={ 6 }
mdOffset={ 3 }
sm={ 8 }
smOffset={ 2 }
xs={ 12 }
>
<EmailSettings
email={ email }
sendMonthlyEmail={ sendMonthlyEmail }
sendNotificationEmail={ sendNotificationEmail }
sendQuincyEmail={ sendQuincyEmail }
toggleMonthlyEmail={ toggleMonthlyEmail }
toggleNotificationEmail={ toggleNotificationEmail }
toggleQuincyEmail={ toggleQuincyEmail }
/>
</Col>
</Row>
<div className='spacer' />
<h2 className='text-center'>Display challenges in:</h2>
<Row>
<Col
md={ 6 }
mdOffset={ 3 }
sm={ 8 }
smOffset={ 2 }
xs={ 12 }
>
<LanguageSettings />
</Col>
</Row>
<div className='spacer' />
<h2 className='text-center'>Danger Zone</h2>
<Row>
<Col
md={ 6 }
mdOffset={ 3 }
sm={ 8 }
smOffset={ 2 }
xs={ 12 }
>
<Button
block={ true }
bsSize='lg'
bsStyle='danger'
className='btn-link-social'
href='/delete-my-account'
>
Delete my freeCodeCamp account
</Button>
<Button
block={ true }
bsSize='lg'
bsStyle='danger'
className='btn-link-social'
href='/reset-my-progress'
>
Reset all of my progress and brownie points
</Button>
</Col>
</Row>
Sign me out of freeCodeCamp
</Button>
</FullWidthRow>
<h1 className='text-center'>{ `Account Settings for ${username}` }</h1>
<AboutSettings />
<Spacer />
<EmailSettings />
<Spacer />
<LanguageSettings />
<Spacer />
<InternetSettings />
<Spacer />
<PortfolioSettings />
<Spacer />
<CertificationSettings />
<Spacer />
<Honesty />
<Spacer />
<DangerZone />
</div>
);
}

View File

@@ -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 = [
<Button
block={ true }
bsSize='lg'
className='btn-link-social btn-github'
href='/link/github'
key='github'
>
<FA name='github' />
{ githubCopy }
</Button>
];
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((
<Button
block={ true }
bsSize='lg'
className={ socialClass }
href={ socialLink }
key={ identifier }
>
<FA name={ identifier } />
{ socialText }
</Button>
));
});
}
return (<div>{ buttons }</div>);
}
SocialSettings.displayName = 'SocialSettings';
SocialSettings.propTypes = propTypes;

View File

@@ -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 (
<div>
<FormFields
fields={ fields }
options={ options }
/>
</div>
);
}
renderPreview() {
const {
fields: {
picture: { value: picture },
name: { value: name },
location: { value: location },
about: { value: about }
},
points,
username
} = this.props;
return (
<Camper
about={ about }
location={ location }
name={ name }
picture={ picture }
points={ points }
username={ username }
/>
);
}
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 (
<div className='about-settings'>
<SectionHeader>
About Settings
</SectionHeader>
<UsernameSettings username={ username }/>
<FullWidthRow>
<Nav
activeKey={ view }
bsStyle='tabs'
className='edit-preview-tabs'
onSelect={k => this.handleTabSelect(k)}
>
<NavItem eventKey='edit' title='Edit Bio'>
Edit Bio
</NavItem>
<NavItem eventKey='preview' title='Preview Bio'>
Preview Bio
</NavItem>
</Nav>
</FullWidthRow>
<br />
<FullWidthRow>
<form id='camper-identity' onSubmit={ handleSubmit(this.handleSubmit) }>
{
this.show[view]()
}
<BlockSaveWrapper>
<BlockSaveButton disabled={ allPristine } />
</BlockSaveWrapper>
</form>
</FullWidthRow>
<Spacer />
<FullWidthRow>
<LockedSettings
isLocked={ isLocked }
toggleIsLocked={ toggleIsLocked }
/>
</FullWidthRow>
<FullWidthRow>
<ThemeSettings
currentTheme={ currentTheme }
toggleNightMode={ toggleTheme }
/>
</FullWidthRow>
</div>
);
}
}
AboutSettings.displayName = 'AboutSettings';
AboutSettings.propTypes = propTypes;
export default reduxForm(
{
form: 'account-settings',
fields: formFields,
validate: validator
},
mapStateToProps,
mapDispatchToProps
)(AboutSettings);

View File

@@ -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 (
<div>
<Row>
<Col className='avatar-container' xs={ 12 }>
<img
alt={ username + '\'s profile picture' }
className='avatar'
src={ picture }
/>
</Col>
</Row>
<br />
<SocialIcons />
<br/>
<h2 className='text-center username'>@{ username }</h2>
{ name && <p className='text-center name'>{ name }</p> }
{ location && <p className='text-center location'>{ location }</p> }
{ about && <p className='bio text-center'>{ about }</p> }
<p className='text-center points'>
{ `${points} ${pluralise('point', points > 1)}` }
</p>
<br/>
</div>
);
}
Camper.displayName = 'Camper';
Camper.propTypes = propTypes;
export default Camper;

View File

@@ -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 (
<div>
<SectionHeader>
Certification Settings
</SectionHeader>
<FullWidthRow>
<p>
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.
</p>
</FullWidthRow>
{
projects.map(({
projectBlockName,
challenges,
superBlock
}) => {
const isCertClaimed = blockNameIsCertMap[projectBlockName];
if (superBlock === jsProjectSuperBlock) {
return (
<JSAlgoAndDSForm
challenges={ challenges }
claimCert={ claimCert }
hardGoTo={ hardGoTo }
isCertClaimed={ isCertClaimed }
jsProjects={ userProjects[superBlock] }
key={ superBlock }
projectBlockName={ projectBlockName }
superBlock={ superBlock }
username={ username }
/>
);
}
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 (
<FullWidthRow key={superBlock}>
<h3 className='project-heading'>{ projectBlockName }</h3>
<Form
buttonText={ fullForm ? 'Claim Certificate' : 'Save Progress' }
enableSubmit={ fullForm }
formFields={ challenges.concat([ 'id' ]) }
hideButton={isCertClaimed}
id={ superBlock }
initialValues={{
...initialValues,
...userValues
}}
options={ options }
submit={ this.handleSubmit }
/>
{
isCertClaimed ?
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
href={ `/c/${username}/${superBlock}`}
>
Show Certificate
</Button> :
null
}
<hr />
</FullWidthRow>
);
})
}
</div>
);
}
}
CertificationSettings.displayName = 'CertificationSettings';
CertificationSettings.propTypes = propTypes;
export default connect(
mapStateToProps,
mapDispatchToProps
)(CertificationSettings);

View File

@@ -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 (
<div>
<FullWidthRow>
<Panel
bsStyle='danger'
className='danger-zone-panel'
header={<h2><strong>Danger Zone</strong></h2>}
>
<Alert
bsStyle='danger'
>
<p>
Tread carefully, changes made in this area are permanent.
They cannot be undone.
</p>
</Alert>
<FullWidthRow>
<Button
block={ true }
bsSize='lg'
bsStyle='danger'
onClick={ this.toggleResetModal }
>
Reset all of my progress
</Button>
<ButtonSpacer />
<Button
block={ true }
bsSize='lg'
bsStyle='danger'
onClick={ this.toggleDeleteModal }
>
Delete my account
</Button>
</FullWidthRow>
</Panel>
<ResetModal
onHide={ this.toggleResetModal }
reset={ resetProgress }
show={ this.state.reset }
/>
<DeleteModal
delete={ deleteAccount }
onHide={ this.toggleDeleteModal }
show={ this.state.delete }
/>
</FullWidthRow>
</div>
);
}
}
DangerZone.displayName = 'DangerZone';
DangerZone.propTypes = propTypes;
export default connect(mapStateToProps, mapDispatchToProps)(DangerZone);

View File

@@ -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 (
<Modal
aria-labelledby='modal-title'
autoFocus={ true }
backdrop={ true }
bsSize='lg'
keyboard={ true }
onHide={ onHide }
show={ show }
>
<Modal.Header closeButton={ true }>
<Modal.Title id='modal-title'>Delete My Account</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>
This will really delete all your data, including all your progress
and account information.
</p>
<p>
We won't be able to recover any of it for you later,
even if you change your mind.
</p>
<p>
If there's something we could do better, send us an email instead and
we'll do our best: &thinsp;
<a href='mailto:team@freecodecamp.org' title='team@freecodecamp.org'>
team@freecodecamp.org
</a>
</p>
<hr />
<Button
block={ true }
bsSize='lg'
bsStyle='success'
onClick={ props.onHide }
>
Nevermind, I don't want to delete my account
</Button>
<div className='button-spacer' />
<Button
block={ true }
bsSize='lg'
bsStyle='danger'
onClick={ props.delete }
>
I am 100% certain. Delete everything related to this account
</Button>
</Modal.Body>
<Modal.Footer>
<Button onClick={ props.onHide }>Close</Button>
</Modal.Footer>
</Modal>
);
}
DeleteModal.displayName = 'DeleteModal';
DeleteModal.propTypes = propTypes;
export default DeleteModal;

View File

@@ -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 (
<Link
style={{ textDecoration: 'none' }}
to={ onRouteUpdateEmail() }
>
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
className='btn-link-social'
>
Edit
</Button>
</Link>
);
}
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 (
<div>
<FullWidthRow>
<p className='large-p text-center'>
You do not have an email associated with this account.
</p>
</FullWidthRow>
<FullWidthRow>
<UpdateEmailButton />
</FullWidthRow>
</div>
);
}
return (
<div className='email-settings'>
<SectionHeader>
Email Settings
</SectionHeader>
{
isEmailVerified ? null :
<FullWidthRow>
<HelpBlock>
<Alert bsStyle='info'>
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.
</Alert>
</HelpBlock>
</FullWidthRow>
}
<FullWidthRow>
<EmailForm
initialValues={{ email, confrimEmail: ''}}
/>
</FullWidthRow>
<Spacer />
<FullWidthRow>
<Row className='inline-form-field' key='sendQuincyEmail'>
<Col sm={ 8 }>
<ControlLabel htmlFor='sendQuincyEmail'>
Send me Quincy&apos;s weekly email
</ControlLabel>
</Col>
<Col sm={ 4 }>
<TB
id='sendQuincyEmail'
name='sendQuincyEmail'
onChange={
() => updateUserBackend({
sendQuincyEmail: !sendQuincyEmail
})
}
value={ sendQuincyEmail }
/>
</Col>
</Row>
</FullWidthRow>
</div>
);
}
}
EmailSettings.displayName = 'EmailSettings';
EmailSettings.propTypes = propTypes;
export default reduxForm(
{
form: 'email-settings',
fields: [ 'email' ]
},
mapStateToProps,
mapDispatchToProps
)(EmailSettings);

View File

@@ -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 (
<form id='email-form' onSubmit={ handleSubmit(this.handleSubmit) }>
<Row className='inline-form-field'>
<Col sm={ 3 } xs={ 12 }>
<ControlLabel htmlFor='email'>
Email
</ControlLabel>
</Col>
<Col sm={ 9 } xs={ 12 }>
<FormControl
bsSize='lg'
id='email'
name='email'
onChange={ email.onChange }
required={ true }
type='email'
value={ email.value }
/>
</Col>
</Row>
<FullWidthRow>
{
!email.pristine && email.error ?
<HelpBlock>
<Alert bsStyle='danger'>
{ email.error }
</Alert>
</HelpBlock> :
null
}
</FullWidthRow>
<Row className='inline-form-field'>
<Col sm={ 3 } xs={ 12 }>
<ControlLabel htmlFor='confirm-email'>
Confirm Email
</ControlLabel>
</Col>
<Col sm={ 9 } xs={ 12 }>
<FormControl
bsSize='lg'
id='confirm-email'
name='confirm-email'
onChange={ confirmEmail.onChange }
required={ true }
type='email'
value={ confirmEmail.value }
/>
</Col>
</Row>
<FullWidthRow>
{
!confirmEmail.pristine && confirmEmail.error ?
<HelpBlock>
<Alert bsStyle='danger'>
{ confirmEmail.error }
</Alert>
</HelpBlock> :
null
}
</FullWidthRow>
<Spacer />
<BlockSaveWrapper>
<BlockSaveButton disabled={ disableForm } />
</BlockSaveWrapper>
</form>
);
}
}
EmailForm.displayName = 'EmailForm';
EmailForm.propTypes = propTypes;
export default reduxForm(
{
form: 'email-form',
fields: [ 'confirmEmail', 'email' ],
validate: validator
},
mapStateToProps,
mapDispatchtoProps
)(EmailForm);

View File

@@ -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 = (
<Panel bsStyle='info'>
<p>
You have already accepted our Academic Honesty Policy
</p>
</Panel>
);
const agreeButton = (
<Button
block={ true }
bsStyle='primary'
onClick={ this.handleAgreeClick }
>
Agree
</Button>
);
return (
<div className='honesty-policy'>
<SectionHeader>
Academic Honesty Policy
</SectionHeader>
<FullWidthRow>
<Panel>
{
policy.map(
(line, i) => (
<p
dangerouslySetInnerHTML={{ __html: line }}
key={ '' + i + line.slice(0, 10) }
/>
)
)
}
<br />
{
isHonest ?
isHonestAgreed :
agreeButton
}
</Panel>
</FullWidthRow>
</div>
);
}
}
Honesty.displayName = 'Honesty';
Honesty.propTypes = propTypes;
export default connect(mapStateToProps, mapDispatchToProps)(Honesty);

View File

@@ -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 (
<div className='internet-settings'>
<SectionHeader>
Your Internet Presence
</SectionHeader>
<FullWidthRow>
<form
id='internet-handle-settings'
onSubmit={ handleSubmit(this.handleSubmit) }
>
<FormFields
fields={ fields }
options={ options }
/>
<Spacer />
<BlockSaveWrapper>
<BlockSaveButton disabled={ allPristine }/>
</BlockSaveWrapper>
</form>
</FullWidthRow>
</div>
);
}
}
InternetSettings.displayName = 'InternetSettings';
InternetSettings.propTypes = propTypes;
export default reduxForm(
{
form: 'internet-settings',
fields: formFields
},
mapStateToProps,
mapDispatchToProps
)(InternetSettings);

View File

@@ -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 (
<FullWidthRow>
<h3 className='project-heading'>{ projectBlockName }</h3>
<p>
To complete this certification, you must first complete the
JavaScript Algorithms and Data Structures project challenges
</p>
<ul className='solution-list'>
{
challenges.map(challenge => (
<div key={ challenge }>
<li className='solution-list-item'>
<p>{ challenge }</p>
{
Object.keys(jsProjects[challenge]).length ?
<div>
<Button
bsSize='lg'
bsStyle='primary'
id={ challenge }
onClick={ this.handleSolutionToggle }
>
{ this.state[challenge] ? 'Hide' : 'Show' } Solution
</Button>
</div> :
<Link to={`${jsProjectPath}${kebabCase(challenge)}`}>
<Button
bsSize='lg'
bsStyle='primary'
>
Complete Project
</Button>
</Link>
}
</li>
{
this.state[challenge] ?
<SolutionViewer files={ jsProjects[challenge] } /> :
null
}
</div>
))
}
</ul>
{
Object.keys(jsProjects).length === 6 ?
<form onSubmit={ this.handleSubmit }>
<BlockSaveButton>
{ isCertClaimed ? 'Show' : 'Claim'} Certificate
</BlockSaveButton>
</form> :
null
}
<hr />
</FullWidthRow>
);
}
}
JSAlgoAndDSForm.displayName = 'JSAlgoAndDSForm';
JSAlgoAndDSForm.propTypes = jsFormPropTypes;
export default JSAlgoAndDSForm;

View File

@@ -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 (
<FormGroup>
<FormControl
className='btn btn-block btn-primary btn-link-social btn-lg'
componentClass='select'
name={ name }
onChange={ this.handleChange }
style={{ height: '45px' }}
value={ value }
>
{ options }
</FormControl>
</FormGroup>
<div>
<SectionHeader>
Language Settings
</SectionHeader>
<FullWidthRow>
<FormGroup>
<Row>
<Col sm={ 4 } xs={ 12 }>
<ControlLabel htmlFor={ name }>
Prefered Language for Challenges
</ControlLabel>
</Col>
<Col sm={ 8 } xs={ 12 }>
<FormControl
className='btn btn-block btn-primary btn-lg'
componentClass='select'
id={ name }
name={ name }
onChange={ this.handleChange }
style={{ height: '45px' }}
value={ value }
>
{ options }
</FormControl>
</Col>
</Row>
</FormGroup>
</FullWidthRow>
</div>
);
}
}

View File

@@ -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 (
<Row className='inline-form'>
<Col sm={ 8 } xs={ 12 }>
<ControlLabel htmlFor='isLocked'>
<p>
<strong>
Make all of my solutions private
<br />
<em>(this disables your certificates)</em>
</strong>
</p>
</ControlLabel>
</Col>
<Col sm={ 4 } xs={ 12 }>
<TB
name='isLocked'
onChange={ toggleIsLocked }
value={ isLocked }
/>
</Col>
</Row>
);
}
LockSettings.displayName = 'LockSettings';
LockSettings.propTypes = propTypes;

View File

@@ -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 (
<div key={ id }>
<FullWidthRow>
<Form
buttonText='Save portfolio item'
formFields={ formFields }
id={ id }
initialValues={ portfolio }
options={ options }
submit={ this.handleSave }
validate={ validator }
/>
<ButtonSpacer />
<Button
block={ true }
bsSize='lg'
bsStyle='danger'
id={`delete-${id}`}
onClick={ () => this.handleDelete(id) }
type='button'
>
Remove this portfolio item
</Button>
{
index + 1 !== arr.length && <hr />
}
</FullWidthRow>
</div>
);
}
render() {
const { portfolio = [] } = this.props;
return (
<section id='portfolio-settings' >
<SectionHeader>
Portfolio Settings
</SectionHeader>
<FullWidthRow>
<div className='portfolio-settings-intro'>
<p className='p-intro'>
Share your non-FreeCodeCamp projects, articles or accepted
pull requests.
</p>
</div>
</FullWidthRow>
{
portfolio.length ? portfolio.map(this.renderPortfolio) : null
}
<FullWidthRow>
<ButtonSpacer />
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
onClick={ this.handleAdd }
type='button'
>
Add a new portfolio Item
</Button>
</FullWidthRow>
</section>
);
}
}
PortfolioSettings.displayName = 'PortfolioSettings';
PortfolioSettings.propTypes = propTypes;
export default connect(mapStateToProps, mapDispatchToProps)(PortfolioSettings);

View File

@@ -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 (
<Modal
aria-labelledby='modal-title'
autoFocus={ true }
backdrop={ true }
bsSize='lg'
keyboard={ true }
onHide={ onHide }
show={ show }
>
<Modal.Header closeButton={ true }>
<Modal.Title id='modal-title'>Reset My Progress</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>
This will really delete all of your progress, points, completed
challenges, our records of your projects, any certificates you have,
everything.
</p>
<p>
We won't be able to recover any of it for you later, even if you
change your mind.
</p>
<hr />
<Button
block={ true }
bsSize='lg'
bsStyle='success'
onClick={ props.onHide }
>
Nevermind, I don't want to delete all of my progress
</Button>
<div className='button-spacer' />
<Button
block={ true }
bsSize='lg'
bsStyle='danger'
onClick={ () =>{ props.reset(); return props.onHide(); } }
>
Reset everything. I want to start from the beginning
</Button>
</Modal.Body>
<Modal.Footer>
<Button onClick={ props.onHide }>Close</Button>
</Modal.Footer>
</Modal>
);
}
ResetModal.displayName = 'ResetModal';
ResetModal.propTypes = propTypes;
export default ResetModal;

View File

@@ -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 (
<FullWidthRow>
<h2>{ children }</h2>
<hr />
</FullWidthRow>
);
}
SectionHeader.displayName = 'SectionHeader';
SectionHeader.propTypes = propTypes;
export default SectionHeader;

View File

@@ -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 (
<a href={ linkedIn } rel='no-follow' target='_blank'>
<FontAwesome
name='linkedin'
size='2x'
/>
</a>
);
}
function githubIcon(ghURL) {
return (
<a href={ ghURL } rel='no-follow' target='_blank'>
<FontAwesome
name='github'
size='2x'
/>
</a>
);
}
function WebsiteIcon(website) {
return (
<a href={ website } rel='no-follow' target='_blank'>
<FontAwesome
name='link'
size='2x'
/>
</a>
);
}
function TwitterIcon(handle) {
return (
<a href={ handle } rel='no-follow' target='_blank' >
<FontAwesome
name='twitter'
size='2x'
/>
</a>
);
}
function SocialIcons(props) {
const {
githubURL,
isLinkedIn,
isGithub,
isTwitter,
isWebsite,
linkedIn,
twitter,
website
} = props;
return (
<Row>
<Col
className='text-center social-media-icons'
sm={ 6 }
smOffset={ 3 }
>
{
isLinkedIn ? LinkedInIcon(linkedIn) : null
}
{
isGithub ? githubIcon(githubURL) : null
}
{
isWebsite ? WebsiteIcon(website) : null
}
{
isTwitter ? TwitterIcon(twitter) : null
}
</Col>
</Row>
);
}
SocialIcons.displayName = 'SocialIcons';
SocialIcons.propTypes = propTypes;
export default connect(mapStateToProps, mapDispatchToProps)(SocialIcons);

View File

@@ -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 (
<div>
<Helmet>
<link href='/css/prism.css' rel='stylesheet' />
</Helmet>
{
Object.keys(files)
.map(key => files[key])
.map(file => (
<Panel
bsStyle='primary'
className='solution-viewer'
header={ file.ext.toUpperCase() }
key={ file.ext }
>
<pre>
<code
className={ `language-${prismLang[file.ext]}` }
dangerouslySetInnerHTML={{
__html: Prism.highlight(
file.contents.trim(),
Prism.languages[prismLang[file.ext]]
)
}}
/>
</pre>
</Panel>
))
}
</div>
);
}
SolutionViewer.displayName = 'SolutionViewer';
SolutionViewer.propTypes = {
files: PropTypes.object
};
export default SolutionViewer;

View File

@@ -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 (
<Row className='inline-form'>
<Col sm={ 8 } xs={ 12 }>
<ControlLabel htmlFor='night-mode'>
<p className='settings-title'>
<strong>Night Mode</strong>
</p>
</ControlLabel>
</Col>
<Col sm={ 4 } xs={ 12 }>
<TB
name='night-mode'
onChange={ () => toggleNightMode(currentTheme) }
value={ currentTheme === 'night' }
/>
</Col>
</Row>
);
}
ThemeSettings.displayName = 'ThemeSettings';
ThemeSettings.propTypes = propTypes;

View File

@@ -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 (
<FullWidthRow>
<Alert bsStyle='danger'>
{ error }
</Alert>
</FullWidthRow>
);
}
if (!validating && !isValidUsername) {
return (
<FullWidthRow>
<Alert bsStyle='danger'>
Username not available
</Alert>
</FullWidthRow>
);
}
if (validating) {
return (
<FullWidthRow>
<Alert bsStyle='info'>
Validating username
</Alert>
</FullWidthRow>
);
}
if (!validating && isValidUsername) {
return (
<FullWidthRow>
<Alert bsStyle='success'>
Username is available
</Alert>
</FullWidthRow>
);
}
return null;
}
function UsernameSettings(props) {
const {
fields: {
username: {
value,
onChange,
error,
pristine,
valid
}
},
handleSubmit,
isValidUsername,
submitAction,
validateUsername,
validating
} = props;
return (
<div>
{
!pristine && renderAlerts(validating, error, isValidUsername)
}
<FullWidthRow>
<form
className='inline-form-field'
id='usernameSettings'
onSubmit={ handleSubmit(submitAction) }
>
<Col className='inline-form' sm={ 3 } xs={ 12 }>
<ControlLabel htmlFor='username-settings'>
<strong>Username</strong>
</ControlLabel>
</Col>
<Col sm={ 7 } xs={ 12 }>
<FormControl
name='username-settings'
onChange={ makeHandleChange(onChange, validateUsername, valid) }
value={ value }
/>
</Col>
<Col sm={ 2 } xs={ 12 }>
<BlockSaveButton disabled={
!(isValidUsername && valid && !pristine)
}
/>
</Col>
</form>
</FullWidthRow>
</div>
);
}
UsernameSettings.displayName = 'UsernameSettings';
UsernameSettings.propTypes = propTypes;
export default reduxForm(
{
form: 'usernameSettings',
fields: [ 'username' ]
},
mapStateToProps,
mapDispatchToProps
)(UsernameSettings);

View File

@@ -0,0 +1,24 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from 'react-bootstrap';
function BlockSaveButton(props) {
return (
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
{...props}
type='submit'
>
{ props.children || 'Save' }
</Button>
);
}
BlockSaveButton.displayName = 'BlockSaveButton';
BlockSaveButton.propTypes = {
children: PropTypes.any
};
export default BlockSaveButton;

View File

@@ -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 (
<div style={ style }>
{ children }
</div>
);
}
BlockSaveWrapper.displayName = 'BlockSaveWrapper';
BlockSaveWrapper.propTypes = propTypes;
export default BlockSaveWrapper;

View File

@@ -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 (
<form id={`dynamic-${id}`} onSubmit={ handleSubmit(submit) }>
<FormFields
errors={ errors }
fields={ fields }
options={ options }
/>
<BlockSaveWrapper>
{
hideButton ?
null :
<BlockSaveButton
disabled={
allPristine && !enableSubmit ||
(!!Object.keys(errors).filter(key => errors[key]).length)
}
>
{
buttonText ? buttonText : null
}
</BlockSaveButton>
}
</BlockSaveWrapper>
</form>
);
}
DynamicForm.displayName = 'DynamicForm';
DynamicForm.propTypes = propTypes;
const DynamicFormWithRedux = reduxForm()(DynamicForm);
export default function Form(props) {
return (
<DynamicFormWithRedux
{...props}
fields={ props.formFields }
form={ props.id }
/>
);
}
Form.propTypes = propTypes;

View File

@@ -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 (
<div>
{
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 (
<Row className='inline-form-field' key={ key }>
<Col sm={ 3 } xs={ 12 }>
{ type === 'hidden' ?
null :
<ControlLabel htmlFor={ key }>
{ _.startCase(name) }
</ControlLabel>
}
</Col>
<Col sm={ 9 } xs={ 12 }>
<FormControl
bsSize='lg'
componentClass={ type === 'textarea' ? type : 'input' }
id={ key }
name={ name }
onChange={ onChange }
placeholder={ placeholder ? name : '' }
required={ !!required[name] }
rows={ 4 }
type={ type }
value={ value }
/>
{
name in errors && !pristine ?
<HelpBlock>
<Alert bsStyle='danger'>
{ errors[name] }
</Alert>
</HelpBlock> :
null
}
</Col>
</Row>
);
})
}
</div>
);
}
FormFields.displayName = 'FormFields';
FormFields.propTypes = propTypes;
export default FormFields;

View File

@@ -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';
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 elses 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 freeCodeCamps team will audit my projects ' +
'to confirm this.',
'In the situations where we discover instances of unambiguous plagiarism, ' +
'we will replace the camper in questions 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 ' +
'<a href="mailto:team@freecodecamp.org">team@freecodecamp.org</a> and we ' +
'will investigate.'
];
export default policy;