diff --git a/common/app/entities/index.js b/common/app/entities/index.js index 7e08d898e5..892b25e0d0 100644 --- a/common/app/entities/index.js +++ b/common/app/entities/index.js @@ -20,6 +20,7 @@ export const types = createTypes([ 'optoUpdatePortfolio', 'regresPortfolio', 'resetFullBlocks', + 'updateLocalProfileUI', 'updateMultipleUserFlags', 'updateTheme', 'updateUserFlag', @@ -56,6 +57,8 @@ export const updateUserLang = createAction( (username, lang) => ({ username, languageTag: lang }) ); +export const updateLocalProfileUI = createAction(types.updateLocalProfileUI); + export const resetFullBlocks = createAction(types.resetFullBlocks); export const updateUserCurrentChallenge = createAction( @@ -294,6 +297,23 @@ export default composeReducers( languageTag } } + }), + [types.updateLocalProfileUI]: + ( + state, + { payload: { username, profileUI } } + ) => ({ + ...state, + user: { + ...state.user, + [username]: { + ...state.user[username], + profileUI: { + ...state.user[username].profileUI, + ...profileUI + } + } + } }) }), defaultState diff --git a/common/app/routes/Profile/Profile.jsx b/common/app/routes/Profile/Profile.jsx index fc12992b36..d3b275d9cd 100644 --- a/common/app/routes/Profile/Profile.jsx +++ b/common/app/routes/Profile/Profile.jsx @@ -38,7 +38,20 @@ const mapStateToProps = createSelector( userFoundSelector, ( isSignedIn, - { isLocked, username: requestedUsername }, + { + username: requestedUsername, + profileUI: { + isLocked, + showAbout, + showCerts, + showHeatMap, + showLocation, + showName, + showPoints, + showPortfolio, + showTimeLine + } = {} + }, { username: paramsUsername }, currentUsername, showLoading, @@ -47,12 +60,20 @@ const mapStateToProps = createSelector( isSignedIn, currentUsername, isCurrentUserProfile: paramsUsername === currentUsername, - isLocked, isUserFound, fetchOtherUserCompleted: typeof isUserFound === 'boolean', paramsUsername, requestedUsername, - showLoading + isLocked, + showLoading, + showAbout, + showCerts, + showHeatMap, + showLocation, + showName, + showPoints, + showPortfolio, + showTimeLine }) ); @@ -71,7 +92,15 @@ const propTypes = { isUserFound: PropTypes.bool, paramsUsername: PropTypes.string, requestedUsername: PropTypes.string, + showAbout: PropTypes.bool, + showCerts: PropTypes.bool, + showHeatMap: PropTypes.bool, showLoading: PropTypes.bool, + showLocation: PropTypes.bool, + showName: PropTypes.bool, + showPoints: PropTypes.bool, + showPortfolio: PropTypes.bool, + showTimeLine: PropTypes.bool, updateTitle: PropTypes.func.isRequired }; @@ -93,7 +122,15 @@ class Profile extends Component { isLocked, isUserFound, isCurrentUserProfile, - paramsUsername + paramsUsername, + showAbout, + showLocation, + showName, + showPoints, + showHeatMap, + showCerts, + showPortfolio, + showTimeLine } = this.props; const takeMeToChallenges = ( @@ -113,8 +150,8 @@ class Profile extends Component {

{ - 'In order to view their progress through the freeCodeCamp ' + - 'curriculum, they need to make all of thie solutions public' + 'In order to view their freeCodeCamp certiciations, ' + + 'they need to make their profile public' }

@@ -136,11 +173,16 @@ class Profile extends Component { } return (
- - - - - + + { showHeatMap ? : null } + { showCerts ? : null } + { showPortfolio ? : null } + { showTimeLine ? : null }
); } diff --git a/common/app/routes/Profile/components/CamperHOC.jsx b/common/app/routes/Profile/components/CamperHOC.jsx index d719fe1ed7..9199cb6907 100644 --- a/common/app/routes/Profile/components/CamperHOC.jsx +++ b/common/app/routes/Profile/components/CamperHOC.jsx @@ -31,6 +31,10 @@ const propTypes = { name: PropTypes.string, picture: PropTypes.string, points: PropTypes.number, + showAbout: PropTypes.bool, + showLocation: PropTypes.bool, + showName: PropTypes.bool, + showPoints: PropTypes.bool, username: PropTypes.string }; @@ -40,17 +44,21 @@ function CamperHOC({ location, points, picture, - about + about, + showAbout, + showLocation, + showName, + showPoints }) { return (

diff --git a/common/app/routes/Settings/Settings.jsx b/common/app/routes/Settings/Settings.jsx index 28d4be17d1..9d16a9a1db 100644 --- a/common/app/routes/Settings/Settings.jsx +++ b/common/app/routes/Settings/Settings.jsx @@ -15,6 +15,7 @@ import EmailSettings from './components/Email-Settings.jsx'; import DangerZone from './components/DangerZone.jsx'; import CertificationSettings from './components/Cert-Settings.jsx'; import PortfolioSettings from './components/Portfolio-Settings.jsx'; +import PrivacySettings from './components/Privacy-Settings.jsx'; import Honesty from './components/Honesty.jsx'; import { @@ -101,6 +102,8 @@ export class Settings extends React.Component {

{ `Account Settings for ${username}` }

+ + diff --git a/common/app/routes/Settings/components/About-Settings.jsx b/common/app/routes/Settings/components/About-Settings.jsx index f9ee1d0881..e4bcdb88be 100644 --- a/common/app/routes/Settings/components/About-Settings.jsx +++ b/common/app/routes/Settings/components/About-Settings.jsx @@ -9,15 +9,12 @@ import { } 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 { updateUserBackend } from '../redux'; import { BlockSaveButton, BlockSaveWrapper, @@ -33,7 +30,6 @@ const mapStateToProps = createSelector( ( { about, - isLocked, location, name, picture, @@ -45,7 +41,6 @@ const mapStateToProps = createSelector( about, currentTheme: theme, initialValues: { name, location, about, picture }, - isLocked, location, name, picture, @@ -79,7 +74,6 @@ const propTypes = { currentTheme: PropTypes.string, fields: PropTypes.object, handleSubmit: PropTypes.func.isRequired, - isLocked: PropTypes.bool, location: PropTypes.string, name: PropTypes.string, picture: PropTypes.string, @@ -163,14 +157,11 @@ class AboutSettings extends PureComponent { 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 (
@@ -205,12 +196,6 @@ class AboutSettings extends PureComponent { - - - { name }

} { location &&

{ location }

} { about &&

{ about }

} -

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

+ { + typeof points === 'number' ? ( +

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

+ ) : null + }
); diff --git a/common/app/routes/Settings/components/Locked-Settings.jsx b/common/app/routes/Settings/components/Locked-Settings.jsx deleted file mode 100644 index ea9bdb6d5e..0000000000 --- a/common/app/routes/Settings/components/Locked-Settings.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { - Row, - Col, - ControlLabel -} from 'react-bootstrap'; - -import TB from '../Toggle-Button'; - -const propTypes = { - isLocked: PropTypes.bool, - toggleIsLocked: PropTypes.func.isRequired -}; - -export default function LockSettings({ isLocked, toggleIsLocked }) { - return ( - - - -

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

-
- - - - -
- ); -} - -LockSettings.displayName = 'LockSettings'; -LockSettings.propTypes = propTypes; diff --git a/common/app/routes/Settings/components/Privacy-Settings.jsx b/common/app/routes/Settings/components/Privacy-Settings.jsx new file mode 100644 index 0000000000..5f22dc49ce --- /dev/null +++ b/common/app/routes/Settings/components/Privacy-Settings.jsx @@ -0,0 +1,126 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { updateMyProfileUI } from '../redux'; +import { userSelector } from '../../../redux'; + +import { FullWidthRow } from '../../../helperComponents'; +import SectionHeader from './SectionHeader.jsx'; +import ToggleSetting from './ToggleSetting.jsx'; + +const mapStateToProps = createSelector( + userSelector, + ({ + profileUI = {} + }) => ({ + ...profileUI + }) +); + +const mapDispatchToProps = dispatch => + bindActionCreators({ updateMyProfileUI }, dispatch); + +const propTypes = { + isLocked: PropTypes.bool, + showAbout: PropTypes.bool, + showCerts: PropTypes.bool, + showHeatMap: PropTypes.bool, + showLocation: PropTypes.bool, + showName: PropTypes.bool, + showPoints: PropTypes.bool, + showPortfolio: PropTypes.bool, + showTimeLine: PropTypes.bool, + updateMyProfileUI: PropTypes.func.isRequired +}; + +function PrivacySettings(props) { + const { + isLocked, + showAbout, + showCerts, + showHeatMap, + showLocation, + showName, + showPoints, + showPortfolio, + showTimeLine, + updateMyProfileUI + } = props; + const toggleFlag = flag => + () => updateMyProfileUI({ profileUI: { [flag]: !props[flag] } }); + return ( +
+ Privacy Settings + +

+ The settings in this section enable you to control what is show on{' '} + your freeCodeCamp public profile. +

+ + + + + + + + + +
+
+ ); +} + +PrivacySettings.displayName = 'PrivacySettings'; +PrivacySettings.propTypes = propTypes; + +export default connect(mapStateToProps, mapDispatchToProps)(PrivacySettings); diff --git a/common/app/routes/Settings/components/ToggleSetting.jsx b/common/app/routes/Settings/components/ToggleSetting.jsx new file mode 100644 index 0000000000..7a0654ef6f --- /dev/null +++ b/common/app/routes/Settings/components/ToggleSetting.jsx @@ -0,0 +1,53 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { + Row, + Col, + ControlLabel +} from 'react-bootstrap'; + +import TB from '../Toggle-Button'; + +const propTypes = { + action: PropTypes.string.isRequired, + explain: PropTypes.string, + flag: PropTypes.bool.isRequired, + flagName: PropTypes.string.isRequired, + toggleFlag: PropTypes.func.isRequired +}; + +export default function ToggleSetting({ + action, + explain, + flag, + flagName, + toggleFlag +}) { + return ( + + + +

+ + { action } + +
+ { + explain ? {explain} : null + } +

+
+ + + + +
+ ); +} + +ToggleSetting.displayName = 'ToggleSetting'; +ToggleSetting.propTypes = propTypes; diff --git a/common/app/routes/Settings/redux/index.js b/common/app/routes/Settings/redux/index.js index 9d19cf23d0..b9b13f7b99 100644 --- a/common/app/routes/Settings/redux/index.js +++ b/common/app/routes/Settings/redux/index.js @@ -34,6 +34,7 @@ export const types = createTypes([ createAsyncTypes('updateUserBackend'), createAsyncTypes('deletePortfolio'), createAsyncTypes('updateMyPortfolio'), + createAsyncTypes('updateMyProfileUI'), 'updateNewUsernameValidity', createAsyncTypes('validateUsername'), createAsyncTypes('refetchCompletedChallenges'), @@ -85,6 +86,14 @@ export const updateMyPortfolioError = createAction( export const deletePortfolio = createAction(types.deletePortfolio.start); export const deletePortfolioError = createAction(types.deletePortfolio.error); +export const updateMyProfileUI = createAction(types.updateMyProfileUI.start); +export const updateMyProfileUIComplete = createAction( + types.updateMyProfileUI.complete +); +export const updateMyProfileUIError = createAction( + types.updateMyProfileUI.error +); + export const resetProgress = createAction(types.resetProgress.start); export const resetProgressComplete = createAction(types.resetProgress.complete); export const resetProgressError = createAction( diff --git a/common/app/routes/Settings/redux/update-user-epic.js b/common/app/routes/Settings/redux/update-user-epic.js index e01258b0b5..a148871f15 100644 --- a/common/app/routes/Settings/redux/update-user-epic.js +++ b/common/app/routes/Settings/redux/update-user-epic.js @@ -5,7 +5,8 @@ import { types, refetchCompletedChallenges, updateUserBackendComplete, - updateMyPortfolioComplete + updateMyPortfolioComplete, + updateMyProfileUIComplete } from './'; import { makeToast } from '../../../Toasts/redux'; import { @@ -18,7 +19,8 @@ import { updateUserEmail, updateMultipleUserFlags, regresPortfolio, - optoUpdatePortfolio + optoUpdatePortfolio, + updateLocalProfileUI } from '../../../entities'; import { postJSON$ } from '../../../../utils/ajax-stream'; @@ -188,9 +190,41 @@ function updateUserEmailEpic(actions, { getState }) { }); } +function updateMyProfileUIEpic(action$, { getState }) { + const toggle = action$::ofType(types.updateMyProfileUI.start); + + const server = toggle.flatMap(({payload: { profileUI }}) => { + const state = getState(); + const { csrfToken: _csrf } = state.app; + const username = usernameSelector(state); + const oldUI = { ...userSelector(state).profileUI }; + return postJSON$('/update-my-profile-ui', { _csrf, profileUI }) + .map(updateMyProfileUIComplete) + .catch( + doActionOnError( + () => Observable.of( + makeToast({ + message: + 'Something went wrong saving your privacy settings, ' + + 'please try again.' + }), + updateLocalProfileUI({username, profileUI: oldUI }) + ) + ) + ); + }); + const optimistic = toggle.flatMap(({payload: { profileUI }}) => { + const username = usernameSelector(getState()); + return Observable.of(updateLocalProfileUI({username, profileUI})); + }); + + return Observable.merge(server, optimistic); +} + export default combineEpics( backendUserUpdateEpic, refetchCompletedChallengesEpic, updateMyPortfolioEpic, - updateUserEmailEpic + updateUserEmailEpic, + updateMyProfileUIEpic ); diff --git a/common/app/routes/Settings/settings.less b/common/app/routes/Settings/settings.less index 471053482d..191218f072 100644 --- a/common/app/routes/Settings/settings.less +++ b/common/app/routes/Settings/settings.less @@ -63,6 +63,24 @@ } } +.privacy-settings { + + .inline-form { + display: flex; + align-items: center; + .btn-group > label { + margin: 10px 0; + } + } + + label { + + em { + font-weight: 400; + } + } +} + .@{ns}-email-container { .below(sm, { text-align: center; diff --git a/common/models/user.js b/common/models/user.js index e703151c88..fc3b9ff386 100644 --- a/common/models/user.js +++ b/common/models/user.js @@ -747,6 +747,22 @@ module.exports = function(User) { `); }; + User.prototype.updateMyProfileUI = function updateMyProfileUI(profileUI) { + const oldUI = { ...this.profileUI }; + const update = { + profileUI: { + ...oldUI, + ...profileUI + } + }; + + return this.update$(update) + .do(() => Object.assign(this, update)) + .map(() => dedent` + Your privacy settings have been updated. + `); + }; + User.prototype.updateMyUsername = function updateMyUsername(newUsername) { return Observable.defer( () => { diff --git a/common/models/user.json b/common/models/user.json index 1c14c82781..7a67551df4 100644 --- a/common/models/user.json +++ b/common/models/user.json @@ -74,10 +74,6 @@ }, "require": true }, - "bio": { - "type": "string", - "default": "" - }, "about": { "type": "string", "default": "" @@ -107,11 +103,6 @@ "type": "boolean", "default": true }, - "isLocked": { - "type": "boolean", - "description": "Campers profile does not show challenges/certificates to the public", - "default": false - }, "currentChallengeId": { "type": "string", "description": "The challenge last visited by the user", @@ -210,6 +201,55 @@ "type": "string", "default": "default" }, + "profileUI": { + "type": { + "isLocked": { + "type": "boolean", + "description": "Campers profile shows only their username and avatar to the public", + "default": true + }, + "showAbout": { + "type": "boolean", + "description": "For granular control of what is shown to the public", + "default": false + }, + "showCerts": { + "type": "boolean", + "description": "For granular control of what is shown to the public", + "default": false + }, + "showHeatMap": { + "type": "boolean", + "description": "For granular control of what is shown to the public", + "default": false + }, + "showLocation": { + "type": "boolean", + "description": "For granular control of what is shown to the public", + "default": false + }, + "showName": { + "type": "boolean", + "description": "For granular control of what is shown to the public", + "default": false + }, + "showPoints": { + "type": "boolean", + "description": "For granular control of what is shown to the public", + "default": false + }, + "showPortfolio": { + "type": "boolean", + "description": "For granular control of what is shown to the public", + "default": false + }, + "showTimeLine": { + "type": "boolean", + "description": "For granular control of what is shown to the public", + "default": false + } + } + }, "badges": { "type": { "coreTeam": { diff --git a/server/boot/certificate.js b/server/boot/certificate.js index 5b1367401d..468ebfddf1 100644 --- a/server/boot/certificate.js +++ b/server/boot/certificate.js @@ -308,7 +308,6 @@ export default function certificate(app) { username, { isCheater: true, - isLocked: true, isFrontEndCert: true, isBackEndCert: true, isFullStackCert: true, @@ -322,11 +321,13 @@ export default function certificate(app) { isHonest: true, username: true, name: true, - completedChallenges: true + completedChallenges: true, + profileUI: true } ) .subscribe( user => { + const { isLocked, showCerts } = user.profileUI; const profile = `/portfolio/${user.username}`; if (!user) { req.flash( @@ -341,7 +342,7 @@ export default function certificate(app) { 'danger', dedent` This user needs to add their name to their account - in order for others to be able to view their certificate. + in order for others to be able to view their certification. ` ); return res.redirect(profile); @@ -351,13 +352,25 @@ export default function certificate(app) { return res.redirect(profile); } - if (user.isLocked) { + if (isLocked) { req.flash( 'danger', dedent` ${username} has chosen to make their profile private. They will need to make their profile public - in order for others to be able to view their certificate. + in order for others to be able to view their certification. + ` + ); + return res.redirect('/'); + } + + if (!showCerts) { + req.flash( + 'danger', + dedent` + ${username} has chosen to make their certifications + private. They will need to make their certifications public + in order for others to be able to view them. ` ); return res.redirect('/'); diff --git a/server/boot/settings.js b/server/boot/settings.js index 7322c63393..cbb989c171 100644 --- a/server/boot/settings.js +++ b/server/boot/settings.js @@ -114,6 +114,18 @@ export default function settingsController(app) { ); } + function updateMyProfileUI(req, res, next) { + const { + user, + body: { profileUI } + } = req; + return user.updateMyProfileUI(profileUI) + .subscribe( + message => res.json({ message }), + next + ); + } + function updateMyProjects(req, res, next) { const { user, @@ -164,6 +176,11 @@ export default function settingsController(app) { ifNoUser401, updateMyPortfolio ); + api.post( + '/update-my-profile-ui', + ifNoUser401, + updateMyProfileUI + ); api.post( '/update-my-projects', ifNoUser401, diff --git a/server/utils/publicUserProps.js b/server/utils/publicUserProps.js index 4ff76b404c..07291d026c 100644 --- a/server/utils/publicUserProps.js +++ b/server/utils/publicUserProps.js @@ -23,13 +23,13 @@ export const publicUserProps = [ 'isHonest', 'isInfosecQaCert', 'isJsAlgoDataStructCert', - 'isLocked', 'isRespWebDesignCert', 'linkedin', 'location', 'name', 'points', 'portfolio', + 'profileUI', 'projects', 'streak', 'twitter',