Skip to content

Commit

Permalink
Merge pull request #1519 from outoftime/link-github
Browse files Browse the repository at this point in the history
Link GitHub identity to existing account
  • Loading branch information
outoftime authored Jul 17, 2018
2 parents d7d07ab + 0110af1 commit 13e5298
Show file tree
Hide file tree
Showing 18 changed files with 276 additions and 132 deletions.
5 changes: 4 additions & 1 deletion locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"new-project": "New Project",
"send-feedback": "Send Feedback",
"session": {
"link-github": "Link GitHub",
"log-in-prompt": "Log in to save",
"log-out-prompt": "Log out"
}
Expand Down Expand Up @@ -46,7 +47,9 @@
"snapshot-not-found": "That snapshot doesn’t seem to exist. Check the link and try again.",
"classroom-export-complete": "Your project is ready to share to Google Classroom",
"classroom-export-error": "Something went wrong trying to share to Google Classroom. Please try again.",
"project-compilation-failed": "Something went wrong trying to display a preview of your project. This is a bug in Popcode and we have been notified. Making another change to your code will likely fix this error."
"project-compilation-failed": "Something went wrong trying to display a preview of your project. This is a bug in Popcode and we have been notified. Making another change to your code will likely fix this error.",
"identity-linked": "Your GitHub account is now linked!",
"link-identity-failed": "There was a problem linking your GitHub account."
},
"languages": {
"html": "HTML",
Expand Down
2 changes: 2 additions & 0 deletions src/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
} from './errors';

