Skip to content

Commit ef2c850

Browse files
committed
Account migration
Saga initiates account migration, which is fully encapsulated in Firebase client. Client creates a second SDK instance which is used to log into the old (GitHub) account using the credential. This instance is used to retrieve old projects and then save them to the current account. Then the credential is detached from the old account and attached to the new one.
1 parent cf4d290 commit ef2c850

13 files changed

Lines changed: 251 additions & 11 deletions

File tree

locales/en/translation.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,19 +78,24 @@
7878
"account-migration": {
7979
"header": {
8080
"proposed": "Combine these accounts?",
81-
"undo-grace-period": "Preparing to combine accounts…"
81+
"undo-grace-period": "Preparing to combine accounts…",
82+
"in-progress": "Combining accounts…",
83+
"complete": "All done!"
8284
},
8385
"proposal": [
8486
"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?",
8587
"All of the saved projects from the GitHub-linked account will be transferred into the account you’re using now."
8688
],
8789
"preparing": "Popcode is preparing to combine your accounts. If you do not wish to do this, click the button below:",
90+
"in-progress": "Popcode is transferring the projects from the other account into your current one.",
91+
"complete": "Popcode has finished combining your accounts.",
8892
"your-account": "Your account",
8993
"account-to-merge": "Account to merge",
9094
"buttons": {
9195
"migrate": "Combine these accounts",
9296
"cancel": "Keep them separate",
93-
"stop": "Stop"
97+
"stop": "Stop",
98+
"dismiss": "Done"
9499
}
95100
},
96101
"utility": {

src/actions/user.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ export const startAccountMigration = createAction('START_ACCOUNT_MIGRATION');
2727
export const dismissAccountMigration =
2828
createAction('DISMISS_ACCOUNT_MIGRATION');
2929

30+
export const accountMigrationUndoPeriodExpired =
31+
createAction('ACCOUNT_MIGRATION_UNDO_PERIOD_EXPIRED');
32+
33+
export const accountMigrationComplete = createAction(
34+
'ACCOUNT_MIGRATION_COMPLETE',
35+
(projects, credential) => ({projects, credential}),
36+
);
37+
3038
export const logOut = createAction('LOG_OUT');
3139

3240
export const userAuthenticated = createAction(

src/clients/firebase.js

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,12 @@ async function loadDatabaseSdk() {
4040
);
4141
}
4242

43-
function buildFirebase() {
43+
function buildFirebase(appName = undefined) {
4444
const app = firebase.initializeApp({
4545
apiKey: config.firebaseApiKey,
4646
authDomain: `${config.firebaseApp}.firebaseapp.com`,
4747
databaseURL: `https://${config.firebaseApp}.firebaseio.com`,
48-
});
48+
}, appName);
4949

5050
return {
5151
auth: firebase.auth(app),
@@ -149,6 +149,51 @@ export async function linkGithub() {
149149
return userCredential.credential;
150150
}
151151

152+
export async function migrateAccount(inboundAccountCredential) {
153+
const inboundAccountFirebase = buildFirebase('migration');
154+
const {auth: inboundAccountAuth} = inboundAccountFirebase;
155+
try {
156+
await inboundAccountAuth.signInWithCredential(inboundAccountCredential);
157+
158+
const migratedProjects = await migrateProjects(inboundAccountFirebase);
159+
await migrateCredential(inboundAccountCredential, inboundAccountFirebase);
160+
161+
return migratedProjects;
162+
} finally {
163+
inboundAccountAuth.app.delete();
164+
}
165+
}
166+
167+
async function migrateCredential(credential, {auth: inboundAccountAuth}) {
168+
await inboundAccountAuth.currentUser.unlink(credential.providerId);
169+
await auth.currentUser.linkWithCredential(credential);
170+
await saveUserCredential({user: auth.currentUser, credential});
171+
}
172+
173+
async function migrateProjects({
174+
auth: inboundAccountAuth,
175+
loadDatabase: loadinboundAccountDatabase,
176+
}) {
177+
const currentAccountDatabase = await loadDatabase();
178+
const inboundAccountDatabase = await loadinboundAccountDatabase();
179+
180+
const allProjectsValue = await inboundAccountDatabase.
181+
ref(`workspaces/${inboundAccountAuth.currentUser.uid}/projects`).
182+
once('value');
183+
184+
if (!isNull(allProjectsValue)) {
185+
const allProjects = allProjectsValue.val();
186+
187+
await currentAccountDatabase.
188+
ref(`workspaces/${auth.currentUser.uid}/projects`).
189+
update(allProjects);
190+
191+
return values(allProjects);
192+
}
193+
194+
return [];
195+
}
196+
152197
async function signInWithGithub() {
153198
return auth.signInWithPopup(githubAuthProvider);
154199
}

src/components/AccountMigration.jsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
} from '../records';
1010
import {AccountMigrationState} from '../enums';
1111

12+
import AccountMigrationComplete from './AccountMigrationComplete';
13+
import AccountMigrationInProgress from './AccountMigrationInProgress';
1214
import AccountMigrationUndoGracePeriod
1315
from './AccountMigrationUndoGracePeriod';
1416
import Modal from './Modal';
@@ -74,6 +76,10 @@ export default function AccountMigration({
7476
);
7577
case AccountMigrationState.UNDO_GRACE_PERIOD:
7678
return <AccountMigrationUndoGracePeriod onDismiss={onDismiss} />;
79+
case AccountMigrationState.IN_PROGRESS:
80+
return <AccountMigrationInProgress />;
81+
case AccountMigrationState.COMPLETE:
82+
return <AccountMigrationComplete onDismiss={onDismiss} />;
7783
}
7884
return null;
7985
})()}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import classnames from 'classnames';
2+
import {t} from 'i18next';
3+
import React, {Fragment} from 'react';
4+
import PropTypes from 'prop-types';
5+
6+
export default function AccountMigrationComplete({onDismiss}) {
7+
return (
8+
<Fragment>
9+
<p>
10+
{t('account-migration.complete')}
11+
</p>
12+
<div className="account-migration__buttons">
13+
<button
14+
className={classnames(
15+
'account-migration__button',
16+
)}
17+
onClick={onDismiss}
18+
>
19+
{t('account-migration.buttons.dismiss')}
20+
</button>
21+
</div>
22+
</Fragment>
23+
);
24+
}
25+
26+
AccountMigrationComplete.propTypes = {
27+
onDismiss: PropTypes.func.isRequired,
28+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import React from 'react';
2+
import {t} from 'i18next';
3+
4+
export default function AccountMigrationInProgress() {
5+
return (
6+
<p>
7+
{t('account-migration.in-progress')}
8+
</p>
9+
);
10+
}

src/records/AccountMigration.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ import {AccountMigrationState} from '../enums';
55
export default Record({
66
state: AccountMigrationState.PROPOSED,
77
userAccountToMerge: null,
8+
firebaseCredential: null,
89
}, 'AccountMigration');

src/reducers/projects.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ export default function reduceProjects(stateIn, action) {
106106
case 'PROJECTS_LOADED':
107107
return action.payload.reduce(addProject, state);
108108

109+
case 'ACCOUNT_MIGRATION_COMPLETE':
110+
return action.payload.projects.reduce(addProject, state);
111+
109112
case 'UPDATE_PROJECT_SOURCE':
110113
return state.setIn(
111114
[action.payload.projectKey, 'sources', action.payload.language],

src/reducers/user.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ function user(stateIn, action) {
6666
action.payload.profile,
6767
action.payload.credential,
6868
),
69+
firebaseCredential: action.payload.credential,
6970
}),
7071
);
7172

