Skip to content

Commit e2f5d11

Browse files
authored
feat: Auto link with account exists
2 parents e20f19c + fb9eb0f commit e2f5d11

File tree

11 files changed

+335
-56
lines changed

11 files changed

+335
-56
lines changed

packages/firebaseui-core/src/auth.ts

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,21 @@ import {
2121
import { handleFirebaseError } from './errors';
2222
import { type TranslationsConfig } from './translations';
2323

24+
async function handlePendingCredential(user: UserCredential): Promise<UserCredential> {
25+
const pendingCredString = window.sessionStorage.getItem('pendingCred');
26+
if (!pendingCredString) return user;
27+
28+
try {
29+
const pendingCred = JSON.parse(pendingCredString);
30+
const result = await linkWithCredential(user.user, pendingCred);
31+
window.sessionStorage.removeItem('pendingCred');
32+
return result;
33+
} catch (error) {
34+
window.sessionStorage.removeItem('pendingCred');
35+
return user;
36+
}
37+
}
38+
2439
export async function fuiSignInWithEmailAndPassword(
2540
auth: Auth,
2641
email: string,
@@ -29,19 +44,22 @@ export async function fuiSignInWithEmailAndPassword(
2944
language?: string;
3045
translations?: TranslationsConfig;
3146
enableAutoUpgradeAnonymous?: boolean;
47+
enableHandleExistingCredential?: boolean;
3248
}
3349
): Promise<UserCredential> {
3450
try {
3551
const currentUser = auth.currentUser;
3652
const credential = EmailAuthProvider.credential(email, password);
3753

3854
if (currentUser?.isAnonymous && opts?.enableAutoUpgradeAnonymous) {
39-
return await linkWithCredential(currentUser, credential);
55+
const result = await linkWithCredential(currentUser, credential);
56+
return handlePendingCredential(result);
4057
}
4158

42-
return await signInWithCredential(auth, credential);
59+
const result = await signInWithCredential(auth, credential);
60+
return handlePendingCredential(result);
4361
} catch (error) {
44-
handleFirebaseError(error, opts?.translations, opts?.language);
62+
return handleFirebaseError(error, opts);
4563
}
4664
}
4765

@@ -53,19 +71,22 @@ export async function fuiCreateUserWithEmailAndPassword(
5371
language?: string;
5472
translations?: TranslationsConfig;
5573
enableAutoUpgradeAnonymous?: boolean;
74+
enableHandleExistingCredential?: boolean;
5675
}
5776
): Promise<UserCredential> {
5877
try {
5978
const currentUser = auth.currentUser;
6079
const credential = EmailAuthProvider.credential(email, password);
6180

6281
if (currentUser?.isAnonymous && opts?.enableAutoUpgradeAnonymous) {
63-
return await linkWithCredential(currentUser, credential);
82+
const result = await linkWithCredential(currentUser, credential);
83+
return handlePendingCredential(result);
6484
}
6585

66-
return await createUserWithEmailAndPassword(auth, email, password);
86+
const result = await createUserWithEmailAndPassword(auth, email, password);
87+
return handlePendingCredential(result);
6788
} catch (error) {
68-
handleFirebaseError(error, opts?.translations, opts?.language);
89+
return handleFirebaseError(error, opts);
6990
}
7091
}
7192

@@ -81,7 +102,7 @@ export async function fuiSignInWithPhoneNumber(
81102
try {
82103
return await signInWithPhoneNumber(auth, phoneNumber, recaptchaVerifier);
83104
} catch (error) {
84-
handleFirebaseError(error, opts?.translations, opts?.language);
105+
return (await handleFirebaseError(error, opts)) as never;
85106
}
86107
}
87108

