diff --git a/client/modules/IDE/components/ErrorModal.jsx b/client/modules/IDE/components/ErrorModal.jsx
index 099065a549..a1d0f8abc2 100644
--- a/client/modules/IDE/components/ErrorModal.jsx
+++ b/client/modules/IDE/components/ErrorModal.jsx
@@ -15,6 +15,19 @@ class ErrorModal extends React.Component {
);
}
+ oauthError() {
+ const { t, service } = this.props;
+ const serviceLabels = {
+ github: 'GitHub',
+ google: 'Google'
+ };
+ return (
+
+ {t('ErrorModal.LinkMessage', { serviceauth: serviceLabels[service] })}
+
+ );
+ }
+
staleSession() {
return (
@@ -42,6 +55,8 @@ class ErrorModal extends React.Component {
return this.staleSession();
} else if (this.props.type === 'staleProject') {
return this.staleProject();
+ } else if (this.props.type === 'oauthError') {
+ return this.oauthError();
}
})()}
@@ -52,7 +67,12 @@ class ErrorModal extends React.Component {
ErrorModal.propTypes = {
type: PropTypes.string.isRequired,
closeModal: PropTypes.func.isRequired,
- t: PropTypes.func.isRequired
+ t: PropTypes.func.isRequired,
+ service: PropTypes.string
+};
+
+ErrorModal.defaultProps = {
+ service: ''
};
export default withTranslation()(ErrorModal);
diff --git a/client/modules/User/actions.js b/client/modules/User/actions.js
index a5b22edbbd..0ce673acae 100644
--- a/client/modules/User/actions.js
+++ b/client/modules/User/actions.js
@@ -270,3 +270,20 @@ export function removeApiKey(keyId) {
Promise.reject(new Error(response.data.error));
});
}
+
+export function unlinkService(service) {
+ return (dispatch) => {
+ if (!['github', 'google'].includes(service)) return;
+ apiClient.delete(`/auth/${service}`)
+ .then((response) => {
+ dispatch({
+ type: ActionTypes.AUTH_USER,
+ user: response.data
+ });
+ }).catch((error) => {
+ const { response } = error;
+ const message = response.message || response.data.error;
+ dispatch(authError(message));
+ });
+ };
+}
diff --git a/client/modules/User/components/AccountForm.jsx b/client/modules/User/components/AccountForm.jsx
index c1bfe6dbfc..9682171b44 100644
--- a/client/modules/User/components/AccountForm.jsx
+++ b/client/modules/User/components/AccountForm.jsx
@@ -115,7 +115,7 @@ AccountForm.propTypes = {
newPassword: PropTypes.object.isRequired, // eslint-disable-line
}).isRequired,
user: PropTypes.shape({
- verified: PropTypes.number.isRequired,
+ verified: PropTypes.string.isRequired,
emailVerificationInitiate: PropTypes.bool.isRequired,
}).isRequired,
handleSubmit: PropTypes.func.isRequired,
diff --git a/client/modules/User/components/SocialAuthButton.jsx b/client/modules/User/components/SocialAuthButton.jsx
index ed79dfd2ad..94527b5ac7 100644
--- a/client/modules/User/components/SocialAuthButton.jsx
+++ b/client/modules/User/components/SocialAuthButton.jsx
@@ -2,11 +2,12 @@ import PropTypes from 'prop-types';
import React from 'react';
import styled from 'styled-components';
import { withTranslation } from 'react-i18next';
+import { useDispatch } from 'react-redux';
import { remSize } from '../../../theme';
-
import { GithubIcon, GoogleIcon } from '../../../common/icons';
import Button from '../../../common/Button';
+import { unlinkService } from '../actions';
const authUrls = {
github: '/auth/github',
@@ -23,22 +24,51 @@ const services = {
google: 'google'
};
+const servicesLabels = {
+ github: 'GitHub',
+ google: 'Google'
+};
+
const StyledButton = styled(Button)`
width: ${remSize(300)};
`;
-function SocialAuthButton({ service, t }) {
+function SocialAuthButton({
+ service, linkStyle, isConnected, t
+}) {
const ServiceIcon = icons[service];
- const labels = {
- github: t('SocialAuthButton.Github'),
- google: t('SocialAuthButton.Google')
- };
+ const serviceLabel = servicesLabels[service];
+ const loginLabel = t('SocialAuthButton.Login', { serviceauth: serviceLabel });
+ const connectLabel = t('SocialAuthButton.Connect', { serviceauth: serviceLabel });
+ const unlinkLabel = t('SocialAuthButton.Unlink', { serviceauth: serviceLabel });
+ const ariaLabel = t('SocialAuthButton.LogoARIA', { serviceauth: service });
+ const dispatch = useDispatch();
+ if (linkStyle) {
+ if (isConnected) {
+ return (
+ }
+ onClick={() => { dispatch(unlinkService(service)); }}
+ >
+ {unlinkLabel}
+
+ );
+ }
+ return (
+ }
+ href={authUrls[service]}
+ >
+ {connectLabel}
+
+ );
+ }
return (
}
+ iconBefore={}
href={authUrls[service]}
>
- {labels[service]}
+ {loginLabel}
);
}
@@ -47,9 +77,16 @@ SocialAuthButton.services = services;
SocialAuthButton.propTypes = {
service: PropTypes.oneOf(['github', 'google']).isRequired,
+ linkStyle: PropTypes.bool,
+ isConnected: PropTypes.bool,
t: PropTypes.func.isRequired
};
+SocialAuthButton.defaultProps = {
+ linkStyle: false,
+ isConnected: false
+};
+
const SocialAuthButtonPublic = withTranslation()(SocialAuthButton);
SocialAuthButtonPublic.services = services;
export default SocialAuthButtonPublic;
diff --git a/client/modules/User/pages/AccountView.jsx b/client/modules/User/pages/AccountView.jsx
index f9f46cc8f6..fa0bd75a15 100644
--- a/client/modules/User/pages/AccountView.jsx
+++ b/client/modules/User/pages/AccountView.jsx
@@ -5,6 +5,8 @@ import { bindActionCreators } from 'redux';
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
import { Helmet } from 'react-helmet';
import { withTranslation } from 'react-i18next';
+import { withRouter, browserHistory } from 'react-router';
+import { parse } from 'query-string';
import { updateSettings, initiateVerification, createApiKey, removeApiKey } from '../actions';
import AccountForm from '../components/AccountForm';
import apiClient from '../../../utils/apiClient';
@@ -12,8 +14,11 @@ import { validateSettings } from '../../../utils/reduxFormUtils';
import SocialAuthButton from '../components/SocialAuthButton';
import APIKeyForm from '../components/APIKeyForm';
import Nav from '../../../components/Nav';
+import ErrorModal from '../../IDE/components/ErrorModal';
+import Overlay from '../../App/components/Overlay';
function SocialLoginPanel(props) {
+ const { user } = props;
return (
@@ -24,19 +29,37 @@ function SocialLoginPanel(props) {
{props.t('AccountView.SocialLoginDescription')}
-
-
+
+
);
}
+SocialLoginPanel.propTypes = {
+ user: PropTypes.shape({
+ github: PropTypes.string,
+ google: PropTypes.string
+ }).isRequired
+};
+
class AccountView extends React.Component {
componentDidMount() {
document.body.className = this.props.theme;
}
render() {
+ const queryParams = parse(this.props.location.search);
+ const showError = !!queryParams.error;
+ const errorType = queryParams.error;
const accessTokensUIEnabled = window.process.env.UI_ACCESS_TOKEN_ENABLED;
return (
@@ -47,6 +70,21 @@ class AccountView extends React.Component {
+ {showError &&
+ {
+ browserHistory.push(this.props.location.pathname);
+ }}
+ >
+
+
+ }
+
{this.props.t('AccountView.Settings')}
@@ -111,13 +149,17 @@ function asyncValidate(formProps, dispatch, props) {
AccountView.propTypes = {
previousPath: PropTypes.string.isRequired,
theme: PropTypes.string.isRequired,
- t: PropTypes.func.isRequired
+ t: PropTypes.func.isRequired,
+ location: PropTypes.shape({
+ search: PropTypes.string.isRequired,
+ pathname: PropTypes.string.isRequired
+ }).isRequired
};
-export default withTranslation()(reduxForm({
+export default withTranslation()(withRouter(reduxForm({
form: 'updateAllSettings',
fields: ['username', 'email', 'currentPassword', 'newPassword'],
validate: validateSettings,
asyncValidate,
asyncBlurFields: ['username', 'email', 'currentPassword']
-}, mapStateToProps, mapDispatchToProps)(AccountView));
+}, mapStateToProps, mapDispatchToProps)(AccountView)));
diff --git a/client/styles/components/_error-modal.scss b/client/styles/components/_error-modal.scss
index f20f05a5f9..aac87d1b12 100644
--- a/client/styles/components/_error-modal.scss
+++ b/client/styles/components/_error-modal.scss
@@ -17,6 +17,10 @@
.error-modal__content {
padding: #{20 / $base-font-size}rem;
- padding-top: 0;
+ padding-top: #{40 / $base-font-size}rem;
padding-bottom: #{60 / $base-font-size}rem;
+ max-width: #{500 / $base-font-size}rem;
+ & p {
+ font-size: #{16 / $base-font-size}rem;
+ }
}
diff --git a/package-lock.json b/package-lock.json
index 98e283194f..b09cc26728 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18461,6 +18461,17 @@
"loose-envify": "^1.2.0",
"query-string": "^4.2.2",
"warning": "^3.0.0"
+ },
+ "dependencies": {
+ "query-string": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz",
+ "integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=",
+ "requires": {
+ "object-assign": "^4.1.0",
+ "strict-uri-encode": "^1.0.0"
+ }
+ }
}
},
"hmac-drbg": {
@@ -28204,6 +28215,18 @@
"prepend-http": "^1.0.0",
"query-string": "^4.1.0",
"sort-keys": "^1.0.0"
+ },
+ "dependencies": {
+ "query-string": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz",
+ "integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=",
+ "dev": true,
+ "requires": {
+ "object-assign": "^4.1.0",
+ "strict-uri-encode": "^1.0.0"
+ }
+ }
}
},
"npm-run-path": {
@@ -31516,12 +31539,20 @@
}
},
"query-string": {
- "version": "4.3.4",
- "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz",
- "integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=",
+ "version": "6.13.2",
+ "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.13.2.tgz",
+ "integrity": "sha512-BMmDaUiLDFU1hlM38jTFcRt7HYiGP/zt1sRzrIWm5zpeEuO1rkbPS0ELI3uehoLuuhHDCS8u8lhFN3fEN4JzPQ==",
"requires": {
- "object-assign": "^4.1.0",
- "strict-uri-encode": "^1.0.0"
+ "decode-uri-component": "^0.2.0",
+ "split-on-first": "^1.0.0",
+ "strict-uri-encode": "^2.0.0"
+ },
+ "dependencies": {
+ "strict-uri-encode": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
+ "integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY="
+ }
}
},
"querystring": {
@@ -35093,6 +35124,11 @@
"integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==",
"dev": true
},
+ "split-on-first": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
+ "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="
+ },
"split-string": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
diff --git a/package.json b/package.json
index f4ee76eb5a..149851bf3b 100644
--- a/package.json
+++ b/package.json
@@ -195,6 +195,7 @@
"primer-tooltips": "^1.5.11",
"prop-types": "^15.6.2",
"q": "^1.4.1",
+ "query-string": "^6.13.2",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-helmet": "^5.1.3",
diff --git a/server/config/passport.js b/server/config/passport.js
index 163db374e4..b9b3c946ef 100644
--- a/server/config/passport.js
+++ b/server/config/passport.js
@@ -10,6 +10,11 @@ import { BasicStrategy } from 'passport-http';
import User from '../models/user';
+function generateUniqueUsername(username) {
+ const adj = friendlyWords.predicates[Math.floor(Math.random() * friendlyWords.predicates.length)];
+ return slugify(`${username} ${adj}`);
+}
+
passport.serializeUser((user, done) => {
done(null, user.id);
});
@@ -91,6 +96,10 @@ passport.use(new GitHubStrategy({
}, (req, accessToken, refreshToken, profile, done) => {
User.findOne({ github: profile.id }, (findByGithubErr, existingUser) => {
if (existingUser) {
+ if (req.user && req.user.email !== existingUser.email) {
+ done(new Error('GitHub account is already linked to another account.'));
+ return;
+ }
done(null, existingUser);
return;
}
@@ -98,26 +107,41 @@ passport.use(new GitHubStrategy({
const emails = getVerifiedEmails(profile.emails);
const primaryEmail = getPrimaryEmail(profile.emails);
- User.findByEmail(emails, (findByEmailErr, existingEmailUser) => {
- if (existingEmailUser) {
- existingEmailUser.email = existingEmailUser.email || primaryEmail;
- existingEmailUser.github = profile.id;
- existingEmailUser.username = existingEmailUser.username || profile.username;
- existingEmailUser.tokens.push({ kind: 'github', accessToken });
- existingEmailUser.name = existingEmailUser.name || profile.displayName;
- existingEmailUser.verified = User.EmailConfirmation.Verified;
- existingEmailUser.save(saveErr => done(null, existingEmailUser));
- } else {
- const user = new User();
- user.email = primaryEmail;
- user.github = profile.id;
- user.username = profile.username;
- user.tokens.push({ kind: 'github', accessToken });
- user.name = profile.displayName;
- user.verified = User.EmailConfirmation.Verified;
- user.save(saveErr => done(null, user));
+ if (req.user) {
+ if (!req.user.github) {
+ req.user.github = profile.id;
+ req.user.tokens.push({ kind: 'github', accessToken });
+ req.user.verified = User.EmailConfirmation.Verified;
}
- });
+ req.user.save(saveErr => done(null, req.user));
+ } else {
+ User.findByEmail(emails, (findByEmailErr, existingEmailUser) => {
+ if (existingEmailUser) {
+ existingEmailUser.email = existingEmailUser.email || primaryEmail;
+ existingEmailUser.github = profile.id;
+ existingEmailUser.username = existingEmailUser.username || profile.username;
+ existingEmailUser.tokens.push({ kind: 'github', accessToken });
+ existingEmailUser.name = existingEmailUser.name || profile.displayName;
+ existingEmailUser.verified = User.EmailConfirmation.Verified;
+ existingEmailUser.save(saveErr => done(null, existingEmailUser));
+ } else {
+ let { username } = profile;
+ User.findByUsername(username, { caseInsensitive: true }, (findByUsernameErr, existingUsernameUser) => {
+ if (existingUsernameUser) {
+ username = generateUniqueUsername(username);
+ }
+ const user = new User();
+ user.email = primaryEmail;
+ user.github = profile.id;
+ user.username = profile.username;
+ user.tokens.push({ kind: 'github', accessToken });
+ user.name = profile.displayName;
+ user.verified = User.EmailConfirmation.Verified;
+ user.save(saveErr => done(null, user));
+ });
+ }
+ });
+ }
});
}));
@@ -133,50 +157,62 @@ passport.use(new GoogleStrategy({
}, (req, accessToken, refreshToken, profile, done) => {
User.findOne({ google: profile._json.emails[0].value }, (findByGoogleErr, existingUser) => {
if (existingUser) {
+ if (req.user && req.user.email !== existingUser.email) {
+ done(new Error('Google account is already linked to another account.'));
+ return;
+ }
done(null, existingUser);
return;
}
const primaryEmail = profile._json.emails[0].value;
- User.findByEmail(primaryEmail, (findByEmailErr, existingEmailUser) => {
- let username = profile._json.emails[0].value.split('@')[0];
- User.findByUsername(username, (findByUsernameErr, existingUsernameUser) => {
- if (existingUsernameUser) {
- const adj = friendlyWords.predicates[Math.floor(Math.random() * friendlyWords.predicates.length)];
- username = slugify(`${username} ${adj}`);
- }
- // what if a username is already taken from the display name too?
- // then, append a random friendly word?
- if (existingEmailUser) {
- existingEmailUser.email = existingEmailUser.email || primaryEmail;
- existingEmailUser.google = profile._json.emails[0].value;
- existingEmailUser.username = existingEmailUser.username || username;
- existingEmailUser.tokens.push({ kind: 'google', accessToken });
- existingEmailUser.name = existingEmailUser.name || profile._json.displayName;
- existingEmailUser.verified = User.EmailConfirmation.Verified;
- existingEmailUser.save((saveErr) => {
- if (saveErr) {
- console.log(saveErr);
- }
- done(null, existingEmailUser);
- });
- } else {
- const user = new User();
- user.email = primaryEmail;
- user.google = profile._json.emails[0].value;
- user.username = username;
- user.tokens.push({ kind: 'google', accessToken });
- user.name = profile._json.displayName;
- user.verified = User.EmailConfirmation.Verified;
- user.save((saveErr) => {
- if (saveErr) {
- console.log(saveErr);
- }
- done(null, user);
- });
- }
+ if (req.user) {
+ if (!req.user.google) {
+ req.user.google = profile._json.emails[0].value;
+ req.user.tokens.push({ kind: 'google', accessToken });
+ req.user.verified = User.EmailConfirmation.Verified;
+ }
+ req.user.save(saveErr => done(null, req.user));
+ } else {
+ User.findByEmail(primaryEmail, (findByEmailErr, existingEmailUser) => {
+ let username = profile._json.emails[0].value.split('@')[0];
+ User.findByUsername(username, { caseInsensitive: true }, (findByUsernameErr, existingUsernameUser) => {
+ if (existingUsernameUser) {
+ username = generateUniqueUsername(username);
+ }
+ // what if a username is already taken from the display name too?
+ // then, append a random friendly word?
+ if (existingEmailUser) {
+ existingEmailUser.email = existingEmailUser.email || primaryEmail;
+ existingEmailUser.google = profile._json.emails[0].value;
+ existingEmailUser.username = existingEmailUser.username || username;
+ existingEmailUser.tokens.push({ kind: 'google', accessToken });
+ existingEmailUser.name = existingEmailUser.name || profile._json.displayName;
+ existingEmailUser.verified = User.EmailConfirmation.Verified;
+ existingEmailUser.save((saveErr) => {
+ if (saveErr) {
+ console.log(saveErr);
+ }
+ done(null, existingEmailUser);
+ });
+ } else {
+ const user = new User();
+ user.email = primaryEmail;
+ user.google = profile._json.emails[0].value;
+ user.username = username;
+ user.tokens.push({ kind: 'google', accessToken });
+ user.name = profile._json.displayName;
+ user.verified = User.EmailConfirmation.Verified;
+ user.save((saveErr) => {
+ if (saveErr) {
+ console.log(saveErr);
+ }
+ done(null, user);
+ });
+ }
+ });
});
- });
+ }
});
}));
diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js
index 0ff791c6d8..a8f21f16ed 100644
--- a/server/controllers/user.controller.js
+++ b/server/controllers/user.controller.js
@@ -18,7 +18,9 @@ export function userResponse(user) {
apiKeys: user.apiKeys,
verified: user.verified,
id: user._id,
- totalSize: user.totalSize
+ totalSize: user.totalSize,
+ github: user.github,
+ google: user.google
};
}
@@ -93,12 +95,7 @@ export function createUser(req, res, next) {
export function duplicateUserCheck(req, res) {
const checkType = req.query.check_type;
const value = req.query[checkType];
- const query = {};
- query[checkType] = value;
- // Don't want to use findByEmailOrUsername here, because in this case we do
- // want to use case-insensitive search for usernames to prevent username
- // duplicates, which overrides the default behavior.
- User.findOne(query).collation({ locale: 'en', strength: 2 }).exec((err, user) => {
+ User.findByEmailOrUsername(value, { caseInsensitive: true }, (err, user) => {
if (user) {
return res.json({
exists: true,
@@ -340,3 +337,23 @@ export function updateSettings(req, res) {
});
}
+export function unlinkGithub(req, res) {
+ if (req.user) {
+ req.user.github = undefined;
+ req.user.tokens = req.user.tokens.filter(token => token.kind !== 'github');
+ saveUser(res, req.user);
+ return;
+ }
+ res.status(404).json({ success: false, message: 'You must be logged in to complete this action.' });
+}
+
+export function unlinkGoogle(req, res) {
+ if (req.user) {
+ req.user.google = undefined;
+ req.user.tokens = req.user.tokens.filter(token => token.kind !== 'google');
+ saveUser(res, req.user);
+ return;
+ }
+ res.status(404).json({ success: false, message: 'You must be logged in to complete this action.' });
+}
+
diff --git a/server/models/user.js b/server/models/user.js
index ae019c6356..b8c38ecbf0 100644
--- a/server/models/user.js
+++ b/server/models/user.js
@@ -50,6 +50,7 @@ const userSchema = new Schema({
verifiedToken: String,
verifiedTokenExpires: Date,
github: { type: String },
+ google: { type: String },
email: { type: String, unique: true },
tokens: Array,
apiKeys: { type: [apiKeySchema] },
@@ -171,14 +172,21 @@ userSchema.statics.findByEmail = function findByEmail(email, cb) {
* Queries User collection by username and returns one User document.
*
* @param {string} username - Username string
+ * @param {Object} [options] - Optional options
+ * @param {boolean} options.caseInsensitive - Does a caseInsensitive query, defaults to false
* @callback [cb] - Optional error-first callback that passes User document
* @return {Promise