Skip to content

Commit

Permalink
Merge pull request #1524 from outoftime/account-migrate-merge
Browse files Browse the repository at this point in the history
Account migrate/merge (experimental only)
  • Loading branch information
outoftime authored Aug 4, 2018
2 parents a19520f + f1050a6 commit 5be011a
Show file tree
Hide file tree
Showing 28 changed files with 913 additions and 14 deletions.
25 changes: 25 additions & 0 deletions locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,31 @@
}
}
},
"account-migration": {
"header": {
"proposed": "Combine these accounts?",
"undo-grace-period": "Preparing to combine accounts…",
"in-progress": "Combining accounts…",
"complete": "All done!",
"error": "Something went wrong."
},
"proposal": [
"Your GitHub login is linked to a different Popcode account. Do you want to combine the account you’re using now with that other account?",
"All of the saved projects from the GitHub-linked account will be transferred into the account you’re using now."
],
"preparing": "Popcode is preparing to combine your accounts. If you do not wish to do this, click the button below:",
"in-progress": "Popcode is transferring the projects from the other account into your current one.",
"complete": "Popcode has finished combining your accounts.",
"error": "There was a problem combining your accounts. Popcode’s developers have been notified.",
"your-account": "Your account",
"account-to-merge": "Account to merge",
"buttons": {
"migrate": "Combine these accounts",
"cancel": "Keep them separate",
"stop": "Stop",
"dismiss": "Done"
}
},
"utility": {
"or": " or "
},
Expand Down
4 changes: 4 additions & 0 deletions src/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ import {
} from './errors';

import {
dismissAccountMigration,
linkGithubIdentity,
logIn,
logOut,
startAccountMigration,
userAuthenticated,
userLoggedOut,
} from './user';
Expand Down Expand Up @@ -114,4 +116,6 @@ export {
hideSaveIndicator,
linkGithubIdentity,
updateResizableFlex,
startAccountMigration,
dismissAccountMigration,
};
23 changes: 23 additions & 0 deletions src/actions/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,29 @@ export const linkIdentityFailed = createAction(
error => ({error}),
);

export const accountMigrationNeeded = createAction(
'ACCOUNT_MIGRATION_NEEDED',
(profile, credential) => ({profile, credential}),
);

export const startAccountMigration = createAction('START_ACCOUNT_MIGRATION');

export const dismissAccountMigration =
createAction('DISMISS_ACCOUNT_MIGRATION');

export const accountMigrationUndoPeriodExpired =
createAction('ACCOUNT_MIGRATION_UNDO_PERIOD_EXPIRED');

export const accountMigrationComplete = createAction(
'ACCOUNT_MIGRATION_COMPLETE',
(projects, credential) => ({projects, credential}),
);

export const accountMigrationError = createAction(
'ACCOUNT_MIGRATION_ERROR',
error => ({error}),
);

export const logOut = createAction('LOG_OUT');

export const userAuthenticated = createAction(
Expand Down
68 changes: 66 additions & 2 deletions src/clients/firebase.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Cookies from 'js-cookie';
import get from 'lodash-es/get';
import isEmpty from 'lodash-es/isEmpty';
import isEqual from 'lodash-es/isEqual';
import isNil from 'lodash-es/isNil';
import isNull from 'lodash-es/isNull';
Expand All @@ -11,6 +12,7 @@ import once from 'lodash-es/once';
import {firebase} from '@firebase/app';
import '@firebase/auth';

import {bugsnagClient} from '../util/bugsnag';
import config from '../config';
import retryingFailedImports from '../util/retryingFailedImports';
import {getGapiSync, SCOPES as GOOGLE_SCOPES} from '../services/gapi';
Expand Down Expand Up @@ -40,12 +42,12 @@ async function loadDatabaseSdk() {
);
}

function buildFirebase() {
function buildFirebase(appName = undefined) {
const app = firebase.initializeApp({
apiKey: config.firebaseApiKey,
authDomain: `${config.firebaseApp}.firebaseapp.com`,
databaseURL: `https://${config.firebaseApp}.firebaseio.com`,
});
}, appName);