@@ -92,6 +113,7 @@ export async function fuiConfirmPhoneNumber(
92113
language?: string;
93114
translations?: TranslationsConfig;
94115
enableAutoUpgradeAnonymous?: boolean;
116+
enableHandleExistingCredential?: boolean;
95117
}
96118
): Promise<UserCredential> {
97119
try {
@@ -101,12 +123,13 @@ export async function fuiConfirmPhoneNumber(
101123

102124
if (currentUser?.isAnonymous && opts?.enableAutoUpgradeAnonymous) {
103125
const result = await linkWithCredential(currentUser, credential);
104-
return result;
126+
return handlePendingCredential(result);
105127
}
106128

107-
return await signInWithCredential(auth, credential);
129+
const result = await signInWithCredential(auth, credential);
130+
return handlePendingCredential(result);
108131
} catch (error) {
109-
handleFirebaseError(error, opts?.translations, opts?.language);
132+
return handleFirebaseError(error, opts);
110133
}
111134
}
112135

@@ -121,7 +144,7 @@ export async function fuiSendPasswordResetEmail(
121144
try {
122145
await sendPasswordResetEmail(auth, email);
123146
} catch (error) {
124-
handleFirebaseError(error, opts?.translations, opts?.language);
147+
return (await handleFirebaseError(error, opts)) as never;
125148
}
126149
}
127150

@@ -148,7 +171,7 @@ export async function fuiSendSignInLinkToEmail(
148171
await sendSignInLinkToEmail(auth, email, actionCodeSettings);
149172
window.localStorage.setItem('emailForSignIn', email);
150173
} catch (error) {
151-
handleFirebaseError(error, opts?.translations, opts?.language);
174+
return (await handleFirebaseError(error, opts)) as never;
152175
}
153176
}
154177

@@ -164,6 +187,7 @@ export async function fuiSignInWithEmailLink(
164187
language?: string;
165188
translations?: TranslationsConfig;
166189
enableAutoUpgradeAnonymous?: boolean;
190+
enableHandleExistingCredential?: boolean;
167191
}
168192
): Promise<UserCredential> {
169193
try {
@@ -174,14 +198,14 @@ export async function fuiSignInWithEmailLink(
174198
if (currentUser?.isAnonymous && isAnonymousUpgrade && opts?.enableAutoUpgradeAnonymous) {
175199
const result = await linkWithCredential(currentUser, credential);
176200
window.localStorage.removeItem('emailLinkAnonymousUpgrade');
177-
return result;
201+
return handlePendingCredential(result);
178202
}
179203

180204
const result = await signInWithCredential(auth, credential);
181205
window.localStorage.removeItem('emailLinkAnonymousUpgrade');
182-
return result;
206+
return handlePendingCredential(result);
183207
} catch (error) {
184-
handleFirebaseError(error, opts?.translations, opts?.language);
208+
return handleFirebaseError(error, opts);
185209
}
186210
}
187211

@@ -193,9 +217,10 @@ export async function fuiSignInAnonymously(
193217
}
194218
): Promise<UserCredential> {
195219
try {
196-
return await signInAnonymously(auth);
220+
const result = await signInAnonymously(auth);
221+
return handlePendingCredential(result);
197222
} catch (error) {
198-
handleFirebaseError(error, opts?.translations, opts?.language);
223+
return handleFirebaseError(error, opts);
199224
}
200225
}
201226

@@ -206,6 +231,7 @@ export async function fuiSignInWithOAuth(
206231
language?: string;
207232
translations?: TranslationsConfig;
208233
enableAutoUpgradeAnonymous?: boolean;
234+
enableHandleExistingCredential?: boolean;
209235
}
210236
): Promise<void> {
211237
try {
@@ -217,7 +243,7 @@ export async function fuiSignInWithOAuth(
217243
await signInWithRedirect(auth, provider);
218244
}
219245
} catch (error) {
220-
handleFirebaseError(error, opts?.translations, opts?.language);
246+
return (await handleFirebaseError(error, opts)) as never;
221247
}
222248
}
223249

