diff --git a/README.md b/README.md index 1c5b45120..40d8ecfb9 100644 --- a/README.md +++ b/README.md @@ -395,6 +395,57 @@ const ui = initializeUI({ }); ``` +#### `legacyFetchSignInWithEmail` + +The `legacyFetchSignInWithEmail` behavior augments OAuth `auth/account-exists-with-different-credential` flows by calling `fetchSignInMethodsForEmail(auth, email)` and storing the returned methods on the UI instance. In the packaged React and Angular screen components, this recovery state is rendered automatically as a modal on `SignInAuthScreen` and `OAuthScreen`. + +The original pending credential is still preserved, so after the user signs in with the correct method, Firebase UI can continue the existing linking flow. + +```ts +import { legacyFetchSignInWithEmail } from '@firebase-oss/ui-core'; + +const ui = initializeUI({ + app, + behaviors: [legacyFetchSignInWithEmail()], +}); +``` + +If you want full control over the UI, hide the built-in recovery component on the screen and read the recovery state directly with `useLegacySignInRecovery()`: + +```tsx +import { GitHubSignInButton, GoogleSignInButton, SignInAuthScreen, useLegacySignInRecovery } from '@firebase-oss/ui-react'; + +function WrongProviderRecovery() { + const { recovery, clearRecovery } = useLegacySignInRecovery(); + + if (!recovery) { + return null; + } + + return ( +
+

You have previously signed in with a different method for {recovery.email}.

+ {recovery.signInMethods.includes('google.com') && ( + + )} + {recovery.signInMethods.includes('github.com') && ( + + )} +
+ ); +} + +export function CustomSignInScreen() { + return ( + + + + ); +} +``` + +Angular apps can hide the built-in recovery UI with `showLegacySignInRecovery="false"` and read the same state with `injectLegacySignInRecovery()` / `injectClearLegacySignInRecovery()`. + #### `oneTapSignIn` The `oneTapSignIn` behavior triggers the [Google One Tap](https://developers.google.com/identity/gsi/web/guides/features) experience to render. @@ -1061,6 +1112,7 @@ By default, any missing translations will fallback to English if not specified. | onSignIn | `(user: User) => void?` | Callback when sign-in succeeds | | onForgotPasswordClick | `() => void?` | Callback when forgot password link is clicked | | onSignUpClick | `() => void?` | Callback when sign-up link is clicked | +| showLegacySignInRecovery | `boolean?` | Whether to show the built-in legacy sign-in recovery UI | **`SignUpAuthScreen`** @@ -1113,6 +1165,7 @@ By default, any missing translations will fallback to English if not specified. |------|:----:|-------------| | onSignIn | `(user: User) => void?` | Callback when sign-in succeeds | | children | `React.ReactNode?` | Child components | +| showLegacySignInRecovery | `boolean?` | Whether to show the built-in legacy sign-in recovery UI | **`OAuthButton`** @@ -1189,6 +1242,10 @@ By default, any missing translations will fallback to English if not specified. | asChild | `boolean?` | Render as child component using Slot | | ...props | `ComponentProps<"button">` | Standard button HTML attributes | + **`LegacySignInRecovery`** + + Default component for displaying suggested previous sign-in methods from `legacyFetchSignInWithEmail`. + **`Card`** Card container component. @@ -1251,6 +1308,12 @@ By default, any missing translations will fallback to English if not specified. Returns `string | undefined`. + **`useLegacySignInRecovery`** + + Gets the legacy sign-in recovery state populated by `legacyFetchSignInWithEmail`. + + Returns `{ recovery: LegacySignInRecovery | undefined; clearRecovery: () => void }`. + **`useSignInAuthFormSchema`** Creates a Zod schema for sign-in form validation. @@ -1700,6 +1763,10 @@ By default, any missing translations will fallback to English if not specified. Screen component for email/password sign-in. + | Input | Type | Description | + |-------|:----:|-------------| + | showLegacySignInRecovery | `boolean` | Whether to show the built-in legacy sign-in recovery UI | + | Output | Type | Description | |--------|:----:|-------------| | signIn | `EventEmitter` | Emitted when sign-in succeeds | @@ -1754,6 +1821,10 @@ By default, any missing translations will fallback to English if not specified. Screen component for OAuth provider sign-in. + | Input | Type | Description | + |-------|:----:|-------------| + | showLegacySignInRecovery | `boolean` | Whether to show the built-in legacy sign-in recovery UI | + | Output | Type | Description | |--------|:----:|-------------| | onSignIn | `EventEmitter` | Emitted when OAuth sign-in succeeds | @@ -1904,6 +1975,12 @@ By default, any missing translations will fallback to English if not specified. Component that displays redirect errors from Firebase UI authentication flow. + **`LegacySignInRecoveryComponent`** + + Selector: `fui-legacy-sign-in-recovery` + + Default component for displaying suggested previous sign-in methods from `legacyFetchSignInWithEmail`. + **`ContentComponent`** Selector: `fui-content` @@ -1922,6 +1999,18 @@ By default, any missing translations will fallback to English if not specified. Returns `Signal`. + **`injectLegacySignInRecovery`** + + Injects the legacy sign-in recovery state from the UI store as a signal. + + Returns `Signal`. + + **`injectClearLegacySignInRecovery`** + + Injects a callback that clears the current legacy sign-in recovery state. + + Returns `() => void`. + **`injectTranslation`** Injects a translated string for a given category and key. diff --git a/examples/react/src/firebase/firebase.ts b/examples/react/src/firebase/firebase.ts index e0132cda3..ac1996822 100644 --- a/examples/react/src/firebase/firebase.ts +++ b/examples/react/src/firebase/firebase.ts @@ -16,7 +16,7 @@ "use client"; -import { countryCodes, initializeUI, oneTapSignIn } from "@firebase-oss/ui-core"; +import { countryCodes, initializeUI, legacyFetchSignInWithEmail, oneTapSignIn } from "@firebase-oss/ui-core"; import { getApps, initializeApp } from "firebase/app"; import { connectAuthEmulator, getAuth } from "firebase/auth"; @@ -30,6 +30,7 @@ export const ui = initializeUI({ app: firebaseApp, behaviors: [ // autoAnonymousLogin(), + legacyFetchSignInWithEmail(), oneTapSignIn({ clientId: "616577669988-led6l3rqek9ckn9t1unj4l8l67070fhp.apps.googleusercontent.com", }), diff --git a/examples/react/src/routes.ts b/examples/react/src/routes.ts index f46f72903..8bd3e3edd 100644 --- a/examples/react/src/routes.ts +++ b/examples/react/src/routes.ts @@ -1,6 +1,7 @@ import SignInAuthScreenPage from "./screens/sign-in-auth-screen"; import SignInAuthScreenWithHandlersPage from "./screens/sign-in-auth-screen-w-handlers"; import SignInAuthScreenWithOAuthPage from "./screens/sign-in-auth-screen-w-oauth"; +import LegacyRecoveryDemoPage from "./screens/legacy-recovery-demo"; import SignUpAuthScreenPage from "./screens/sign-up-auth-screen"; import SignUpAuthScreenWithHandlersPage from "./screens/sign-up-auth-screen-w-handlers"; import SignUpAuthScreenWithOAuthPage from "./screens/sign-up-auth-screen-w-oauth"; @@ -32,6 +33,12 @@ export const routes = [ path: "/screens/sign-in-auth-screen-w-oauth", component: SignInAuthScreenWithOAuthPage, }, + { + name: "Legacy Recovery Demo", + description: "Use this screen to test wrong-provider recovery for email/password and OAuth attempts.", + path: "/screens/legacy-recovery-demo", + component: LegacyRecoveryDemoPage, + }, { name: "Sign Up Screen", description: "A sign up screen with email and password.", diff --git a/examples/react/src/screens/legacy-recovery-demo.tsx b/examples/react/src/screens/legacy-recovery-demo.tsx new file mode 100644 index 000000000..004dd4d8b --- /dev/null +++ b/examples/react/src/screens/legacy-recovery-demo.tsx @@ -0,0 +1,60 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + AppleSignInButton, + FacebookSignInButton, + GitHubSignInButton, + GoogleSignInButton, + MicrosoftSignInButton, + SignInAuthScreen, + TwitterSignInButton, + YahooSignInButton, +} from "@firebase-oss/ui-react"; +import { useNavigate } from "react-router"; + +export default function LegacyRecoveryDemoPage() { + const navigate = useNavigate(); + + return ( +
+
+

Legacy recovery demo

+

Use this screen to test wrong-provider recovery with both email/password and OAuth attempts.

+

+ Suggested flow: create an account with Google first, sign out, then come back here and try the same email with + with email/password or another provider like GitHub. +

+
+ + { + navigate("/"); + }} + > +
+ + + + + + + +
+
+
+ ); +} diff --git a/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts b/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts index 9a9578775..b57b0702d 100644 --- a/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts @@ -31,6 +31,32 @@ import { import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; import { ContentComponent } from "../../components/content"; +import { LegacySignInRecoveryComponent } from "../../components/legacy-sign-in-recovery"; + +jest.mock("@angular/fire/auth", () => { + const actual = jest.requireActual("@angular/fire/auth"); + return { + ...actual, + GoogleAuthProvider: class GoogleAuthProvider { + providerId = "google.com"; + }, + GithubAuthProvider: class GithubAuthProvider { + providerId = "github.com"; + }, + FacebookAuthProvider: class FacebookAuthProvider { + providerId = "facebook.com"; + }, + TwitterAuthProvider: class TwitterAuthProvider { + providerId = "twitter.com"; + }, + OAuthProvider: class OAuthProvider { + providerId: string; + constructor(providerId: string) { + this.providerId = providerId; + } + }, + }; +}); jest.mock("../../../provider", () => ({ injectTranslation: jest.fn(), @@ -54,6 +80,13 @@ class MockPoliciesComponent {} }) class MockRedirectErrorComponent {} +@Component({ + selector: "fui-legacy-sign-in-recovery", + template: '
Legacy Recovery
', + standalone: true, +}) +class MockLegacySignInRecoveryComponent {} + @Component({ template: ` @@ -84,6 +117,13 @@ class TestHostWithMultipleProvidersComponent {} }) class TestHostWithoutContentComponent {} +@Component({ + template: ``, + standalone: true, + imports: [OAuthScreenComponent], +}) +class TestHostWithoutRecoveryComponent {} + @Component({ selector: "fui-multi-factor-auth-assertion-screen", template: '
MFA Assertion Screen
', @@ -146,6 +186,15 @@ describe("", () => { multiFactorResolver: null, }); }); + + TestBed.overrideComponent(OAuthScreenComponent, { + remove: { + imports: [LegacySignInRecoveryComponent], + }, + add: { + imports: [MockLegacySignInRecoveryComponent], + }, + }); }); afterEach(() => { @@ -161,6 +210,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -181,6 +231,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -201,6 +252,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -222,6 +274,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -261,12 +314,53 @@ describe("", () => { expect(redirectErrorElement).toBeInTheDocument(); }); + it("renders legacy recovery by default", async () => { + const { container } = await render(TestHostWithoutContentComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + expect(container.querySelector("fui-legacy-sign-in-recovery")).toBeInTheDocument(); + }); + + it("does not render legacy recovery when disabled", async () => { + const { container } = await render(TestHostWithoutRecoveryComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + expect(container.querySelector("fui-legacy-sign-in-recovery")).not.toBeInTheDocument(); + }); + it("has correct CSS classes", async () => { const { container } = await render(TestHostWithoutContentComponent, { imports: [ OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -292,6 +386,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -325,6 +420,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -358,6 +454,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -391,6 +488,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -428,6 +526,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -465,6 +564,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, diff --git a/packages/angular/src/lib/auth/screens/oauth-screen.ts b/packages/angular/src/lib/auth/screens/oauth-screen.ts index d12851600..c5f9af4c4 100644 --- a/packages/angular/src/lib/auth/screens/oauth-screen.ts +++ b/packages/angular/src/lib/auth/screens/oauth-screen.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, computed, Output, EventEmitter } from "@angular/core"; +import { Component, computed, Output, EventEmitter, input } from "@angular/core"; import { CommonModule } from "@angular/common"; import { CardComponent, @@ -27,6 +27,7 @@ import { injectTranslation, injectUI, injectUserAuthenticated } from "../../prov import { PoliciesComponent } from "../../components/policies"; import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; +import { LegacySignInRecoveryComponent } from "../../components/legacy-sign-in-recovery"; import { type User } from "@angular/fire/auth"; @Component({ @@ -45,6 +46,7 @@ import { type User } from "@angular/fire/auth"; PoliciesComponent, MultiFactorAuthAssertionScreenComponent, RedirectErrorComponent, + LegacySignInRecoveryComponent, ], template: ` @if (mfaResolver()) { @@ -59,6 +61,9 @@ import { type User } from "@angular/fire/auth";
+ @if (showLegacySignInRecovery()) { + + }
@@ -76,6 +81,8 @@ import { type User } from "@angular/fire/auth"; */ export class OAuthScreenComponent { private ui = injectUI(); + /** Whether to show the default legacy sign-in recovery UI. */ + showLegacySignInRecovery = input(true); mfaResolver = computed(() => this.ui().multiFactorResolver); diff --git a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts index bed61fbfe..019f2324d 100644 --- a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts @@ -32,6 +32,32 @@ import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; import { TotpMultiFactorAssertionFormComponent } from "../forms/mfa/totp-multi-factor-assertion-form"; import { TotpMultiFactorGenerator } from "firebase/auth"; +import { LegacySignInRecoveryComponent } from "../../components/legacy-sign-in-recovery"; + +jest.mock("@angular/fire/auth", () => { + const actual = jest.requireActual("@angular/fire/auth"); + return { + ...actual, + GoogleAuthProvider: class GoogleAuthProvider { + providerId = "google.com"; + }, + GithubAuthProvider: class GithubAuthProvider { + providerId = "github.com"; + }, + FacebookAuthProvider: class FacebookAuthProvider { + providerId = "facebook.com"; + }, + TwitterAuthProvider: class TwitterAuthProvider { + providerId = "twitter.com"; + }, + OAuthProvider: class OAuthProvider { + providerId: string; + constructor(providerId: string) { + this.providerId = providerId; + } + }, + }; +}); @Component({ selector: "fui-sign-in-auth-form", @@ -47,6 +73,13 @@ class MockSignInAuthFormComponent {} }) class MockRedirectErrorComponent {} +@Component({ + selector: "fui-legacy-sign-in-recovery", + template: '
Legacy Recovery
', + standalone: true, +}) +class MockLegacySignInRecoveryComponent {} + @Component({ template: ` @@ -65,6 +98,13 @@ class TestHostWithContentComponent {} }) class TestHostWithoutContentComponent {} +@Component({ + template: ``, + standalone: true, + imports: [SignInAuthScreenComponent], +}) +class TestHostWithoutRecoveryComponent {} + describe("", () => { let authStateSubject: Subject; let userAuthenticatedCallback: ((user: User) => void) | null = null; @@ -106,6 +146,15 @@ describe("", () => { multiFactorResolver: null, }); }); + + TestBed.overrideComponent(SignInAuthScreenComponent, { + remove: { + imports: [LegacySignInRecoveryComponent], + }, + add: { + imports: [MockLegacySignInRecoveryComponent], + }, + }); }); afterEach(() => { @@ -121,6 +170,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -140,6 +190,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -160,6 +211,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -180,6 +232,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -193,12 +246,51 @@ describe("", () => { expect(redirectErrorElement).toBeInTheDocument(); }); + it("renders legacy recovery by default", async () => { + const { container } = await render(TestHostWithoutContentComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(container.querySelector("fui-legacy-sign-in-recovery")).toBeInTheDocument(); + }); + + it("does not render legacy recovery when disabled", async () => { + const { container } = await render(TestHostWithoutRecoveryComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(container.querySelector("fui-legacy-sign-in-recovery")).not.toBeInTheDocument(); + }); + it("has correct CSS classes", async () => { const { container } = await render(TestHostWithoutContentComponent, { imports: [ SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -223,6 +315,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -255,6 +348,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -287,6 +381,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -323,6 +418,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -359,6 +455,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -395,6 +492,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, diff --git a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts index 86cba026b..db81b1475 100644 --- a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts @@ -14,13 +14,14 @@ * limitations under the License. */ -import { Component, Output, EventEmitter, computed, inject, effect } from "@angular/core"; +import { Component, Output, EventEmitter, computed, input } from "@angular/core"; import { CommonModule } from "@angular/common"; import { injectTranslation, injectUI, injectUserAuthenticated } from "../../provider"; import { SignInAuthFormComponent } from "../forms/sign-in-auth-form"; import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; +import { LegacySignInRecoveryComponent } from "../../components/legacy-sign-in-recovery"; import { CardComponent, CardHeaderComponent, @@ -45,6 +46,7 @@ import { Auth, authState, User, UserCredential } from "@angular/fire/auth"; SignInAuthFormComponent, MultiFactorAuthAssertionScreenComponent, RedirectErrorComponent, + LegacySignInRecoveryComponent, ], template: ` @if (mfaResolver()) { @@ -58,6 +60,9 @@ import { Auth, authState, User, UserCredential } from "@angular/fire/auth"; + @if (showLegacySignInRecovery()) { + + } @@ -73,6 +78,8 @@ import { Auth, authState, User, UserCredential } from "@angular/fire/auth"; */ export class SignInAuthScreenComponent { private ui = injectUI(); + /** Whether to show the default legacy sign-in recovery UI. */ + showLegacySignInRecovery = input(true); mfaResolver = computed(() => this.ui().multiFactorResolver); titleText = injectTranslation("labels", "signIn"); diff --git a/packages/angular/src/lib/components/legacy-sign-in-recovery.spec.ts b/packages/angular/src/lib/components/legacy-sign-in-recovery.spec.ts new file mode 100644 index 000000000..afec4d52c --- /dev/null +++ b/packages/angular/src/lib/components/legacy-sign-in-recovery.spec.ts @@ -0,0 +1,251 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, fireEvent } from "@testing-library/angular"; +import { Component, EventEmitter } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { LegacySignInRecoveryComponent } from "./legacy-sign-in-recovery"; +import { AppleSignInButtonComponent } from "../auth/oauth/apple-sign-in-button"; +import { FacebookSignInButtonComponent } from "../auth/oauth/facebook-sign-in-button"; +import { GitHubSignInButtonComponent } from "../auth/oauth/github-sign-in-button"; +import { GoogleSignInButtonComponent } from "../auth/oauth/google-sign-in-button"; +import { MicrosoftSignInButtonComponent } from "../auth/oauth/microsoft-sign-in-button"; +import { TwitterSignInButtonComponent } from "../auth/oauth/twitter-sign-in-button"; +import { YahooSignInButtonComponent } from "../auth/oauth/yahoo-sign-in-button"; + +jest.mock("@angular/fire/auth", () => { + const actual = jest.requireActual("@angular/fire/auth"); + return { + ...actual, + GoogleAuthProvider: class GoogleAuthProvider { + providerId = "google.com"; + }, + GithubAuthProvider: class GithubAuthProvider { + providerId = "github.com"; + }, + FacebookAuthProvider: class FacebookAuthProvider { + providerId = "facebook.com"; + }, + TwitterAuthProvider: class TwitterAuthProvider { + providerId = "twitter.com"; + }, + OAuthProvider: class OAuthProvider { + providerId: string; + constructor(providerId: string) { + this.providerId = providerId; + } + }, + }; +}); + +jest.mock("../provider", () => ({ + injectClearLegacySignInRecovery: jest.fn(), + injectLegacySignInRecovery: jest.fn(), + injectTranslation: jest.fn(), + injectUI: jest.fn(), +})); + +@Component({ + template: ``, + standalone: true, + imports: [LegacySignInRecoveryComponent], +}) +class TestHostComponent {} + +@Component({ + selector: "fui-google-sign-in-button", + template: '', + standalone: true, + outputs: ["signIn"], +}) +class MockGoogleSignInButtonComponent { + signIn = new EventEmitter(); +} + +@Component({ + selector: "fui-github-sign-in-button", + template: '', + standalone: true, + outputs: ["signIn"], +}) +class MockGitHubSignInButtonComponent { + signIn = new EventEmitter(); +} + +@Component({ + selector: "fui-facebook-sign-in-button", + template: '', + standalone: true, +}) +class MockFacebookSignInButtonComponent {} + +@Component({ + selector: "fui-apple-sign-in-button", + template: '', + standalone: true, +}) +class MockAppleSignInButtonComponent {} + +@Component({ + selector: "fui-microsoft-sign-in-button", + template: '', + standalone: true, +}) +class MockMicrosoftSignInButtonComponent {} + +@Component({ + selector: "fui-twitter-sign-in-button", + template: '', + standalone: true, +}) +class MockTwitterSignInButtonComponent {} + +@Component({ + selector: "fui-yahoo-sign-in-button", + template: '', + standalone: true, +}) +class MockYahooSignInButtonComponent {} + +describe("", () => { + beforeEach(() => { + const { + injectClearLegacySignInRecovery, + injectLegacySignInRecovery, + injectTranslation, + injectUI, + } = require("../provider"); + + injectClearLegacySignInRecovery.mockReturnValue(jest.fn()); + injectLegacySignInRecovery.mockReturnValue(() => undefined); + injectUI.mockReturnValue(() => ({ + locale: { + locale: "en-US", + translations: { + messages: { + legacySignInRecoveryPrompt: "You have previously signed in with a different method for {email}.", + }, + }, + }, + state: "idle", + })); + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + signInWithGoogle: "Sign in with Google", + signInWithGitHub: "Sign in with GitHub", + dismiss: "Dismiss", + }, + messages: { + legacySignInRecoveryPrompt: "You have previously signed in with a different method for {email}.", + legacySignInRecoverySelectMethod: "Choose one of your previous sign-in methods to continue.", + legacySignInRecoveryEmailPassword: "Use the email and password form to continue.", + legacySignInRecoveryEmailLink: "Use your email link sign-in flow to continue.", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + TestBed.overrideComponent(LegacySignInRecoveryComponent, { + remove: { + imports: [ + AppleSignInButtonComponent, + FacebookSignInButtonComponent, + GitHubSignInButtonComponent, + GoogleSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, + YahooSignInButtonComponent, + ], + }, + add: { + imports: [ + MockAppleSignInButtonComponent, + MockFacebookSignInButtonComponent, + MockGitHubSignInButtonComponent, + MockGoogleSignInButtonComponent, + MockMicrosoftSignInButtonComponent, + MockTwitterSignInButtonComponent, + MockYahooSignInButtonComponent, + ], + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("renders nothing when there is no recovery state", async () => { + const { container } = await render(TestHostComponent); + + expect(container.querySelector(".fui-legacy-sign-in-recovery")).toBeNull(); + }); + + it("renders recovery copy and recognized provider buttons", async () => { + const { injectLegacySignInRecovery } = require("../provider"); + injectLegacySignInRecovery.mockReturnValue(() => ({ + email: "test@example.com", + signInMethods: ["google.com", "github.com", "password", "emailLink"], + })); + + await render(TestHostComponent); + + expect(screen.getByRole("dialog")).toBeDefined(); + expect( + screen.getByText("You have previously signed in with a different method for test@example.com.") + ).toBeDefined(); + expect(screen.getByText("Choose one of your previous sign-in methods to continue.")).toBeDefined(); + expect(screen.getByRole("button", { name: "Sign in with Google" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Sign in with GitHub" })).toBeDefined(); + expect(screen.getByText("Use the email and password form to continue.")).toBeDefined(); + expect(screen.getByText("Use your email link sign-in flow to continue.")).toBeDefined(); + }); + + it("clears recovery state when dismissed", async () => { + const { injectLegacySignInRecovery, injectClearLegacySignInRecovery } = require("../provider"); + const clearRecovery = jest.fn(); + + injectLegacySignInRecovery.mockReturnValue(() => ({ + email: "test@example.com", + signInMethods: ["google.com"], + })); + injectClearLegacySignInRecovery.mockReturnValue(clearRecovery); + + await render(TestHostComponent); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + + expect(clearRecovery).toHaveBeenCalledTimes(1); + }); + + it("clears recovery state when the modal backdrop is clicked", async () => { + const { injectLegacySignInRecovery, injectClearLegacySignInRecovery } = require("../provider"); + const clearRecovery = jest.fn(); + + injectLegacySignInRecovery.mockReturnValue(() => ({ + email: "test@example.com", + signInMethods: ["google.com"], + })); + injectClearLegacySignInRecovery.mockReturnValue(clearRecovery); + + await render(TestHostComponent); + + fireEvent.click(screen.getByRole("dialog").parentElement as HTMLElement); + + expect(clearRecovery).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/angular/src/lib/components/legacy-sign-in-recovery.ts b/packages/angular/src/lib/components/legacy-sign-in-recovery.ts new file mode 100644 index 000000000..f4ef627d1 --- /dev/null +++ b/packages/angular/src/lib/components/legacy-sign-in-recovery.ts @@ -0,0 +1,156 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CommonModule } from "@angular/common"; +import { Component, HostListener } from "@angular/core"; +import { ButtonComponent } from "./button"; +import { injectClearLegacySignInRecovery, injectLegacySignInRecovery, injectTranslation } from "../provider"; +import { AppleSignInButtonComponent } from "../auth/oauth/apple-sign-in-button"; +import { FacebookSignInButtonComponent } from "../auth/oauth/facebook-sign-in-button"; +import { GitHubSignInButtonComponent } from "../auth/oauth/github-sign-in-button"; +import { GoogleSignInButtonComponent } from "../auth/oauth/google-sign-in-button"; +import { MicrosoftSignInButtonComponent } from "../auth/oauth/microsoft-sign-in-button"; +import { TwitterSignInButtonComponent } from "../auth/oauth/twitter-sign-in-button"; +import { YahooSignInButtonComponent } from "../auth/oauth/yahoo-sign-in-button"; + +@Component({ + selector: "fui-legacy-sign-in-recovery", + standalone: true, + imports: [ + CommonModule, + ButtonComponent, + AppleSignInButtonComponent, + FacebookSignInButtonComponent, + GitHubSignInButtonComponent, + GoogleSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, + YahooSignInButtonComponent, + ], + host: { + style: "display: block;", + }, + template: ` + @if (recovery()) { + + } + `, +}) +/** + * Displays default recovery UI for legacy sign-in method suggestions. + */ +export class LegacySignInRecoveryComponent { + recovery = injectLegacySignInRecovery(); + private clearLegacyRecovery = injectClearLegacySignInRecovery(); + recoveryPromptTemplate = injectTranslation("messages", "legacySignInRecoveryPrompt" as never); + selectMethodText = injectTranslation("messages", "legacySignInRecoverySelectMethod" as never); + emailPasswordText = injectTranslation("messages", "legacySignInRecoveryEmailPassword" as never); + emailLinkText = injectTranslation("messages", "legacySignInRecoveryEmailLink" as never); + dismissText = injectTranslation("labels", "dismiss" as never); + + recoveryPromptLabel() { + const recovery = this.recovery(); + if (!recovery) { + return ""; + } + + return this.recoveryPromptTemplate().replace("{email}", recovery.email); + } + + selectMethodLabel() { + return this.selectMethodText(); + } + + emailPasswordLabel() { + return this.emailPasswordText(); + } + + emailLinkLabel() { + return this.emailLinkText(); + } + + dismissLabel() { + return this.dismissText(); + } + + hasMethod(method: string) { + return this.recovery()?.signInMethods.includes(method) ?? false; + } + + clearRecovery() { + this.clearLegacyRecovery(); + } + + handleBackdropClick(event: MouseEvent) { + if (event.target === event.currentTarget) { + this.clearRecovery(); + } + } + + @HostListener("document:keydown.escape") + onEscapeKey() { + if (this.recovery()) { + this.clearRecovery(); + } + } +} diff --git a/packages/angular/src/lib/provider.spec.ts b/packages/angular/src/lib/provider.spec.ts index 2833bcf41..fffe5a232 100644 --- a/packages/angular/src/lib/provider.spec.ts +++ b/packages/angular/src/lib/provider.spec.ts @@ -15,7 +15,12 @@ import { TestBed } from "@angular/core/testing"; import { FirebaseApps } from "@angular/fire/app"; -import { injectTranslation, provideFirebaseUI } from "./provider"; +import { + injectClearLegacySignInRecovery, + injectLegacySignInRecovery, + injectTranslation, + provideFirebaseUI, +} from "./provider"; import { getTranslation, type TranslationCategory, type TranslationKey } from "@firebase-oss/ui-core"; const mockUI = { @@ -23,6 +28,11 @@ const mockUI = { locale: "en-US", translations: {}, }, + legacySignInRecovery: { + email: "test@example.com", + signInMethods: ["google.com"], + }, + clearLegacySignInRecovery: jest.fn(), }; describe("injectTranslation", () => { @@ -96,6 +106,44 @@ describe("injectTranslation", () => { }); }); +describe("legacy sign-in recovery injectors", () => { + const mockStore = { + get: () => mockUI, + subscribe: jest.fn(() => () => {}), + }; + + beforeEach(() => { + jest.clearAllMocks(); + + TestBed.configureTestingModule({ + providers: [ + { provide: FirebaseApps, useValue: [{ name: "test-app" }] }, + provideFirebaseUI(() => mockStore as any), + ], + }); + }); + + it("returns the current legacy sign-in recovery state", () => { + TestBed.runInInjectionContext(() => { + const recovery = injectLegacySignInRecovery(); + + expect(recovery()).toEqual({ + email: "test@example.com", + signInMethods: ["google.com"], + }); + }); + }); + + it("returns a callback that clears the recovery state", () => { + TestBed.runInInjectionContext(() => { + const clearRecovery = injectClearLegacySignInRecovery(); + clearRecovery(); + + expect(mockUI.clearLegacySignInRecovery).toHaveBeenCalledTimes(1); + }); + }); +}); + /** * Compile-time type safety tests for TranslationCategory and TranslationKey. * diff --git a/packages/angular/src/lib/provider.ts b/packages/angular/src/lib/provider.ts index 70843c55e..1e5ad4def 100644 --- a/packages/angular/src/lib/provider.ts +++ b/packages/angular/src/lib/provider.ts @@ -67,6 +67,18 @@ type PolicyConfig = { privacyPolicyUrl: string; }; +type LegacySignInRecovery = { + email: string; + signInMethods: string[]; + attemptedProviderId?: string; + pendingProviderId?: string; +}; + +type FirebaseUIWithLegacyRecovery = FirebaseUIType & { + legacySignInRecovery?: LegacySignInRecovery; + clearLegacySignInRecovery?: () => void; +}; + /** * Provides FirebaseUI configuration for the Angular application. * @@ -482,3 +494,23 @@ export function injectRedirectError(): Signal { return redirectError instanceof Error ? redirectError.message : String(redirectError); }); } + +/** + * Injects legacy sign-in recovery data populated by the legacyFetchSignInWithEmail behavior. + * + * @returns A computed signal containing the recovery data, or undefined when no recovery is active. + */ +export function injectLegacySignInRecovery(): Signal { + const ui = injectUI(); + return computed(() => (ui() as FirebaseUIWithLegacyRecovery).legacySignInRecovery); +} + +/** + * Injects a callback for clearing legacy sign-in recovery data. + * + * @returns A function that clears the current recovery state. + */ +export function injectClearLegacySignInRecovery(): () => void { + const ui = injectUI(); + return () => (ui() as FirebaseUIWithLegacyRecovery).clearLegacySignInRecovery?.(); +} diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts index 684bb430f..f99215fb7 100644 --- a/packages/angular/src/public-api.ts +++ b/packages/angular/src/public-api.ts @@ -62,6 +62,7 @@ export { export { ContentComponent } from "./lib/components/content"; export { CountrySelectorComponent } from "./lib/components/country-selector"; export { DividerComponent } from "./lib/components/divider"; +export { LegacySignInRecoveryComponent } from "./lib/components/legacy-sign-in-recovery"; export { PoliciesComponent } from "./lib/components/policies"; export { RedirectErrorComponent } from "./lib/components/redirect-error"; diff --git a/packages/core/src/auth.test.ts b/packages/core/src/auth.test.ts index 692757355..9b56880e4 100644 --- a/packages/core/src/auth.test.ts +++ b/packages/core/src/auth.test.ts @@ -167,7 +167,16 @@ describe("signInWithEmailAndPassword", () => { await signInWithEmailAndPassword(mockUI, email, password); - expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + expect(handleFirebaseError).toHaveBeenCalledWith( + mockUI, + expect.objectContaining({ + ...error, + email, + customData: { + email, + }, + }) + ); expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); }); diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 60b0bf08f..efd5f244d 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -61,9 +61,34 @@ async function handlePendingCredential(_ui: FirebaseUI, user: UserCredential): P function setPendingState(ui: FirebaseUI) { ui.setRedirectError(undefined); + ui.clearLegacySignInRecovery(); ui.setState("pending"); } +function attachEmailToError(error: unknown, email: string): unknown { + if (!error || typeof error !== "object") { + return error; + } + + const emailError = error as { + code?: string; + message?: string; + name?: string; + email?: string; + customData?: { + email?: string; + }; + }; + + emailError.email = emailError.email ?? email; + emailError.customData = { + ...emailError.customData, + email: emailError.customData?.email ?? email, + }; + + return emailError; +} + /** * Signs in with an email and password. * @@ -94,7 +119,7 @@ export async function signInWithEmailAndPassword( const result = await _signInWithCredential(ui.auth, credential); return handlePendingCredential(ui, result); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, attachEmailToError(error, email)); } finally { ui.setState("idle"); } @@ -146,7 +171,7 @@ export async function createUserWithEmailAndPassword( return handlePendingCredential(ui, result); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -199,7 +224,7 @@ export async function verifyPhoneNumber( return await provider.verifyPhoneNumber(phoneNumber, appVerifier); } } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -236,7 +261,7 @@ export async function confirmPhoneNumber( const result = await _signInWithCredential(ui.auth, credential); return handlePendingCredential(ui, result); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -254,7 +279,7 @@ export async function sendPasswordResetEmail(ui: FirebaseUI, email: string): Pro setPendingState(ui); await _sendPasswordResetEmail(ui.auth, email); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -282,7 +307,7 @@ export async function sendSignInLinkToEmail(ui: FirebaseUI, email: string): Prom // TODO: Should this be a behavior ("storageStrategy")? window.localStorage.setItem("emailForSignIn", email); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -326,7 +351,7 @@ export async function signInWithCredential(ui: FirebaseUI, credential: AuthCrede const result = await _signInWithCredential(ui.auth, credential); return handlePendingCredential(ui, result); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -345,7 +370,7 @@ export async function signInWithCustomToken(ui: FirebaseUI, customToken: string) const result = await _signInWithCustomToken(ui.auth, customToken); return handlePendingCredential(ui, result); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -363,7 +388,7 @@ export async function signInAnonymously(ui: FirebaseUI): Promise const result = await _signInAnonymously(ui.auth); return handlePendingCredential(ui, result); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -399,7 +424,7 @@ export async function signInWithProvider(ui: FirebaseUI, provider: AuthProvider) // Otherwise, they will have been redirected. return handlePendingCredential(ui, result); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -475,7 +500,7 @@ export async function signInWithMultiFactorAssertion(ui: FirebaseUI, assertion: ui.setMultiFactorResolver(undefined); return result; } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -498,7 +523,7 @@ export async function enrollWithMultiFactorAssertion( setPendingState(ui); await multiFactor(ui.auth.currentUser!).enroll(assertion, displayName); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -517,7 +542,7 @@ export async function generateTotpSecret(ui: FirebaseUI): Promise { const session = await mfaUser.getSession(); return await TotpMultiFactorGenerator.generateSecret(session); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } diff --git a/packages/core/src/behaviors/index.test.ts b/packages/core/src/behaviors/index.test.ts index 0994558eb..a6b718070 100644 --- a/packages/core/src/behaviors/index.test.ts +++ b/packages/core/src/behaviors/index.test.ts @@ -21,6 +21,7 @@ import { autoUpgradeAnonymousUsers, getBehavior, hasBehavior, + legacyFetchSignInWithEmail, recaptchaVerification, requireDisplayName, defaultBehaviors, @@ -36,6 +37,10 @@ vi.mock("./require-display-name", () => ({ requireDisplayNameHandler: vi.fn(), })); +vi.mock("./legacy-fetch-sign-in-with-email", () => ({ + legacyFetchSignInWithEmailHandler: vi.fn(), +})); + vi.mock("firebase/auth", () => ({ RecaptchaVerifier: vi.fn(), signInWithPopup: vi.fn(), @@ -74,6 +79,7 @@ describe("hasBehavior", () => { autoUpgradeAnonymousProvider: { type: "callable" as const, handler: vi.fn() }, recaptchaVerification: { type: "callable" as const, handler: vi.fn() }, requireDisplayName: { type: "callable" as const, handler: vi.fn() }, + legacyFetchSignInWithEmail: { type: "callable" as const, handler: vi.fn() }, } as any, }); @@ -82,6 +88,7 @@ describe("hasBehavior", () => { expect(hasBehavior(mockUI, "autoUpgradeAnonymousProvider")).toBe(true); expect(hasBehavior(mockUI, "recaptchaVerification")).toBe(true); expect(hasBehavior(mockUI, "requireDisplayName")).toBe(true); + expect(hasBehavior(mockUI, "legacyFetchSignInWithEmail")).toBe(true); }); }); @@ -110,6 +117,7 @@ describe("getBehavior", () => { autoUpgradeAnonymousProvider: { type: "callable" as const, handler: vi.fn() }, recaptchaVerification: { type: "callable" as const, handler: vi.fn() }, requireDisplayName: { type: "callable" as const, handler: vi.fn() }, + legacyFetchSignInWithEmail: { type: "callable" as const, handler: vi.fn() }, }; const ui = createMockUI({ behaviors: mockBehaviors as any }); @@ -121,6 +129,7 @@ describe("getBehavior", () => { expect(getBehavior(ui, "autoUpgradeAnonymousProvider")).toBe(mockBehaviors.autoUpgradeAnonymousProvider.handler); expect(getBehavior(ui, "recaptchaVerification")).toBe(mockBehaviors.recaptchaVerification.handler); expect(getBehavior(ui, "requireDisplayName")).toBe(mockBehaviors.requireDisplayName.handler); + expect(getBehavior(ui, "legacyFetchSignInWithEmail")).toBe(mockBehaviors.legacyFetchSignInWithEmail.handler); }); }); @@ -263,6 +272,29 @@ describe("requireDisplayName", () => { }); }); +describe("legacyFetchSignInWithEmail", () => { + it("should return behavior with correct structure", () => { + const behavior = legacyFetchSignInWithEmail(); + + expect(behavior).toHaveProperty("legacyFetchSignInWithEmail"); + expect(behavior.legacyFetchSignInWithEmail).toHaveProperty("type", "callable"); + expect(behavior.legacyFetchSignInWithEmail).toHaveProperty("handler"); + expect(typeof behavior.legacyFetchSignInWithEmail.handler).toBe("function"); + }); + + it("should call the legacyFetchSignInWithEmailHandler when executed", async () => { + const behavior = legacyFetchSignInWithEmail(); + const mockUI = createMockUI(); + const mockError = { code: "auth/account-exists-with-different-credential", message: "Mismatch" } as any; + + const { legacyFetchSignInWithEmailHandler } = await import("./legacy-fetch-sign-in-with-email"); + + await behavior.legacyFetchSignInWithEmail.handler(mockUI, mockError); + + expect(legacyFetchSignInWithEmailHandler).toHaveBeenCalledWith(mockUI, mockError); + }); +}); + describe("defaultBehaviors", () => { it("should include recaptchaVerification by default", () => { expect(defaultBehaviors).toHaveProperty("recaptchaVerification"); @@ -276,5 +308,6 @@ describe("defaultBehaviors", () => { expect(defaultBehaviors).not.toHaveProperty("autoUpgradeAnonymousCredential"); expect(defaultBehaviors).not.toHaveProperty("autoUpgradeAnonymousProvider"); expect(defaultBehaviors).not.toHaveProperty("requireDisplayName"); + expect(defaultBehaviors).not.toHaveProperty("legacyFetchSignInWithEmail"); }); }); diff --git a/packages/core/src/behaviors/index.ts b/packages/core/src/behaviors/index.ts index 5841d41e5..3cb4797de 100644 --- a/packages/core/src/behaviors/index.ts +++ b/packages/core/src/behaviors/index.ts @@ -23,6 +23,7 @@ import * as providerStrategyHandlers from "./provider-strategy"; import * as oneTapSignInHandlers from "./one-tap"; import * as requireDisplayNameHandlers from "./require-display-name"; import * as countryCodesHandlers from "./country-codes"; +import * as legacyFetchSignInWithEmailHandlers from "./legacy-fetch-sign-in-with-email"; import { callableBehavior, initBehavior, @@ -51,6 +52,9 @@ type Registry = { oneTapSignIn: InitBehavior<(ui: FirebaseUI) => ReturnType>; requireDisplayName: CallableBehavior; countryCodes: CallableBehavior; + legacyFetchSignInWithEmail: CallableBehavior< + typeof legacyFetchSignInWithEmailHandlers.legacyFetchSignInWithEmailHandler + >; }; /** A behavior or set of behaviors from the registry. */ @@ -183,6 +187,17 @@ export function countryCodes(options?: countryCodesHandlers.CountryCodesOptions) }; } +/** + * Fetches previous sign-in methods for OAuth account mismatch flows. + * + * @returns A behavior that populates legacy sign-in recovery state. + */ +export function legacyFetchSignInWithEmail(): Behavior<"legacyFetchSignInWithEmail"> { + return { + legacyFetchSignInWithEmail: callableBehavior(legacyFetchSignInWithEmailHandlers.legacyFetchSignInWithEmailHandler), + }; +} + /** * Checks if a specific behavior is enabled for the given FirebaseUI instance. * diff --git a/packages/core/src/behaviors/legacy-fetch-sign-in-with-email.test.ts b/packages/core/src/behaviors/legacy-fetch-sign-in-with-email.test.ts new file mode 100644 index 000000000..8f2fc7327 --- /dev/null +++ b/packages/core/src/behaviors/legacy-fetch-sign-in-with-email.test.ts @@ -0,0 +1,226 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { legacyFetchSignInWithEmailHandler } from "./legacy-fetch-sign-in-with-email"; +import { createMockUI } from "~/tests/utils"; + +vi.mock("firebase/auth", () => ({ + fetchSignInMethodsForEmail: vi.fn(), +})); + +import { fetchSignInMethodsForEmail } from "firebase/auth"; + +let mockSessionStorage: Record; + +beforeEach(() => { + vi.clearAllMocks(); + + mockSessionStorage = {}; + Object.defineProperty(window, "sessionStorage", { + value: { + setItem: vi.fn((key: string, value: string) => { + mockSessionStorage[key] = value; + }), + getItem: vi.fn((key: string) => mockSessionStorage[key] || null), + removeItem: vi.fn((key: string) => { + delete mockSessionStorage[key]; + }), + clear: vi.fn(() => { + Object.keys(mockSessionStorage).forEach((key) => delete mockSessionStorage[key]); + }), + }, + writable: true, + }); +}); + +describe("legacyFetchSignInWithEmailHandler", () => { + it("stores the pending credential and recovery data when the email is available", async () => { + const ui = createMockUI(); + const credential = { + providerId: "google.com", + toJSON: vi.fn().mockReturnValue({ providerId: "google.com", token: "token" }), + } as any; + const error = { + code: "auth/account-exists-with-different-credential", + message: "Mismatch", + credential, + customData: { + email: "test@example.com", + }, + } as any; + + vi.mocked(fetchSignInMethodsForEmail).mockResolvedValue(["password", "emailLink"]); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(window.sessionStorage.setItem).toHaveBeenCalledWith("pendingCred", JSON.stringify(credential.toJSON())); + expect(fetchSignInMethodsForEmail).toHaveBeenCalledWith(ui.auth, "test@example.com"); + expect(ui.setLegacySignInRecovery).toHaveBeenCalledWith({ + email: "test@example.com", + signInMethods: ["password", "emailLink"], + attemptedProviderId: "google.com", + pendingProviderId: "google.com", + }); + }); + + it("falls back to the top-level error email field", async () => { + const ui = createMockUI(); + const error = { + code: "auth/account-exists-with-different-credential", + message: "Mismatch", + email: "fallback@example.com", + } as any; + + vi.mocked(fetchSignInMethodsForEmail).mockResolvedValue(["github.com"]); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(fetchSignInMethodsForEmail).toHaveBeenCalledWith(ui.auth, "fallback@example.com"); + expect(ui.setLegacySignInRecovery).toHaveBeenCalledWith({ + email: "fallback@example.com", + signInMethods: ["github.com"], + attemptedProviderId: undefined, + pendingProviderId: undefined, + }); + }); + + it("marks password as the attempted provider for wrong-password recovery", async () => { + const ui = createMockUI(); + const error = { + code: "auth/wrong-password", + message: "Wrong password", + customData: { + email: "password@example.com", + }, + } as any; + + vi.mocked(fetchSignInMethodsForEmail).mockResolvedValue(["google.com"]); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(fetchSignInMethodsForEmail).toHaveBeenCalledWith(ui.auth, "password@example.com"); + expect(ui.setLegacySignInRecovery).toHaveBeenCalledWith({ + email: "password@example.com", + signInMethods: ["google.com"], + attemptedProviderId: "password", + pendingProviderId: undefined, + }); + expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + }); + + it("marks password as the attempted provider for invalid-credential recovery", async () => { + const ui = createMockUI(); + const error = { + code: "auth/invalid-credential", + message: "Invalid credential", + customData: { + email: "invalid@example.com", + }, + } as any; + + vi.mocked(fetchSignInMethodsForEmail).mockResolvedValue(["github.com"]); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(ui.setLegacySignInRecovery).toHaveBeenCalledWith({ + email: "invalid@example.com", + signInMethods: ["github.com"], + attemptedProviderId: "password", + pendingProviderId: undefined, + }); + }); + + it("marks password as the attempted provider for invalid-login-credentials recovery", async () => { + const ui = createMockUI(); + const error = { + code: "auth/invalid-login-credentials", + message: "Invalid login credentials", + customData: { + email: "login@example.com", + }, + } as any; + + vi.mocked(fetchSignInMethodsForEmail).mockResolvedValue(["google.com"]); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(ui.setLegacySignInRecovery).toHaveBeenCalledWith({ + email: "login@example.com", + signInMethods: ["google.com"], + attemptedProviderId: "password", + pendingProviderId: undefined, + }); + }); + + it("clears recovery state when no email can be extracted", async () => { + const ui = createMockUI(); + const error = { + code: "auth/account-exists-with-different-credential", + message: "Mismatch", + } as any; + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(fetchSignInMethodsForEmail).not.toHaveBeenCalled(); + expect(ui.clearLegacySignInRecovery).toHaveBeenCalledTimes(1); + }); + + it("clears recovery state when fetching sign-in methods fails", async () => { + const ui = createMockUI(); + const credential = { + providerId: "google.com", + toJSON: vi.fn().mockReturnValue({ providerId: "google.com", token: "token" }), + } as any; + const error = { + code: "auth/account-exists-with-different-credential", + message: "Mismatch", + credential, + customData: { + email: "test@example.com", + }, + } as any; + + vi.mocked(fetchSignInMethodsForEmail).mockRejectedValue(new Error("Network failure")); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(window.sessionStorage.setItem).toHaveBeenCalledWith("pendingCred", JSON.stringify(credential.toJSON())); + expect(ui.clearLegacySignInRecovery).toHaveBeenCalledTimes(1); + }); + + it("preserves an empty sign-in method list", async () => { + const ui = createMockUI(); + const error = { + code: "auth/account-exists-with-different-credential", + message: "Mismatch", + customData: { + email: "test@example.com", + }, + } as any; + + vi.mocked(fetchSignInMethodsForEmail).mockResolvedValue([]); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(ui.setLegacySignInRecovery).toHaveBeenCalledWith({ + email: "test@example.com", + signInMethods: [], + attemptedProviderId: undefined, + pendingProviderId: undefined, + }); + }); +}); diff --git a/packages/core/src/behaviors/legacy-fetch-sign-in-with-email.ts b/packages/core/src/behaviors/legacy-fetch-sign-in-with-email.ts new file mode 100644 index 000000000..1072695ea --- /dev/null +++ b/packages/core/src/behaviors/legacy-fetch-sign-in-with-email.ts @@ -0,0 +1,80 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { FirebaseError } from "firebase/app"; +import { fetchSignInMethodsForEmail } from "firebase/auth"; +import type { AuthCredential } from "firebase/auth"; +import type { LegacySignInRecovery, FirebaseUI } from "~/config"; + +type FirebaseErrorWithCredential = FirebaseError & { credential: AuthCredential }; +type FirebaseErrorWithEmail = FirebaseError & { + email?: string; + customData?: { + email?: string; + }; +}; + +function errorContainsCredential(error: FirebaseError): error is FirebaseErrorWithCredential { + return "credential" in error; +} + +function getEmailFromError(error: FirebaseError): string | undefined { + const emailError = error as FirebaseErrorWithEmail; + return emailError.customData?.email ?? emailError.email; +} + +function buildRecovery(error: FirebaseError, email: string, signInMethods: string[]): LegacySignInRecovery { + const pendingProviderId = errorContainsCredential(error) ? error.credential.providerId : undefined; + const attemptedProviderId = + pendingProviderId ?? + (error.code === "auth/wrong-password" || + error.code === "auth/invalid-credential" || + error.code === "auth/invalid-login-credentials" + ? "password" + : undefined); + + return { + email, + signInMethods, + attemptedProviderId, + pendingProviderId, + }; +} + +function persistPendingCredential(error: FirebaseError) { + if (!errorContainsCredential(error)) { + return; + } + + window.sessionStorage.setItem("pendingCred", JSON.stringify(error.credential.toJSON())); +} + +export async function legacyFetchSignInWithEmailHandler(ui: FirebaseUI, error: FirebaseError): Promise { + persistPendingCredential(error); + + const email = getEmailFromError(error); + if (!email) { + ui.clearLegacySignInRecovery(); + return; + } + + try { + const signInMethods = await fetchSignInMethodsForEmail(ui.auth, email); + ui.setLegacySignInRecovery(buildRecovery(error, email, signInMethods)); + } catch { + ui.clearLegacySignInRecovery(); + } +} diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index 52bc5fa64..494594ca0 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -444,6 +444,37 @@ describe("initializeUI", () => { expect(ui.get().redirectError).toBeUndefined(); }); + it("should have legacySignInRecovery undefined by default", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + expect(ui.get().legacySignInRecovery).toBeUndefined(); + }); + + it("should set and clear legacySignInRecovery correctly", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + const recovery = { + email: "test@example.com", + signInMethods: ["google.com", "password"], + attemptedProviderId: "github.com", + pendingProviderId: "github.com", + }; + + expect(ui.get().legacySignInRecovery).toBeUndefined(); + ui.get().setLegacySignInRecovery(recovery); + expect(ui.get().legacySignInRecovery).toEqual(recovery); + ui.get().clearLegacySignInRecovery(); + expect(ui.get().legacySignInRecovery).toBeUndefined(); + }); + it("should handle redirect error when getRedirectResult throws", async () => { Object.defineProperty(global, "window", { value: {}, diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index e190397ef..caa1c91b6 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -39,6 +39,20 @@ export type FirebaseUIOptions = { behaviors?: Behavior[]; }; +/** + * Recovery state populated when a sign-in attempt should be redirected to a previously-used method. + */ +export type LegacySignInRecovery = { + /** The email address associated with the conflicting account. */ + email: string; + /** The sign-in methods returned by fetchSignInMethodsForEmail(). */ + signInMethods: string[]; + /** The provider used for the failed sign-in attempt, if known. */ + attemptedProviderId?: string; + /** The provider from the pending credential that can be linked after recovery, if known. */ + pendingProviderId?: string; +}; + /** * The main FirebaseUI instance that provides access to Firebase Auth and UI state management. * @@ -69,6 +83,12 @@ export type FirebaseUI = { redirectError?: Error; /** Sets the redirect error. */ setRedirectError: (error?: Error) => void; + /** Recovery data for guiding a user back to their previous sign-in method. */ + legacySignInRecovery?: LegacySignInRecovery; + /** Sets the legacy sign-in recovery data. */ + setLegacySignInRecovery: (recovery?: LegacySignInRecovery) => void; + /** Clears the legacy sign-in recovery data. */ + clearLegacySignInRecovery: () => void; }; export const $config = map>>({}); @@ -137,6 +157,15 @@ export function initializeUI(config: FirebaseUIOptions, name: string = "[DEFAULT const current = $config.get()[name]!; current.setKey(`redirectError`, error); }, + legacySignInRecovery: undefined, + setLegacySignInRecovery: (recovery?: LegacySignInRecovery) => { + const current = $config.get()[name]!; + current.setKey(`legacySignInRecovery`, recovery); + }, + clearLegacySignInRecovery: () => { + const current = $config.get()[name]!; + current.setKey(`legacySignInRecovery`, undefined); + }, }) ); @@ -169,9 +198,9 @@ export function initializeUI(config: FirebaseUIOptions, name: string = "[DEFAULT .then((result) => { return Promise.all(redirectBehaviors.map((behavior) => behavior.handler(ui, result))); }) - .catch((error) => { + .catch(async (error) => { try { - handleFirebaseError(ui, error); + await handleFirebaseError(ui, error); } catch (error) { ui.setRedirectError(error instanceof Error ? error : new Error(String(error))); } diff --git a/packages/core/src/errors.test.ts b/packages/core/src/errors.test.ts index 36ca4d2da..47aa9d20f 100644 --- a/packages/core/src/errors.test.ts +++ b/packages/core/src/errors.test.ts @@ -14,28 +14,35 @@ * limitations under the License. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { FirebaseError } from "firebase/app"; import { Auth, AuthCredential, MultiFactorResolver } from "firebase/auth"; +import { ERROR_CODE_MAP } from "@firebase-oss/ui-translations"; import { FirebaseUIError, handleFirebaseError } from "./errors"; import { createMockUI } from "~/tests/utils"; -import { ERROR_CODE_MAP } from "@firebase-oss/ui-translations"; vi.mock("./translations", () => ({ getTranslation: vi.fn(), })); +vi.mock("./behaviors", () => ({ + hasBehavior: vi.fn(), + getBehavior: vi.fn(), +})); + vi.mock("firebase/auth", () => ({ getMultiFactorResolver: vi.fn(), })); import { getTranslation } from "./translations"; +import { getBehavior, hasBehavior } from "./behaviors"; import { getMultiFactorResolver } from "firebase/auth"; -let mockSessionStorage: { [key: string]: string }; +let mockSessionStorage: Record; beforeEach(() => { vi.clearAllMocks(); + vi.mocked(hasBehavior).mockReturnValue(false); mockSessionStorage = {}; Object.defineProperty(window, "sessionStorage", { @@ -56,188 +63,251 @@ beforeEach(() => { }); describe("FirebaseUIError", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should create a FirebaseUIError with translated message", () => { + it("creates a FirebaseUIError with translated message", () => { const mockUI = createMockUI(); const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found"); - const expectedTranslation = "User not found (translated)"; - vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getTranslation).mockReturnValue("User not found (translated)"); const error = new FirebaseUIError(mockUI, mockFirebaseError); expect(error).toBeInstanceOf(FirebaseError); expect(error.code).toBe("auth/user-not-found"); - expect(error.message).toBe(expectedTranslation); + expect(error.message).toBe("User not found (translated)"); expect(getTranslation).toHaveBeenCalledWith(mockUI, "errors", ERROR_CODE_MAP["auth/user-not-found"]); }); - it("should handle unknown error codes gracefully", () => { + it("handles unknown error codes gracefully", () => { const mockUI = createMockUI(); const mockFirebaseError = new FirebaseError("auth/unknown-error", "Unknown error"); - const expectedTranslation = "Unknown error (translated)"; - vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getTranslation).mockReturnValue("Unknown error (translated)"); const error = new FirebaseUIError(mockUI, mockFirebaseError); expect(error.code).toBe("auth/unknown-error"); - expect(error.message).toBe(expectedTranslation); - expect(getTranslation).toHaveBeenCalledWith( - mockUI, - "errors", - ERROR_CODE_MAP["auth/unknown-error" as keyof typeof ERROR_CODE_MAP] - ); + expect(error.message).toBe("Unknown error (translated)"); }); }); describe("handleFirebaseError", () => { - it("should throw non-Firebase errors as-is", () => { + it("throws non-Firebase errors as-is", async () => { const mockUI = createMockUI(); - const nonFirebaseError = new Error("Regular error"); - expect(() => handleFirebaseError(mockUI, nonFirebaseError)).toThrow("Regular error"); + await expect(handleFirebaseError(mockUI, new Error("Regular error"))).rejects.toThrow("Regular error"); }); - it("should throw non-Firebase errors with different types", () => { + it("throws non-Firebase errors with different types", async () => { const mockUI = createMockUI(); - const stringError = "String error"; - const numberError = 42; - const nullError = null; - const undefinedError = undefined; - - expect(() => handleFirebaseError(mockUI, stringError)).toThrow("String error"); - expect(() => handleFirebaseError(mockUI, numberError)).toThrow(); - expect(() => handleFirebaseError(mockUI, nullError)).toThrow(); - expect(() => handleFirebaseError(mockUI, undefinedError)).toThrow(); + + await expect(handleFirebaseError(mockUI, "String error")).rejects.toBe("String error"); + await expect(handleFirebaseError(mockUI, 42)).rejects.toBe(42); + await expect(handleFirebaseError(mockUI, null)).rejects.toBeNull(); + await expect(handleFirebaseError(mockUI, undefined)).rejects.toBeUndefined(); }); - it("should throw FirebaseUIError for Firebase errors", () => { + it("throws FirebaseUIError for Firebase errors", async () => { const mockUI = createMockUI(); const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found"); - const expectedTranslation = "User not found (translated)"; - vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getTranslation).mockReturnValue("User not found (translated)"); - expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + await expect(handleFirebaseError(mockUI, mockFirebaseError)).rejects.toBeInstanceOf(FirebaseUIError); try { - handleFirebaseError(mockUI, mockFirebaseError); + await handleFirebaseError(mockUI, mockFirebaseError); } catch (error) { expect(error).toBeInstanceOf(FirebaseUIError); expect(error).toBeInstanceOf(FirebaseError); expect((error as FirebaseUIError).code).toBe("auth/user-not-found"); - expect((error as FirebaseUIError).message).toBe(expectedTranslation); + expect((error as FirebaseUIError).message).toBe("User not found (translated)"); } }); - it("should store credential in sessionStorage for account-exists-with-different-credential", () => { + it("stores credential in sessionStorage for account-exists-with-different-credential by default", async () => { const mockUI = createMockUI(); const mockCredential = { providerId: "google.com", toJSON: vi.fn().mockReturnValue({ providerId: "google.com", token: "mock-token" }), } as unknown as AuthCredential; - const mockFirebaseError = { code: "auth/account-exists-with-different-credential", message: "Account exists with different credential", credential: mockCredential, } as FirebaseError & { credential: AuthCredential }; - const expectedTranslation = "Account exists with different credential (translated)"; - vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getTranslation).mockReturnValue("Account exists with different credential (translated)"); - expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + await expect(handleFirebaseError(mockUI, mockFirebaseError)).rejects.toBeInstanceOf(FirebaseUIError); expect(window.sessionStorage.setItem).toHaveBeenCalledWith("pendingCred", JSON.stringify(mockCredential.toJSON())); expect(mockCredential.toJSON).toHaveBeenCalled(); }); - it("should not store credential for other error types", () => { + it("delegates account-exists-with-different-credential to the behavior when enabled", async () => { + const mockUI = createMockUI(); + const mockCredential = { + providerId: "google.com", + toJSON: vi.fn().mockReturnValue({ providerId: "google.com", token: "mock-token" }), + } as unknown as AuthCredential; + const mockFirebaseError = { + code: "auth/account-exists-with-different-credential", + message: "Account exists with different credential", + credential: mockCredential, + customData: { + email: "test@example.com", + }, + } as unknown as FirebaseError & { credential: AuthCredential }; + const behavior = vi.fn().mockResolvedValue(undefined); + + vi.mocked(getTranslation).mockReturnValue("Account exists with different credential (translated)"); + vi.mocked(hasBehavior).mockImplementation((_, key) => key === "legacyFetchSignInWithEmail"); + vi.mocked(getBehavior).mockReturnValue(behavior); + + await expect(handleFirebaseError(mockUI, mockFirebaseError)).rejects.toBeInstanceOf(FirebaseUIError); + + expect(getBehavior).toHaveBeenCalledWith(mockUI, "legacyFetchSignInWithEmail"); + expect(behavior).toHaveBeenCalledWith(mockUI, mockFirebaseError); + expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + }); + + it("delegates wrong-password to the recovery behavior when enabled", async () => { + const mockUI = createMockUI(); + const mockFirebaseError = { + code: "auth/wrong-password", + message: "Wrong password", + customData: { + email: "test@example.com", + }, + } as unknown as FirebaseError; + const behavior = vi.fn().mockResolvedValue(undefined); + + vi.mocked(getTranslation).mockReturnValue("Wrong password (translated)"); + vi.mocked(hasBehavior).mockImplementation((_, key) => key === "legacyFetchSignInWithEmail"); + vi.mocked(getBehavior).mockReturnValue(behavior); + + await expect(handleFirebaseError(mockUI, mockFirebaseError)).rejects.toBeInstanceOf(FirebaseUIError); + + expect(getBehavior).toHaveBeenCalledWith(mockUI, "legacyFetchSignInWithEmail"); + expect(behavior).toHaveBeenCalledWith(mockUI, mockFirebaseError); + expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + }); + + it("delegates invalid-credential to the recovery behavior when enabled", async () => { + const mockUI = createMockUI(); + const mockFirebaseError = { + code: "auth/invalid-credential", + message: "Invalid credential", + customData: { + email: "test@example.com", + }, + } as unknown as FirebaseError; + const behavior = vi.fn().mockResolvedValue(undefined); + + vi.mocked(getTranslation).mockReturnValue("Invalid credential (translated)"); + vi.mocked(hasBehavior).mockImplementation((_, key) => key === "legacyFetchSignInWithEmail"); + vi.mocked(getBehavior).mockReturnValue(behavior); + + await expect(handleFirebaseError(mockUI, mockFirebaseError)).rejects.toBeInstanceOf(FirebaseUIError); + + expect(getBehavior).toHaveBeenCalledWith(mockUI, "legacyFetchSignInWithEmail"); + expect(behavior).toHaveBeenCalledWith(mockUI, mockFirebaseError); + expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + }); + + it("delegates invalid-login-credentials to the recovery behavior when enabled", async () => { + const mockUI = createMockUI(); + const mockFirebaseError = { + code: "auth/invalid-login-credentials", + message: "Invalid login credentials", + customData: { + email: "test@example.com", + }, + } as unknown as FirebaseError; + const behavior = vi.fn().mockResolvedValue(undefined); + + vi.mocked(getTranslation).mockReturnValue("Invalid login credentials (translated)"); + vi.mocked(hasBehavior).mockImplementation((_, key) => key === "legacyFetchSignInWithEmail"); + vi.mocked(getBehavior).mockReturnValue(behavior); + + await expect(handleFirebaseError(mockUI, mockFirebaseError)).rejects.toBeInstanceOf(FirebaseUIError); + + expect(getBehavior).toHaveBeenCalledWith(mockUI, "legacyFetchSignInWithEmail"); + expect(behavior).toHaveBeenCalledWith(mockUI, mockFirebaseError); + expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + }); + + it("does not store credential for other error types", async () => { const mockUI = createMockUI(); const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found"); - const expectedTranslation = "User not found (translated)"; - vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getTranslation).mockReturnValue("User not found (translated)"); - expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + await expect(handleFirebaseError(mockUI, mockFirebaseError)).rejects.toBeInstanceOf(FirebaseUIError); expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); }); - it("should handle account-exists-with-different-credential without credential", () => { + it("handles account-exists-with-different-credential without credential", async () => { const mockUI = createMockUI(); const mockFirebaseError = { code: "auth/account-exists-with-different-credential", message: "Account exists with different credential", } as FirebaseError; - const expectedTranslation = "Account exists with different credential (translated)"; - vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getTranslation).mockReturnValue("Account exists with different credential (translated)"); - expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + await expect(handleFirebaseError(mockUI, mockFirebaseError)).rejects.toBeInstanceOf(FirebaseUIError); expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); }); - it("should call setMultiFactorResolver when auth/multi-factor-auth-required error is thrown", () => { + it("calls setMultiFactorResolver when auth/multi-factor-auth-required is thrown", async () => { const mockUI = createMockUI(); const mockResolver = { auth: {} as Auth, session: null, hints: [], } as unknown as MultiFactorResolver; - const error = new FirebaseError("auth/multi-factor-auth-required", "Multi-factor authentication required"); - const expectedTranslation = "Multi-factor authentication required (translated)"; - vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getTranslation).mockReturnValue("Multi-factor authentication required (translated)"); vi.mocked(getMultiFactorResolver).mockReturnValue(mockResolver); - expect(() => handleFirebaseError(mockUI, error)).toThrow(FirebaseUIError); + await expect(handleFirebaseError(mockUI, error)).rejects.toBeInstanceOf(FirebaseUIError); expect(getMultiFactorResolver).toHaveBeenCalledWith(mockUI.auth, error); expect(mockUI.setMultiFactorResolver).toHaveBeenCalledWith(mockResolver); }); - it("should still throw FirebaseUIError after setting multi-factor resolver", () => { + it("still throws FirebaseUIError after setting multi-factor resolver", async () => { const mockUI = createMockUI(); const mockResolver = { auth: {} as Auth, session: null, hints: [], } as unknown as MultiFactorResolver; - const error = new FirebaseError("auth/multi-factor-auth-required", "Multi-factor authentication required"); - const expectedTranslation = "Multi-factor authentication required (translated)"; - vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getTranslation).mockReturnValue("Multi-factor authentication required (translated)"); vi.mocked(getMultiFactorResolver).mockReturnValue(mockResolver); - expect(() => handleFirebaseError(mockUI, error)).toThrow(FirebaseUIError); - - expect(getMultiFactorResolver).toHaveBeenCalledWith(mockUI.auth, error); - expect(mockUI.setMultiFactorResolver).toHaveBeenCalledWith(mockResolver); - try { - handleFirebaseError(mockUI, error); - } catch (error) { - expect(error).toBeInstanceOf(FirebaseUIError); - expect(error).toBeInstanceOf(FirebaseError); - expect((error as FirebaseUIError).code).toBe("auth/multi-factor-auth-required"); - expect((error as FirebaseUIError).message).toBe(expectedTranslation); + await handleFirebaseError(mockUI, error); + } catch (caught) { + expect(getMultiFactorResolver).toHaveBeenCalledWith(mockUI.auth, error); + expect(mockUI.setMultiFactorResolver).toHaveBeenCalledWith(mockResolver); + expect(caught).toBeInstanceOf(FirebaseUIError); + expect((caught as FirebaseUIError).code).toBe("auth/multi-factor-auth-required"); + expect((caught as FirebaseUIError).message).toBe("Multi-factor authentication required (translated)"); } }); - it("should not call setMultiFactorResolver for other error types", () => { + it("does not call setMultiFactorResolver for other error types", async () => { const mockUI = createMockUI(); const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found"); - const expectedTranslation = "User not found (translated)"; - vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getTranslation).mockReturnValue("User not found (translated)"); - expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + await expect(handleFirebaseError(mockUI, mockFirebaseError)).rejects.toBeInstanceOf(FirebaseUIError); expect(getMultiFactorResolver).not.toHaveBeenCalled(); expect(mockUI.setMultiFactorResolver).not.toHaveBeenCalled(); @@ -245,62 +315,29 @@ describe("handleFirebaseError", () => { }); describe("isFirebaseError utility", () => { - it("should identify FirebaseError objects", () => { - const firebaseError = new FirebaseError("auth/user-not-found", "User not found"); - + it("identifies FirebaseError objects", async () => { const mockUI = createMockUI(); vi.mocked(getTranslation).mockReturnValue("translated message"); - expect(() => handleFirebaseError(mockUI, firebaseError)).toThrow(FirebaseUIError); + await expect( + handleFirebaseError(mockUI, new FirebaseError("auth/user-not-found", "User not found")) + ).rejects.toBeInstanceOf(FirebaseUIError); }); - it("should reject non-FirebaseError objects", () => { - const mockUI = createMockUI(); - const nonFirebaseError = { code: "test", message: "test" }; - - expect(() => handleFirebaseError(mockUI, nonFirebaseError)).toThrow(); - }); - - it("should reject objects without code and message", () => { - const mockUI = createMockUI(); - const invalidObject = { someProperty: "value" }; - - expect(() => handleFirebaseError(mockUI, invalidObject)).toThrow(); - }); -}); - -describe("errorContainsCredential utility", () => { - it("should identify FirebaseError with credential", () => { + it("treats plain objects with code and message like Firebase errors", async () => { const mockUI = createMockUI(); - const mockCredential = { - providerId: "google.com", - toJSON: vi.fn().mockReturnValue({ providerId: "google.com" }), - } as unknown as AuthCredential; - - const firebaseErrorWithCredential = { - code: "auth/account-exists-with-different-credential", - message: "Account exists with different credential", - credential: mockCredential, - } as FirebaseError & { credential: AuthCredential }; - vi.mocked(getTranslation).mockReturnValue("translated message"); - expect(() => handleFirebaseError(mockUI, firebaseErrorWithCredential)).toThrowError(FirebaseUIError); - - expect(window.sessionStorage.setItem).toHaveBeenCalledWith("pendingCred", JSON.stringify(mockCredential.toJSON())); + await expect(handleFirebaseError(mockUI, { code: "test", message: "test" })).rejects.toBeInstanceOf( + FirebaseUIError + ); }); - it("should handle FirebaseError without credential", () => { + it("rejects objects without code and message", async () => { const mockUI = createMockUI(); - const firebaseErrorWithoutCredential = { - code: "auth/account-exists-with-different-credential", - message: "Account exists with different credential", - } as FirebaseError; - vi.mocked(getTranslation).mockReturnValue("translated message"); - - expect(() => handleFirebaseError(mockUI, firebaseErrorWithoutCredential)).toThrowError(FirebaseUIError); - - expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + await expect(handleFirebaseError(mockUI, { someProperty: "value" })).rejects.toEqual({ + someProperty: "value", + }); }); }); diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index a2633f224..55eb1f935 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -18,6 +18,7 @@ import { ERROR_CODE_MAP, type ErrorCode } from "@firebase-oss/ui-translations"; import { FirebaseError } from "firebase/app"; import { type AuthCredential, getMultiFactorResolver, type MultiFactorError } from "firebase/auth"; import { type FirebaseUI } from "./config"; +import { getBehavior, hasBehavior } from "./behaviors"; import { getTranslation } from "./translations"; /** @@ -42,18 +43,26 @@ export class FirebaseUIError extends FirebaseError { * * @param ui - The FirebaseUI instance. * @param error - The error to handle. - * @returns {never} A never type. + * @returns {Promise} A never type wrapped in a promise. */ -export function handleFirebaseError(ui: FirebaseUI, error: unknown): never { +export async function handleFirebaseError(ui: FirebaseUI, error: unknown): Promise { // If it's not a Firebase error, then we just throw it and preserve the original error. if (!isFirebaseError(error)) { throw error; } - // TODO(ehesp): Type error as unknown, check instance of FirebaseError - // TODO(ehesp): Support via behavior - if (error.code === "auth/account-exists-with-different-credential" && errorContainsCredential(error)) { - window.sessionStorage.setItem("pendingCred", JSON.stringify(error.credential.toJSON())); + const shouldHandleLegacyRecovery = + error.code === "auth/account-exists-with-different-credential" || + error.code === "auth/wrong-password" || + error.code === "auth/invalid-credential" || + error.code === "auth/invalid-login-credentials"; + + if (shouldHandleLegacyRecovery) { + if (hasBehavior(ui, "legacyFetchSignInWithEmail")) { + await getBehavior(ui, "legacyFetchSignInWithEmail")(ui, error); + } else if (error.code === "auth/account-exists-with-different-credential" && errorContainsCredential(error)) { + window.sessionStorage.setItem("pendingCred", JSON.stringify(error.credential.toJSON())); + } } // Update the UI with the multi-factor resolver if the error is thrown. diff --git a/packages/core/tests/utils.ts b/packages/core/tests/utils.ts index fa5e6daff..a2b264b7f 100644 --- a/packages/core/tests/utils.ts +++ b/packages/core/tests/utils.ts @@ -34,6 +34,9 @@ export function createMockUI(overrides?: Partial): FirebaseUI { setMultiFactorResolver: vi.fn(), redirectError: undefined, setRedirectError: vi.fn(), + legacySignInRecovery: undefined, + setLegacySignInRecovery: vi.fn(), + clearLegacySignInRecovery: vi.fn(), ...overrides, }; } diff --git a/packages/react/src/auth/screens/oauth-screen.test.tsx b/packages/react/src/auth/screens/oauth-screen.test.tsx index efc480075..4498f5d8d 100644 --- a/packages/react/src/auth/screens/oauth-screen.test.tsx +++ b/packages/react/src/auth/screens/oauth-screen.test.tsx @@ -32,6 +32,10 @@ vi.mock("~/components/redirect-error", () => ({ RedirectError: () =>
Redirect Error
, })); +vi.mock("~/components/legacy-sign-in-recovery", () => ({ + LegacySignInRecovery: () =>
Legacy Recovery
, +})); + vi.mock("~/auth/screens/multi-factor-auth-assertion-screen", () => ({ MultiFactorAuthAssertionScreen: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => (
@@ -118,6 +122,30 @@ describe("", () => { expect(screen.getByTestId("policies")).toBeDefined(); }); + it("renders LegacySignInRecovery by default", () => { + const ui = createMockUI(); + + render( + + OAuth Provider + + ); + + expect(screen.getByTestId("legacy-sign-in-recovery")).toBeDefined(); + }); + + it("does not render LegacySignInRecovery when disabled", () => { + const ui = createMockUI(); + + render( + + OAuth Provider + + ); + + expect(screen.queryByTestId("legacy-sign-in-recovery")).toBeNull(); + }); + it("renders children before the Policies component", () => { const ui = createMockUI(); diff --git a/packages/react/src/auth/screens/oauth-screen.tsx b/packages/react/src/auth/screens/oauth-screen.tsx index 5454938f5..73f26a22c 100644 --- a/packages/react/src/auth/screens/oauth-screen.tsx +++ b/packages/react/src/auth/screens/oauth-screen.tsx @@ -22,11 +22,14 @@ import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/compon import { Policies } from "~/components/policies"; import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; import { RedirectError } from "~/components/redirect-error"; +import { LegacySignInRecovery } from "~/components/legacy-sign-in-recovery"; /** Props for the OAuthScreen component. */ export type OAuthScreenProps = PropsWithChildren<{ /** Callback function called when sign-in is successful. */ onSignIn?: (user: User) => void; + /** Whether to show the default legacy sign-in recovery UI. */ + showLegacySignInRecovery?: boolean; }>; /** @@ -37,7 +40,7 @@ export type OAuthScreenProps = PropsWithChildren<{ * * @returns The OAuth screen component. */ -export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { +export function OAuthScreen({ children, onSignIn, showLegacySignInRecovery = true }: OAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); @@ -59,6 +62,7 @@ export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { {children} + {showLegacySignInRecovery ? : null} diff --git a/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx b/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx index cf315722c..66660b726 100644 --- a/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx @@ -51,6 +51,10 @@ vi.mock("~/components/redirect-error", () => ({ RedirectError: () =>
Redirect Error
, })); +vi.mock("~/components/legacy-sign-in-recovery", () => ({ + LegacySignInRecovery: () =>
Legacy Recovery
, +})); + vi.mock("~/auth/screens/multi-factor-auth-assertion-screen", () => ({ MultiFactorAuthAssertionScreen: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => (
@@ -110,6 +114,30 @@ describe("", () => { expect(screen.getByTestId("sign-in-auth-form")).toBeDefined(); }); + it("renders LegacySignInRecovery by default", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("legacy-sign-in-recovery")).toBeDefined(); + }); + + it("does not render LegacySignInRecovery when disabled", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.queryByTestId("legacy-sign-in-recovery")).toBeNull(); + }); + it("passes onForgotPasswordClick to SignInAuthForm", () => { const mockOnForgotPasswordClick = vi.fn(); const ui = createMockUI(); diff --git a/packages/react/src/auth/screens/sign-in-auth-screen.tsx b/packages/react/src/auth/screens/sign-in-auth-screen.tsx index f7308fa74..f38f53f64 100644 --- a/packages/react/src/auth/screens/sign-in-auth-screen.tsx +++ b/packages/react/src/auth/screens/sign-in-auth-screen.tsx @@ -23,11 +23,14 @@ import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../co import { SignInAuthForm, type SignInAuthFormProps } from "../forms/sign-in-auth-form"; import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; import { RedirectError } from "~/components/redirect-error"; +import { LegacySignInRecovery } from "~/components/legacy-sign-in-recovery"; /** Props for the SignInAuthScreen component. */ export type SignInAuthScreenProps = PropsWithChildren> & { /** Callback function called when sign-in is successful. */ onSignIn?: (user: User) => void; + /** Whether to show the default legacy sign-in recovery UI. */ + showLegacySignInRecovery?: boolean; }; /** @@ -37,7 +40,12 @@ export type SignInAuthScreenProps = PropsWithChildren + {showLegacySignInRecovery ? : null} {children ? ( <> {getTranslation(ui, "messages", "dividerOr")} diff --git a/packages/react/src/components/index.tsx b/packages/react/src/components/index.tsx index 2dda12d95..b8f5103cf 100644 --- a/packages/react/src/components/index.tsx +++ b/packages/react/src/components/index.tsx @@ -25,5 +25,6 @@ export { } from "./country-selector"; export { Divider, type DividerProps } from "./divider"; export { form } from "./form"; +export { LegacySignInRecovery } from "./legacy-sign-in-recovery"; export { Policies, type PolicyProps, type PolicyURL } from "./policies"; export { RedirectError } from "./redirect-error"; diff --git a/packages/react/src/components/legacy-sign-in-recovery.test.tsx b/packages/react/src/components/legacy-sign-in-recovery.test.tsx new file mode 100644 index 000000000..ec9ca01ff --- /dev/null +++ b/packages/react/src/components/legacy-sign-in-recovery.test.tsx @@ -0,0 +1,166 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { registerLocale } from "@firebase-oss/ui-translations"; +import { LegacySignInRecovery } from "~/components/legacy-sign-in-recovery"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; + +vi.mock("~/auth/oauth/google-sign-in-button", () => ({ + GoogleSignInButton: ({ onSignIn }: { onSignIn?: (credential: unknown) => void }) => ( + + ), +})); + +vi.mock("~/auth/oauth/github-sign-in-button", () => ({ + GitHubSignInButton: ({ onSignIn }: { onSignIn?: (credential: unknown) => void }) => ( + + ), +})); + +vi.mock("~/auth/oauth/facebook-sign-in-button", () => ({ + FacebookSignInButton: () => , +})); + +vi.mock("~/auth/oauth/apple-sign-in-button", () => ({ + AppleSignInButton: () => , +})); + +vi.mock("~/auth/oauth/microsoft-sign-in-button", () => ({ + MicrosoftSignInButton: () => , +})); + +vi.mock("~/auth/oauth/twitter-sign-in-button", () => ({ + TwitterSignInButton: () => , +})); + +vi.mock("~/auth/oauth/yahoo-sign-in-button", () => ({ + YahooSignInButton: () => , +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("", () => { + const recoveryLocale = registerLocale("legacy-recovery", { + messages: { + legacySignInRecoveryPrompt: "You have previously signed in with a different method for {email}.", + legacySignInRecoverySelectMethod: "Choose one of your previous sign-in methods to continue.", + legacySignInRecoveryEmailPassword: "Use the email and password form to continue.", + legacySignInRecoveryEmailLink: "Use your email link sign-in flow to continue.", + }, + labels: { + dismiss: "Dismiss", + }, + }); + + it("returns null when there is no recovery state", () => { + const ui = createMockUI(); + + const { container } = render( + + + + ); + + expect(container.firstChild).toBeNull(); + }); + + it("renders recovery copy and recognized provider buttons", () => { + const ui = createMockUI({ locale: recoveryLocale }); + ui.get().setLegacySignInRecovery({ + email: "test@example.com", + signInMethods: ["google.com", "github.com", "password", "emailLink"], + }); + + render( + + + + ); + + expect(screen.getByRole("dialog")).toBeDefined(); + expect( + screen.getByText("You have previously signed in with a different method for test@example.com.") + ).toBeDefined(); + expect(screen.getByText("Choose one of your previous sign-in methods to continue.")).toBeDefined(); + expect(screen.getByTestId("google-recovery-button")).toBeDefined(); + expect(screen.getByTestId("github-recovery-button")).toBeDefined(); + expect(screen.getByText("Use the email and password form to continue.")).toBeDefined(); + expect(screen.getByText("Use your email link sign-in flow to continue.")).toBeDefined(); + }); + + it("clears recovery when dismissed", () => { + const ui = createMockUI({ locale: recoveryLocale }); + ui.get().setLegacySignInRecovery({ + email: "test@example.com", + signInMethods: ["google.com"], + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + + expect(ui.get().legacySignInRecovery).toBeUndefined(); + }); + + it("clears recovery when the modal backdrop is clicked", () => { + const ui = createMockUI({ locale: recoveryLocale }); + ui.get().setLegacySignInRecovery({ + email: "test@example.com", + signInMethods: ["google.com"], + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("dialog").parentElement as HTMLElement); + + expect(ui.get().legacySignInRecovery).toBeUndefined(); + }); + + it("clears recovery after a successful recovery sign-in", () => { + const ui = createMockUI({ locale: recoveryLocale }); + ui.get().setLegacySignInRecovery({ + email: "test@example.com", + signInMethods: ["google.com"], + }); + + render( + + + + ); + + fireEvent.click(screen.getByTestId("google-recovery-button")); + + expect(ui.get().legacySignInRecovery).toBeUndefined(); + }); +}); diff --git a/packages/react/src/components/legacy-sign-in-recovery.tsx b/packages/react/src/components/legacy-sign-in-recovery.tsx new file mode 100644 index 000000000..95b0423d3 --- /dev/null +++ b/packages/react/src/components/legacy-sign-in-recovery.tsx @@ -0,0 +1,130 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { getTranslation } from "@firebase-oss/ui-core"; +import { useCallback, useEffect, useId } from "react"; +import { AppleSignInButton } from "~/auth/oauth/apple-sign-in-button"; +import { FacebookSignInButton } from "~/auth/oauth/facebook-sign-in-button"; +import { GitHubSignInButton } from "~/auth/oauth/github-sign-in-button"; +import { GoogleSignInButton } from "~/auth/oauth/google-sign-in-button"; +import { MicrosoftSignInButton } from "~/auth/oauth/microsoft-sign-in-button"; +import { TwitterSignInButton } from "~/auth/oauth/twitter-sign-in-button"; +import { YahooSignInButton } from "~/auth/oauth/yahoo-sign-in-button"; +import { useLegacySignInRecovery, useUI } from "~/hooks"; +import { Button } from "./button"; + +function hasMethod(signInMethods: string[], method: string) { + return signInMethods.includes(method); +} + +/** + * Displays default recovery UI for legacy sign-in method suggestions. + * + * Returns null if there is no recovery state. + */ +export function LegacySignInRecovery() { + const ui = useUI(); + const { recovery, clearRecovery } = useLegacySignInRecovery(); + const descriptionId = useId(); + const handleRecoverySignIn = useCallback(() => { + clearRecovery(); + }, [clearRecovery]); + const handleBackdropClick = useCallback( + (event: React.MouseEvent) => { + if (event.target === event.currentTarget) { + clearRecovery(); + } + }, + [clearRecovery] + ); + + useEffect(() => { + if (!recovery) { + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + clearRecovery(); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [recovery, clearRecovery]); + + if (!recovery) { + return null; + } + + return ( +
+
+
Account Found
+
+

+ {getTranslation(ui, "messages", "legacySignInRecoveryPrompt", { email: recovery.email })} +

+

+ {getTranslation(ui, "messages", "legacySignInRecoverySelectMethod")} +

+
+
+ {hasMethod(recovery.signInMethods, "google.com") ? ( + + ) : null} + {hasMethod(recovery.signInMethods, "github.com") ? ( + + ) : null} + {hasMethod(recovery.signInMethods, "facebook.com") ? ( + + ) : null} + {hasMethod(recovery.signInMethods, "apple.com") ? ( + + ) : null} + {hasMethod(recovery.signInMethods, "microsoft.com") ? ( + + ) : null} + {hasMethod(recovery.signInMethods, "twitter.com") ? ( + + ) : null} + {hasMethod(recovery.signInMethods, "yahoo.com") ? ( + + ) : null} +
+
+ {hasMethod(recovery.signInMethods, "password") ? ( +

{getTranslation(ui, "messages", "legacySignInRecoveryEmailPassword")}

+ ) : null} + {hasMethod(recovery.signInMethods, "emailLink") ? ( +

{getTranslation(ui, "messages", "legacySignInRecoveryEmailLink")}

+ ) : null} +
+ +
+
+ ); +} diff --git a/packages/react/src/hooks.test.tsx b/packages/react/src/hooks.test.tsx index 9425e1b6c..2a5499f0f 100644 --- a/packages/react/src/hooks.test.tsx +++ b/packages/react/src/hooks.test.tsx @@ -19,6 +19,7 @@ import { renderHook, act, cleanup, waitFor } from "@testing-library/react"; import { useUI, useRedirectError, + useLegacySignInRecovery, useSignInAuthFormSchema, useSignUpAuthFormSchema, useForgotPasswordAuthFormSchema, @@ -843,6 +844,69 @@ describe("useRedirectError", () => { }); }); +describe("useLegacySignInRecovery", () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + }); + + it("returns undefined when no recovery state exists", () => { + const mockUI = createMockUI(); + + const { result } = renderHook(() => useLegacySignInRecovery(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current.recovery).toBeUndefined(); + }); + + it("returns recovery data from UI state", () => { + const mockUI = createMockUI(); + const recovery = { + email: "test@example.com", + signInMethods: ["google.com", "password"], + attemptedProviderId: "github.com", + pendingProviderId: "github.com", + }; + + act(() => { + mockUI.get().setLegacySignInRecovery(recovery); + }); + + const { result } = renderHook(() => useLegacySignInRecovery(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current.recovery).toEqual(recovery); + }); + + it("clears the recovery state", () => { + const mockUI = createMockUI(); + const recovery = { + email: "test@example.com", + signInMethods: ["google.com"], + }; + + act(() => { + mockUI.get().setLegacySignInRecovery(recovery); + }); + + const { result, rerender } = renderHook(() => useLegacySignInRecovery(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current.recovery).toEqual(recovery); + + act(() => { + result.current.clearRecovery(); + }); + + rerender(); + + expect(result.current.recovery).toBeUndefined(); + }); +}); + describe("useRecaptchaVerifier", () => { beforeEach(() => { cleanup(); diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index 071c7e158..1d40ea801 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useContext, useMemo, useEffect, useRef, useState } from "react"; +import { useContext, useMemo, useEffect, useRef, useState, useCallback } from "react"; import type { RecaptchaVerifier, User } from "firebase/auth"; import { createEmailLinkAuthFormSchema, @@ -90,6 +90,26 @@ export function useRedirectError() { }, [ui.redirectError]); } +/** + * Gets legacy sign-in recovery data populated by the legacyFetchSignInWithEmail behavior. + * + * @returns The recovery data and a callback to clear it. + */ +export function useLegacySignInRecovery() { + const ui = useUI(); + const clearRecovery = useCallback(() => { + ui.clearLegacySignInRecovery(); + }, [ui]); + + return useMemo( + () => ({ + recovery: ui.legacySignInRecovery, + clearRecovery, + }), + [ui.legacySignInRecovery, clearRecovery] + ); +} + /** * Gets a memoized Zod schema for sign-in form validation. * diff --git a/packages/react/tests/utils.tsx b/packages/react/tests/utils.tsx index 67ac68d2b..2c15d6a5e 100644 --- a/packages/react/tests/utils.tsx +++ b/packages/react/tests/utils.tsx @@ -28,13 +28,32 @@ export function createMockUI(overrides?: Partial): FirebaseUI const { auth, ...restOverrides } = overrides || {}; - return initializeUI({ + const store = initializeUI({ app: {} as FirebaseApp, auth: auth ?? defaultAuth, locale: enUs, behaviors: [] as Behavior[], ...restOverrides, }); + + const ui = store.get() as FirebaseUI & { + setLegacySignInRecovery?: (recovery?: FirebaseUI["legacySignInRecovery"]) => void; + clearLegacySignInRecovery?: () => void; + }; + + if (!ui.setLegacySignInRecovery) { + ui.setLegacySignInRecovery = (recovery) => { + store.setKey("legacySignInRecovery", recovery as FirebaseUI["legacySignInRecovery"]); + }; + } + + if (!ui.clearLegacySignInRecovery) { + ui.clearLegacySignInRecovery = () => { + store.setKey("legacySignInRecovery", undefined); + }; + } + + return store; } export const createFirebaseUIProvider = ({ children, ui }: { children: React.ReactNode; ui: FirebaseUIStore }) => ( diff --git a/packages/styles/src/base.css b/packages/styles/src/base.css index 7d2d66d03..4cf3bb692 100644 --- a/packages/styles/src/base.css +++ b/packages/styles/src/base.css @@ -234,6 +234,34 @@ @apply hover:underline font-semibold; } + :where(.fui-legacy-sign-in-recovery-modal) { + @apply fixed inset-0 z-50 flex items-center justify-center p-4; + background: color-mix(in srgb, var(--color-text) 18%, transparent); + backdrop-filter: blur(10px); + } + + :where(.fui-legacy-sign-in-recovery-modal__card) { + @apply w-full max-w-md space-y-5 border-border shadow-2xl; + background: + linear-gradient(180deg, color-mix(in srgb, var(--color-background) 94%, var(--color-primary) 6%), var(--color-background)); + } + + :where(.fui-legacy-sign-in-recovery-modal__eyebrow) { + @apply inline-flex items-center rounded-full border border-border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-text-muted; + } + + :where(.fui-legacy-sign-in-recovery-modal__content) { + @apply space-y-2; + } + + :where(.fui-legacy-sign-in-recovery-modal__prompt) { + @apply text-lg font-semibold text-text text-balance; + } + + :where(.fui-legacy-sign-in-recovery-modal__notes) { + @apply space-y-2 text-sm text-text-muted; + } + .fui-provider__button[data-provider="google.com"][data-themed="true"] { --google-primary: #131314; --color-primary: var(--google-primary); diff --git a/packages/translations/src/locales/en-us.ts b/packages/translations/src/locales/en-us.ts index f1f2e08c1..5dc74e375 100644 --- a/packages/translations/src/locales/en-us.ts +++ b/packages/translations/src/locales/en-us.ts @@ -58,6 +58,10 @@ export const enUS = { dividerOr: "or", termsAndPrivacy: "By continuing, you agree to our {tos} and {privacy}.", mfaSmsAssertionPrompt: "A verification code will be sent to {phoneNumber} to complete the authentication process.", + legacySignInRecoveryPrompt: "You have previously signed in with a different method for {email}.", + legacySignInRecoverySelectMethod: "Choose one of your previous sign-in methods to continue.", + legacySignInRecoveryEmailPassword: "Use the email and password form to continue.", + legacySignInRecoveryEmailLink: "Use your email link sign-in flow to continue.", }, labels: { emailAddress: "Email Address", @@ -88,6 +92,7 @@ export const enUS = { privacyPolicy: "Privacy Policy", resendCode: "Resend Code", sending: "Sending...", + dismiss: "Dismiss", multiFactorEnrollment: "Multi-factor Enrollment", multiFactorAssertion: "Multi-factor Authentication", mfaTotpVerification: "TOTP Verification", diff --git a/packages/translations/src/mapping.test.ts b/packages/translations/src/mapping.test.ts index b6cff2b86..4863a7fd8 100644 --- a/packages/translations/src/mapping.test.ts +++ b/packages/translations/src/mapping.test.ts @@ -29,6 +29,7 @@ describe("mapping.ts", () => { it("should map Firebase auth error codes to translation keys", () => { expect(ERROR_CODE_MAP["auth/user-not-found"]).toBe("userNotFound"); expect(ERROR_CODE_MAP["auth/wrong-password"]).toBe("wrongPassword"); + expect(ERROR_CODE_MAP["auth/invalid-login-credentials"]).toBe("invalidCredential"); expect(ERROR_CODE_MAP["auth/invalid-email"]).toBe("invalidEmail"); expect(ERROR_CODE_MAP["auth/user-disabled"]).toBe("userDisabled"); expect(ERROR_CODE_MAP["auth/network-request-failed"]).toBe("networkRequestFailed"); @@ -109,6 +110,7 @@ describe("mapping.ts", () => { const testErrorCodes: ErrorCode[] = [ "auth/user-not-found", "auth/wrong-password", + "auth/invalid-login-credentials", "auth/invalid-email", "auth/network-request-failed", ]; diff --git a/packages/translations/src/mapping.ts b/packages/translations/src/mapping.ts index 0ff0eaf12..75f3bad33 100644 --- a/packages/translations/src/mapping.ts +++ b/packages/translations/src/mapping.ts @@ -22,6 +22,7 @@ import type { ErrorKey, TranslationCategory, TranslationKey, TranslationSet } fr export const ERROR_CODE_MAP = { "auth/user-not-found": "userNotFound", "auth/wrong-password": "wrongPassword", + "auth/invalid-login-credentials": "invalidCredential", "auth/invalid-email": "invalidEmail", "auth/unverified-email": "unverifiedEmail", "auth/user-disabled": "userDisabled", diff --git a/packages/translations/src/types.ts b/packages/translations/src/types.ts index 7cd41537f..5ee812cc5 100644 --- a/packages/translations/src/types.ts +++ b/packages/translations/src/types.ts @@ -117,6 +117,14 @@ export type Translations = { termsAndPrivacy?: string; /** Translation for MFA SMS assertion prompt message. */ mfaSmsAssertionPrompt?: string; + /** Translation for legacy sign-in recovery prompt message. */ + legacySignInRecoveryPrompt?: string; + /** Translation for selecting a previous sign-in method. */ + legacySignInRecoverySelectMethod?: string; + /** Translation for continuing with email and password. */ + legacySignInRecoveryEmailPassword?: string; + /** Translation for continuing with an email link. */ + legacySignInRecoveryEmailLink?: string; }; /** UI label translations. */ labels?: { @@ -174,6 +182,8 @@ export type Translations = { resendCode?: string; /** Translation for sending state text. */ sending?: string; + /** Translation for dismiss action. */ + dismiss?: string; /** Translation for multi-factor enrollment label. */ multiFactorEnrollment?: string; /** Translation for multi-factor assertion label. */