From 0aeb8d85fbd48429a2679b2837b063b96d3228b5 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 23 Mar 2026 16:56:13 +0000 Subject: [PATCH 01/15] feat: inital implementation for setting up fetchsigninwithemail behaviour --- .../src/lib/auth/screens/oauth-screen.ts | 9 +- .../lib/auth/screens/sign-in-auth-screen.ts | 9 +- .../lib/components/legacy-sign-in-recovery.ts | 131 ++++++++++++++++++ packages/angular/src/lib/provider.ts | 32 +++++ packages/angular/src/public-api.ts | 1 + packages/core/src/auth.ts | 27 ++-- packages/core/src/behaviors/index.ts | 13 ++ .../legacy-fetch-sign-in-with-email.ts | 72 ++++++++++ packages/core/src/config.ts | 33 ++++- packages/core/src/errors.ts | 15 +- .../react/src/auth/screens/oauth-screen.tsx | 6 +- .../src/auth/screens/sign-in-auth-screen.tsx | 11 +- packages/react/src/components/index.tsx | 1 + .../components/legacy-sign-in-recovery.tsx | 85 ++++++++++++ packages/react/src/hooks.ts | 22 ++- packages/translations/src/locales/en-us.ts | 5 + packages/translations/src/types.ts | 10 ++ 17 files changed, 456 insertions(+), 26 deletions(-) create mode 100644 packages/angular/src/lib/components/legacy-sign-in-recovery.ts create mode 100644 packages/core/src/behaviors/legacy-fetch-sign-in-with-email.ts create mode 100644 packages/react/src/components/legacy-sign-in-recovery.tsx 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.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.ts b/packages/angular/src/lib/components/legacy-sign-in-recovery.ts new file mode 100644 index 000000000..15469095b --- /dev/null +++ b/packages/angular/src/lib/components/legacy-sign-in-recovery.ts @@ -0,0 +1,131 @@ +/** + * Copyright 2025 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 } 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(); + } +} 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.ts b/packages/core/src/auth.ts index 60b0bf08f..23abd4ec5 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -61,6 +61,7 @@ async function handlePendingCredential(_ui: FirebaseUI, user: UserCredential): P function setPendingState(ui: FirebaseUI) { ui.setRedirectError(undefined); + ui.clearLegacySignInRecovery(); ui.setState("pending"); } @@ -94,7 +95,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, error); } finally { ui.setState("idle"); } @@ -146,7 +147,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 +200,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 +237,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 +255,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 +283,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 +327,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 +346,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 +364,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 +400,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 +476,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 +499,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 +518,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.ts b/packages/core/src/behaviors/index.ts index 5841d41e5..dc6192a6a 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,7 @@ type Registry = { oneTapSignIn: InitBehavior<(ui: FirebaseUI) => ReturnType>; requireDisplayName: CallableBehavior; countryCodes: CallableBehavior; + legacyFetchSignInWithEmail: CallableBehavior; }; /** A behavior or set of behaviors from the registry. */ @@ -183,6 +185,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.ts b/packages/core/src/behaviors/legacy-fetch-sign-in-with-email.ts new file mode 100644 index 000000000..842c8f32e --- /dev/null +++ b/packages/core/src/behaviors/legacy-fetch-sign-in-with-email.ts @@ -0,0 +1,72 @@ +/** + * Copyright 2025 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 { FirebaseError } from "firebase/app"; +import { fetchSignInMethodsForEmail, 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; + + return { + email, + signInMethods, + attemptedProviderId: pendingProviderId, + 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.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.ts b/packages/core/src/errors.ts index a2633f224..08c8e48f8 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,20 @@ 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())); + if (error.code === "auth/account-exists-with-different-credential") { + if (hasBehavior(ui, "legacyFetchSignInWithEmail")) { + await getBehavior(ui, "legacyFetchSignInWithEmail")(ui, error); + } else if (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/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.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.tsx b/packages/react/src/components/legacy-sign-in-recovery.tsx new file mode 100644 index 000000000..2c3e4f7c6 --- /dev/null +++ b/packages/react/src/components/legacy-sign-in-recovery.tsx @@ -0,0 +1,85 @@ +/** + * Copyright 2025 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 } 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 handleRecoverySignIn = useCallback(() => { + clearRecovery(); + }, [clearRecovery]); + + if (!recovery) { + return null; + } + + return ( +
+

{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.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/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/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. */ From bf8c27158530807cbc628c565f9ebd7ce27a2d42 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 23 Mar 2026 16:57:02 +0000 Subject: [PATCH 02/15] test: test the new legacySignInWithEmail behaviour --- .../src/lib/auth/screens/oauth-screen.spec.ts | 100 ++++++++ .../auth/screens/sign-in-auth-screen.spec.ts | 98 ++++++++ .../legacy-sign-in-recovery.spec.ts | 231 ++++++++++++++++++ packages/angular/src/lib/provider.spec.ts | 45 +++- packages/core/src/behaviors/index.test.ts | 33 +++ .../legacy-fetch-sign-in-with-email.test.ts | 158 ++++++++++++ packages/core/src/config.test.ts | 31 +++ packages/core/src/errors.test.ts | 211 +++++++--------- packages/core/tests/utils.ts | 3 + .../src/auth/screens/oauth-screen.test.tsx | 28 +++ .../auth/screens/sign-in-auth-screen.test.tsx | 28 +++ packages/react/src/hooks.test.tsx | 64 +++++ packages/react/tests/utils.tsx | 21 +- 13 files changed, 928 insertions(+), 123 deletions(-) create mode 100644 packages/angular/src/lib/components/legacy-sign-in-recovery.spec.ts create mode 100644 packages/core/src/behaviors/legacy-fetch-sign-in-with-email.test.ts 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/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/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..90c23d5fe --- /dev/null +++ b/packages/angular/src/lib/components/legacy-sign-in-recovery.spec.ts @@ -0,0 +1,231 @@ +/** + * Copyright 2025 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.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); + }); +}); diff --git a/packages/angular/src/lib/provider.spec.ts b/packages/angular/src/lib/provider.spec.ts index 2833bcf41..ad4e69a87 100644 --- a/packages/angular/src/lib/provider.spec.ts +++ b/packages/angular/src/lib/provider.spec.ts @@ -15,7 +15,7 @@ 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 +23,11 @@ const mockUI = { locale: "en-US", translations: {}, }, + legacySignInRecovery: { + email: "test@example.com", + signInMethods: ["google.com"], + }, + clearLegacySignInRecovery: jest.fn(), }; describe("injectTranslation", () => { @@ -96,6 +101,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/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/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..c9166e057 --- /dev/null +++ b/packages/core/src/behaviors/legacy-fetch-sign-in-with-email.test.ts @@ -0,0 +1,158 @@ +/** + * Copyright 2025 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("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/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/errors.test.ts b/packages/core/src/errors.test.ts index 36ca4d2da..dac33c658 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,185 @@ 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 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("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 +249,27 @@ 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); - }); - - it("should reject non-FirebaseError objects", () => { - const mockUI = createMockUI(); - const nonFirebaseError = { code: "test", message: "test" }; - - expect(() => handleFirebaseError(mockUI, nonFirebaseError)).toThrow(); + await expect( + handleFirebaseError(mockUI, new FirebaseError("auth/user-not-found", "User not found")) + ).rejects.toBeInstanceOf(FirebaseUIError); }); - it("should reject objects without code and message", () => { + it("treats plain objects with code and message like Firebase errors", async () => { const mockUI = createMockUI(); - const invalidObject = { someProperty: "value" }; - - expect(() => handleFirebaseError(mockUI, invalidObject)).toThrow(); - }); -}); - -describe("errorContainsCredential utility", () => { - it("should identify FirebaseError with credential", () => { - 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/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/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/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/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 }) => ( From 8af2a9f51269768d1f5dd5a9fcbf576409934385 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 23 Mar 2026 16:57:10 +0000 Subject: [PATCH 03/15] chore: pnpm-lock --- pnpm-lock.yaml | 148 ++++++++++++++++++------------------------------- 1 file changed, 55 insertions(+), 93 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a666192b1..5af9555d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,7 +163,7 @@ importers: version: 20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1) '@angular/fire': specifier: ^20.0.1 - version: 20.0.1(567573b864ff578578a14734fda777a6) + version: 20.0.1(pssp3fb7un7rxguvqqskilqpky) '@angular/forms': specifier: ^20.2.2 version: 20.3.7(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.7(@angular/animations@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) @@ -181,7 +181,7 @@ importers: version: 20.3.7(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.7(@angular/animations@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@angular/ssr': specifier: ^20.2.2 - version: 20.3.7(64ca8375dbaf48ae24b53908d91cad2b) + version: 20.3.7(ukwkoczqzncgjagaf6lj5a7e3m) '@firebase-oss/ui-angular': specifier: workspace:* version: link:../../packages/angular @@ -221,7 +221,7 @@ importers: version: 0.2003.10(chokidar@4.0.3) '@angular-devkit/build-angular': specifier: latest - version: 20.3.10(2529a6727af0abeddcf8d134a84a80aa) + version: 20.3.10(ya74ocnojpf73zlfudqa4yywva) '@angular-devkit/core': specifier: latest version: 20.3.10(chokidar@4.0.3) @@ -297,9 +297,6 @@ importers: cors: specifier: ^2.8.5 version: 2.8.5 - dev: - specifier: ^0.1.3 - version: 0.1.3 dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -719,7 +716,7 @@ importers: devDependencies: '@angular-devkit/build-angular': specifier: 'catalog:' - version: 20.3.7(352e872bc592fcb4f0bf67d221329859) + version: 20.3.7(66sowzrgjajitzkosdyemr3fs4) '@angular/cli': specifier: 'catalog:' version: 20.3.7(@types/node@24.9.2)(chokidar@4.0.3) @@ -737,7 +734,7 @@ importers: version: 20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1) '@angular/fire': specifier: 'catalog:' - version: 20.0.1(567573b864ff578578a14734fda777a6) + version: 20.0.1(pssp3fb7un7rxguvqqskilqpky) '@angular/forms': specifier: 'catalog:' version: 20.3.7(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.7(@angular/animations@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) @@ -752,7 +749,7 @@ importers: version: 20.3.7(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.7(@angular/animations@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@testing-library/angular': specifier: ^18.1.0 - version: 18.1.0(3ac39ba085ff33b647bd2bd0643e19cd) + version: 18.1.0(7e6gvldceoopsh4pfpilajrzom) '@testing-library/jest-dom': specifier: 'catalog:' version: 6.9.1 @@ -773,7 +770,7 @@ importers: version: 30.2.0 jest-preset-angular: specifier: ^15.0.2 - version: 15.0.3(46c3213d83638fa91c791d28859d16ce) + version: 15.0.3(v3hhyleeikathq5mu2er4s76aa) jsdom: specifier: ^25.0.0 version: 25.0.1 @@ -5710,9 +5707,6 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - bindings@1.5.0: - resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - bl@5.1.0: resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==} @@ -6294,10 +6288,6 @@ packages: detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} - dev@0.1.3: - resolution: {integrity: sha512-flCHQwAkXk3+1up/wo93Ms9E5Wf9K28ZGA7Oz55nBZ8OTdPPhyvZrwsT+twtySTFHIwv0w9CUNtagZgu1MgzXQ==} - hasBin: true - devalue@5.4.2: resolution: {integrity: sha512-MwPZTKEPK2k8Qgfmqrd48ZKVvzSQjgW0lXLxiIBA8dQjtf/6mw6pggHNLcyDKyf+fI6eXxlQwPsfaCMTU5U+Bw==} @@ -6734,9 +6724,6 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - file-uri-to-path@1.0.0: - resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -7196,11 +7183,6 @@ packages: injection-js@2.6.1: resolution: {integrity: sha512-dbR5bdhi7TWDoCye9cByZqeg/gAfamm8Vu3G1KZOTYkOif8WkuM8CD0oeDPtZYMzT5YH76JAFB7bkmyY9OJi2A==} - inotify@1.4.6: - resolution: {integrity: sha512-WW8/uqIA04O3AePQVe/Ms3ZLR0yGamaz8YOEpaXc4WBAGOPZfzu58wWErEPSUYaPyDrJRIeCn6PEIQgC1ZyQ5w==} - engines: {node: '>=0.8'} - os: [linux] - input-otp@1.4.2: resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} peerDependencies: @@ -8226,9 +8208,6 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nan@2.25.0: - resolution: {integrity: sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==} - nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -10548,13 +10527,13 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular-devkit/build-angular@20.3.10(2529a6727af0abeddcf8d134a84a80aa)': + '@angular-devkit/build-angular@20.3.10(ya74ocnojpf73zlfudqa4yywva)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2003.10(chokidar@4.0.3) '@angular-devkit/build-webpack': 0.2003.10(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2(esbuild@0.25.9)) '@angular-devkit/core': 20.3.10(chokidar@4.0.3) - '@angular/build': 20.3.10(1be407f5110624cbb4aee41c10128f32) + '@angular/build': 20.3.10(p7tj3rp522ucot4e7lbjpc73ra) '@angular/compiler-cli': 20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3) '@babel/core': 7.28.3 '@babel/generator': 7.28.3 @@ -10569,10 +10548,10 @@ snapshots: '@ngtools/webpack': 20.3.10(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(typescript@5.9.3)(webpack@5.101.2(esbuild@0.25.9)) ansi-colors: 4.1.3 autoprefixer: 10.4.21(postcss@8.5.6) - babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2) + babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2(esbuild@0.25.9)) browserslist: 4.27.0 - copy-webpack-plugin: 13.0.1(webpack@5.101.2) - css-loader: 7.1.2(webpack@5.101.2) + copy-webpack-plugin: 13.0.1(webpack@5.101.2(esbuild@0.25.9)) + css-loader: 7.1.2(webpack@5.101.2(esbuild@0.25.9)) esbuild-wasm: 0.25.9 fast-glob: 3.3.3 http-proxy-middleware: 3.0.5 @@ -10580,22 +10559,22 @@ snapshots: jsonc-parser: 3.3.1 karma-source-map-support: 1.4.0 less: 4.4.0 - less-loader: 12.3.0(less@4.4.0)(webpack@5.101.2) - license-webpack-plugin: 4.0.2(webpack@5.101.2) + less-loader: 12.3.0(less@4.4.0)(webpack@5.101.2(esbuild@0.25.9)) + license-webpack-plugin: 4.0.2(webpack@5.101.2(esbuild@0.25.9)) loader-utils: 3.3.1 - mini-css-extract-plugin: 2.9.4(webpack@5.101.2) + mini-css-extract-plugin: 2.9.4(webpack@5.101.2(esbuild@0.25.9)) open: 10.2.0 ora: 8.2.0 picomatch: 4.0.3 piscina: 5.1.3 postcss: 8.5.6 - postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.9.3)(webpack@5.101.2) + postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.9.3)(webpack@5.101.2(esbuild@0.25.9)) resolve-url-loader: 5.0.0 rxjs: 7.8.2 sass: 1.90.0 - sass-loader: 16.0.5(sass@1.90.0)(webpack@5.101.2) + sass-loader: 16.0.5(sass@1.90.0)(webpack@5.101.2(esbuild@0.25.9)) semver: 7.7.2 - source-map-loader: 5.0.0(webpack@5.101.2) + source-map-loader: 5.0.0(webpack@5.101.2(esbuild@0.25.9)) source-map-support: 0.5.21 terser: 5.43.1 tree-kill: 1.2.2 @@ -10605,12 +10584,12 @@ snapshots: webpack-dev-middleware: 7.4.2(webpack@5.101.2) webpack-dev-server: 5.2.2(webpack@5.101.2) webpack-merge: 6.0.1 - webpack-subresource-integrity: 5.1.0(webpack@5.101.2) + webpack-subresource-integrity: 5.1.0(webpack@5.101.2(esbuild@0.25.9)) optionalDependencies: '@angular/core': 20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1) '@angular/platform-browser': 20.3.7(@angular/animations@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)) '@angular/platform-server': 20.3.7(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/compiler@20.3.7)(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.7(@angular/animations@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) - '@angular/ssr': 20.3.7(64ca8375dbaf48ae24b53908d91cad2b) + '@angular/ssr': 20.3.7(ukwkoczqzncgjagaf6lj5a7e3m) esbuild: 0.25.9 jest: 30.2.0(@types/node@20.19.24) jest-environment-jsdom: 30.2.0 @@ -10639,13 +10618,13 @@ snapshots: - webpack-cli - yaml - '@angular-devkit/build-angular@20.3.7(352e872bc592fcb4f0bf67d221329859)': + '@angular-devkit/build-angular@20.3.7(66sowzrgjajitzkosdyemr3fs4)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2003.7(chokidar@4.0.3) - '@angular-devkit/build-webpack': 0.2003.7(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2) + '@angular-devkit/build-webpack': 0.2003.7(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2(esbuild@0.25.9)) '@angular-devkit/core': 20.3.7(chokidar@4.0.3) - '@angular/build': 20.3.7(1849c53c536bd3612d435156c655d6b9) + '@angular/build': 20.3.7(w6cdb5bg23fwg2rl5cbaqscsxq) '@angular/compiler-cli': 20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3) '@babel/core': 7.28.3 '@babel/generator': 7.28.3 @@ -10657,13 +10636,13 @@ snapshots: '@babel/preset-env': 7.28.3(@babel/core@7.28.3) '@babel/runtime': 7.28.3 '@discoveryjs/json-ext': 0.6.3 - '@ngtools/webpack': 20.3.7(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(typescript@5.9.3)(webpack@5.101.2) + '@ngtools/webpack': 20.3.7(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(typescript@5.9.3)(webpack@5.101.2(esbuild@0.25.9)) ansi-colors: 4.1.3 autoprefixer: 10.4.21(postcss@8.5.6) - babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2) + babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2(esbuild@0.25.9)) browserslist: 4.27.0 - copy-webpack-plugin: 13.0.1(webpack@5.101.2) - css-loader: 7.1.2(webpack@5.101.2) + copy-webpack-plugin: 13.0.1(webpack@5.101.2(esbuild@0.25.9)) + css-loader: 7.1.2(webpack@5.101.2(esbuild@0.25.9)) esbuild-wasm: 0.25.9 fast-glob: 3.3.3 http-proxy-middleware: 3.0.5 @@ -10671,22 +10650,22 @@ snapshots: jsonc-parser: 3.3.1 karma-source-map-support: 1.4.0 less: 4.4.0 - less-loader: 12.3.0(less@4.4.0)(webpack@5.101.2) - license-webpack-plugin: 4.0.2(webpack@5.101.2) + less-loader: 12.3.0(less@4.4.0)(webpack@5.101.2(esbuild@0.25.9)) + license-webpack-plugin: 4.0.2(webpack@5.101.2(esbuild@0.25.9)) loader-utils: 3.3.1 - mini-css-extract-plugin: 2.9.4(webpack@5.101.2) + mini-css-extract-plugin: 2.9.4(webpack@5.101.2(esbuild@0.25.9)) open: 10.2.0 ora: 8.2.0 picomatch: 4.0.3 piscina: 5.1.3 postcss: 8.5.6 - postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.9.3)(webpack@5.101.2) + postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.9.3)(webpack@5.101.2(esbuild@0.25.9)) resolve-url-loader: 5.0.0 rxjs: 7.8.2 sass: 1.90.0 - sass-loader: 16.0.5(sass@1.90.0)(webpack@5.101.2) + sass-loader: 16.0.5(sass@1.90.0)(webpack@5.101.2(esbuild@0.25.9)) semver: 7.7.2 - source-map-loader: 5.0.0(webpack@5.101.2) + source-map-loader: 5.0.0(webpack@5.101.2(esbuild@0.25.9)) source-map-support: 0.5.21 terser: 5.43.1 tree-kill: 1.2.2 @@ -10696,12 +10675,12 @@ snapshots: webpack-dev-middleware: 7.4.2(webpack@5.101.2) webpack-dev-server: 5.2.2(webpack@5.101.2) webpack-merge: 6.0.1 - webpack-subresource-integrity: 5.1.0(webpack@5.101.2) + webpack-subresource-integrity: 5.1.0(webpack@5.101.2(esbuild@0.25.9)) optionalDependencies: '@angular/core': 20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1) '@angular/platform-browser': 20.3.7(@angular/animations@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)) '@angular/platform-server': 20.3.7(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/compiler@20.3.7)(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.7(@angular/animations@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) - '@angular/ssr': 20.3.7(64ca8375dbaf48ae24b53908d91cad2b) + '@angular/ssr': 20.3.7(ukwkoczqzncgjagaf6lj5a7e3m) esbuild: 0.25.9 jest: 30.2.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)) jest-environment-jsdom: 30.2.0 @@ -10739,7 +10718,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular-devkit/build-webpack@0.2003.7(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2)': + '@angular-devkit/build-webpack@0.2003.7(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2(esbuild@0.25.9))': dependencies: '@angular-devkit/architect': 0.2003.7(chokidar@4.0.3) rxjs: 7.8.2 @@ -10848,7 +10827,7 @@ snapshots: '@angular/core': 20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1) tslib: 2.8.1 - '@angular/build@20.3.10(1be407f5110624cbb4aee41c10128f32)': + '@angular/build@20.3.10(p7tj3rp522ucot4e7lbjpc73ra)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2003.10(chokidar@4.0.3) @@ -10884,7 +10863,7 @@ snapshots: '@angular/core': 20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1) '@angular/platform-browser': 20.3.7(@angular/animations@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)) '@angular/platform-server': 20.3.7(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/compiler@20.3.7)(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.7(@angular/animations@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) - '@angular/ssr': 20.3.7(64ca8375dbaf48ae24b53908d91cad2b) + '@angular/ssr': 20.3.7(ukwkoczqzncgjagaf6lj5a7e3m) less: 4.4.0 lmdb: 3.4.2 ng-packagr: 20.3.0(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(tailwindcss@4.1.16)(tslib@2.8.1)(typescript@5.9.3) @@ -10904,7 +10883,7 @@ snapshots: - tsx - yaml - '@angular/build@20.3.7(1849c53c536bd3612d435156c655d6b9)': + '@angular/build@20.3.7(w6cdb5bg23fwg2rl5cbaqscsxq)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2003.7(chokidar@4.0.3) @@ -10940,7 +10919,7 @@ snapshots: '@angular/core': 20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1) '@angular/platform-browser': 20.3.7(@angular/animations@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)) '@angular/platform-server': 20.3.7(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/compiler@20.3.7)(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.7(@angular/animations@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) - '@angular/ssr': 20.3.7(64ca8375dbaf48ae24b53908d91cad2b) + '@angular/ssr': 20.3.7(ukwkoczqzncgjagaf6lj5a7e3m) less: 4.4.0 lmdb: 3.4.2 ng-packagr: 20.3.0(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(tailwindcss@4.1.16)(tslib@2.8.1)(typescript@5.9.3) @@ -11044,7 +11023,7 @@ snapshots: '@angular/compiler': 20.3.7 zone.js: 0.15.1 - '@angular/fire@20.0.1(567573b864ff578578a14734fda777a6)': + '@angular/fire@20.0.1(pssp3fb7un7rxguvqqskilqpky)': dependencies: '@angular-devkit/schematics': 20.3.7(chokidar@4.0.3) '@angular/common': 20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) @@ -11104,7 +11083,7 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 - '@angular/ssr@20.3.7(64ca8375dbaf48ae24b53908d91cad2b)': + '@angular/ssr@20.3.7(ukwkoczqzncgjagaf6lj5a7e3m)': dependencies: '@angular/common': 20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) '@angular/core': 20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1) @@ -13510,7 +13489,7 @@ snapshots: typescript: 5.9.3 webpack: 5.101.2(esbuild@0.25.9) - '@ngtools/webpack@20.3.7(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(typescript@5.9.3)(webpack@5.101.2)': + '@ngtools/webpack@20.3.7(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(typescript@5.9.3)(webpack@5.101.2(esbuild@0.25.9))': dependencies: '@angular/compiler-cli': 20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3) typescript: 5.9.3 @@ -14768,7 +14747,7 @@ snapshots: '@tanstack/store@0.7.7': {} - '@testing-library/angular@18.1.0(3ac39ba085ff33b647bd2bd0643e19cd)': + '@testing-library/angular@18.1.0(7e6gvldceoopsh4pfpilajrzom)': dependencies: '@angular/common': 20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) '@angular/core': 20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1) @@ -15696,7 +15675,7 @@ snapshots: transitivePeerDependencies: - supports-color - babel-loader@10.0.0(@babel/core@7.28.3)(webpack@5.101.2): + babel-loader@10.0.0(@babel/core@7.28.3)(webpack@5.101.2(esbuild@0.25.9)): dependencies: '@babel/core': 7.28.3 find-up: 5.0.0 @@ -15790,10 +15769,6 @@ snapshots: binary-extensions@2.3.0: {} - bindings@1.5.0: - dependencies: - file-uri-to-path: 1.0.0 - bl@5.1.0: dependencies: buffer: 6.0.3 @@ -16129,7 +16104,7 @@ snapshots: dependencies: is-what: 3.14.1 - copy-webpack-plugin@13.0.1(webpack@5.101.2): + copy-webpack-plugin@13.0.1(webpack@5.101.2(esbuild@0.25.9)): dependencies: glob-parent: 6.0.2 normalize-path: 3.0.0 @@ -16175,7 +16150,7 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-loader@7.1.2(webpack@5.101.2): + css-loader@7.1.2(webpack@5.101.2(esbuild@0.25.9)): dependencies: icss-utils: 5.1.0(postcss@8.5.6) postcss: 8.5.6 @@ -16354,10 +16329,6 @@ snapshots: detect-node@2.1.0: {} - dev@0.1.3: - dependencies: - inotify: 1.4.6 - devalue@5.4.2: {} diff@4.0.2: {} @@ -16974,8 +16945,6 @@ snapshots: dependencies: flat-cache: 4.0.1 - file-uri-to-path@1.0.0: {} - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -17553,11 +17522,6 @@ snapshots: dependencies: tslib: 2.8.1 - inotify@1.4.6: - dependencies: - bindings: 1.5.0 - nan: 2.25.0 - input-otp@1.4.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: react: 19.2.1 @@ -18033,7 +17997,7 @@ snapshots: optionalDependencies: jest-resolve: 30.2.0 - jest-preset-angular@15.0.3(46c3213d83638fa91c791d28859d16ce): + jest-preset-angular@15.0.3(v3hhyleeikathq5mu2er4s76aa): dependencies: '@angular/compiler-cli': 20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3) '@angular/core': 20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1) @@ -18393,7 +18357,7 @@ snapshots: picocolors: 1.1.1 shell-quote: 1.8.3 - less-loader@12.3.0(less@4.4.0)(webpack@5.101.2): + less-loader@12.3.0(less@4.4.0)(webpack@5.101.2(esbuild@0.25.9)): dependencies: less: 4.4.0 optionalDependencies: @@ -18436,7 +18400,7 @@ snapshots: libphonenumber-js@1.12.25: {} - license-webpack-plugin@4.0.2(webpack@5.101.2): + license-webpack-plugin@4.0.2(webpack@5.101.2(esbuild@0.25.9)): dependencies: webpack-sources: 3.3.3 optionalDependencies: @@ -18727,7 +18691,7 @@ snapshots: min-indent@1.0.1: {} - mini-css-extract-plugin@2.9.4(webpack@5.101.2): + mini-css-extract-plugin@2.9.4(webpack@5.101.2(esbuild@0.25.9)): dependencies: schema-utils: 4.3.3 tapable: 2.3.0 @@ -18892,8 +18856,6 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nan@2.25.0: {} - nanoid@3.3.11: {} nanostores@0.11.4: {} @@ -19374,7 +19336,7 @@ snapshots: postcss: 8.5.6 tsx: 4.20.6 - postcss-loader@8.1.1(postcss@8.5.6)(typescript@5.9.3)(webpack@5.101.2): + postcss-loader@8.1.1(postcss@8.5.6)(typescript@5.9.3)(webpack@5.101.2(esbuild@0.25.9)): dependencies: cosmiconfig: 9.0.0(typescript@5.9.3) jiti: 1.21.7 @@ -19920,7 +19882,7 @@ snapshots: safer-buffer@2.1.2: {} - sass-loader@16.0.5(sass@1.90.0)(webpack@5.101.2): + sass-loader@16.0.5(sass@1.90.0)(webpack@5.101.2(esbuild@0.25.9)): dependencies: neo-async: 2.6.2 optionalDependencies: @@ -20247,7 +20209,7 @@ snapshots: source-map-js@1.2.1: {} - source-map-loader@5.0.0(webpack@5.101.2): + source-map-loader@5.0.0(webpack@5.101.2(esbuild@0.25.9)): dependencies: iconv-lite: 0.6.3 source-map-js: 1.2.1 @@ -21394,7 +21356,7 @@ snapshots: webpack-sources@3.3.3: {} - webpack-subresource-integrity@5.1.0(webpack@5.101.2): + webpack-subresource-integrity@5.1.0(webpack@5.101.2(esbuild@0.25.9)): dependencies: typed-assert: 1.0.9 webpack: 5.101.2(esbuild@0.25.9) From b4be9b4ee0800f8395f789553c26bf34c696be78 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 23 Mar 2026 16:57:27 +0000 Subject: [PATCH 04/15] test: legacy-sign-in component --- .../legacy-sign-in-recovery.test.tsx | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 packages/react/src/components/legacy-sign-in-recovery.test.tsx 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..41c5c924b --- /dev/null +++ b/packages/react/src/components/legacy-sign-in-recovery.test.tsx @@ -0,0 +1,147 @@ +/** + * Copyright 2025 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.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 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(); + }); +}); From 4c27682e9688635943e5c38ae677ce45e2667ae3 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 23 Mar 2026 16:59:07 +0000 Subject: [PATCH 05/15] docs: update README with legacyFetchSignInWithEmail behaviour --- README.md | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/README.md b/README.md index 1c5b45120..1044a80cb 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 via a default recovery UI 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. From 0e6bed5dd6473d386f42ce8623c8d7d75acb06fe Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 23 Mar 2026 16:59:19 +0000 Subject: [PATCH 06/15] chore: remove dev dependency from example --- examples/custom-auth-server/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/custom-auth-server/package.json b/examples/custom-auth-server/package.json index 0a7ee0de4..2f53ffb29 100644 --- a/examples/custom-auth-server/package.json +++ b/examples/custom-auth-server/package.json @@ -11,7 +11,6 @@ }, "dependencies": { "cors": "^2.8.5", - "dev": "^0.1.3", "dotenv": "^16.4.5", "express": "^5.0.1", "express-rate-limit": "^7.4.1", From 5977d79f3c8b91f5f29bd3a732caf86eff5b3a88 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 23 Mar 2026 17:34:39 +0000 Subject: [PATCH 07/15] feat: increase errors that trigger recovery flow --- .../src/screens/legacy-recovery-demo.tsx | 60 ++++++++++ .../lib/components/legacy-sign-in-recovery.ts | 95 ++++++++++------ packages/core/src/auth.ts | 26 ++++- .../legacy-fetch-sign-in-with-email.ts | 14 ++- packages/core/src/errors.ts | 10 +- .../components/legacy-sign-in-recovery.tsx | 103 ++++++++++++------ packages/translations/src/mapping.ts | 1 + 7 files changed, 237 insertions(+), 72 deletions(-) create mode 100644 examples/react/src/screens/legacy-recovery-demo.tsx 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..ff115080c --- /dev/null +++ b/examples/react/src/screens/legacy-recovery-demo.tsx @@ -0,0 +1,60 @@ +/** + * Copyright 2025 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/components/legacy-sign-in-recovery.ts b/packages/angular/src/lib/components/legacy-sign-in-recovery.ts index 15469095b..e5ceab03b 100644 --- a/packages/angular/src/lib/components/legacy-sign-in-recovery.ts +++ b/packages/angular/src/lib/components/legacy-sign-in-recovery.ts @@ -15,7 +15,7 @@ */ import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; +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"; @@ -45,41 +45,53 @@ import { YahooSignInButtonComponent } from "../auth/oauth/yahoo-sign-in-button"; }, template: ` @if (recovery()) { -