Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,9 @@ const ui = initializeUI({

#### `autoUpgradeAnonymousUsers`

The `autoUpgradeAnonymousUsers` behavior will automatically upgrade a user who is anonymously authenticated with your application upon a successful sign in (including OAuth). You can optionally provide a callback to handle an upgrade (such as merging account data). During the async callback, the UI will stay in a pending state.
The `autoUpgradeAnonymousUsers` behavior will automatically upgrade a user who is anonymously authenticated with your application upon a successful sign in (including OAuth). You can optionally provide callbacks to handle successful upgrades and failed upgrade attempts. During async callbacks, the UI will stay in a pending state.

When an upgrade succeeds, the anonymous user's UID is preserved and the new credential is linked to that user. When an upgrade fails (for example, because an OAuth credential is already linked to another account), `onUpgradeFailure` receives the original error and the anonymous user's `oldUserId` so your app can decide whether to migrate anonymous user data into the existing account. Return `"handled"` from `onUpgradeFailure` to suppress the default FirebaseUI error. Return `undefined`, omit the callback, or throw from the callback to preserve the default error behavior.

```ts
import { autoUpgradeAnonymousUsers } from '@firebase-oss/ui-core';
Expand All @@ -346,7 +348,12 @@ const ui = initializeUI({
behaviors: [autoUpgradeAnonymousUsers({
async onUpgrade(ui, oldUserId, credential) {
// Some account upgrade logic.
}
},
async onUpgradeFailure({ ui, oldUserId, error, credential, provider }) {
// Optional merge-conflict handling.
// Return "handled" if your app handled the failure and FirebaseUI
// should not show the default error.
},
})],
});
```
Expand Down
126 changes: 126 additions & 0 deletions packages/core/src/behaviors/anonymous-upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ vi.mock("~/behaviors", () => ({

beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
});

describe("autoUpgradeAnonymousCredentialHandler", () => {
Expand Down Expand Up @@ -95,6 +96,67 @@ describe("autoUpgradeAnonymousCredentialHandler", () => {
);
});

it("should call onUpgradeFailure and rethrow when credential linking fails", async () => {
const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User;
const mockAuth = { currentUser: mockUser } as Auth;
const mockUI = createMockUI({ auth: mockAuth });
const mockCredential = { providerId: "google.com" } as AuthCredential;
const mockError = new Error("Link failed");
const onUpgradeFailure = vi.fn().mockResolvedValue(undefined);

vi.mocked(linkWithCredential).mockRejectedValue(mockError);

await expect(
autoUpgradeAnonymousCredentialHandler(mockUI, mockCredential, undefined, onUpgradeFailure)
).rejects.toThrow("Link failed");

expect(onUpgradeFailure).toHaveBeenCalledWith({
ui: mockUI,
oldUserId: "anonymous-123",
error: mockError,
credential: mockCredential,
});
});

it("should suppress credential linking errors when onUpgradeFailure returns handled", async () => {
const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User;
const mockAuth = { currentUser: mockUser } as Auth;
const mockUI = createMockUI({ auth: mockAuth });
const mockCredential = { providerId: "google.com" } as AuthCredential;
const mockError = new Error("Link failed");
const onUpgradeFailure = vi.fn().mockResolvedValue("handled");

vi.mocked(linkWithCredential).mockRejectedValue(mockError);

const result = await autoUpgradeAnonymousCredentialHandler(mockUI, mockCredential, undefined, onUpgradeFailure);

expect(result).toBeUndefined();
expect(onUpgradeFailure).toHaveBeenCalledWith({
ui: mockUI,
oldUserId: "anonymous-123",
error: mockError,
credential: mockCredential,
});
});

it("should surface onUpgradeFailure callback errors for credential linking", async () => {
const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User;
const mockAuth = { currentUser: mockUser } as Auth;
const mockUI = createMockUI({ auth: mockAuth });
const mockCredential = { providerId: "google.com" } as AuthCredential;
const mockError = new Error("Link failed");
const callbackError = new Error("Callback failed");
const onUpgradeFailure = vi.fn().mockRejectedValue(callbackError);

vi.mocked(linkWithCredential).mockRejectedValue(mockError);

await expect(
autoUpgradeAnonymousCredentialHandler(mockUI, mockCredential, undefined, onUpgradeFailure)
).rejects.toThrow("Callback failed");

expect((callbackError as Error & { cause?: unknown }).cause).toBe(mockError);
});

it("should not upgrade when user is not anonymous", async () => {
const mockUser = { isAnonymous: false, uid: "regular-user-123" } as User;
const mockAuth = { currentUser: mockUser } as Auth;
Expand Down Expand Up @@ -179,6 +241,70 @@ describe("autoUpgradeAnonymousProviderHandler", () => {
);
});

it("should call onUpgradeFailure and rethrow when provider linking fails", async () => {
const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User;
const mockAuth = { currentUser: mockUser } as Auth;
const mockUI = createMockUI({ auth: mockAuth });
const mockProvider = { providerId: "google.com" } as AuthProvider;
const mockError = new Error("Provider link failed");
const onUpgradeFailure = vi.fn().mockResolvedValue(undefined);
const mockProviderLinkStrategy = vi.fn().mockRejectedValue(mockError);
vi.mocked(getBehavior).mockReturnValue(mockProviderLinkStrategy);

await expect(
autoUpgradeAnonymousProviderHandler(mockUI, mockProvider, undefined, onUpgradeFailure)
).rejects.toThrow("Provider link failed");

expect(onUpgradeFailure).toHaveBeenCalledWith({
ui: mockUI,
oldUserId: "anonymous-123",
error: mockError,
provider: mockProvider,
});
expect(window.localStorage.getItem("fbui:upgrade:oldUserId")).toBeNull();
});

it("should suppress provider linking errors when onUpgradeFailure returns handled", async () => {
const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User;
const mockAuth = { currentUser: mockUser } as Auth;
const mockUI = createMockUI({ auth: mockAuth });
const mockProvider = { providerId: "google.com" } as AuthProvider;
const mockError = new Error("Provider link failed");
const onUpgradeFailure = vi.fn().mockResolvedValue("handled");
const mockProviderLinkStrategy = vi.fn().mockRejectedValue(mockError);
vi.mocked(getBehavior).mockReturnValue(mockProviderLinkStrategy);

const result = await autoUpgradeAnonymousProviderHandler(mockUI, mockProvider, undefined, onUpgradeFailure);

expect(result).toBeUndefined();
expect(onUpgradeFailure).toHaveBeenCalledWith({
ui: mockUI,
oldUserId: "anonymous-123",
error: mockError,
provider: mockProvider,
});
expect(window.localStorage.getItem("fbui:upgrade:oldUserId")).toBeNull();
});

it("should surface onUpgradeFailure callback errors for provider linking", async () => {
const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User;
const mockAuth = { currentUser: mockUser } as Auth;
const mockUI = createMockUI({ auth: mockAuth });
const mockProvider = { providerId: "google.com" } as AuthProvider;
const mockError = new Error("Provider link failed");
const callbackError = new Error("Callback failed");
const onUpgradeFailure = vi.fn().mockRejectedValue(callbackError);
const mockProviderLinkStrategy = vi.fn().mockRejectedValue(mockError);
vi.mocked(getBehavior).mockReturnValue(mockProviderLinkStrategy);

await expect(
autoUpgradeAnonymousProviderHandler(mockUI, mockProvider, undefined, onUpgradeFailure)
).rejects.toThrow("Callback failed");

expect((callbackError as Error & { cause?: unknown }).cause).toBe(mockError);
expect(window.localStorage.getItem("fbui:upgrade:oldUserId")).toBeNull();
});

it("should not upgrade when user is not anonymous", async () => {
const mockUser = { isAnonymous: false, uid: "regular-user-123" } as User;
const mockAuth = { currentUser: mockUser } as Auth;
Expand Down
61 changes: 53 additions & 8 deletions packages/core/src/behaviors/anonymous-upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,38 @@ import { type FirebaseUI } from "~/config";
import { getBehavior } from "~/behaviors";

export type OnUpgradeCallback = (ui: FirebaseUI, oldUserId: string, credential: UserCredential) => Promise<void> | void;
export type OnUpgradeFailureResult = "handled" | void;
export type OnUpgradeFailureContext = {
ui: FirebaseUI;
oldUserId: string;
error: unknown;
credential?: AuthCredential;
provider?: AuthProvider;
};
export type OnUpgradeFailureCallback = (
context: OnUpgradeFailureContext
) => Promise<OnUpgradeFailureResult> | OnUpgradeFailureResult;

async function handleUpgradeFailure(
context: OnUpgradeFailureContext,
onUpgradeFailure?: OnUpgradeFailureCallback
): Promise<boolean> {
try {
return (await onUpgradeFailure?.(context)) === "handled";
} catch (callbackError) {
if (callbackError instanceof Error && !("cause" in callbackError)) {
(callbackError as Error & { cause?: unknown }).cause = context.error;
}

throw callbackError;
}
}

export const autoUpgradeAnonymousCredentialHandler = async (
ui: FirebaseUI,
credential: AuthCredential,
onUpgrade?: OnUpgradeCallback
onUpgrade?: OnUpgradeCallback,
onUpgradeFailure?: OnUpgradeFailureCallback
) => {
const currentUser = ui.auth.currentUser;

Expand All @@ -33,7 +60,17 @@ export const autoUpgradeAnonymousCredentialHandler = async (

const oldUserId = currentUser.uid;

const result = await linkWithCredential(currentUser, credential);
let result: UserCredential;

try {
result = await linkWithCredential(currentUser, credential);
} catch (error) {
if (await handleUpgradeFailure({ ui, oldUserId, error, credential }, onUpgradeFailure)) {
return;
}

throw error;
}

if (onUpgrade) {
await onUpgrade(ui, oldUserId, result);
Expand All @@ -45,7 +82,8 @@ export const autoUpgradeAnonymousCredentialHandler = async (
export const autoUpgradeAnonymousProviderHandler = async (
ui: FirebaseUI,
provider: AuthProvider,
onUpgrade?: OnUpgradeCallback
onUpgrade?: OnUpgradeCallback,
onUpgradeFailure?: OnUpgradeFailureCallback
) => {
const currentUser = ui.auth.currentUser;

Expand All @@ -57,13 +95,20 @@ export const autoUpgradeAnonymousProviderHandler = async (

window.localStorage.setItem("fbui:upgrade:oldUserId", oldUserId);

const result = await getBehavior(ui, "providerLinkStrategy")(ui, currentUser, provider);
let result: UserCredential;

// If we got here, the user has been linked via a popup, so we need to call the onUpgrade callback
// and delete the oldUserId from localStorage.
// If we didn't get here, they'll be redirected and we'll handle the result inside of the autoUpgradeAnonymousUserRedirectHandler.
try {
result = await getBehavior(ui, "providerLinkStrategy")(ui, currentUser, provider);
} catch (error) {
if (await handleUpgradeFailure({ ui, oldUserId, error, provider }, onUpgradeFailure)) {
return;
}

window.localStorage.removeItem("fbui:upgrade:oldUserId");
throw error;
} finally {
// When the link attempt settles locally, the stored ID is no longer needed.
window.localStorage.removeItem("fbui:upgrade:oldUserId");
}
Comment thread
russellwheatley marked this conversation as resolved.
Outdated

if (onUpgrade) {
await onUpgrade(ui, oldUserId, result);
Expand Down
30 changes: 27 additions & 3 deletions packages/core/src/behaviors/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,23 @@ describe("autoUpgradeAnonymousUsers", () => {
expect(typeof behavior.autoUpgradeAnonymousUserRedirectHandler.handler).toBe("function");
});

it("should work with onUpgradeFailure callback option", () => {
const mockOnUpgradeFailure = vi.fn();
const behavior = autoUpgradeAnonymousUsers({ onUpgradeFailure: mockOnUpgradeFailure });

expect(behavior).toHaveProperty("autoUpgradeAnonymousCredential");
expect(behavior).toHaveProperty("autoUpgradeAnonymousProvider");
expect(behavior).toHaveProperty("autoUpgradeAnonymousUserRedirectHandler");

expect(typeof behavior.autoUpgradeAnonymousCredential.handler).toBe("function");
expect(typeof behavior.autoUpgradeAnonymousProvider.handler).toBe("function");
expect(typeof behavior.autoUpgradeAnonymousUserRedirectHandler.handler).toBe("function");
});

it("should pass onUpgrade callback to handlers when called", async () => {
const mockOnUpgrade = vi.fn();
const behavior = autoUpgradeAnonymousUsers({ onUpgrade: mockOnUpgrade });
const mockOnUpgradeFailure = vi.fn();
const behavior = autoUpgradeAnonymousUsers({ onUpgrade: mockOnUpgrade, onUpgradeFailure: mockOnUpgradeFailure });

const mockUI = createMockUI();
const mockCredential = { providerId: "password" } as any;
Expand All @@ -192,8 +206,18 @@ describe("autoUpgradeAnonymousUsers", () => {
await behavior.autoUpgradeAnonymousProvider.handler(mockUI, mockProvider);
await behavior.autoUpgradeAnonymousUserRedirectHandler.handler(mockUI, mockUserCredential);

expect(autoUpgradeAnonymousCredentialHandler).toHaveBeenCalledWith(mockUI, mockCredential, mockOnUpgrade);
expect(autoUpgradeAnonymousProviderHandler).toHaveBeenCalledWith(mockUI, mockProvider, mockOnUpgrade);
expect(autoUpgradeAnonymousCredentialHandler).toHaveBeenCalledWith(
mockUI,
mockCredential,
mockOnUpgrade,
mockOnUpgradeFailure
);
expect(autoUpgradeAnonymousProviderHandler).toHaveBeenCalledWith(
mockUI,
mockProvider,
mockOnUpgrade,
mockOnUpgradeFailure
);
expect(autoUpgradeAnonymousUserRedirectHandler).toHaveBeenCalledWith(mockUI, mockUserCredential, mockOnUpgrade);
});

Expand Down
16 changes: 14 additions & 2 deletions packages/core/src/behaviors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export function autoAnonymousLogin(): Behavior<"autoAnonymousLogin"> {
export type AutoUpgradeAnonymousUsersOptions = {
/** Optional callback function that is called when an anonymous user is upgraded. */
onUpgrade?: anonymousUpgradeHandlers.OnUpgradeCallback;
/** Optional callback function that is called when credential or provider linking fails locally. */
onUpgradeFailure?: anonymousUpgradeHandlers.OnUpgradeFailureCallback;
};

/**
Expand All @@ -91,10 +93,20 @@ export function autoUpgradeAnonymousUsers(
> {
return {
autoUpgradeAnonymousCredential: callableBehavior((ui, credential) =>
anonymousUpgradeHandlers.autoUpgradeAnonymousCredentialHandler(ui, credential, options?.onUpgrade)
anonymousUpgradeHandlers.autoUpgradeAnonymousCredentialHandler(
ui,
credential,
options?.onUpgrade,
options?.onUpgradeFailure
)
),
autoUpgradeAnonymousProvider: callableBehavior((ui, provider) =>
anonymousUpgradeHandlers.autoUpgradeAnonymousProviderHandler(ui, provider, options?.onUpgrade)
anonymousUpgradeHandlers.autoUpgradeAnonymousProviderHandler(
ui,
provider,
options?.onUpgrade,
options?.onUpgradeFailure
)
),
autoUpgradeAnonymousUserRedirectHandler: redirectBehavior((ui, credential) =>
anonymousUpgradeHandlers.autoUpgradeAnonymousUserRedirectHandler(ui, credential, options?.onUpgrade)
Expand Down
Loading