@@ -228,6 +254,7 @@ export async function fuiCompleteEmailLinkSignIn(
228254
language?: string;
229255
translations?: TranslationsConfig;
230256
enableAutoUpgradeAnonymous?: boolean;
257+
enableHandleExistingCredential?: boolean;
231258
}
232259
): Promise<UserCredential | null> {
233260
try {
@@ -239,9 +266,11 @@ export async function fuiCompleteEmailLinkSignIn(
239266
if (!email) return null;
240267

241268
const result = await fuiSignInWithEmailLink(auth, email, currentUrl, opts);
242-
window.localStorage.removeItem('emailForSignIn');
243-
return result;
269+
return handlePendingCredential(result);
244270
} catch (error) {
245-
handleFirebaseError(error, opts?.translations, opts?.language);
271+
return handleFirebaseError(error, opts);
272+
} finally {
273+
window.localStorage.removeItem('emailForSignIn');
274+
window.localStorage.removeItem('emailLinkAnonymousUpgrade');
246275
}
247276
}

packages/firebaseui-core/src/errors.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { UserCredential } from 'firebase/auth';
12
import { ERROR_CODE_MAP, getTranslation, type TranslationsConfig } from './translations';
23

34
export class FirebaseUIError extends Error {
@@ -14,10 +15,30 @@ export class FirebaseUIError extends Error {
1415
}
1516
}
1617

17-
export function handleFirebaseError(error: any, translations?: TranslationsConfig, language?: string): never {
18+
export async function handleFirebaseError(
19+
error: any,
20+
opts?: { language?: string; translations?: TranslationsConfig; enableHandleExistingCredential?: boolean }
21+
): Promise<never | UserCredential> {
22+
if (error?.code === 'auth/account-exists-with-different-credential' && opts?.enableHandleExistingCredential) {
23+
if (error.credential) {
24+
window.sessionStorage.setItem('pendingCred', JSON.stringify(error.credential));
25+
}
26+
27+
throw new FirebaseUIError(
28+
{
29+
code: 'auth/account-exists-with-different-credential',
30+
customData: {
31+
email: error.customData?.email,
32+
},
33+
},
34+
opts?.translations,
35+
opts?.language
36+
);
37+
}
38+
1839
// TODO: Debug why instanceof FirebaseError is not working
1940
if (error?.name === 'FirebaseError') {
20-
throw new FirebaseUIError(error, translations, language);
41+
throw new FirebaseUIError(error, opts?.translations, opts?.language);
2142
}
22-
throw new FirebaseUIError({ code: 'unknown' }, translations, language);
43+
throw new FirebaseUIError({ code: 'unknown' }, opts?.translations, opts?.language);
2344
}

packages/firebaseui-core/src/translations.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const ERROR_CODE_MAP: Record<string, ErrorKey> = {
2626
'auth/requires-recent-login': 'requiresRecentLogin',
2727
'auth/provider-already-linked': 'providerAlreadyLinked',
2828
'auth/invalid-verification-code': 'invalidVerificationCode',
29+
'auth/account-exists-with-different-credential': 'accountExistsWithDifferentCredential',
2930
};
3031

3132
type TranslationCategory = keyof Required<TranslationStrings>;
@@ -82,6 +83,8 @@ export const defaultTranslations: Record<'en', TranslationStrings> = {
8283
invalidVerificationCode: 'Invalid verification code. Please try again',
8384
unknownError: 'An unexpected error occurred',
8485
popupClosed: 'The sign-in popup was closed. Please try again.',
86+
accountExistsWithDifferentCredential:
87+
'An account already exists with this email. Please sign in with the original provider.',
8588
},
8689
messages: {
8790
passwordResetEmailSent: 'Password reset email sent successfully',

packages/firebaseui-core/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export type TranslationStrings = {
2525
invalidVerificationCode?: string;
2626
unknownError?: string;
2727
popupClosed?: string;
28+
accountExistsWithDifferentCredential?: string;
2829
};
2930
messages?: {
3031
passwordResetEmailSent?: string;
@@ -73,6 +74,7 @@ export interface FUIConfig {
7374
language?: string;
7475
enableAutoAnonymousLogin?: boolean;
7576
enableAutoUpgradeAnonymous?: boolean;
77+
enableHandleExistingCredential?: boolean;
7678
translations?: Partial<Record<string, Partial<TranslationStrings>>>;
7779
tosUrl?: string;
7880
privacyPolicyUrl?: string;

packages/firebaseui-core/tests/unit/auth.test.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import {
2828
fuiSignInWithOAuth,
2929
fuiCompleteEmailLinkSignIn,
3030
} from '../../src/auth';
31-
import { FirebaseUIError } from '../../src/errors';
3231

3332
// Mock all Firebase Auth functions
3433
vi.mock('firebase/auth', async () => {
@@ -69,6 +68,7 @@ describe('Firebase UI Auth', () => {
6968
vi.clearAllMocks();
7069
mockAuth = { currentUser: null } as Auth;
7170
window.localStorage.clear();
71+
window.sessionStorage.clear();
7272
(EmailAuthProvider.credential as any).mockReturnValue(mockCredential);
7373
(EmailAuthProvider.credentialWithLink as any).mockReturnValue(mockCredential);
7474
(PhoneAuthProvider.credential as any).mockReturnValue(mockCredential);
@@ -338,5 +338,85 @@ describe('Firebase UI Auth', () => {
338338

339339
expect(result).toBeNull();
340340
});
341+
342+
it('should clean up storage even when sign in fails', async () => {
343+
window.localStorage.setItem('emailForSignIn', '[email protected]');
344+
window.localStorage.setItem('emailLinkAnonymousUpgrade', 'true');
345+
346+
(isSignInWithEmailLink as any).mockReturnValue(true);
347+
(signInWithCredential as any).mockRejectedValue(new Error('Sign in failed'));
348+
349+
await expect(fuiCompleteEmailLinkSignIn(mockAuth, 'mock-url')).rejects.toThrow();
350+
351+
expect(window.localStorage.getItem('emailForSignIn')).toBeNull();
352+
expect(window.localStorage.getItem('emailLinkAnonymousUpgrade')).toBeNull();
353+
});
354+
});
355+
356+
describe('Pending Credential Handling', () => {
357+
it('should handle pending credential during email sign in', async () => {
358+
const storedCred = { type: 'google.com', token: 'stored-token' };
359+
window.sessionStorage.setItem('pendingCred', JSON.stringify(storedCred));
360+
(signInWithCredential as any).mockResolvedValue(mockUserCredential);
361+
(linkWithCredential as any).mockResolvedValue({ ...mockUserCredential, user: { uid: 'linked-uid' } });
362+
363+
const result = await fuiSignInWithEmailAndPassword(mockAuth, '[email protected]', 'password');
364+
365+
expect(linkWithCredential).toHaveBeenCalledWith(mockUserCredential.user, storedCred);
366+
expect(window.sessionStorage.getItem('pendingCred')).toBeNull();
367+
expect(result.user.uid).toBe('linked-uid');
368+
});
369+
370+
it('should handle invalid pending credential gracefully', async () => {
371+
window.sessionStorage.setItem('pendingCred', 'invalid-json');
372+
(signInWithCredential as any).mockResolvedValue(mockUserCredential);
373+
374+
const result = await fuiSignInWithEmailAndPassword(mockAuth, '[email protected]', 'password');
375+
376+
expect(result).toBe(mockUserCredential);
377+
expect(window.sessionStorage.getItem('pendingCred')).toBeNull();
378+
});
379+
380+
it('should handle linking failure gracefully', async () => {
381+
const storedCred = { type: 'google.com', token: 'stored-token' };
382+
window.sessionStorage.setItem('pendingCred', JSON.stringify(storedCred));
383+
(signInWithCredential as any).mockResolvedValue(mockUserCredential);
384+
(linkWithCredential as any).mockRejectedValue(new Error('Linking failed'));
385+
386+
const result = await fuiSignInWithEmailAndPassword(mockAuth, '[email protected]', 'password');
387+
388+
expect(result).toBe(mockUserCredential);
389+
expect(window.sessionStorage.getItem('pendingCred')).toBeNull();
390+
});
391+
});
392+
393+
describe('Storage Management', () => {
394+
it('should clean up all storage items after successful email link sign in', async () => {
395+
window.localStorage.setItem('emailForSignIn', '[email protected]');
396+
window.localStorage.setItem('emailLinkAnonymousUpgrade', 'true');
397+
window.sessionStorage.setItem('pendingCred', JSON.stringify(mockCredential));
398+
399+
(isSignInWithEmailLink as any).mockReturnValue(true);
400+
(signInWithCredential as any).mockResolvedValue(mockUserCredential);
401+
402+
await fuiCompleteEmailLinkSignIn(mockAuth, 'mock-url');
403+
404+
expect(window.localStorage.getItem('emailForSignIn')).toBeNull();
405+
expect(window.localStorage.getItem('emailLinkAnonymousUpgrade')).toBeNull();
406+
expect(window.sessionStorage.getItem('pendingCred')).toBeNull();
407+
});
408+
409+
it('should clean up storage even when sign in fails', async () => {
410+
window.localStorage.setItem('emailForSignIn', '[email protected]');
411+
window.localStorage.setItem('emailLinkAnonymousUpgrade', 'true');
412+
413+
(isSignInWithEmailLink as any).mockReturnValue(true);
414+
(signInWithCredential as any).mockRejectedValue(new Error('Sign in failed'));
415+
416+
await expect(fuiCompleteEmailLinkSignIn(mockAuth, 'mock-url')).rejects.toThrow();
417+
418+
expect(window.localStorage.getItem('emailForSignIn')).toBeNull();
419+
expect(window.localStorage.getItem('emailLinkAnonymousUpgrade')).toBeNull();
420+
});
341421
});
342422
});

0 commit comments

Comments
 (0)