return {
auth: firebase.auth(app),
Expand Down Expand Up @@ -149,6 +151,68 @@ export async function linkGithub() {
return userCredential.credential;
}

export async function migrateAccount(inboundAccountCredential) {
const inboundAccountFirebase = buildFirebase('migration');
const {auth: inboundAccountAuth} = inboundAccountFirebase;
try {
await inboundAccountAuth.signInWithCredential(inboundAccountCredential);
const inboundUid = inboundAccountAuth.currentUser.uid;
await logMigration(inboundUid, 'attempt');

const migratedProjects = await migrateProjects(inboundAccountFirebase);
await migrateCredential(inboundAccountCredential, inboundAccountFirebase);

await logMigration(inboundUid, 'success');

return migratedProjects;
} finally {
inboundAccountAuth.app.delete();
}
}

async function migrateCredential(credential, {auth: inboundAccountAuth}) {
await inboundAccountAuth.currentUser.unlink(credential.providerId);
await auth.currentUser.linkWithCredential(credential);
await saveUserCredential({user: auth.currentUser, credential});
}

async function migrateProjects({
auth: inboundAccountAuth,
loadDatabase: loadinboundAccountDatabase,
}) {
const currentAccountDatabase = await loadDatabase();
const inboundAccountDatabase = await loadinboundAccountDatabase();

const allProjectsValue = await inboundAccountDatabase.
ref(`workspaces/${inboundAccountAuth.currentUser.uid}/projects`).
once('value');

if (isNull(allProjectsValue)) {
return [];
}
const allProjects = allProjectsValue.val();

if (isNull(allProjects) || isEmpty(allProjects)) {
return [];
}

await currentAccountDatabase.
ref(`workspaces/${auth.currentUser.uid}/projects`).
update(allProjects);

return values(allProjects);
}

async function logMigration(inboundUid, eventName) {
bugsnagClient.notify(
new Error(`Account migration ${eventName}`),
{
metaData: {migration: {inboundUid}},
severity: 'info',
},
);
}

async function signInWithGithub() {
return auth.signInWithPopup(githubAuthProvider);
}
Expand Down
5 changes: 5 additions & 0 deletions src/clients/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ function normalizeTitle(title) {
return titleWithoutPunctuationAndWhitespace;
}

export async function getProfileForAuthenticatedUser(accessToken) {
const github = await createClient(accessToken);
return github.getUser().getProfile();
}

export async function createOrUpdateRepoFromProject(project, accessToken) {
const repoAlreadyExists = Boolean(project.externalLocations.githubRepoName);
if (repoAlreadyExists) {
Expand Down
104 changes: 104 additions & 0 deletions src/components/AccountMigration.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import isNull from 'lodash-es/isNull';
import PropTypes from 'prop-types';
import React from 'react';
import {t} from 'i18next';

import {
AccountMigration as AccountMigrationRecord,
UserAccount as UserAccountRecord,
} from '../records';
import {AccountMigrationState} from '../enums';

import AccountMigrationComplete from './AccountMigrationComplete';
import AccountMigrationInProgress from './AccountMigrationInProgress';
import AccountMigrationUndoGracePeriod
from './AccountMigrationUndoGracePeriod';
import Modal from './Modal';
import ProposedAccountMigration from './ProposedAccountMigration';
import AccountMigrationError from './AccountMigrationError';

export default function AccountMigration({
currentUserAccount,
migration,
onDismiss,
onMigrate,
}) {
if (isNull(currentUserAccount) || isNull(migration)) {
return null;
}

return (
<Modal>
<div className="account-migration">
<h1 className="account-migration__header">
{t(`account-migration.header.${
migration.state.key.toLowerCase().replace(/_/g, '-')
}`)}
</h1>
<div className="account-migration__accounts">
<div className="account-migration__account">
<p className="account-migration__account-label">
{t('account-migration.your-account')}
</p>
<img
className="account-migration__avatar"
src={currentUserAccount.avatarUrl}
/>
<div className="account-migration__user-name">
{currentUserAccount.displayName}
</div>
</div>
<div
className="account-migration__merge-icon u__icon u__icon_disabled"
>
&#xf0ec;
</div>
<div className="account-migration__account">
<p className="account-migration__account-label">
{t('account-migration.account-to-merge')}
</p>
<img
className="account-migration__avatar"
src={migration.userAccountToMerge.avatarUrl}
/>
<div className="account-migration__user-name">
{migration.userAccountToMerge.displayName}
</div>
</div>
</div>
{(() => {
switch (migration.state) {
case AccountMigrationState.PROPOSED:
return (
<ProposedAccountMigration
onDismiss={onDismiss}
onMigrate={onMigrate}
/>
);
case AccountMigrationState.UNDO_GRACE_PERIOD:
return <AccountMigrationUndoGracePeriod onDismiss={onDismiss} />;
case AccountMigrationState.IN_PROGRESS:
return <AccountMigrationInProgress />;
case AccountMigrationState.COMPLETE:
return <AccountMigrationComplete onDismiss={onDismiss} />;
case AccountMigrationState.ERROR:
return <AccountMigrationError onDismiss={onDismiss} />;
}
return null;
})()}
</div>
</Modal>
);
}

AccountMigration.propTypes = {
currentUserAccount: PropTypes.instanceOf(UserAccountRecord),
migration: PropTypes.instanceOf(AccountMigrationRecord),
onDismiss: PropTypes.func.isRequired,
onMigrate: PropTypes.func.isRequired,
};

AccountMigration.defaultProps = {
currentUserAccount: null,
migration: null,
};
28 changes: 28 additions & 0 deletions src/components/AccountMigrationComplete.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import classnames from 'classnames';
import {t} from 'i18next';
import React, {Fragment} from 'react';
import PropTypes from 'prop-types';

export default function AccountMigrationComplete({onDismiss}) {
return (
<Fragment>
<p>
{t('account-migration.complete')}
</p>
<div className="account-migration__buttons">
<button
className={classnames(
'account-migration__button',
)}
onClick={onDismiss}
>
{t('account-migration.buttons.dismiss')}
</button>
</div>
</Fragment>
);
}

AccountMigrationComplete.propTypes = {
onDismiss: PropTypes.func.isRequired,
};
29 changes: 29 additions & 0 deletions src/components/AccountMigrationError.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import classnames from 'classnames';
import PropTypes from 'prop-types';
import React, {Fragment} from 'react';
import {t} from 'i18next';


export default function AccountMigrationError({onDismiss}) {
return (
<Fragment>
<p>
{t('account-migration.error')}
</p>
<div className="account-migration__buttons">
<button
className={classnames(
'account-migration__button',
)}
onClick={onDismiss}
>
{t('account-migration.buttons.dismiss')}
</button>
</div>
</Fragment>
);
}

AccountMigrationError.propTypes = {
onDismiss: PropTypes.func.isRequired,
};
10 changes: 10 additions & 0 deletions src/components/AccountMigrationInProgress.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from 'react';
import {t} from 'i18next';

export default function AccountMigrationInProgress() {
return (
<p>
{t('account-migration.in-progress')}
</p>
);
}
28 changes: 28 additions & 0 deletions src/components/AccountMigrationUndoGracePeriod.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import classnames from 'classnames';
import PropTypes from 'prop-types';
import React, {Fragment} from 'react';
import {t} from 'i18next';

export default function AccountMigrationUndoGracePeriod({onDismiss}) {
return (
<Fragment>
<p>
{t('account-migration.preparing')}
</p>
<div className="account-migration__buttons">
<button
className={classnames(
'account-migration__button',
)}
onClick={onDismiss}
>
{t('account-migration.buttons.stop')}
</button>
</div>
</Fragment>
);
}

AccountMigrationUndoGracePeriod.propTypes = {
onDismiss: PropTypes.func.isRequired,
};
Loading

0 comments on commit 5be011a

Please sign in to comment.