Feat(privacy): Add granular privacy controls of public profile (#17178)

* feat(privacy): Add granular privacy controls of public profile

* feat(certs): Hide certs if showCerts is false
This commit is contained in:
Stuart Taylor
2018-05-20 04:07:41 +01:00
committed by Quincy Larson
parent a1f2fc7c5c
commit bb4bcbfb45
17 changed files with 441 additions and 95 deletions

View File

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

View File

@ -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 = (
<a href='/challenges/current-challenge'>
@ -113,8 +150,8 @@ class Profile extends Component {
<Alert bsStyle='info'>
<p>
{
'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'
}
</p>
</Alert>
@ -136,11 +173,16 @@ class Profile extends Component {
}
return (
<div>
<CamperHOC />
<HeatMap />
<Certificates />
<Portfolio />
<Timeline className='timelime-container' />
<CamperHOC
showAbout={ showAbout }
showLocation={ showLocation }
showName={ showName }
showPoints={ showPoints }
/>
{ showHeatMap ? <HeatMap /> : null }
{ showCerts ? <Certificates /> : null }
{ showPortfolio ? <Portfolio /> : null }
{ showTimeLine ? <Timeline className='timelime-container' /> : null }
</div>
);
}

View File

@ -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 (
<div>
<Camper
about={ about }
location={ location }
name={ name }
about={ showAbout && about }
location={ showLocation && location }
name={ showName && name }
picture={ picture }
points={ points }
points={ showPoints && points }
username={ username }
/>
<hr />

View File

@ -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 {
<h1 className='text-center'>{ `Account Settings for ${username}` }</h1>
<AboutSettings />
<Spacer />
<PrivacySettings />
<Spacer />
<EmailSettings />
<Spacer />
<InternetSettings />

View File

@ -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 (
<div className='about-settings'>
@ -205,12 +196,6 @@ class AboutSettings extends PureComponent {
</form>
</FullWidthRow>
<Spacer />
<FullWidthRow>
<LockedSettings
isLocked={ isLocked }
toggleIsLocked={ toggleIsLocked }
/>
</FullWidthRow>
<FullWidthRow>
<ThemeSettings
currentTheme={ currentTheme }

View File

@ -42,9 +42,13 @@ function Camper({
{ name && <p className='text-center name'>{ name }</p> }
{ location && <p className='text-center location'>{ location }</p> }
{ about && <p className='bio text-center'>{ about }</p> }
{
typeof points === 'number' ? (
<p className='text-center points'>
{ `${points} ${pluralise('point', points > 1)}` }
</p>
) : null
}
<br/>
</div>
);

View File

@ -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 (
<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,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 (
<div className='privacy-settings'>
<SectionHeader>Privacy Settings</SectionHeader>
<FullWidthRow>
<p>
The settings in this section enable you to control what is show on{' '}
your freeCodeCamp public profile.
</p>
<ToggleSetting
action='Make my profile completely private'
explain='Your certifications will be disabled'
flag={ isLocked }
flagName='isLocked'
toggleFlag={ toggleFlag('isLocked') }
/>
<ToggleSetting
action='Make my name completely private'
flag={ !showName }
flagName='name'
toggleFlag={ toggleFlag('showName') }
/>
<ToggleSetting
action='Make my location completely private'
flag={ !showLocation }
flagName='showLocation'
toggleFlag={ toggleFlag('showLocation') }
/>
<ToggleSetting
action='Make my "about me" completely private'
flag={ !showAbout }
flagName='showAbout'
toggleFlag={ toggleFlag('showAbout') }
/>
<ToggleSetting
action='Make my points completely private'
flag={ !showPoints }
flagName='showPoints'
toggleFlag={ toggleFlag('showPoints') }
/>
<ToggleSetting
action='Make my heat map completely private'
flag={ !showHeatMap }
flagName='showHeatMap'
toggleFlag={ toggleFlag('showHeatMap') }
/>
<ToggleSetting
action='Make my certifications completely private'
explain='Your certifications will be disabled'
flag={ !showCerts }
flagName='showCerts'
toggleFlag={ toggleFlag('showCerts') }
/>
<ToggleSetting
action='Make my portfolio completely private'
flag={ !showPortfolio }
flagName='showPortfolio'
toggleFlag={ toggleFlag('showPortfolio') }
/>
<ToggleSetting
action='Make my time line completely private'
flag={ !showTimeLine }
flagName='showTimeLine'
toggleFlag={ toggleFlag('showTimeLine') }
/>
</FullWidthRow>
</div>
);
}
PrivacySettings.displayName = 'PrivacySettings';
PrivacySettings.propTypes = propTypes;
export default connect(mapStateToProps, mapDispatchToProps)(PrivacySettings);

View File

@ -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 (
<Row className='inline-form'>
<Col sm={ 8 } xs={ 12 }>
<ControlLabel htmlFor={ flagName }>
<p>
<strong>
{ action }
</strong>
<br />
{
explain ? <em>{explain}</em> : null
}
</p>
</ControlLabel>
</Col>
<Col sm={ 4 } xs={ 12 }>
<TB
name={ flagName }
onChange={ toggleFlag }
value={ flag }
/>
</Col>
</Row>
);
}
ToggleSetting.displayName = 'ToggleSetting';
ToggleSetting.propTypes = propTypes;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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('/');

View File

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

View File

@ -23,13 +23,13 @@ export const publicUserProps = [
'isHonest',
'isInfosecQaCert',
'isJsAlgoDataStructCert',
'isLocked',
'isRespWebDesignCert',
'linkedin',
'location',
'name',
'points',
'portfolio',
'profileUI',
'projects',
'streak',
'twitter',