Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Account migrate/merge (experimental only) #1524

Merged
merged 9 commits into from
Aug 4, 2018
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')}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest adding a countdown timer here.

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