@@ -78,6 +79,21 @@ function user(stateIn, action) {
7879
case 'DISMISS_ACCOUNT_MIGRATION':
7980
return state.delete('currentMigration');
8081

82+
case 'ACCOUNT_MIGRATION_UNDO_PERIOD_EXPIRED':
83+
return state.setIn(
84+
['currentMigration', 'state'],
85+
AccountMigrationState.IN_PROGRESS,
86+
);
87+
88+
case 'ACCOUNT_MIGRATION_COMPLETE':
89+
return addCredential(
90+
state.setIn(
91+
['currentMigration', 'state'],
92+
AccountMigrationState.COMPLETE,
93+
),
94+
action.payload.credential,
95+
);
96+
8197
case 'USER_LOGGED_OUT':
8298
return new User().set('loginState', LoginState.ANONYMOUS);
8399

src/sagas/user.js

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,35 @@ import {bugsnagClient} from '../util/bugsnag';
22
import isEmpty from 'lodash-es/isEmpty';
33
import isError from 'lodash-es/isError';
44
import isString from 'lodash-es/isString';
5-
import {all, call, put, take, takeEvery} from 'redux-saga/effects';
5+
import {
6+
all,
7+
call,
8+
put,
9+
race,
10+
select,
11+
take,
12+
takeEvery,
13+
} from 'redux-saga/effects';
14+
import {delay} from 'redux-saga';
615
import isNil from 'lodash-es/isNil';
716
import {notificationTriggered} from '../actions/ui';
817
import {
18+
accountMigrationComplete,
919
accountMigrationNeeded,
20+
accountMigrationUndoPeriodExpired,
1021
identityLinked,
1122
linkIdentityFailed,
1223
userAuthenticated,
1324
userLoggedOut,
1425
} from '../actions/user';
26+
import {
27+
getCurrentAccountMigration,
28+
} from '../selectors';
1529
import loginState from '../channels/loginState';
1630
import {
1731
getSessionUid,
1832
linkGithub,
33+
migrateAccount,
1934
signIn,
2035
signOut,
2136
startSessionHeartbeat,
@@ -122,6 +137,17 @@ export function* linkGithubIdentity() {
122137
}
123138
}
124139

140+
export function* startAccountMigration() {
141+
yield race({
142+
shouldContinue: call(delay, 5000, true),
143+
cancel: take('DISMISS_ACCOUNT_MIGRATION'),
144+
});
145+
yield put(accountMigrationUndoPeriodExpired());
146+
const {firebaseCredential} = yield select(getCurrentAccountMigration);
147+
const projects = yield call(migrateAccount, firebaseCredential);
148+
yield put(accountMigrationComplete(projects, firebaseCredential));
149+
}
150+
125151
export function* logOut() {
126152
yield call(signOut);
127153
}
@@ -132,5 +158,6 @@ export default function* () {
132158
takeEvery('LINK_GITHUB_IDENTITY', linkGithubIdentity),
133159
takeEvery('LOG_IN', logIn),
134160
takeEvery('LOG_OUT', logOut),
161+
takeEvery('START_ACCOUNT_MIGRATION', startAccountMigration),
135162
]);
136163
}

0 commit comments

Comments
 (0)