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-19 22:07:41 -05:00
committed by Quincy Larson
parent a1f2fc7c5c
commit bb4bcbfb45
17 changed files with 441 additions and 95 deletions
+20
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
+53 -11
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>
);
}
@@ -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 />
+3
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 />
@@ -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 }
@@ -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> }
<p className='text-center points'>
{ `${points} ${pluralise('point', points > 1)}` }
</p>
{
typeof points === 'number' ? (
<p className='text-center points'>
{ `${points} ${pluralise('point', points > 1)}` }
</p>
) : null
}
<br/>
</div>
);
@@ -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;
@@ -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);
@@ -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;
@@ -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(
@@ -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
);
+18
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;
+16
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(
() => {
+49 -9
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": {