import {
linkGithubIdentity,
logIn,
logOut,
userAuthenticated,
Expand Down Expand Up @@ -111,4 +112,5 @@ export {
projectSuccessfullySaved,
showSaveIndicator,
hideSaveIndicator,
linkGithubIdentity,
};
18 changes: 16 additions & 2 deletions src/actions/user.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import {createAction} from 'redux-actions';
import identity from 'lodash-es/identity';

export const logIn = createAction(
'LOG_IN',
provider => ({provider}),
);

export const linkGithubIdentity = createAction('LINK_GITHUB_IDENTITY');

export const identityLinked = createAction(
'IDENTITY_LINKED',
credential => ({credential}),
);

export const linkIdentityFailed = createAction(
'LINK_IDENTITY_FAILED',
error => ({error}),
);

export const logOut = createAction('LOG_OUT');

export const userAuthenticated = createAction('USER_AUTHENTICATED', identity);
export const userAuthenticated = createAction(
'USER_AUTHENTICATED',
(user, credentials) => ({user, credentials}),
);

export const userLoggedOut = createAction('USER_LOGGED_OUT');
4 changes: 3 additions & 1 deletion src/channels/loginState.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@ import {eventChannel} from 'redux-saga';
import {onAuthStateChanged} from '../clients/firebase';

export default eventChannel(
emit => onAuthStateChanged(userCredential => emit({userCredential})),
emit => onAuthStateChanged(
({user, credentials}) => emit({user, credentials}),
),
);
51 changes: 23 additions & 28 deletions src/clients/firebase.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import Cookies from 'js-cookie';
import get from 'lodash-es/get';
import isEqual from 'lodash-es/isEqual';
import isNil from 'lodash-es/isNil';
import isNull from 'lodash-es/isNull';
import map from 'lodash-es/map';
import omit from 'lodash-es/omit';
import values from 'lodash-es/values';
import uuid from 'uuid/v4';
Expand All @@ -20,9 +22,9 @@ const SESSION_TTL_MS = 5 * 60 * 1000;
export function onAuthStateChanged(listener) {
const unsubscribe = auth.onAuthStateChanged(async(user) => {
if (isNull(user)) {
listener(null);
listener({user: null});
} else {
listener(await userCredentialForUserData(user));
listener(await decorateUserWithCredentials(user));
}
});
return unsubscribe;
Expand Down Expand Up @@ -65,21 +67,22 @@ export async function saveProject(uid, project) {
setWithPriority(project, -Date.now());
}

async function userCredentialForUserData(user) {
async function decorateUserWithCredentials(user) {
const database = await loadDatabase();
const path = providerPath(user.uid, user.providerData[0].providerId);
const [credentialEvent, providerInfoEvent] = await Promise.all([
database.ref(`authTokens/${path}`).once('value'),
database.ref(`providerInfo/${path}`).once('value'),
]);
const credential = credentialEvent.val();
const additionalUserInfo = providerInfoEvent.val();
if (isNil(credential)) {
const credentialEvent =
await database.ref(`authTokens/${user.uid}`).once('value');
const credentials = values(credentialEvent.val() || {});
if (
!isEqual(
map(credentials, 'providerId').sort(),
map(user.providerData, 'providerId').sort(),
)
) {
await auth.signOut();
return null;
return {user: null};
}

return {user, credential, additionalUserInfo};
return {user, credentials};
}

export async function signIn(provider) {
Expand All @@ -101,6 +104,13 @@ export async function signIn(provider) {
}
}

export async function linkGithub() {
const userCredential =
await auth.currentUser.linkWithPopup(githubAuthProvider);
await saveUserCredential(userCredential);
return userCredential.credential;
}

async function signInWithGithub() {
return auth.signInWithPopup(githubAuthProvider);
}
Expand All @@ -125,28 +135,13 @@ export async function signOut() {
async function saveUserCredential({
user: {uid},
credential,
additionalUserInfo,
}) {
await Promise.all([
saveProviderInfo(uid, additionalUserInfo),
saveCredentials(uid, credential),
]);
}

async function saveCredentials(uid, credential) {
const database = await loadDatabase();
await database.
ref(`authTokens/${providerPath(uid, credential.providerId)}`).
set(credential);
}

async function saveProviderInfo(uid, providerInfo) {
const database = await loadDatabase();
await database.
ref(`providerInfo/${providerPath(uid, providerInfo.providerId)}`).
set(providerInfo);
}

function providerPath(uid, providerId) {
return `${uid}/${providerId.replace('.', '_')}`;
}
Expand Down
13 changes: 12 additions & 1 deletion src/components/TopBar/CurrentUser.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,21 @@ export default function CurrentUser({
isLoginAvailable,
isUserAnonymous,
isUserAuthenticated,
isUserAuthenticatedWithGithub,
user,
onLinkGitHub,
onLogOut,
onStartLogIn,
}) {
if (isUserAuthenticated) {
return <CurrentUserMenu user={user} onLogOut={onLogOut} />;
return (
<CurrentUserMenu
isUserAuthenticatedWithGithub={isUserAuthenticatedWithGithub}
user={user}
onLinkGitHub={onLinkGitHub}
onLogOut={onLogOut}
/>
);
}

if (isUserAnonymous && isLoginAvailable) {
Expand All @@ -40,7 +49,9 @@ CurrentUser.propTypes = {
isLoginAvailable: PropTypes.bool.isRequired,
isUserAnonymous: PropTypes.bool.isRequired,
isUserAuthenticated: PropTypes.bool.isRequired,
isUserAuthenticatedWithGithub: PropTypes.bool.isRequired,
user: PropTypes.instanceOf(UserAccount),
onLinkGitHub: PropTypes.func.isRequired,
onLogOut: PropTypes.func.isRequired,
onStartLogIn: PropTypes.func.isRequired,
};
Expand Down
21 changes: 16 additions & 5 deletions src/components/TopBar/CurrentUserMenu.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import React from 'react';
import React, {Fragment} from 'react';
import {t} from 'i18next';

import createMenu, {MenuItem} from './createMenu';
Expand All @@ -10,16 +10,27 @@ const CurrentUserMenu = createMenu({
name: 'currentUser',

// eslint-disable-next-line react/prop-types
renderItems({onLogOut}) {
renderItems({isUserAuthenticatedWithGithub, onLinkGitHub, onLogOut}) {
return (
<MenuItem onClick={onLogOut}>
{t('top-bar.session.log-out-prompt')}
</MenuItem>
<Fragment>
{
!isUserAuthenticatedWithGithub && (
<MenuItem onClick={onLinkGitHub}>
{t('top-bar.session.link-github')}
</MenuItem>
)
}
<MenuItem onClick={onLogOut}>
{t('top-bar.session.log-out-prompt')}
</MenuItem>
</Fragment>
);
},
})(CurrentUserButton);

CurrentUserMenu.propTypes = {
isUserAuthenticatedWithGithub: PropTypes.bool.isRequired,
onLinkGitHub: PropTypes.func.isRequired,
onLogOut: PropTypes.func.isRequired,
};

Expand Down
4 changes: 4 additions & 0 deletions src/components/TopBar/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export default function TopBar({
onExportGist,
onExportRepo,
onExportToClassroom,
onLinkGitHub,
onLogOut,
onStartGithubLogIn,
onStartGoogleLogIn,
Expand Down Expand Up @@ -115,9 +116,11 @@ export default function TopBar({
isOpen={openMenu === 'currentUser'}
isUserAnonymous={isUserAnonymous}
isUserAuthenticated={isUserAuthenticated}
isUserAuthenticatedWithGithub={isUserAuthenticatedWithGithub}
user={currentUser}
onClick={partial(onClickMenu, 'currentUser')}
onClose={partial(onCloseMenu, 'currentUser')}
onLinkGitHub={onLinkGitHub}
onLogOut={onLogOut}
onStartLogIn={isExperimental ? onStartGoogleLogIn : onStartGithubLogIn}
/>
Expand Down Expand Up @@ -163,6 +166,7 @@ TopBar.propTypes = {
onExportGist: PropTypes.func.isRequired,
onExportRepo: PropTypes.func.isRequired,
onExportToClassroom: PropTypes.func.isRequired,
onLinkGitHub: PropTypes.func.isRequired,
onLogOut: PropTypes.func.isRequired,
onStartEditingInstructions: PropTypes.func.isRequired,
onStartGithubLogIn: PropTypes.func.isRequired,
Expand Down
5 changes: 5 additions & 0 deletions src/containers/TopBar.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
createProject,
createSnapshot,
exportProject,
linkGithubIdentity,
startEditingInstructions,
toggleEditorTextSize,
toggleLibrary,
Expand Down Expand Up @@ -110,6 +111,10 @@ function mapDispatchToProps(dispatch) {
dispatch(toggleLibrary(projectKey, libraryKey));
},

onLinkGitHub() {
dispatch(linkGithubIdentity());
},

onLogOut() {
dispatch(logOut());
},
Expand Down
13 changes: 13 additions & 0 deletions src/reducers/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export default function ui(stateIn, action) {
);

case 'USER_LOGGED_OUT':
case 'LINK_GITHUB_IDENTITY':
return state.updateIn(
['topBar', 'openMenu'],
menu => menu === 'currentUser' ? null : menu,
Expand Down Expand Up @@ -236,6 +237,18 @@ export default function ui(stateIn, action) {
case 'HIDE_SAVE_INDICATOR':
return state.set('saveIndicatorShown', false);

case 'IDENTITY_LINKED': {
return addNotification(
state,
'identity-linked',
'notice',
{provider: action.payload.credential.providerId},
);
}

case 'LINK_IDENTITY_FAILED':
return addNotification(state, 'link-identity-failed', 'error');

default:
return state;
}
Expand Down
50 changes: 28 additions & 22 deletions src/reducers/user.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import get from 'lodash-es/get';
import reduce from 'lodash-es/reduce';

import {User, UserAccount} from '../records';
import {LoginState} from '../enums';
Expand All @@ -12,34 +12,40 @@ function getToken(credential) {
return null;
}

function addCredential(state, credential) {
return state.updateIn(
['account', 'accessTokens'],
accessTokens => accessTokens.set(
credential.providerId,
getToken(credential),
),
);
}

function user(stateIn, action) {
const state = stateIn || new User();

switch (action.type) {
case 'USER_AUTHENTICATED': {
const {user: userData, credential, additionalUserInfo} = action.payload;

const profileData = get(userData, ['providerData', 0], userData);

return state.merge({
loginState: LoginState.AUTHENTICATED,
account: new UserAccount({
id: userData.uid,
displayName: profileData.displayName || get(
additionalUserInfo,
'username',
),
avatarUrl: profileData.photoURL,
}).update(
'accessTokens',
accessTokens => accessTokens.set(
credential.providerId,
getToken(credential),
),
),
});
const {user: userData, credentials} = action.payload;

return reduce(
credentials,
addCredential,
state.merge({
loginState: LoginState.AUTHENTICATED,
account: new UserAccount({
id: userData.uid,
displayName: userData.displayName,
avatarUrl: userData.photoURL,
}),
}),
);
}

case 'IDENTITY_LINKED':
return addCredential(state, action.payload.credential);

case 'USER_LOGGED_OUT':
return new User().set('loginState', LoginState.ANONYMOUS);

Expand Down
Loading

0 comments on commit 13e5298

Please sign in to comment.