From bb139c82a31809ee4e187efd715742b4bf1d9f98 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 28 Oct 2025 11:00:14 +0000 Subject: [PATCH 01/12] [WIP] feat(angular): MFA enrollment wip --- ...i-factor-enrollment-form.component.spec.ts | 345 +++++++++++++++++ ...-multi-factor-enrollment-form.component.ts | 227 +++++++++++ ...i-factor-enrollment-form.component.spec.ts | 360 ++++++++++++++++++ ...-multi-factor-enrollment-form.component.ts | 213 +++++++++++ ...tor-auth-enrollment-form.component.spec.ts | 203 ++++++++++ ...i-factor-auth-enrollment-form.component.ts | 82 ++++ ...r-auth-enrollment-screen.component.spec.ts | 235 ++++++++++++ ...factor-auth-enrollment-screen.component.ts | 71 ++++ 8 files changed, 1736 insertions(+) create mode 100644 packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.component.spec.ts create mode 100644 packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.component.ts create mode 100644 packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.component.spec.ts create mode 100644 packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.component.ts create mode 100644 packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.component.spec.ts create mode 100644 packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.component.ts create mode 100644 packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.spec.ts create mode 100644 packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.ts diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.component.spec.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.component.spec.ts new file mode 100644 index 00000000..905f7f25 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.component.spec.ts @@ -0,0 +1,345 @@ +/** + * 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 { CommonModule } from "@angular/common"; +import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; +import { SmsMultiFactorEnrollmentFormComponent } from "./sms-multi-factor-enrollment-form.component"; +import { + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, +} from "../../../components/form/form.component"; +import { CountrySelectorComponent } from "../../../components/country-selector/country-selector.component"; +import { PoliciesComponent } from "../../../components/policies/policies.component"; + +describe("", () => { + let mockVerifyPhoneNumber: any; + let mockEnrollWithMultiFactorAssertion: any; + let mockFormatPhoneNumber: any; + let mockFirebaseUIError: any; + let mockMultiFactor: any; + let mockPhoneAuthProvider: any; + let mockPhoneMultiFactorGenerator: any; + + beforeEach(() => { + const { + verifyPhoneNumber, + enrollWithMultiFactorAssertion, + formatPhoneNumber, + FirebaseUIError, + } = require("@firebase-ui/core"); + const { PhoneAuthProvider, PhoneMultiFactorGenerator, multiFactor } = require("firebase/auth"); + + mockVerifyPhoneNumber = verifyPhoneNumber; + mockEnrollWithMultiFactorAssertion = enrollWithMultiFactorAssertion; + mockFormatPhoneNumber = formatPhoneNumber; + mockFirebaseUIError = FirebaseUIError; + mockMultiFactor = multiFactor; + mockPhoneAuthProvider = PhoneAuthProvider; + mockPhoneMultiFactorGenerator = PhoneMultiFactorGenerator; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should create", async () => { + const { fixture } = await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + PoliciesComponent, + ], + }); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it("should render phone number form initially", async () => { + await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + PoliciesComponent, + ], + }); + + expect(screen.getByLabelText("Display Name")).toBeInTheDocument(); + expect(screen.getByLabelText("Phone Number")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send Verification Code" })).toBeInTheDocument(); + }); + + it("should render verification form after phone number is submitted", async () => { + const mockVerificationId = "test-verification-id"; + mockVerifyPhoneNumber.mockResolvedValue(mockVerificationId); + + const { fixture } = await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + component.verificationId.set(mockVerificationId); + fixture.detectChanges(); + + expect(screen.getByLabelText("Verification Code")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); + }); + + it("should handle phone number submission", async () => { + const mockVerificationId = "test-verification-id"; + mockVerifyPhoneNumber.mockResolvedValue(mockVerificationId); + + const { fixture } = await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.phoneForm.setFieldValue("displayName", "Test User"); + component.phoneForm.setFieldValue("phoneNumber", "1234567890"); + component.country.set("US" as any); + fixture.detectChanges(); + + await component.phoneForm.handleSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.verificationId()).toBe(mockVerificationId); + expect(component.displayName()).toBe("Test User"); + }); + + it("should handle verification code submission", async () => { + mockEnrollWithMultiFactorAssertion.mockResolvedValue(undefined); + + const { fixture } = await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + component.verificationId.set("test-verification-id"); + component.displayName.set("Test User"); + fixture.detectChanges(); + + const enrollmentSpy = jest.spyOn(component.onEnrollment, "emit"); + + component.verificationForm.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await component.verificationForm.handleSubmit(); + await fixture.whenStable(); + + expect(enrollmentSpy).toHaveBeenCalled(); + }); + + it("should handle FirebaseUIError in phone verification", async () => { + const errorMessage = "Invalid phone number"; + mockVerifyPhoneNumber.mockRejectedValue(new mockFirebaseUIError(errorMessage)); + + const { fixture } = await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.phoneForm.setFieldValue("displayName", "Test User"); + component.phoneForm.setFieldValue("phoneNumber", "1234567890"); + fixture.detectChanges(); + + await component.phoneForm.handleSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + expect(component.verificationId()).toBeNull(); + }); + + it("should handle FirebaseUIError in code verification", async () => { + const errorMessage = "Invalid verification code"; + mockEnrollWithMultiFactorAssertion.mockRejectedValue(new mockFirebaseUIError(errorMessage)); + + const { fixture } = await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + component.verificationId.set("test-verification-id"); + component.displayName.set("Test User"); + fixture.detectChanges(); + + component.verificationForm.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await component.verificationForm.handleSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + + it("should format phone number correctly", async () => { + const formattedNumber = "+1 (234) 567-8900"; + mockFormatPhoneNumber.mockReturnValue(formattedNumber); + mockVerifyPhoneNumber.mockResolvedValue("test-verification-id"); + + const { fixture } = await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.phoneForm.setFieldValue("displayName", "Test User"); + component.phoneForm.setFieldValue("phoneNumber", "1234567890"); + component.country.set("US" as any); + fixture.detectChanges(); + + await component.phoneForm.handleSubmit(); + await fixture.whenStable(); + + expect(mockFormatPhoneNumber).toHaveBeenCalledWith("1234567890", expect.objectContaining({ code: "US" })); + expect(mockVerifyPhoneNumber).toHaveBeenCalledWith( + expect.any(Object), + formattedNumber, + expect.any(Object), + expect.any(Object) + ); + }); + + it("should throw error if user is not authenticated", async () => { + const { fixture } = await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + // Mock UI to return null currentUser + const mockUI = { + auth: { currentUser: null }, + }; + jest.spyOn(component as any, "ui").mockReturnValue(() => mockUI); + + component.phoneForm.setFieldValue("displayName", "Test User"); + component.phoneForm.setFieldValue("phoneNumber", "1234567890"); + fixture.detectChanges(); + + await component.phoneForm.handleSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(screen.getByText("User must be authenticated to enroll with multi-factor authentication")).toBeInTheDocument(); + }); + + it("should have correct CSS classes", async () => { + const { container } = await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + PoliciesComponent, + ], + }); + + expect(container.querySelector(".fui-form-container")).toBeInTheDocument(); + expect(container.querySelector(".fui-form")).toBeInTheDocument(); + expect(container.querySelector(".fui-recaptcha-container")).toBeInTheDocument(); + }); +}); diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.component.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.component.ts new file mode 100644 index 00000000..b9c26449 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.component.ts @@ -0,0 +1,227 @@ +/** + * 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 { Component, signal, effect, viewChild, computed, output } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { TanStackField, TanStackAppField, injectForm, injectStore } from "@tanstack/angular-form"; +import { ElementRef } from "@angular/core"; +import { RecaptchaVerifier } from "firebase/auth"; +import { PhoneAuthProvider, PhoneMultiFactorGenerator } from "firebase/auth"; +import { + verifyPhoneNumber, + enrollWithMultiFactorAssertion, + formatPhoneNumber, + FirebaseUIError, +} from "@firebase-ui/core"; +import { multiFactor } from "firebase/auth"; +import { + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, +} from "../../../components/form/form.component"; +import { CountrySelectorComponent } from "../../../components/country-selector/country-selector.component"; +import { PoliciesComponent } from "../../../components/policies/policies.component"; +import { + injectUI, + injectTranslation, + injectPhoneAuthFormSchema, + injectPhoneAuthVerifyFormSchema, + injectDefaultCountry, +} from "../../../provider"; + +@Component({ + selector: "fui-sms-multi-factor-enrollment-form", + standalone: true, + imports: [ + CommonModule, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + PoliciesComponent, + ], + template: ` +
+ @if (!verificationId()) { + +
+
+ +
+
+ + +
+
+
+
+ +
+ + {{ sendCodeLabel() }} + + +
+ + } @else { + +
+
+ +
+ +
+ + {{ verifyCodeLabel() }} + + +
+ + } +
+ `, +}) +export class SmsMultiFactorEnrollmentFormComponent { + private ui = injectUI(); + private phoneFormSchema = injectPhoneAuthFormSchema(); + private verificationFormSchema = injectPhoneAuthVerifyFormSchema(); + private defaultCountry = injectDefaultCountry(); + + verificationId = signal(null); + country = signal(this.defaultCountry().code); + displayName = signal(""); + + displayNameLabel = injectTranslation("labels", "displayName"); + phoneNumberLabel = injectTranslation("labels", "phoneNumber"); + sendCodeLabel = injectTranslation("labels", "sendCode"); + verificationCodeLabel = injectTranslation("labels", "verificationCode"); + verifyCodeLabel = injectTranslation("labels", "verifyCode"); + unknownErrorLabel = injectTranslation("errors", "unknownError"); + + onEnrollment = output(); + + recaptchaContainer = viewChild.required>("recaptchaContainer"); + + recaptchaVerifier = computed(() => { + return new RecaptchaVerifier(this.ui().auth, this.recaptchaContainer().nativeElement, { + size: "normal", + }); + }); + + phoneForm = injectForm({ + defaultValues: { + displayName: "", + phoneNumber: "", + }, + }); + + verificationForm = injectForm({ + defaultValues: { + verificationCode: "", + }, + }); + + phoneState = injectStore(this.phoneForm, (state) => state); + verificationState = injectStore(this.verificationForm, (state) => state); + + constructor() { + effect(() => { + this.phoneForm.update({ + validators: { + onBlur: this.phoneFormSchema(), + onSubmit: this.phoneFormSchema(), + onSubmitAsync: async ({ value }) => { + try { + if (!this.ui().auth.currentUser) { + throw new Error("User must be authenticated to enroll with multi-factor authentication"); + } + + const mfaUser = multiFactor(this.ui().auth.currentUser); + const formattedPhoneNumber = formatPhoneNumber(value.phoneNumber, this.defaultCountry()); + const verificationId = await verifyPhoneNumber( + this.ui(), + formattedPhoneNumber, + this.recaptchaVerifier(), + mfaUser + ); + + this.displayName.set(value.displayName); + this.verificationId.set(verificationId); + return; + } catch (error) { + if (error instanceof FirebaseUIError) { + return error.message; + } + return this.unknownErrorLabel(); + } + }, + }, + }); + }); + + effect(() => { + this.verificationForm.update({ + validators: { + onBlur: this.verificationFormSchema(), + onSubmit: this.verificationFormSchema(), + onSubmitAsync: async ({ value }) => { + try { + const credential = PhoneAuthProvider.credential(this.verificationId()!, value.verificationCode); + const assertion = PhoneMultiFactorGenerator.assertion(credential); + await enrollWithMultiFactorAssertion(this.ui(), assertion, this.displayName()); + this.onEnrollment.emit(); + return; + } catch (error) { + if (error instanceof FirebaseUIError) { + return error.message; + } + return this.unknownErrorLabel(); + } + }, + }, + }); + }); + } + + async handlePhoneSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.phoneForm.handleSubmit(); + } + + async handleVerificationSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.verificationForm.handleSubmit(); + } +} diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.component.spec.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.component.spec.ts new file mode 100644 index 00000000..cbbcd8f5 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.component.spec.ts @@ -0,0 +1,360 @@ +/** + * 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 } from "@testing-library/angular"; +import { CommonModule } from "@angular/common"; +import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; +import { TotpMultiFactorEnrollmentFormComponent } from "./totp-multi-factor-enrollment-form.component"; +import { + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, +} from "../../../components/form/form.component"; +import { PoliciesComponent } from "../../../components/policies/policies.component"; + +describe("", () => { + let mockGenerateTotpSecret: any; + let mockEnrollWithMultiFactorAssertion: any; + let mockGenerateTotpQrCode: any; + let mockFirebaseUIError: any; + let mockTotpMultiFactorGenerator: any; + + beforeEach(() => { + const { + generateTotpSecret, + enrollWithMultiFactorAssertion, + generateTotpQrCode, + FirebaseUIError, + } = require("@firebase-ui/core"); + const { TotpMultiFactorGenerator } = require("firebase/auth"); + + mockGenerateTotpSecret = generateTotpSecret; + mockEnrollWithMultiFactorAssertion = enrollWithMultiFactorAssertion; + mockGenerateTotpQrCode = generateTotpQrCode; + mockFirebaseUIError = FirebaseUIError; + mockTotpMultiFactorGenerator = TotpMultiFactorGenerator; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should create", async () => { + const { fixture } = await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it("should render display name form initially", async () => { + await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + + expect(screen.getByLabelText("Display Name")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Generate QR Code" })).toBeInTheDocument(); + }); + + it("should render QR code and verification form after display name is submitted", async () => { + const mockSecret = { + generateQrCodeUrl: jest.fn(), + sessionInfo: {}, + auth: {}, + secretKey: new Uint8Array(), + hashingAlgorithm: "SHA1", + codeLength: 6, + timeStepSize: 30 + } as any; + mockGenerateTotpSecret.mockResolvedValue(mockSecret); + mockGenerateTotpQrCode.mockReturnValue("data:image/png;base64,test-qr-code"); + + const { fixture } = await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + component.enrollment.set({ secret: mockSecret, displayName: "Test User" }); + fixture.detectChanges(); + + expect(screen.getByAltText("TOTP QR Code")).toBeInTheDocument(); + expect(screen.getByText("TODO: Scan this QR code with your authenticator app")).toBeInTheDocument(); + expect(screen.getByLabelText("Verification Code")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); + }); + + it("should handle display name submission", async () => { + const mockSecret = { + generateQrCodeUrl: jest.fn(), + sessionInfo: {}, + auth: {}, + secretKey: new Uint8Array(), + hashingAlgorithm: "SHA1", + codeLength: 6, + timeStepSize: 30 + } as any; + mockGenerateTotpSecret.mockResolvedValue(mockSecret); + + const { fixture } = await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.displayNameForm.setFieldValue("displayName", "Test User"); + fixture.detectChanges(); + + await component.displayNameForm.handleSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.enrollment()).toEqual({ secret: mockSecret, displayName: "Test User" }); + }); + + it("should handle verification code submission", async () => { + const mockSecret = { + generateQrCodeUrl: jest.fn(), + sessionInfo: {}, + auth: {}, + secretKey: new Uint8Array(), + hashingAlgorithm: "SHA1", + codeLength: 6, + timeStepSize: 30 + } as any; + mockEnrollWithMultiFactorAssertion.mockResolvedValue(undefined); + + const { fixture } = await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + component.enrollment.set({ secret: mockSecret, displayName: "Test User" }); + fixture.detectChanges(); + + const enrollmentSpy = jest.spyOn(component.onEnrollment, "emit"); + + component.verificationForm.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await component.verificationForm.handleSubmit(); + await fixture.whenStable(); + + expect(enrollmentSpy).toHaveBeenCalled(); + }); + + it("should handle FirebaseUIError in secret generation", async () => { + const errorMessage = "Failed to generate TOTP secret"; + mockGenerateTotpSecret.mockRejectedValue(new mockFirebaseUIError(errorMessage)); + + const { fixture } = await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.displayNameForm.setFieldValue("displayName", "Test User"); + fixture.detectChanges(); + + await component.displayNameForm.handleSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + expect(component.enrollment()).toBeNull(); + }); + + it("should handle FirebaseUIError in verification", async () => { + const errorMessage = "Invalid verification code"; + mockEnrollWithMultiFactorAssertion.mockRejectedValue(new mockFirebaseUIError(errorMessage)); + + const { fixture } = await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + const mockSecret = { + generateQrCodeUrl: jest.fn(), + sessionInfo: {}, + auth: {}, + secretKey: new Uint8Array(), + hashingAlgorithm: "SHA1", + codeLength: 6, + timeStepSize: 30 + } as any; + component.enrollment.set({ secret: mockSecret, displayName: "Test User" }); + fixture.detectChanges(); + + component.verificationForm.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await component.verificationForm.handleSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + + it("should throw error if user is not authenticated", async () => { + const { fixture } = await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + // Mock UI to return null currentUser + const mockUI = { + auth: { currentUser: null }, + }; + jest.spyOn(component as any, "ui").mockReturnValue(() => mockUI); + + component.displayNameForm.setFieldValue("displayName", "Test User"); + fixture.detectChanges(); + + await component.displayNameForm.handleSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(screen.getByText("User must be authenticated to enroll with multi-factor authentication")).toBeInTheDocument(); + }); + + it("should generate QR code with correct parameters", async () => { + const mockSecret = { + generateQrCodeUrl: jest.fn(), + sessionInfo: {}, + auth: {}, + secretKey: new Uint8Array(), + hashingAlgorithm: "SHA1", + codeLength: 6, + timeStepSize: 30 + } as any; + const mockQrCodeDataUrl = "data:image/png;base64,test-qr-code"; + mockGenerateTotpSecret.mockResolvedValue(mockSecret); + mockGenerateTotpQrCode.mockReturnValue(mockQrCodeDataUrl); + + const { fixture } = await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + component.enrollment.set({ secret: mockSecret, displayName: "Test User" }); + fixture.detectChanges(); + + expect(component.qrCodeDataUrl()).toBe(mockQrCodeDataUrl); + expect(mockGenerateTotpQrCode).toHaveBeenCalledWith( + expect.any(Object), + mockSecret, + "Test User" + ); + }); + + it("should have correct CSS classes", async () => { + const { container } = await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + + expect(container.querySelector(".fui-form-container")).toBeInTheDocument(); + expect(container.querySelector(".fui-form")).toBeInTheDocument(); + }); +}); diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.component.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.component.ts new file mode 100644 index 00000000..4b119118 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.component.ts @@ -0,0 +1,213 @@ +/** + * 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 { Component, signal, effect, output, computed } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { TanStackField, TanStackAppField, injectForm, injectStore } from "@tanstack/angular-form"; +import { TotpMultiFactorGenerator, type TotpSecret } from "firebase/auth"; +import { z } from "zod"; +import { + enrollWithMultiFactorAssertion, + generateTotpSecret, + generateTotpQrCode, + FirebaseUIError, +} from "@firebase-ui/core"; +import { + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, +} from "../../../components/form/form.component"; +import { PoliciesComponent } from "../../../components/policies/policies.component"; +import { + injectUI, + injectTranslation, +} from "../../../provider"; + +@Component({ + selector: "fui-totp-multi-factor-enrollment-form", + standalone: true, + imports: [ + CommonModule, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + template: ` +
+ @if (!enrollment()) { + +
+
+ +
+ +
+ + {{ generateQrCodeLabel() }} + + +
+ + } @else { + +
+ TOTP QR Code +

TODO: Scan this QR code with your authenticator app

+
+
+
+ +
+ +
+ + {{ verifyCodeLabel() }} + + +
+ + } +
+ `, +}) +export class TotpMultiFactorEnrollmentFormComponent { + private ui = injectUI(); + + enrollment = signal<{ secret: TotpSecret; displayName: string } | null>(null); + + displayNameLabel = injectTranslation("labels", "displayName"); + generateQrCodeLabel = injectTranslation("labels", "generateQrCode"); + verificationCodeLabel = injectTranslation("labels", "verificationCode"); + verifyCodeLabel = injectTranslation("labels", "verifyCode"); + unknownErrorLabel = injectTranslation("errors", "unknownError"); + + onEnrollment = output(); + + displayNameForm = injectForm({ + defaultValues: { + displayName: "", + }, + }); + + verificationForm = injectForm({ + defaultValues: { + verificationCode: "", + }, + }); + + displayNameState = injectStore(this.displayNameForm, (state) => state); + verificationState = injectStore(this.verificationForm, (state) => state); + + qrCodeDataUrl = computed(() => { + const enrollmentData = this.enrollment(); + if (!enrollmentData) return ""; + return generateTotpQrCode(this.ui(), enrollmentData.secret, enrollmentData.displayName); + }); + + constructor() { + effect(() => { + this.displayNameForm.update({ + validators: { + onBlur: z.object({ + displayName: z.string().min(1, "Display name is required"), + }), + onSubmit: z.object({ + displayName: z.string().min(1, "Display name is required"), + }), + onSubmitAsync: async ({ value }) => { + try { + if (!this.ui().auth.currentUser) { + throw new Error("User must be authenticated to enroll with multi-factor authentication"); + } + + const secret = await generateTotpSecret(this.ui()); + this.enrollment.set({ secret, displayName: value.displayName }); + return; + } catch (error) { + if (error instanceof FirebaseUIError) { + return error.message; + } + return this.unknownErrorLabel(); + } + }, + }, + }); + }); + + effect(() => { + this.verificationForm.update({ + validators: { + onBlur: z.object({ + verificationCode: z.string().refine((val) => val.length === 6, { + message: "Verification code must be 6 digits", + }), + }), + onSubmit: z.object({ + verificationCode: z.string().refine((val) => val.length === 6, { + message: "Verification code must be 6 digits", + }), + }), + onSubmitAsync: async ({ value }) => { + try { + const enrollmentData = this.enrollment(); + if (!enrollmentData) { + throw new Error("No enrollment data available"); + } + + const assertion = TotpMultiFactorGenerator.assertionForEnrollment( + enrollmentData.secret, + value.verificationCode + ); + await enrollWithMultiFactorAssertion(this.ui(), assertion, enrollmentData.displayName); + this.onEnrollment.emit(); + return; + } catch (error) { + if (error instanceof FirebaseUIError) { + return error.message; + } + return this.unknownErrorLabel(); + } + }, + }, + }); + }); + } + + async handleDisplayNameSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.displayNameForm.handleSubmit(); + } + + async handleVerificationSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.verificationForm.handleSubmit(); + } +} diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.component.spec.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.component.spec.ts new file mode 100644 index 00000000..3e95d283 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.component.spec.ts @@ -0,0 +1,203 @@ +/** + * 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 { CommonModule } from "@angular/common"; +import { MultiFactorAuthEnrollmentFormComponent } from "./multi-factor-auth-enrollment-form.component"; +import { SmsMultiFactorEnrollmentFormComponent } from "./mfa/sms-multi-factor-enrollment-form.component"; +import { TotpMultiFactorEnrollmentFormComponent } from "./mfa/totp-multi-factor-enrollment-form.component"; +import { ButtonComponent } from "../../../components/button/button.component"; +import { FactorId } from "firebase/auth"; + +describe("", () => { + it("should create", async () => { + const { fixture } = await render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + }); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it("should render selection buttons when multiple hints are provided", async () => { + await render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + componentInputs: { + hints: [FactorId.TOTP, FactorId.PHONE], + }, + }); + + expect(screen.getByRole("button", { name: "SMS Verification" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "TOTP Verification" })).toBeInTheDocument(); + }); + + it("should auto-select single hint when only one is provided", async () => { + await render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + componentInputs: { + hints: [FactorId.PHONE], + }, + }); + + // Should not show selection buttons + expect(screen.queryByRole("button", { name: "SMS Verification" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "TOTP Verification" })).not.toBeInTheDocument(); + + // Should show SMS form directly + expect(screen.getByLabelText("Display Name")).toBeInTheDocument(); + expect(screen.getByLabelText("Phone Number")).toBeInTheDocument(); + }); + + it("should show SMS form when SMS hint is selected", async () => { + const { fixture } = await render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + componentInputs: { + hints: [FactorId.TOTP, FactorId.PHONE], + }, + }); + + const smsButton = screen.getByRole("button", { name: "SMS Verification" }); + fireEvent.click(smsButton); + fixture.detectChanges(); + + expect(screen.getByLabelText("Display Name")).toBeInTheDocument(); + expect(screen.getByLabelText("Phone Number")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send Verification Code" })).toBeInTheDocument(); + }); + + it("should show TOTP form when TOTP hint is selected", async () => { + const { fixture } = await render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + componentInputs: { + hints: [FactorId.TOTP, FactorId.PHONE], + }, + }); + + const totpButton = screen.getByRole("button", { name: "TOTP Verification" }); + fireEvent.click(totpButton); + fixture.detectChanges(); + + expect(screen.getByLabelText("Display Name")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Generate QR Code" })).toBeInTheDocument(); + }); + + it("should emit onEnrollment when SMS form completes enrollment", async () => { + const { fixture } = await render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + componentInputs: { + hints: [FactorId.PHONE], + }, + }); + + const component = fixture.componentInstance; + const enrollmentSpy = jest.spyOn(component.onEnrollment, "emit"); + + // Get the SMS form component and emit enrollment + const smsFormComponent = fixture.debugElement.query( + (el) => el.componentInstance instanceof SmsMultiFactorEnrollmentFormComponent + )?.componentInstance as SmsMultiFactorEnrollmentFormComponent; + + expect(smsFormComponent).toBeTruthy(); + smsFormComponent.onEnrollment.emit(); + + expect(enrollmentSpy).toHaveBeenCalled(); + }); + + it("should emit onEnrollment when TOTP form completes enrollment", async () => { + const { fixture } = await render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + componentInputs: { + hints: [FactorId.TOTP], + }, + }); + + const component = fixture.componentInstance; + const enrollmentSpy = jest.spyOn(component.onEnrollment, "emit"); + + // Get the TOTP form component and emit enrollment + const totpFormComponent = fixture.debugElement.query( + (el) => el.componentInstance instanceof TotpMultiFactorEnrollmentFormComponent + )?.componentInstance as TotpMultiFactorEnrollmentFormComponent; + + expect(totpFormComponent).toBeTruthy(); + totpFormComponent.onEnrollment.emit(); + + expect(enrollmentSpy).toHaveBeenCalled(); + }); + + it("should throw error when no hints are provided", () => { + expect(() => { + new MultiFactorAuthEnrollmentFormComponent(); + }).toThrow("MultiFactorAuthEnrollmentForm must have at least one hint"); + }); + + it("should have correct CSS classes", async () => { + const { container } = await render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + componentInputs: { + hints: [FactorId.TOTP, FactorId.PHONE], + }, + }); + + expect(container.querySelector(".fui-content")).toBeInTheDocument(); + }); +}); diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.component.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.component.ts new file mode 100644 index 00000000..d707a325 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.component.ts @@ -0,0 +1,82 @@ +/** + * 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 { Component, signal, input, output } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { FactorId } from "firebase/auth"; +import { injectTranslation } from "../../../provider"; +import { SmsMultiFactorEnrollmentFormComponent } from "./mfa/sms-multi-factor-enrollment-form.component"; +import { TotpMultiFactorEnrollmentFormComponent } from "./mfa/totp-multi-factor-enrollment-form.component"; +import { ButtonComponent } from "../../../components/button/button.component"; + +type Hint = (typeof FactorId)[keyof typeof FactorId]; + +@Component({ + selector: "fui-multi-factor-auth-enrollment-form", + standalone: true, + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + template: ` +
+ @if (selectedHint()) { + @if (selectedHint() === 'phone') { + + } @else if (selectedHint() === 'totp') { + + } + } @else { + @for (hint of hints(); track hint) { + @if (hint === 'phone') { + + } @else if (hint === 'totp') { + + } + } + } +
+ `, +}) +export class MultiFactorAuthEnrollmentFormComponent { + hints = input([FactorId.TOTP, FactorId.PHONE]); + onEnrollment = output(); + + selectedHint = signal(undefined); + + smsVerificationLabel = injectTranslation("labels", "mfaSmsVerification"); + totpVerificationLabel = injectTranslation("labels", "mfaTotpVerification"); + + constructor() { + // If only a single hint is provided, select it by default to improve UX + const hints = this.hints(); + if (hints.length === 1) { + this.selectedHint.set(hints[0]); + } else if (hints.length === 0) { + throw new Error("MultiFactorAuthEnrollmentForm must have at least one hint"); + } + } + + selectHint(hint: Hint) { + this.selectedHint.set(hint); + } +} diff --git a/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.spec.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.spec.ts new file mode 100644 index 00000000..b9097d36 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.spec.ts @@ -0,0 +1,235 @@ +/** + * 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 } from "@testing-library/angular"; +import { Component } from "@angular/core"; +import { MultiFactorAuthEnrollmentScreenComponent } from "./multi-factor-auth-enrollment-screen.component"; +import { MultiFactorAuthEnrollmentFormComponent } from "../../forms/multi-factor-auth-enrollment-form.component"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../../components/card/card.component"; +import { RedirectErrorComponent } from "../../../components/redirect-error/redirect-error.component"; +import { FactorId } from "firebase/auth"; + +@Component({ + selector: "fui-multi-factor-auth-enrollment-form", + template: '
MFA Enrollment Form
', + standalone: true, +}) +class MockMultiFactorAuthEnrollmentFormComponent {} + +@Component({ + selector: "fui-redirect-error", + template: '
Redirect Error
', + standalone: true, +}) +class MockRedirectErrorComponent {} + +@Component({ + template: ` + +
Test Content
+
+ `, + standalone: true, + imports: [MultiFactorAuthEnrollmentScreenComponent], +}) +class TestHostWithContentComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [MultiFactorAuthEnrollmentScreenComponent], +}) +class TestHostWithoutContentComponent {} + +describe("", () => { + beforeEach(() => { + const { injectTranslation } = require("../../../provider"); + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + multiFactorEnrollment: "Multi-Factor Enrollment", + }, + prompts: { + mfaEnrollmentPrompt: "Set up multi-factor authentication for your account", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + }); + + it("renders with correct title and subtitle", async () => { + await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthEnrollmentScreenComponent, + MockMultiFactorAuthEnrollmentFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.getByRole("heading", { name: "Multi-Factor Enrollment" })).toBeInTheDocument(); + expect(screen.getByText("Set up multi-factor authentication for your account")).toBeInTheDocument(); + }); + + it("includes the MultiFactorAuthEnrollmentForm component", async () => { + await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthEnrollmentScreenComponent, + MockMultiFactorAuthEnrollmentFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const form = screen.getByTestId("mfa-enrollment-form"); + expect(form).toBeInTheDocument(); + expect(form).toHaveTextContent("MFA Enrollment Form"); + }); + + it("renders projected content when provided", async () => { + await render(TestHostWithContentComponent, { + imports: [ + MultiFactorAuthEnrollmentScreenComponent, + MockMultiFactorAuthEnrollmentFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const projectedContent = screen.getByTestId("projected-content"); + expect(projectedContent).toBeInTheDocument(); + expect(projectedContent).toHaveTextContent("Test Content"); + }); + + it("renders RedirectError component", async () => { + const { container } = await render(TestHostWithContentComponent, { + imports: [ + MultiFactorAuthEnrollmentScreenComponent, + MockMultiFactorAuthEnrollmentFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const redirectErrorElement = container.querySelector("fui-redirect-error"); + expect(redirectErrorElement).toBeInTheDocument(); + }); + + it("has correct CSS classes", async () => { + const { container } = await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthEnrollmentScreenComponent, + MockMultiFactorAuthEnrollmentFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(container.querySelector(".fui-screen")).toBeInTheDocument(); + expect(container.querySelector(".fui-card")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__header")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__title")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__subtitle")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__content")).toBeInTheDocument(); + }); + + it("calls injectTranslation with correct parameters", async () => { + const { injectTranslation } = require("../../../provider"); + await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthEnrollmentScreenComponent, + MockMultiFactorAuthEnrollmentFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(injectTranslation).toHaveBeenCalledWith("labels", "multiFactorEnrollment"); + expect(injectTranslation).toHaveBeenCalledWith("prompts", "mfaEnrollmentPrompt"); + }); + + it("passes hints to the form component", async () => { + const { fixture } = await render(MultiFactorAuthEnrollmentScreenComponent, { + imports: [ + MultiFactorAuthEnrollmentScreenComponent, + MockMultiFactorAuthEnrollmentFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + componentInputs: { + hints: [FactorId.TOTP, FactorId.PHONE], + }, + }); + + const component = fixture.componentInstance; + expect(component.hints()).toEqual([FactorId.TOTP, FactorId.PHONE]); + }); + + it("emits onEnrollment event", async () => { + const { fixture } = await render(MultiFactorAuthEnrollmentScreenComponent, { + imports: [ + MultiFactorAuthEnrollmentScreenComponent, + MockMultiFactorAuthEnrollmentFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.componentInstance; + const enrollmentSpy = jest.spyOn(component.onEnrollment, "emit"); + + component.onEnrollment.emit(); + expect(enrollmentSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.ts new file mode 100644 index 00000000..055932c3 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.ts @@ -0,0 +1,71 @@ +/** + * 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 { Component, output, input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { FactorId } from "firebase/auth"; +import { injectTranslation } from "../../../provider"; +import { MultiFactorAuthEnrollmentFormComponent } from "./multi-factor-auth-enrollment-form.component"; +import { RedirectErrorComponent } from "../../../components/redirect-error/redirect-error.component"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../../components/card/card.component"; + +type Hint = (typeof FactorId)[keyof typeof FactorId]; + +@Component({ + selector: "fui-multi-factor-auth-enrollment-screen", + standalone: true, + imports: [ + CommonModule, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + MultiFactorAuthEnrollmentFormComponent, + RedirectErrorComponent, + ], + template: ` +
+ + + {{ titleText() }} + {{ subtitleText() }} + + + + + + + +
+ `, +}) +export class MultiFactorAuthEnrollmentScreenComponent { + hints = input([FactorId.TOTP, FactorId.PHONE]); + onEnrollment = output(); + + titleText = injectTranslation("labels", "multiFactorEnrollment"); + subtitleText = injectTranslation("prompts", "mfaEnrollmentPrompt"); +} From 8ccc49e4ff1494afcd036a785035aa500536fce8 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 28 Oct 2025 11:11:41 +0000 Subject: [PATCH 02/12] feat(angular): MFA Enrollment --- .../email-link-form.component.ts | 172 ------ .../email-password-form.component.ts | 220 ------- .../forgot-password-form.component.ts | 174 ------ ...-multi-factor-enrollment-form.component.ts | 15 +- ...-multi-factor-enrollment-form.component.ts | 27 +- ...i-factor-auth-enrollment-form.component.ts | 12 +- .../forms/phone-form/phone-form.component.ts | 541 ------------------ .../register-form/register-form.component.ts | 209 ------- ...r-auth-enrollment-screen.component.spec.ts | 2 - ...factor-auth-enrollment-screen.component.ts | 2 +- packages/angular/src/lib/provider.ts | 24 + .../angular/src/lib/tests/test-helpers.ts | 31 + 12 files changed, 77 insertions(+), 1352 deletions(-) delete mode 100644 packages/angular/src/lib/auth/forms/email-link-form/email-link-form.component.ts delete mode 100644 packages/angular/src/lib/auth/forms/email-password-form/email-password-form.component.ts delete mode 100644 packages/angular/src/lib/auth/forms/forgot-password-form/forgot-password-form.component.ts delete mode 100644 packages/angular/src/lib/auth/forms/phone-form/phone-form.component.ts delete mode 100644 packages/angular/src/lib/auth/forms/register-form/register-form.component.ts diff --git a/packages/angular/src/lib/auth/forms/email-link-form/email-link-form.component.ts b/packages/angular/src/lib/auth/forms/email-link-form/email-link-form.component.ts deleted file mode 100644 index b4bc7f05..00000000 --- a/packages/angular/src/lib/auth/forms/email-link-form/email-link-form.component.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * 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 { Component, inject, Input, OnInit } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { injectForm, TanStackField } from "@tanstack/angular-form"; -import { FirebaseUI } from "../../../provider"; -import { ButtonComponent } from "../../../components/button/button.component"; -import { TermsAndPrivacyComponent } from "../../../components/terms-and-privacy/terms-and-privacy.component"; -import { - createEmailLinkFormSchema, - FirebaseUIError, - completeEmailLinkSignIn, - sendSignInLinkToEmail, - FirebaseUI, -} from "@firebase-ui/core"; -import { firstValueFrom } from "rxjs"; - -@Component({ - selector: "fui-email-link-form", - standalone: true, - imports: [CommonModule, TanStackField, ButtonComponent, TermsAndPrivacyComponent], - template: ` -
- {{ emailSentMessage | async }} -
-
-
- - - -
- - - -
- - {{ sendSignInLinkLabel | async }} - -
{{ formError }}
-
-
- `, -}) -export class EmailLinkFormComponent implements OnInit { - private ui = inject(FirebaseUI); - - formError: string | null = null; - emailSent = false; - private formSchema: any; - private config: FirebaseUI; - - form = injectForm({ - defaultValues: { - email: "", - }, - }); - - async ngOnInit() { - try { - this.config = await firstValueFrom(this.ui.config()); - - this.formSchema = createEmailLinkFormSchema(this.config); - - this.form.update({ - validators: { - onSubmit: this.formSchema, - onBlur: this.formSchema, - }, - }); - - this.completeSignIn(); - } catch (error) { - this.formError = await firstValueFrom(this.ui.translation("errors", "unknownError")); - } - } - - private async completeSignIn() { - try { - await completeEmailLinkSignIn(await firstValueFrom(this.ui.config()), window.location.href); - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - } - } - } - - async handleSubmit(event: SubmitEvent) { - event.preventDefault(); - event.stopPropagation(); - - const email = this.form.state.values.email; - - if (!email) { - return; - } - - await this.sendSignInLink(email); - } - - async sendSignInLink(email: string) { - this.formError = null; - - try { - const validationResult = this.formSchema.safeParse({ - email, - }); - - if (!validationResult.success) { - const validationErrors = validationResult.error.format(); - - if (validationErrors.email?._errors?.length) { - this.formError = validationErrors.email._errors[0]; - return; - } - - this.formError = await firstValueFrom(this.ui.translation("errors", "unknownError")); - return; - } - - await sendSignInLinkToEmail(await firstValueFrom(this.ui.config()), email); - - this.emailSent = true; - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - return; - } - - this.formError = await firstValueFrom(this.ui.translation("errors", "unknownError")); - } - } - - get emailLabel() { - return this.ui.translation("labels", "emailAddress"); - } - - get sendSignInLinkLabel() { - return this.ui.translation("labels", "sendSignInLink"); - } - - get emailSentMessage() { - return this.ui.translation("messages", "signInLinkSent"); - } -} diff --git a/packages/angular/src/lib/auth/forms/email-password-form/email-password-form.component.ts b/packages/angular/src/lib/auth/forms/email-password-form/email-password-form.component.ts deleted file mode 100644 index aa97b6fc..00000000 --- a/packages/angular/src/lib/auth/forms/email-password-form/email-password-form.component.ts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * 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 { Component, inject, Input, OnInit } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { injectForm, TanStackField } from "@tanstack/angular-form"; -import { FirebaseUI } from "../../../provider"; -import { ButtonComponent } from "../../../components/button/button.component"; -import { TermsAndPrivacyComponent } from "../../../components/terms-and-privacy/terms-and-privacy.component"; -import { - createEmailFormSchema, - EmailFormSchema, - FirebaseUI, - FirebaseUIError, - signInWithEmailAndPassword, -} from "@firebase-ui/core"; -import { firstValueFrom } from "rxjs"; -import { Router } from "@angular/router"; - -@Component({ - selector: "fui-email-password-form", - standalone: true, - imports: [CommonModule, TanStackField, ButtonComponent, TermsAndPrivacyComponent], - template: ` -
-
- - - -
-
- - - -
- - - -
- - {{ signInLabel | async }} - -
{{ formError }}
-
- -
- -
-
- `, -}) -export class EmailPasswordFormComponent implements OnInit { - private ui = inject(FirebaseUI); - private router = inject(Router); - - @Input({ required: true }) forgotPasswordRoute!: string; - @Input({ required: true }) registerRoute!: string; - - formError: string | null = null; - private formSchema: any; - private config: FirebaseUI; - - form = injectForm({ - defaultValues: { - email: "", - password: "", - }, - }); - - async ngOnInit() { - try { - // Get config once - this.config = await firstValueFrom(this.ui.config()); - - // Create schema once - this.formSchema = createEmailFormSchema(this.config); - - // Apply schema to form validators - this.form.update({ - validators: { - onSubmit: this.formSchema, - onBlur: this.formSchema, - }, - }); - } catch (error) { - this.formError = await firstValueFrom(this.ui.translation("errors", "unknownError")); - } - } - - async handleSubmit(event: SubmitEvent) { - event.preventDefault(); - event.stopPropagation(); - - const email = this.form.state.values.email; - const password = this.form.state.values.password; - - if (!email || !password) { - return; - } - - await this.validateAndSignIn(email, password); - } - - async validateAndSignIn(email: string, password: string) { - try { - const validationResult = this.formSchema.safeParse({ - email, - password, - }); - - if (!validationResult.success) { - const validationErrors = validationResult.error.format(); - - if (validationErrors.email?._errors?.length) { - this.formError = validationErrors.email._errors[0]; - return; - } - - if (validationErrors.password?._errors?.length) { - this.formError = validationErrors.password._errors[0]; - return; - } - - this.formError = await firstValueFrom(this.ui.translation("errors", "unknownError")); - return; - } - - this.formError = null; - await signInWithEmailAndPassword(await firstValueFrom(this.ui.config()), email, password); - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - return; - } - - this.formError = await firstValueFrom(this.ui.translation("errors", "unknownError")); - } - } - - navigateTo(route: string) { - this.router.navigateByUrl(route); - } - - get emailLabel() { - return this.ui.translation("labels", "emailAddress"); - } - - get passwordLabel() { - return this.ui.translation("labels", "password"); - } - - get forgotPasswordLabel() { - return this.ui.translation("labels", "forgotPassword"); - } - - get signInLabel() { - return this.ui.translation("labels", "signIn"); - } - - get noAccountLabel() { - return this.ui.translation("prompts", "noAccount"); - } - - get registerLabel() { - return this.ui.translation("labels", "register"); - } -} diff --git a/packages/angular/src/lib/auth/forms/forgot-password-form/forgot-password-form.component.ts b/packages/angular/src/lib/auth/forms/forgot-password-form/forgot-password-form.component.ts deleted file mode 100644 index 2f5fec9f..00000000 --- a/packages/angular/src/lib/auth/forms/forgot-password-form/forgot-password-form.component.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * 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 { Component, inject, Input, OnInit } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { injectForm, TanStackField } from "@tanstack/angular-form"; -import { FirebaseUI } from "../../../provider"; -import { Auth } from "@angular/fire/auth"; -import { ButtonComponent } from "../../../components/button/button.component"; -import { TermsAndPrivacyComponent } from "../../../components/terms-and-privacy/terms-and-privacy.component"; -import { createForgotPasswordFormSchema, FirebaseUI, FirebaseUIError, sendPasswordResetEmail } from "@firebase-ui/core"; -import { firstValueFrom } from "rxjs"; -import { Router } from "@angular/router"; - -@Component({ - selector: "fui-forgot-password-form", - standalone: true, - imports: [CommonModule, TanStackField, ButtonComponent, TermsAndPrivacyComponent], - template: ` -
- {{ checkEmailForResetMessage | async }} -
-
-
- - - -
- - - -
- - {{ resetPasswordLabel | async }} - -
{{ formError }}
-
- -
- -
-
- `, -}) -export class ForgotPasswordFormComponent implements OnInit { - private ui = inject(FirebaseUI); - private router = inject(Router); - - @Input({ required: true }) signInRoute!: string; - - formError: string | null = null; - emailSent = false; - private formSchema: any; - private config: FirebaseUI; - - form = injectForm({ - defaultValues: { - email: "", - }, - }); - - async ngOnInit() { - try { - this.config = await firstValueFrom(this.ui.config()); - - this.formSchema = createForgotPasswordFormSchema(this.config); - - this.form.update({ - validators: { - onSubmit: this.formSchema, - onBlur: this.formSchema, - }, - }); - } catch (error) { - this.formError = await firstValueFrom(this.ui.translation("errors", "unknownError")); - } - } - - async handleSubmit(event: SubmitEvent) { - event.preventDefault(); - event.stopPropagation(); - - const email = this.form.state.values.email; - - if (!email) { - return; - } - - await this.resetPassword(email); - } - - async resetPassword(email: string) { - this.formError = null; - - try { - const validationResult = this.formSchema.safeParse({ - email, - }); - - if (!validationResult.success) { - const validationErrors = validationResult.error.format(); - - if (validationErrors.email?._errors?.length) { - this.formError = validationErrors.email._errors[0]; - return; - } - - this.formError = await firstValueFrom(this.ui.translation("errors", "unknownError")); - return; - } - - // Send password reset email - await sendPasswordResetEmail(await firstValueFrom(this.ui.config()), email); - - this.emailSent = true; - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - return; - } - - this.formError = await firstValueFrom(this.ui.translation("errors", "unknownError")); - } - } - - navigateTo(route: string) { - this.router.navigateByUrl(route); - } - - get emailLabel() { - return this.ui.translation("labels", "emailAddress"); - } - - get resetPasswordLabel() { - return this.ui.translation("labels", "resetPassword"); - } - - get backToSignInLabel() { - return this.ui.translation("labels", "backToSignIn"); - } - - get checkEmailForResetMessage() { - return this.ui.translation("messages", "checkEmailForReset"); - } -} diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.component.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.component.ts index b9c26449..ecca10cf 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.component.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.component.ts @@ -37,8 +37,8 @@ import { PoliciesComponent } from "../../../components/policies/policies.compone import { injectUI, injectTranslation, - injectPhoneAuthFormSchema, - injectPhoneAuthVerifyFormSchema, + injectMultiFactorPhoneAuthNumberFormSchema, + injectMultiFactorPhoneAuthVerifyFormSchema, injectDefaultCountry, } from "../../../provider"; @@ -58,7 +58,6 @@ import { template: `
@if (!verificationId()) { -
} @else { -
(null); @@ -162,11 +160,12 @@ export class SmsMultiFactorEnrollmentFormComponent { onSubmit: this.phoneFormSchema(), onSubmitAsync: async ({ value }) => { try { - if (!this.ui().auth.currentUser) { + const currentUser = this.ui().auth.currentUser; + if (!currentUser) { throw new Error("User must be authenticated to enroll with multi-factor authentication"); } - const mfaUser = multiFactor(this.ui().auth.currentUser); + const mfaUser = multiFactor(currentUser); const formattedPhoneNumber = formatPhoneNumber(value.phoneNumber, this.defaultCountry()); const verificationId = await verifyPhoneNumber( this.ui(), diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.component.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.component.ts index 4b119118..9f896e2c 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.component.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.component.ts @@ -18,7 +18,6 @@ import { Component, signal, effect, output, computed } from "@angular/core"; import { CommonModule } from "@angular/common"; import { TanStackField, TanStackAppField, injectForm, injectStore } from "@tanstack/angular-form"; import { TotpMultiFactorGenerator, type TotpSecret } from "firebase/auth"; -import { z } from "zod"; import { enrollWithMultiFactorAssertion, generateTotpSecret, @@ -34,6 +33,8 @@ import { PoliciesComponent } from "../../../components/policies/policies.compone import { injectUI, injectTranslation, + injectMultiFactorTotpAuthNumberFormSchema, + injectMultiFactorTotpAuthVerifyFormSchema, } from "../../../provider"; @Component({ @@ -51,7 +52,6 @@ import { template: `
@if (!enrollment()) { -
} @else { -
TOTP QR Code

TODO: Scan this QR code with your authenticator app

@@ -98,6 +97,8 @@ import { }) export class TotpMultiFactorEnrollmentFormComponent { private ui = injectUI(); + private displayNameFormSchema = injectMultiFactorTotpAuthNumberFormSchema(); + private verificationFormSchema = injectMultiFactorTotpAuthVerifyFormSchema(); enrollment = signal<{ secret: TotpSecret; displayName: string } | null>(null); @@ -134,12 +135,8 @@ export class TotpMultiFactorEnrollmentFormComponent { effect(() => { this.displayNameForm.update({ validators: { - onBlur: z.object({ - displayName: z.string().min(1, "Display name is required"), - }), - onSubmit: z.object({ - displayName: z.string().min(1, "Display name is required"), - }), + onBlur: this.displayNameFormSchema(), + onSubmit: this.displayNameFormSchema(), onSubmitAsync: async ({ value }) => { try { if (!this.ui().auth.currentUser) { @@ -163,16 +160,8 @@ export class TotpMultiFactorEnrollmentFormComponent { effect(() => { this.verificationForm.update({ validators: { - onBlur: z.object({ - verificationCode: z.string().refine((val) => val.length === 6, { - message: "Verification code must be 6 digits", - }), - }), - onSubmit: z.object({ - verificationCode: z.string().refine((val) => val.length === 6, { - message: "Verification code must be 6 digits", - }), - }), + onBlur: this.verificationFormSchema(), + onSubmit: this.verificationFormSchema(), onSubmitAsync: async ({ value }) => { try { const enrollmentData = this.enrollment(); diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.component.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.component.ts index d707a325..e789cea3 100644 --- a/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.component.ts +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.component.ts @@ -17,10 +17,10 @@ import { Component, signal, input, output } from "@angular/core"; import { CommonModule } from "@angular/common"; import { FactorId } from "firebase/auth"; -import { injectTranslation } from "../../../provider"; +import { injectTranslation } from "../../provider"; import { SmsMultiFactorEnrollmentFormComponent } from "./mfa/sms-multi-factor-enrollment-form.component"; import { TotpMultiFactorEnrollmentFormComponent } from "./mfa/totp-multi-factor-enrollment-form.component"; -import { ButtonComponent } from "../../../components/button/button.component"; +import { ButtonComponent } from "../../components/button/button.component"; type Hint = (typeof FactorId)[keyof typeof FactorId]; @@ -36,18 +36,18 @@ type Hint = (typeof FactorId)[keyof typeof FactorId]; template: `
@if (selectedHint()) { - @if (selectedHint() === 'phone') { + @if (selectedHint() === "phone") { - } @else if (selectedHint() === 'totp') { + } @else if (selectedHint() === "totp") { } } @else { @for (hint of hints(); track hint) { - @if (hint === 'phone') { + @if (hint === "phone") { - } @else if (hint === 'totp') { + } @else if (hint === "totp") { diff --git a/packages/angular/src/lib/auth/forms/phone-form/phone-form.component.ts b/packages/angular/src/lib/auth/forms/phone-form/phone-form.component.ts deleted file mode 100644 index 218512e0..00000000 --- a/packages/angular/src/lib/auth/forms/phone-form/phone-form.component.ts +++ /dev/null @@ -1,541 +0,0 @@ -/** - * 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 { Component, inject, Input, OnDestroy, OnInit, ViewChild, ElementRef } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { injectForm, TanStackField } from "@tanstack/angular-form"; -import { FirebaseUI } from "../../../provider"; -import { Auth, ConfirmationResult, RecaptchaVerifier } from "@angular/fire/auth"; -import { map } from "rxjs/operators"; -import { ButtonComponent } from "../../../components/button/button.component"; -import { TermsAndPrivacyComponent } from "../../../components/terms-and-privacy/terms-and-privacy.component"; -import { CountrySelectorComponent } from "../../../components/country-selector/country-selector.component"; -import { - CountryData, - countryData, - createPhoneFormSchema, - FirebaseUIError, - formatPhoneNumberWithCountry, - confirmPhoneNumber, - signInWithPhoneNumber, - FirebaseUI, -} from "@firebase-ui/core"; -import { interval, Subscription, firstValueFrom } from "rxjs"; -import { Router } from "@angular/router"; -import { takeWhile } from "rxjs/operators"; - -@Component({ - selector: "fui-phone-number-form", - standalone: true, - imports: [CommonModule, TanStackField, ButtonComponent, TermsAndPrivacyComponent, CountrySelectorComponent], - template: ` -
-
- - - -
- -
-
-
- - - -
- - {{ sendCodeLabel | async }} - -
{{ formError }}
-
-
- `, -}) -export class PhoneNumberFormComponent implements OnInit, OnDestroy { - private ui = inject(FirebaseUI); - - @Input() onSubmit!: (phoneNumber: string) => Promise; - @Input() formError: string | null = null; - @Input() showTerms = true; - @ViewChild("recaptchaContainer", { static: true }) - recaptchaContainer!: ElementRef; - - recaptchaVerifier: RecaptchaVerifier | null = null; - selectedCountry: CountryData = countryData[0]; - private formSchema: any; - private config: FirebaseUI; - - form = injectForm({ - defaultValues: { - phoneNumber: "", - }, - }); - - async ngOnInit() { - try { - this.config = await firstValueFrom(this.ui.config()); - - this.formSchema = createPhoneFormSchema(this.config).pick({ - phoneNumber: true, - }); - - this.form.update({ - validators: { - onSubmit: this.formSchema, - onBlur: this.formSchema, - }, - }); - - await this.initRecaptcha(); - } catch (error) { - console.error(error); - } - } - - ngOnDestroy() { - if (this.recaptchaVerifier) { - this.recaptchaVerifier.clear(); - this.recaptchaVerifier = null; - } - } - - async initRecaptcha() { - const verifier = new RecaptchaVerifier( - (await firstValueFrom(this.ui.config())).getAuth(), - this.recaptchaContainer.nativeElement, - { - size: this.config?.recaptchaMode ?? "normal", - } - ); - this.recaptchaVerifier = verifier; - } - - async handleSubmit(event: SubmitEvent) { - event.preventDefault(); - event.stopPropagation(); - - const phoneNumber = this.form.state.values.phoneNumber; - - if (!phoneNumber) { - return; - } - - this.submitPhoneNumber(phoneNumber); - } - - async submitPhoneNumber(phoneNumber: string) { - try { - // Validate phoneNumber - const validationResult = this.formSchema.safeParse({ - phoneNumber, - }); - - if (!validationResult.success) { - const validationErrors = validationResult.error.format(); - - if (validationErrors.phoneNumber?._errors?.length) { - // We can't set formError directly since it's an input, so we need to call the parent - await this.onSubmit("VALIDATION_ERROR:" + validationErrors.phoneNumber._errors[0]); - return; - } - - await this.onSubmit("VALIDATION_ERROR:Invalid phone number"); - return; - } - - // Format number and submit - const formattedNumber = formatPhoneNumberWithCountry(phoneNumber, this.selectedCountry.dialCode); - await this.onSubmit(formattedNumber); - } catch (error) { - console.error(error); - } - } - - handleCountryChange(country: CountryData) { - this.selectedCountry = country; - } - - get phoneNumberLabel() { - return this.ui.translation("labels", "phoneNumber"); - } - - get sendCodeLabel() { - return this.ui.translation("labels", "sendCode"); - } -} - -@Component({ - selector: "fui-verification-form", - standalone: true, - imports: [CommonModule, TanStackField, ButtonComponent, TermsAndPrivacyComponent], - template: ` -
-
- - - -
- -
-
-
- - - - - -
- - {{ verifyCodeLabel | async }} - - - - {{ sendingLabel | async }} - - - {{ resendCodeLabel | async }} ({{ timeLeft }}s) - - - {{ resendCodeLabel | async }} - - -
{{ formError }}
-
- - -
- `, -}) -export class VerificationFormComponent implements OnInit, OnDestroy { - private ui = inject(FirebaseUI); - - @Input() onSubmit!: (code: string) => Promise; - @Input() onResend!: () => Promise; - @Input() formError: string | null = null; - @Input() showTerms = false; - @Input() isResending = false; - @Input() canResend = false; - @Input() timeLeft = 0; - @ViewChild("recaptchaContainer", { static: true }) - recaptchaContainer!: ElementRef; - - private formSchema: any; - private config: any; - - form = injectForm({ - defaultValues: { - verificationCode: "", - }, - }); - - async ngOnInit() { - try { - this.config = await firstValueFrom(this.ui.config()); - - // Create schema once - this.formSchema = createPhoneFormSchema(this.config?.translations).pick({ - verificationCode: true, - }); - - this.form.update({ - validators: { - onSubmit: this.formSchema, - onBlur: this.formSchema, - }, - }); - } catch (error) { - console.error(error); - } - } - - ngOnDestroy() {} - - async handleSubmit(event: SubmitEvent) { - event.preventDefault(); - event.stopPropagation(); - - const code = this.form.state.values.verificationCode; - - if (!code) { - return; - } - - await this.verifyCode(code); - } - - async verifyCode(code: string) { - try { - const validationResult = this.formSchema.safeParse({ - verificationCode: code, - }); - - if (!validationResult.success) { - const validationErrors = validationResult.error.format(); - - if (validationErrors.verificationCode?._errors?.length) { - await this.onSubmit("VALIDATION_ERROR:" + validationErrors.verificationCode._errors[0]); - return; - } - - await this.onSubmit("VALIDATION_ERROR:Invalid verification code"); - return; - } - - await this.onSubmit(code); - } catch (error) { - console.error(error); - } - } - - get verificationCodeLabel() { - return this.ui.translation("labels", "verificationCode"); - } - - get verifyCodeLabel() { - return this.ui.translation("labels", "verifyCode"); - } - - get resendCodeLabel() { - return this.ui.translation("labels", "resendCode"); - } - - get sendingLabel() { - return this.ui.translation("labels", "sending"); - } -} - -@Component({ - selector: "fui-phone-form", - standalone: true, - imports: [CommonModule, PhoneNumberFormComponent, VerificationFormComponent], - template: ` -
- - - - - - -
- `, -}) -export class PhoneFormComponent implements OnInit, OnDestroy { - private ui = inject(FirebaseUI); - private config: any; - - @Input() resendDelay = 30; - - formError: string | null = null; - confirmationResult: ConfirmationResult | null = null; - recaptchaVerifier: RecaptchaVerifier | null = null; - phoneNumber = ""; - isResending = false; - timeLeft = 0; - canResend = false; - timerSubscription: Subscription | null = null; - - async ngOnInit() { - try { - this.config = await firstValueFrom(this.ui.config()); - } catch (error) { - console.error(error); - } - } - - ngOnDestroy() { - if (this.timerSubscription) { - this.timerSubscription.unsubscribe(); - } - } - - async handlePhoneSubmit(number: string): Promise { - this.formError = null; - - if (number.startsWith("VALIDATION_ERROR:")) { - this.formError = number.substring("VALIDATION_ERROR:".length); - return; - } - - try { - if (!this.recaptchaVerifier) { - throw new Error("ReCAPTCHA not initialized"); - } - - const result = await signInWithPhoneNumber( - await firstValueFrom(this.ui.config()), - number, - this.recaptchaVerifier - ); - - this.phoneNumber = number; - this.confirmationResult = result; - this.startTimer(); - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - return; - } - console.error(error); - this.formError = await firstValueFrom(this.ui.translation("errors", "unknownError")); - } - } - - async handleResend(): Promise { - if (this.isResending || !this.canResend || !this.phoneNumber) { - return; - } - - this.isResending = true; - this.formError = null; - - try { - if (this.recaptchaVerifier) { - this.recaptchaVerifier.clear(); - } - - // We need to get the recaptcha container from the verification form - // This is a bit hacky, but it works for now - const recaptchaContainer = document.querySelector(".fui-recaptcha-container") as HTMLDivElement; - if (!recaptchaContainer) { - throw new Error("ReCAPTCHA container not found"); - } - - const verifier = new RecaptchaVerifier((await firstValueFrom(this.ui.config())).getAuth(), recaptchaContainer, { - size: this.config?.recaptchaMode ?? "normal", - }); - this.recaptchaVerifier = verifier; - - const result = await signInWithPhoneNumber(await firstValueFrom(this.ui.config()), this.phoneNumber, verifier); - - this.confirmationResult = result; - this.startTimer(); - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - } else { - console.error(error); - this.ui.translation("errors", "unknownError").subscribe((message) => { - this.formError = message; - }); - } - } finally { - this.isResending = false; - } - } - - async handleVerificationSubmit(code: string): Promise { - if (code.startsWith("VALIDATION_ERROR:")) { - this.formError = code.substring("VALIDATION_ERROR:".length); - return; - } - - if (!this.confirmationResult) { - throw new Error("Confirmation result not initialized"); - } - - this.formError = null; - - try { - await confirmPhoneNumber(await firstValueFrom(this.ui.config()), this.confirmationResult, code); - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - return; - } - console.error(error); - this.formError = await firstValueFrom(this.ui.translation("errors", "unknownError")); - } - } - - startTimer() { - if (this.timerSubscription) { - this.timerSubscription.unsubscribe(); - } - - this.timeLeft = this.resendDelay; - this.canResend = false; - - this.timerSubscription = interval(1000) - .pipe(takeWhile(() => this.timeLeft > 0)) - .subscribe(() => { - this.timeLeft--; - if (this.timeLeft === 0) { - this.canResend = true; - if (this.timerSubscription) { - this.timerSubscription.unsubscribe(); - } - } - }); - } -} diff --git a/packages/angular/src/lib/auth/forms/register-form/register-form.component.ts b/packages/angular/src/lib/auth/forms/register-form/register-form.component.ts deleted file mode 100644 index 1458e61a..00000000 --- a/packages/angular/src/lib/auth/forms/register-form/register-form.component.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * 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 { Component, inject, Input, OnInit } from "@angular/core"; -import { ButtonComponent } from "../../../components/button/button.component"; -import { FirebaseUI } from "../../../provider"; -import { CommonModule } from "@angular/common"; -import { injectForm, TanStackField } from "@tanstack/angular-form"; -import { - createEmailFormSchema, - EmailFormSchema, - FirebaseUIError, - createUserWithEmailAndPassword, - FirebaseUI, -} from "@firebase-ui/core"; -import { Auth } from "@angular/fire/auth"; -import { TermsAndPrivacyComponent } from "../../../components/terms-and-privacy/terms-and-privacy.component"; -import { firstValueFrom } from "rxjs"; -import { Router } from "@angular/router"; - -@Component({ - selector: "fui-register-form", - imports: [CommonModule, TanStackField, ButtonComponent, TermsAndPrivacyComponent], - template: ` -
-
- - - -
-
- - - -
- - - -
- - {{ createAccountLabel | async }} - -
{{ formError }}
-
- -
- -
-
- `, - standalone: true, -}) -export class RegisterFormComponent implements OnInit { - private ui = inject(FirebaseUI); - private router = inject(Router); - - @Input({ required: true }) signInRoute!: string; - - formError: string | null = null; - private formSchema: any; - private config: FirebaseUI; - - form = injectForm({ - defaultValues: { - email: "", - password: "", - }, - }); - - async ngOnInit() { - try { - this.config = await firstValueFrom(this.ui.config()); - - this.formSchema = createEmailFormSchema(this.config); - - this.form.update({ - validators: { - onSubmit: this.formSchema, - onBlur: this.formSchema, - }, - }); - } catch (error) { - this.formError = await firstValueFrom(this.ui.translation("errors", "unknownError")); - } - } - - async handleSubmit(event: SubmitEvent) { - event.preventDefault(); - event.stopPropagation(); - - const email = this.form.state.values.email; - const password = this.form.state.values.password; - - if (!email || !password) { - return; - } - - await this.registerUser(email, password); - } - - async registerUser(email: string, password: string) { - this.formError = null; - - try { - const validationResult = this.formSchema.safeParse({ - email, - password, - }); - - if (!validationResult.success) { - const validationErrors = validationResult.error.format(); - - if (validationErrors.email?._errors?.length) { - this.formError = validationErrors.email._errors[0]; - return; - } - - if (validationErrors.password?._errors?.length) { - this.formError = validationErrors.password._errors[0]; - return; - } - - this.formError = await firstValueFrom(this.ui.translation("errors", "unknownError")); - return; - } - - await createUserWithEmailAndPassword(await firstValueFrom(this.ui.config()), email, password); - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - return; - } - - this.formError = await firstValueFrom(this.ui.translation("errors", "unknownError")); - } - } - - navigateTo(route: string) { - this.router.navigateByUrl(route); - } - - get emailLabel() { - return this.ui.translation("labels", "emailAddress"); - } - - get passwordLabel() { - return this.ui.translation("labels", "password"); - } - - get createAccountLabel() { - return this.ui.translation("labels", "createAccount"); - } - - get haveAccountLabel() { - return this.ui.translation("prompts", "haveAccount"); - } - - get signInLabel() { - return this.ui.translation("labels", "signIn"); - } -} diff --git a/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.spec.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.spec.ts index b9097d36..240b5837 100644 --- a/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.spec.ts +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.spec.ts @@ -17,7 +17,6 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; import { MultiFactorAuthEnrollmentScreenComponent } from "./multi-factor-auth-enrollment-screen.component"; -import { MultiFactorAuthEnrollmentFormComponent } from "../../forms/multi-factor-auth-enrollment-form.component"; import { CardComponent, CardHeaderComponent, @@ -25,7 +24,6 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../../components/card/card.component"; -import { RedirectErrorComponent } from "../../../components/redirect-error/redirect-error.component"; import { FactorId } from "firebase/auth"; @Component({ diff --git a/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.ts index 055932c3..148a4eb1 100644 --- a/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.ts +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.ts @@ -18,7 +18,7 @@ import { Component, output, input } from "@angular/core"; import { CommonModule } from "@angular/common"; import { FactorId } from "firebase/auth"; import { injectTranslation } from "../../../provider"; -import { MultiFactorAuthEnrollmentFormComponent } from "./multi-factor-auth-enrollment-form.component"; +import { MultiFactorAuthEnrollmentFormComponent } from "../../forms/multi-factor-auth-enrollment-form.component"; import { RedirectErrorComponent } from "../../../components/redirect-error/redirect-error.component"; import { CardComponent, diff --git a/packages/angular/src/lib/provider.ts b/packages/angular/src/lib/provider.ts index 56eea72b..af2aa126 100644 --- a/packages/angular/src/lib/provider.ts +++ b/packages/angular/src/lib/provider.ts @@ -33,6 +33,10 @@ import { createPhoneAuthVerifyFormSchema, createSignInAuthFormSchema, createSignUpAuthFormSchema, + createMultiFactorPhoneAuthNumberFormSchema, + createMultiFactorPhoneAuthVerifyFormSchema, + createMultiFactorTotpAuthNumberFormSchema, + createMultiFactorTotpAuthVerifyFormSchema, FirebaseUIStore, type FirebaseUI as FirebaseUIType, getTranslation, @@ -120,6 +124,26 @@ export function injectPhoneAuthVerifyFormSchema(): Signal createPhoneAuthVerifyFormSchema(ui())); } +export function injectMultiFactorPhoneAuthNumberFormSchema(): Signal> { + const ui = injectUI(); + return computed(() => createMultiFactorPhoneAuthNumberFormSchema(ui())); +} + +export function injectMultiFactorPhoneAuthVerifyFormSchema(): Signal> { + const ui = injectUI(); + return computed(() => createMultiFactorPhoneAuthVerifyFormSchema(ui())); +} + +export function injectMultiFactorTotpAuthNumberFormSchema(): Signal> { + const ui = injectUI(); + return computed(() => createMultiFactorTotpAuthNumberFormSchema(ui())); +} + +export function injectMultiFactorTotpAuthVerifyFormSchema(): Signal> { + const ui = injectUI(); + return computed(() => createMultiFactorTotpAuthVerifyFormSchema(ui())); +} + export function injectPolicies(): PolicyConfig | null { return inject(FIREBASE_UI_POLICIES, { optional: true }); } diff --git a/packages/angular/src/lib/tests/test-helpers.ts b/packages/angular/src/lib/tests/test-helpers.ts index 8eb9e6cd..279e76f9 100644 --- a/packages/angular/src/lib/tests/test-helpers.ts +++ b/packages/angular/src/lib/tests/test-helpers.ts @@ -191,6 +191,37 @@ export const injectPhoneAuthVerifyFormSchema = jest.fn().mockReturnValue(() => { }); }); +export const injectMultiFactorPhoneAuthNumberFormSchema = jest.fn().mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + displayName: z.string().min(1, "Display name is required"), + phoneNumber: z.string().min(1, "Phone number is required"), + }); +}); + +export const injectMultiFactorPhoneAuthVerifyFormSchema = jest.fn().mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + verificationCode: z.string().min(1, "Verification code is required"), + }); +}); + +export const injectMultiFactorTotpAuthNumberFormSchema = jest.fn().mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + displayName: z.string().min(1, "Display name is required"), + }); +}); + +export const injectMultiFactorTotpAuthVerifyFormSchema = jest.fn().mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + verificationCode: z.string().refine((val: string) => val.length === 6, { + message: "Verification code must be 6 digits", + }), + }); +}); + export const injectCountries = jest.fn().mockReturnValue(() => countryData); export const injectDefaultCountry = jest.fn().mockReturnValue(() => "US"); From 0e73b91f639dda09f6dff4787a95b705ff964727 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 28 Oct 2025 11:38:11 +0000 Subject: [PATCH 03/12] chore(angular): Rename component/test files and flatten --- ...t.spec.ts => email-link-auth-form.spec.ts} | 10 ++-- ...m.component.ts => email-link-auth-form.ts} | 10 ++-- ...c.ts => forgot-password-auth-form.spec.ts} | 6 +-- ...ponent.ts => forgot-password-auth-form.ts} | 0 ... sms-multi-factor-enrollment-form.spec.ts} | 18 +++---- ...ts => sms-multi-factor-enrollment-form.ts} | 10 ++-- ...totp-multi-factor-enrollment-form.spec.ts} | 40 +++++++-------- ...s => totp-multi-factor-enrollment-form.ts} | 8 +-- ...multi-factor-auth-enrollment-form.spec.ts} | 6 +-- ...s => multi-factor-auth-enrollment-form.ts} | 6 +-- ...ponent.spec.ts => phone-auth-form.spec.ts} | 8 +-- ...h-form.component.ts => phone-auth-form.ts} | 12 ++--- ...nent.spec.ts => sign-in-auth-form.spec.ts} | 6 +-- ...form.component.ts => sign-in-auth-form.ts} | 0 ...nent.spec.ts => sign-up-auth-form.spec.ts} | 6 +-- ...form.component.ts => sign-up-auth-form.ts} | 6 +-- ...t.spec.ts => apple-sign-in-button.spec.ts} | 2 +- ...n.component.ts => apple-sign-in-button.ts} | 2 +- ...pec.ts => facebook-sign-in-button.spec.ts} | 2 +- ...omponent.ts => facebook-sign-in-button.ts} | 2 +- ....spec.ts => github-sign-in-button.spec.ts} | 2 +- ....component.ts => github-sign-in-button.ts} | 2 +- ....spec.ts => google-sign-in-button.spec.ts} | 2 +- ....component.ts => google-sign-in-button.ts} | 2 +- ...ec.ts => microsoft-sign-in-button.spec.ts} | 2 +- ...mponent.ts => microsoft-sign-in-button.ts} | 2 +- ...component.spec.ts => oauth-button.spec.ts} | 2 +- ...th-button.component.ts => oauth-button.ts} | 2 +- ...spec.ts => twitter-sign-in-button.spec.ts} | 2 +- ...component.ts => twitter-sign-in-button.ts} | 2 +- ...spec.ts => email-link-auth-screen.spec.ts} | 4 +- ...component.ts => email-link-auth-screen.ts} | 8 +-- ...ts => forgot-password-auth-screen.spec.ts} | 4 +- ...nent.ts => forgot-password-auth-screen.ts} | 8 +-- ...lti-factor-auth-enrollment-screen.spec.ts} | 4 +- ...=> multi-factor-auth-enrollment-screen.ts} | 13 ++--- ...component.spec.ts => oauth-screen.spec.ts} | 6 +-- ...th-screen.component.ts => oauth-screen.ts} | 10 ++-- ...nent.spec.ts => phone-auth-screen.spec.ts} | 4 +- ...reen.component.ts => phone-auth-screen.ts} | 8 +-- ...nt.spec.ts => sign-in-auth-screen.spec.ts} | 4 +- ...en.component.ts => sign-in-auth-screen.ts} | 8 +-- ...nt.spec.ts => sign-up-auth-screen.spec.ts} | 4 +- ...en.component.ts => sign-up-auth-screen.ts} | 8 +-- ...utton.component.spec.ts => button.spec.ts} | 2 +- .../{button/button.component.ts => button.ts} | 0 .../card.component.spec.ts => card.spec.ts} | 2 +- .../{card/card.component.ts => card.ts} | 0 ...tent.component.spec.ts => content.spec.ts} | 2 +- .../content.component.ts => content.ts} | 4 +- ...onent.spec.ts => country-selector.spec.ts} | 2 +- ...ector.component.ts => country-selector.ts} | 2 +- ...ider.component.spec.ts => divider.spec.ts} | 2 +- .../divider.component.ts => divider.ts} | 11 +--- .../form.component.spec.ts => form.spec.ts} | 9 +--- .../{form/form.component.ts => form.ts} | 2 +- ...ies.component.spec.ts => policies.spec.ts} | 5 +- .../policies.component.ts => policies.ts} | 2 +- ...mponent.spec.ts => redirect-error.spec.ts} | 2 +- ...t-error.component.ts => redirect-error.ts} | 2 +- packages/angular/src/lib/provider.ts | 16 ++++-- packages/angular/src/public-api.ts | 50 +++++++++---------- 62 files changed, 173 insertions(+), 215 deletions(-) rename packages/angular/src/lib/auth/forms/{email-link-auth-form/email-link-auth-form.component.spec.ts => email-link-auth-form.spec.ts} (97%) rename packages/angular/src/lib/auth/forms/{email-link-auth-form/email-link-auth-form.component.ts => email-link-auth-form.ts} (93%) rename packages/angular/src/lib/auth/forms/{forgot-password-auth-form/forgot-password-auth-form.component.spec.ts => forgot-password-auth-form.spec.ts} (98%) rename packages/angular/src/lib/auth/forms/{forgot-password-auth-form/forgot-password-auth-form.component.ts => forgot-password-auth-form.ts} (100%) rename packages/angular/src/lib/auth/forms/mfa/{sms-multi-factor-enrollment-form.component.spec.ts => sms-multi-factor-enrollment-form.spec.ts} (95%) rename packages/angular/src/lib/auth/forms/mfa/{sms-multi-factor-enrollment-form.component.ts => sms-multi-factor-enrollment-form.ts} (96%) rename packages/angular/src/lib/auth/forms/mfa/{totp-multi-factor-enrollment-form.component.spec.ts => totp-multi-factor-enrollment-form.spec.ts} (93%) rename packages/angular/src/lib/auth/forms/mfa/{totp-multi-factor-enrollment-form.component.ts => totp-multi-factor-enrollment-form.ts} (96%) rename packages/angular/src/lib/auth/forms/{multi-factor-auth-enrollment-form.component.spec.ts => multi-factor-auth-enrollment-form.spec.ts} (98%) rename packages/angular/src/lib/auth/forms/{multi-factor-auth-enrollment-form.component.ts => multi-factor-auth-enrollment-form.ts} (94%) rename packages/angular/src/lib/auth/forms/{phone-auth-form/phone-auth-form.component.spec.ts => phone-auth-form.spec.ts} (98%) rename packages/angular/src/lib/auth/forms/{phone-auth-form/phone-auth-form.component.ts => phone-auth-form.ts} (95%) rename packages/angular/src/lib/auth/forms/{sign-in-auth-form/sign-in-auth-form.component.spec.ts => sign-in-auth-form.spec.ts} (98%) rename packages/angular/src/lib/auth/forms/{sign-in-auth-form/sign-in-auth-form.component.ts => sign-in-auth-form.ts} (100%) rename packages/angular/src/lib/auth/forms/{sign-up-auth-form/sign-up-auth-form.component.spec.ts => sign-up-auth-form.spec.ts} (98%) rename packages/angular/src/lib/auth/forms/{sign-up-auth-form/sign-up-auth-form.component.ts => sign-up-auth-form.ts} (96%) rename packages/angular/src/lib/auth/oauth/{apple-sign-in-button.component.spec.ts => apple-sign-in-button.spec.ts} (99%) rename packages/angular/src/lib/auth/oauth/{apple-sign-in-button.component.ts => apple-sign-in-button.ts} (95%) rename packages/angular/src/lib/auth/oauth/{facebook-sign-in-button.component.spec.ts => facebook-sign-in-button.spec.ts} (99%) rename packages/angular/src/lib/auth/oauth/{facebook-sign-in-button.component.ts => facebook-sign-in-button.ts} (95%) rename packages/angular/src/lib/auth/oauth/{github-sign-in-button.component.spec.ts => github-sign-in-button.spec.ts} (99%) rename packages/angular/src/lib/auth/oauth/{github-sign-in-button.component.ts => github-sign-in-button.ts} (95%) rename packages/angular/src/lib/auth/oauth/{google-sign-in-button.component.spec.ts => google-sign-in-button.spec.ts} (99%) rename packages/angular/src/lib/auth/oauth/{google-sign-in-button.component.ts => google-sign-in-button.ts} (95%) rename packages/angular/src/lib/auth/oauth/{microsoft-sign-in-button.component.spec.ts => microsoft-sign-in-button.spec.ts} (99%) rename packages/angular/src/lib/auth/oauth/{microsoft-sign-in-button.component.ts => microsoft-sign-in-button.ts} (95%) rename packages/angular/src/lib/auth/oauth/{oauth-button.component.spec.ts => oauth-button.spec.ts} (98%) rename packages/angular/src/lib/auth/oauth/{oauth-button.component.ts => oauth-button.ts} (96%) rename packages/angular/src/lib/auth/oauth/{twitter-sign-in-button.component.spec.ts => twitter-sign-in-button.spec.ts} (99%) rename packages/angular/src/lib/auth/oauth/{twitter-sign-in-button.component.ts => twitter-sign-in-button.ts} (95%) rename packages/angular/src/lib/auth/screens/{email-link-auth-screen/email-link-auth-screen.component.spec.ts => email-link-auth-screen.spec.ts} (98%) rename packages/angular/src/lib/auth/screens/{email-link-auth-screen/email-link-auth-screen.component.ts => email-link-auth-screen.ts} (85%) rename packages/angular/src/lib/auth/screens/{forgot-password-auth-screen/forgot-password-auth-screen.component.spec.ts => forgot-password-auth-screen.spec.ts} (98%) rename packages/angular/src/lib/auth/screens/{forgot-password-auth-screen/forgot-password-auth-screen.component.ts => forgot-password-auth-screen.ts} (84%) rename packages/angular/src/lib/auth/screens/{multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.spec.ts => multi-factor-auth-enrollment-screen.spec.ts} (98%) rename packages/angular/src/lib/auth/screens/{multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.ts => multi-factor-auth-enrollment-screen.ts} (80%) rename packages/angular/src/lib/auth/screens/{oauth-screen/oauth-screen.component.spec.ts => oauth-screen.spec.ts} (97%) rename packages/angular/src/lib/auth/screens/{oauth-screen/oauth-screen.component.ts => oauth-screen.ts} (82%) rename packages/angular/src/lib/auth/screens/{phone-auth-screen/phone-auth-screen.component.spec.ts => phone-auth-screen.spec.ts} (97%) rename packages/angular/src/lib/auth/screens/{phone-auth-screen/phone-auth-screen.component.ts => phone-auth-screen.ts} (85%) rename packages/angular/src/lib/auth/screens/{sign-in-auth-screen/sign-in-auth-screen.component.spec.ts => sign-in-auth-screen.spec.ts} (98%) rename packages/angular/src/lib/auth/screens/{sign-in-auth-screen/sign-in-auth-screen.component.ts => sign-in-auth-screen.ts} (86%) rename packages/angular/src/lib/auth/screens/{sign-up-auth-screen/sign-up-auth-screen.component.spec.ts => sign-up-auth-screen.spec.ts} (98%) rename packages/angular/src/lib/auth/screens/{sign-up-auth-screen/sign-up-auth-screen.component.ts => sign-up-auth-screen.ts} (85%) rename packages/angular/src/lib/components/{button/button.component.spec.ts => button.spec.ts} (97%) rename packages/angular/src/lib/components/{button/button.component.ts => button.ts} (100%) rename packages/angular/src/lib/components/{card/card.component.spec.ts => card.spec.ts} (99%) rename packages/angular/src/lib/components/{card/card.component.ts => card.ts} (100%) rename packages/angular/src/lib/components/{content/content.component.spec.ts => content.spec.ts} (98%) rename packages/angular/src/lib/components/{content/content.component.ts => content.ts} (89%) rename packages/angular/src/lib/components/{country-selector/country-selector.component.spec.ts => country-selector.spec.ts} (98%) rename packages/angular/src/lib/components/{country-selector/country-selector.component.ts => country-selector.ts} (96%) rename packages/angular/src/lib/components/{divider/divider.component.spec.ts => divider.spec.ts} (96%) rename packages/angular/src/lib/components/{divider/divider.component.ts => divider.ts} (87%) rename packages/angular/src/lib/components/{form/form.component.spec.ts => form.spec.ts} (96%) rename packages/angular/src/lib/components/{form/form.component.ts => form.ts} (97%) rename packages/angular/src/lib/components/{policies/policies.component.spec.ts => policies.spec.ts} (98%) rename packages/angular/src/lib/components/{policies/policies.component.ts => policies.ts} (97%) rename packages/angular/src/lib/components/{redirect-error/redirect-error.component.spec.ts => redirect-error.spec.ts} (98%) rename packages/angular/src/lib/components/{redirect-error/redirect-error.component.ts => redirect-error.ts} (94%) diff --git a/packages/angular/src/lib/auth/forms/email-link-auth-form/email-link-auth-form.component.spec.ts b/packages/angular/src/lib/auth/forms/email-link-auth-form.spec.ts similarity index 97% rename from packages/angular/src/lib/auth/forms/email-link-auth-form/email-link-auth-form.component.spec.ts rename to packages/angular/src/lib/auth/forms/email-link-auth-form.spec.ts index 7d41b559..62d2b983 100644 --- a/packages/angular/src/lib/auth/forms/email-link-auth-form/email-link-auth-form.component.spec.ts +++ b/packages/angular/src/lib/auth/forms/email-link-auth-form.spec.ts @@ -17,13 +17,9 @@ import { render, screen, waitFor } from "@testing-library/angular"; import { CommonModule } from "@angular/common"; import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; -import { EmailLinkAuthFormComponent } from "./email-link-auth-form.component"; -import { - FormInputComponent, - FormSubmitComponent, - FormErrorMessageComponent, -} from "../../../components/form/form.component"; -import { PoliciesComponent } from "../../../components/policies/policies.component"; +import { EmailLinkAuthFormComponent } from "./email-link-auth-form"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../components/form"; +import { PoliciesComponent } from "../../components/policies"; import { UserCredential } from "@angular/fire/auth"; describe("", () => { diff --git a/packages/angular/src/lib/auth/forms/email-link-auth-form/email-link-auth-form.component.ts b/packages/angular/src/lib/auth/forms/email-link-auth-form.ts similarity index 93% rename from packages/angular/src/lib/auth/forms/email-link-auth-form/email-link-auth-form.component.ts rename to packages/angular/src/lib/auth/forms/email-link-auth-form.ts index b891cfbb..9ab4f60b 100644 --- a/packages/angular/src/lib/auth/forms/email-link-auth-form/email-link-auth-form.component.ts +++ b/packages/angular/src/lib/auth/forms/email-link-auth-form.ts @@ -20,13 +20,9 @@ import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanst import { UserCredential } from "@angular/fire/auth"; import { FirebaseUIError, completeEmailLinkSignIn, sendSignInLinkToEmail } from "@firebase-ui/core"; -import { - FormInputComponent, - FormSubmitComponent, - FormErrorMessageComponent, -} from "../../../components/form/form.component"; -import { PoliciesComponent } from "../../../components/policies/policies.component"; -import { injectEmailLinkAuthFormSchema, injectTranslation, injectUI } from "../../../provider"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../components/form"; +import { PoliciesComponent } from "../../components/policies"; +import { injectEmailLinkAuthFormSchema, injectTranslation, injectUI } from "../../provider"; @Component({ selector: "fui-email-link-auth-form", diff --git a/packages/angular/src/lib/auth/forms/forgot-password-auth-form/forgot-password-auth-form.component.spec.ts b/packages/angular/src/lib/auth/forms/forgot-password-auth-form.spec.ts similarity index 98% rename from packages/angular/src/lib/auth/forms/forgot-password-auth-form/forgot-password-auth-form.component.spec.ts rename to packages/angular/src/lib/auth/forms/forgot-password-auth-form.spec.ts index b15fb1cf..7c30d7f8 100644 --- a/packages/angular/src/lib/auth/forms/forgot-password-auth-form/forgot-password-auth-form.component.spec.ts +++ b/packages/angular/src/lib/auth/forms/forgot-password-auth-form.spec.ts @@ -17,14 +17,14 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/angular"; import { CommonModule } from "@angular/common"; import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; -import { ForgotPasswordAuthFormComponent } from "./forgot-password-auth-form.component"; +import { ForgotPasswordAuthFormComponent } from "./forgot-password-auth-form"; import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent, FormActionComponent, -} from "../../../components/form/form.component"; -import { PoliciesComponent } from "../../../components/policies/policies.component"; +} from "../../components/form"; +import { PoliciesComponent } from "../../components/policies"; describe("", () => { let mockSendPasswordResetEmail: any; diff --git a/packages/angular/src/lib/auth/forms/forgot-password-auth-form/forgot-password-auth-form.component.ts b/packages/angular/src/lib/auth/forms/forgot-password-auth-form.ts similarity index 100% rename from packages/angular/src/lib/auth/forms/forgot-password-auth-form/forgot-password-auth-form.component.ts rename to packages/angular/src/lib/auth/forms/forgot-password-auth-form.ts diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.component.spec.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts similarity index 95% rename from packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.component.spec.ts rename to packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts index 905f7f25..207747a5 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.component.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts @@ -14,17 +14,13 @@ * limitations under the License. */ -import { render, screen, fireEvent } from "@testing-library/angular"; +import { render, screen } from "@testing-library/angular"; import { CommonModule } from "@angular/common"; import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; -import { SmsMultiFactorEnrollmentFormComponent } from "./sms-multi-factor-enrollment-form.component"; -import { - FormInputComponent, - FormSubmitComponent, - FormErrorMessageComponent, -} from "../../../components/form/form.component"; -import { CountrySelectorComponent } from "../../../components/country-selector/country-selector.component"; -import { PoliciesComponent } from "../../../components/policies/policies.component"; +import { SmsMultiFactorEnrollmentFormComponent } from "./sms-multi-factor-enrollment-form"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; +import { CountrySelectorComponent } from "../../../components/country-selector"; +import { PoliciesComponent } from "../../../components/policies"; describe("", () => { let mockVerifyPhoneNumber: any; @@ -320,7 +316,9 @@ describe("", () => { await fixture.whenStable(); fixture.detectChanges(); - expect(screen.getByText("User must be authenticated to enroll with multi-factor authentication")).toBeInTheDocument(); + expect( + screen.getByText("User must be authenticated to enroll with multi-factor authentication") + ).toBeInTheDocument(); }); it("should have correct CSS classes", async () => { diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.component.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts similarity index 96% rename from packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.component.ts rename to packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts index ecca10cf..035e47df 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.component.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts @@ -27,13 +27,9 @@ import { FirebaseUIError, } from "@firebase-ui/core"; import { multiFactor } from "firebase/auth"; -import { - FormInputComponent, - FormSubmitComponent, - FormErrorMessageComponent, -} from "../../../components/form/form.component"; -import { CountrySelectorComponent } from "../../../components/country-selector/country-selector.component"; -import { PoliciesComponent } from "../../../components/policies/policies.component"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; +import { CountrySelectorComponent } from "../../../components/country-selector"; +import { PoliciesComponent } from "../../../components/policies"; import { injectUI, injectTranslation, diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.component.spec.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts similarity index 93% rename from packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.component.spec.ts rename to packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts index cbbcd8f5..b8c4ae4c 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.component.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts @@ -17,13 +17,9 @@ import { render, screen } from "@testing-library/angular"; import { CommonModule } from "@angular/common"; import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; -import { TotpMultiFactorEnrollmentFormComponent } from "./totp-multi-factor-enrollment-form.component"; -import { - FormInputComponent, - FormSubmitComponent, - FormErrorMessageComponent, -} from "../../../components/form/form.component"; -import { PoliciesComponent } from "../../../components/policies/policies.component"; +import { TotpMultiFactorEnrollmentFormComponent } from "./totp-multi-factor-enrollment-form"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; +import { PoliciesComponent } from "../../../components/policies"; describe("", () => { let mockGenerateTotpSecret: any; @@ -87,14 +83,14 @@ describe("", () => { }); it("should render QR code and verification form after display name is submitted", async () => { - const mockSecret = { + const mockSecret = { generateQrCodeUrl: jest.fn(), sessionInfo: {}, auth: {}, secretKey: new Uint8Array(), hashingAlgorithm: "SHA1", codeLength: 6, - timeStepSize: 30 + timeStepSize: 30, } as any; mockGenerateTotpSecret.mockResolvedValue(mockSecret); mockGenerateTotpQrCode.mockReturnValue("data:image/png;base64,test-qr-code"); @@ -123,14 +119,14 @@ describe("", () => { }); it("should handle display name submission", async () => { - const mockSecret = { + const mockSecret = { generateQrCodeUrl: jest.fn(), sessionInfo: {}, auth: {}, secretKey: new Uint8Array(), hashingAlgorithm: "SHA1", codeLength: 6, - timeStepSize: 30 + timeStepSize: 30, } as any; mockGenerateTotpSecret.mockResolvedValue(mockSecret); @@ -160,14 +156,14 @@ describe("", () => { }); it("should handle verification code submission", async () => { - const mockSecret = { + const mockSecret = { generateQrCodeUrl: jest.fn(), sessionInfo: {}, auth: {}, secretKey: new Uint8Array(), hashingAlgorithm: "SHA1", codeLength: 6, - timeStepSize: 30 + timeStepSize: 30, } as any; mockEnrollWithMultiFactorAssertion.mockResolvedValue(undefined); @@ -247,14 +243,14 @@ describe("", () => { }); const component = fixture.componentInstance; - const mockSecret = { + const mockSecret = { generateQrCodeUrl: jest.fn(), sessionInfo: {}, auth: {}, secretKey: new Uint8Array(), hashingAlgorithm: "SHA1", codeLength: 6, - timeStepSize: 30 + timeStepSize: 30, } as any; component.enrollment.set({ secret: mockSecret, displayName: "Test User" }); fixture.detectChanges(); @@ -298,18 +294,20 @@ describe("", () => { await fixture.whenStable(); fixture.detectChanges(); - expect(screen.getByText("User must be authenticated to enroll with multi-factor authentication")).toBeInTheDocument(); + expect( + screen.getByText("User must be authenticated to enroll with multi-factor authentication") + ).toBeInTheDocument(); }); it("should generate QR code with correct parameters", async () => { - const mockSecret = { + const mockSecret = { generateQrCodeUrl: jest.fn(), sessionInfo: {}, auth: {}, secretKey: new Uint8Array(), hashingAlgorithm: "SHA1", codeLength: 6, - timeStepSize: 30 + timeStepSize: 30, } as any; const mockQrCodeDataUrl = "data:image/png;base64,test-qr-code"; mockGenerateTotpSecret.mockResolvedValue(mockSecret); @@ -333,11 +331,7 @@ describe("", () => { fixture.detectChanges(); expect(component.qrCodeDataUrl()).toBe(mockQrCodeDataUrl); - expect(mockGenerateTotpQrCode).toHaveBeenCalledWith( - expect.any(Object), - mockSecret, - "Test User" - ); + expect(mockGenerateTotpQrCode).toHaveBeenCalledWith(expect.any(Object), mockSecret, "Test User"); }); it("should have correct CSS classes", async () => { diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.component.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.ts similarity index 96% rename from packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.component.ts rename to packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.ts index 9f896e2c..d5882c5b 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.component.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.ts @@ -24,12 +24,8 @@ import { generateTotpQrCode, FirebaseUIError, } from "@firebase-ui/core"; -import { - FormInputComponent, - FormSubmitComponent, - FormErrorMessageComponent, -} from "../../../components/form/form.component"; -import { PoliciesComponent } from "../../../components/policies/policies.component"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; +import { PoliciesComponent } from "../../../components/policies"; import { injectUI, injectTranslation, diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.component.spec.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts similarity index 98% rename from packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.component.spec.ts rename to packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts index 3e95d283..f657c8e0 100644 --- a/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.component.spec.ts +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts @@ -16,9 +16,9 @@ import { render, screen, fireEvent } from "@testing-library/angular"; import { CommonModule } from "@angular/common"; -import { MultiFactorAuthEnrollmentFormComponent } from "./multi-factor-auth-enrollment-form.component"; -import { SmsMultiFactorEnrollmentFormComponent } from "./mfa/sms-multi-factor-enrollment-form.component"; -import { TotpMultiFactorEnrollmentFormComponent } from "./mfa/totp-multi-factor-enrollment-form.component"; +import { MultiFactorAuthEnrollmentFormComponent } from "./multi-factor-auth-enrollment-form"; +import { SmsMultiFactorEnrollmentFormComponent } from "./mfa/sms-multi-factor-enrollment-form"; +import { TotpMultiFactorEnrollmentFormComponent } from "./mfa/totp-multi-factor-enrollment-form"; import { ButtonComponent } from "../../../components/button/button.component"; import { FactorId } from "firebase/auth"; diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.component.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.ts similarity index 94% rename from packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.component.ts rename to packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.ts index e789cea3..5d5a09a4 100644 --- a/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.component.ts +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.ts @@ -18,9 +18,9 @@ import { Component, signal, input, output } from "@angular/core"; import { CommonModule } from "@angular/common"; import { FactorId } from "firebase/auth"; import { injectTranslation } from "../../provider"; -import { SmsMultiFactorEnrollmentFormComponent } from "./mfa/sms-multi-factor-enrollment-form.component"; -import { TotpMultiFactorEnrollmentFormComponent } from "./mfa/totp-multi-factor-enrollment-form.component"; -import { ButtonComponent } from "../../components/button/button.component"; +import { SmsMultiFactorEnrollmentFormComponent } from "./mfa/sms-multi-factor-enrollment-form"; +import { TotpMultiFactorEnrollmentFormComponent } from "./mfa/totp-multi-factor-enrollment-form"; +import { ButtonComponent } from "../../components/button"; type Hint = (typeof FactorId)[keyof typeof FactorId]; diff --git a/packages/angular/src/lib/auth/forms/phone-auth-form/phone-auth-form.component.spec.ts b/packages/angular/src/lib/auth/forms/phone-auth-form.spec.ts similarity index 98% rename from packages/angular/src/lib/auth/forms/phone-auth-form/phone-auth-form.component.spec.ts rename to packages/angular/src/lib/auth/forms/phone-auth-form.spec.ts index 0561849e..f8765e31 100644 --- a/packages/angular/src/lib/auth/forms/phone-auth-form/phone-auth-form.component.spec.ts +++ b/packages/angular/src/lib/auth/forms/phone-auth-form.spec.ts @@ -17,17 +17,13 @@ import { render, screen } from "@testing-library/angular"; import { CommonModule } from "@angular/common"; import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; -import { - PhoneAuthFormComponent, - PhoneNumberFormComponent, - VerificationFormComponent, -} from "./phone-auth-form.component"; +import { PhoneAuthFormComponent, PhoneNumberFormComponent, VerificationFormComponent } from "./phone-auth-form"; import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent, FormActionComponent, -} from "../../../components/form/form.component"; +} from "../../components/form"; import { UserCredential } from "@angular/fire/auth"; describe("", () => { diff --git a/packages/angular/src/lib/auth/forms/phone-auth-form/phone-auth-form.component.ts b/packages/angular/src/lib/auth/forms/phone-auth-form.ts similarity index 95% rename from packages/angular/src/lib/auth/forms/phone-auth-form/phone-auth-form.component.ts rename to packages/angular/src/lib/auth/forms/phone-auth-form.ts index d4d87752..a05112d2 100644 --- a/packages/angular/src/lib/auth/forms/phone-auth-form/phone-auth-form.component.ts +++ b/packages/angular/src/lib/auth/forms/phone-auth-form.ts @@ -22,15 +22,11 @@ import { injectPhoneAuthVerifyFormSchema, injectTranslation, injectUI, -} from "../../../provider"; +} from "../../provider"; import { RecaptchaVerifier, UserCredential } from "@angular/fire/auth"; -import { PoliciesComponent } from "../../../components/policies/policies.component"; -import { CountrySelectorComponent } from "../../../components/country-selector/country-selector.component"; -import { - FormInputComponent, - FormSubmitComponent, - FormErrorMessageComponent, -} from "../../../components/form/form.component"; +import { PoliciesComponent } from "../../components/policies"; +import { CountrySelectorComponent } from "../../components/country-selector"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../components/form"; import { countryData, FirebaseUIError, diff --git a/packages/angular/src/lib/auth/forms/sign-in-auth-form/sign-in-auth-form.component.spec.ts b/packages/angular/src/lib/auth/forms/sign-in-auth-form.spec.ts similarity index 98% rename from packages/angular/src/lib/auth/forms/sign-in-auth-form/sign-in-auth-form.component.spec.ts rename to packages/angular/src/lib/auth/forms/sign-in-auth-form.spec.ts index 4a01945c..b4a640f1 100644 --- a/packages/angular/src/lib/auth/forms/sign-in-auth-form/sign-in-auth-form.component.spec.ts +++ b/packages/angular/src/lib/auth/forms/sign-in-auth-form.spec.ts @@ -17,14 +17,14 @@ import { render, screen, fireEvent } from "@testing-library/angular"; import { CommonModule } from "@angular/common"; import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; -import { SignInAuthFormComponent } from "./sign-in-auth-form.component"; +import { SignInAuthFormComponent } from "./sign-in-auth-form"; import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent, FormActionComponent, -} from "../../../components/form/form.component"; -import { PoliciesComponent } from "../../../components/policies/policies.component"; +} from "../../components/form"; +import { PoliciesComponent } from "../../components/policies"; import { UserCredential } from "@angular/fire/auth"; describe("", () => { diff --git a/packages/angular/src/lib/auth/forms/sign-in-auth-form/sign-in-auth-form.component.ts b/packages/angular/src/lib/auth/forms/sign-in-auth-form.ts similarity index 100% rename from packages/angular/src/lib/auth/forms/sign-in-auth-form/sign-in-auth-form.component.ts rename to packages/angular/src/lib/auth/forms/sign-in-auth-form.ts diff --git a/packages/angular/src/lib/auth/forms/sign-up-auth-form/sign-up-auth-form.component.spec.ts b/packages/angular/src/lib/auth/forms/sign-up-auth-form.spec.ts similarity index 98% rename from packages/angular/src/lib/auth/forms/sign-up-auth-form/sign-up-auth-form.component.spec.ts rename to packages/angular/src/lib/auth/forms/sign-up-auth-form.spec.ts index b6c41964..356aa883 100644 --- a/packages/angular/src/lib/auth/forms/sign-up-auth-form/sign-up-auth-form.component.spec.ts +++ b/packages/angular/src/lib/auth/forms/sign-up-auth-form.spec.ts @@ -17,14 +17,14 @@ import { render, screen, fireEvent } from "@testing-library/angular"; import { CommonModule } from "@angular/common"; import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; -import { SignUpAuthFormComponent } from "./sign-up-auth-form.component"; +import { SignUpAuthFormComponent } from "./sign-up-auth-form"; import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent, FormActionComponent, -} from "../../../components/form/form.component"; -import { PoliciesComponent } from "../../../components/policies/policies.component"; +} from "../../components/form"; +import { PoliciesComponent } from "../../components/policies"; import { UserCredential } from "@angular/fire/auth"; describe("", () => { diff --git a/packages/angular/src/lib/auth/forms/sign-up-auth-form/sign-up-auth-form.component.ts b/packages/angular/src/lib/auth/forms/sign-up-auth-form.ts similarity index 96% rename from packages/angular/src/lib/auth/forms/sign-up-auth-form/sign-up-auth-form.component.ts rename to packages/angular/src/lib/auth/forms/sign-up-auth-form.ts index 8ea4f2e3..7d3aa01e 100644 --- a/packages/angular/src/lib/auth/forms/sign-up-auth-form/sign-up-auth-form.component.ts +++ b/packages/angular/src/lib/auth/forms/sign-up-auth-form.ts @@ -20,14 +20,14 @@ import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanst import { FirebaseUIError, createUserWithEmailAndPassword, hasBehavior } from "@firebase-ui/core"; import { UserCredential } from "@angular/fire/auth"; -import { PoliciesComponent } from "../../../components/policies/policies.component"; -import { injectSignUpAuthFormSchema, injectTranslation, injectUI } from "../../../provider"; +import { PoliciesComponent } from "../../components/policies"; +import { injectSignUpAuthFormSchema, injectTranslation, injectUI } from "../../provider"; import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent, FormActionComponent, -} from "../../../components/form/form.component"; +} from "../../components/form"; @Component({ selector: "fui-sign-up-auth-form", diff --git a/packages/angular/src/lib/auth/oauth/apple-sign-in-button.component.spec.ts b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts similarity index 99% rename from packages/angular/src/lib/auth/oauth/apple-sign-in-button.component.spec.ts rename to packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts index ebd54b9d..22bf762b 100644 --- a/packages/angular/src/lib/auth/oauth/apple-sign-in-button.component.spec.ts +++ b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts @@ -17,7 +17,7 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { AppleSignInButtonComponent } from "./apple-sign-in-button.component"; +import { AppleSignInButtonComponent } from "./apple-sign-in-button"; // Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts diff --git a/packages/angular/src/lib/auth/oauth/apple-sign-in-button.component.ts b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.ts similarity index 95% rename from packages/angular/src/lib/auth/oauth/apple-sign-in-button.component.ts rename to packages/angular/src/lib/auth/oauth/apple-sign-in-button.ts index f1861859..a9e358f6 100644 --- a/packages/angular/src/lib/auth/oauth/apple-sign-in-button.component.ts +++ b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.ts @@ -16,7 +16,7 @@ import { Component, input } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { OAuthButtonComponent } from "./oauth-button.component"; +import { OAuthButtonComponent } from "./oauth-button"; import { injectTranslation, injectUI } from "../../provider"; import { OAuthProvider } from "@angular/fire/auth"; import { AppleLogoComponent } from "../../components/logos/apple"; diff --git a/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.component.spec.ts b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts similarity index 99% rename from packages/angular/src/lib/auth/oauth/facebook-sign-in-button.component.spec.ts rename to packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts index 43be29b9..c8c6bc4c 100644 --- a/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.component.spec.ts +++ b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts @@ -17,7 +17,7 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { FacebookSignInButtonComponent } from "./facebook-sign-in-button.component"; +import { FacebookSignInButtonComponent } from "./facebook-sign-in-button"; // Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts diff --git a/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.component.ts b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts similarity index 95% rename from packages/angular/src/lib/auth/oauth/facebook-sign-in-button.component.ts rename to packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts index 7b625e7b..8a1be2f1 100644 --- a/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.component.ts +++ b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts @@ -17,7 +17,7 @@ import { Component, input } from "@angular/core"; import { CommonModule } from "@angular/common"; import { FacebookAuthProvider } from "@angular/fire/auth"; -import { OAuthButtonComponent } from "./oauth-button.component"; +import { OAuthButtonComponent } from "./oauth-button"; import { injectTranslation, injectUI } from "../../provider"; import { FacebookLogoComponent } from "../../components/logos/facebook"; diff --git a/packages/angular/src/lib/auth/oauth/github-sign-in-button.component.spec.ts b/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts similarity index 99% rename from packages/angular/src/lib/auth/oauth/github-sign-in-button.component.spec.ts rename to packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts index ec8b7f1a..f8b82dbb 100644 --- a/packages/angular/src/lib/auth/oauth/github-sign-in-button.component.spec.ts +++ b/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts @@ -17,7 +17,7 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { GithubSignInButtonComponent } from "./github-sign-in-button.component"; +import { GithubSignInButtonComponent } from "./github-sign-in-button"; // Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts diff --git a/packages/angular/src/lib/auth/oauth/github-sign-in-button.component.ts b/packages/angular/src/lib/auth/oauth/github-sign-in-button.ts similarity index 95% rename from packages/angular/src/lib/auth/oauth/github-sign-in-button.component.ts rename to packages/angular/src/lib/auth/oauth/github-sign-in-button.ts index 97325ef0..d76cf686 100644 --- a/packages/angular/src/lib/auth/oauth/github-sign-in-button.component.ts +++ b/packages/angular/src/lib/auth/oauth/github-sign-in-button.ts @@ -16,7 +16,7 @@ import { Component, input } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { OAuthButtonComponent } from "./oauth-button.component"; +import { OAuthButtonComponent } from "./oauth-button"; import { injectTranslation } from "../../provider"; import { GithubAuthProvider } from "@angular/fire/auth"; import { GithubLogoComponent } from "../../components/logos/github"; diff --git a/packages/angular/src/lib/auth/oauth/google-sign-in-button.component.spec.ts b/packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts similarity index 99% rename from packages/angular/src/lib/auth/oauth/google-sign-in-button.component.spec.ts rename to packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts index f26aad48..bce57459 100644 --- a/packages/angular/src/lib/auth/oauth/google-sign-in-button.component.spec.ts +++ b/packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts @@ -17,7 +17,7 @@ import { render, screen } from "@testing-library/angular"; import { Component, signal } from "@angular/core"; -import { GoogleSignInButtonComponent } from "./google-sign-in-button.component"; +import { GoogleSignInButtonComponent } from "./google-sign-in-button"; // Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts diff --git a/packages/angular/src/lib/auth/oauth/google-sign-in-button.component.ts b/packages/angular/src/lib/auth/oauth/google-sign-in-button.ts similarity index 95% rename from packages/angular/src/lib/auth/oauth/google-sign-in-button.component.ts rename to packages/angular/src/lib/auth/oauth/google-sign-in-button.ts index 7ee8fa54..cece30b4 100644 --- a/packages/angular/src/lib/auth/oauth/google-sign-in-button.component.ts +++ b/packages/angular/src/lib/auth/oauth/google-sign-in-button.ts @@ -18,7 +18,7 @@ import { Component, input } from "@angular/core"; import { CommonModule } from "@angular/common"; import { GoogleAuthProvider } from "@angular/fire/auth"; import { injectTranslation, injectUI } from "../../provider"; -import { OAuthButtonComponent } from "./oauth-button.component"; +import { OAuthButtonComponent } from "./oauth-button"; import { GoogleLogoComponent } from "../../components/logos/google"; @Component({ diff --git a/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.component.spec.ts b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts similarity index 99% rename from packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.component.spec.ts rename to packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts index 61ec61d9..92c91d37 100644 --- a/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.component.spec.ts +++ b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts @@ -17,7 +17,7 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { MicrosoftSignInButtonComponent } from "./microsoft-sign-in-button.component"; +import { MicrosoftSignInButtonComponent } from "./microsoft-sign-in-button"; // Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts diff --git a/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.component.ts b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.ts similarity index 95% rename from packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.component.ts rename to packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.ts index 9ab1d2ff..e2b05039 100644 --- a/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.component.ts +++ b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.ts @@ -16,7 +16,7 @@ import { Component, input } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { OAuthButtonComponent } from "./oauth-button.component"; +import { OAuthButtonComponent } from "./oauth-button"; import { injectTranslation } from "../../provider"; import { OAuthProvider } from "@angular/fire/auth"; import { MicrosoftLogoComponent } from "../../components/logos/microsoft"; diff --git a/packages/angular/src/lib/auth/oauth/oauth-button.component.spec.ts b/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts similarity index 98% rename from packages/angular/src/lib/auth/oauth/oauth-button.component.spec.ts rename to packages/angular/src/lib/auth/oauth/oauth-button.spec.ts index 87e97017..b3b63f0a 100644 --- a/packages/angular/src/lib/auth/oauth/oauth-button.component.spec.ts +++ b/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts @@ -16,7 +16,7 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { OAuthButtonComponent } from "./oauth-button.component"; +import { OAuthButtonComponent } from "./oauth-button"; // ButtonComponent is imported by OAuthButtonComponent import { AuthProvider } from "@angular/fire/auth"; diff --git a/packages/angular/src/lib/auth/oauth/oauth-button.component.ts b/packages/angular/src/lib/auth/oauth/oauth-button.ts similarity index 96% rename from packages/angular/src/lib/auth/oauth/oauth-button.component.ts rename to packages/angular/src/lib/auth/oauth/oauth-button.ts index 0c3f4be1..9336aa2a 100644 --- a/packages/angular/src/lib/auth/oauth/oauth-button.component.ts +++ b/packages/angular/src/lib/auth/oauth/oauth-button.ts @@ -16,7 +16,7 @@ import { Component, input, signal } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { ButtonComponent } from "../../components/button/button.component"; +import { ButtonComponent } from "../../components/button"; import { injectTranslation, injectUI } from "../../provider"; import { AuthProvider } from "@angular/fire/auth"; import { FirebaseUIError, signInWithProvider } from "@firebase-ui/core"; diff --git a/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.component.spec.ts b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts similarity index 99% rename from packages/angular/src/lib/auth/oauth/twitter-sign-in-button.component.spec.ts rename to packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts index 3876a83e..49f20a4b 100644 --- a/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.component.spec.ts +++ b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts @@ -17,7 +17,7 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { TwitterSignInButtonComponent } from "./twitter-sign-in-button.component"; +import { TwitterSignInButtonComponent } from "./twitter-sign-in-button"; // Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts diff --git a/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.component.ts b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.ts similarity index 95% rename from packages/angular/src/lib/auth/oauth/twitter-sign-in-button.component.ts rename to packages/angular/src/lib/auth/oauth/twitter-sign-in-button.ts index 98c372db..1c090bc2 100644 --- a/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.component.ts +++ b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.ts @@ -16,7 +16,7 @@ import { Component, input } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { OAuthButtonComponent } from "./oauth-button.component"; +import { OAuthButtonComponent } from "./oauth-button"; import { injectTranslation } from "../../provider"; import { TwitterAuthProvider } from "@angular/fire/auth"; import { TwitterLogoComponent } from "../../components/logos/twitter"; diff --git a/packages/angular/src/lib/auth/screens/email-link-auth-screen/email-link-auth-screen.component.spec.ts b/packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts similarity index 98% rename from packages/angular/src/lib/auth/screens/email-link-auth-screen/email-link-auth-screen.component.spec.ts rename to packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts index eb8e52e5..03355da3 100644 --- a/packages/angular/src/lib/auth/screens/email-link-auth-screen/email-link-auth-screen.component.spec.ts +++ b/packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts @@ -17,14 +17,14 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { EmailLinkAuthScreenComponent } from "./email-link-auth-screen.component"; +import { EmailLinkAuthScreenComponent } from "./email-link-auth-screen"; import { CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent, CardContentComponent, -} from "../../../components/card/card.component"; +} from "../../components/card"; @Component({ selector: "fui-email-link-auth-form", diff --git a/packages/angular/src/lib/auth/screens/email-link-auth-screen/email-link-auth-screen.component.ts b/packages/angular/src/lib/auth/screens/email-link-auth-screen.ts similarity index 85% rename from packages/angular/src/lib/auth/screens/email-link-auth-screen/email-link-auth-screen.component.ts rename to packages/angular/src/lib/auth/screens/email-link-auth-screen.ts index 9438057f..20c4ab63 100644 --- a/packages/angular/src/lib/auth/screens/email-link-auth-screen/email-link-auth-screen.component.ts +++ b/packages/angular/src/lib/auth/screens/email-link-auth-screen.ts @@ -22,10 +22,10 @@ import { CardTitleComponent, CardSubtitleComponent, CardContentComponent, -} from "../../../components/card/card.component"; -import { injectTranslation } from "../../../provider"; -import { EmailLinkAuthFormComponent } from "../../forms/email-link-auth-form/email-link-auth-form.component"; -import { RedirectErrorComponent } from "../../../components/redirect-error/redirect-error.component"; +} from "../../components/card"; +import { injectTranslation } from "../../provider"; +import { EmailLinkAuthFormComponent } from "../forms/email-link-auth-form"; +import { RedirectErrorComponent } from "../../components/redirect-error"; import { UserCredential } from "@angular/fire/auth"; @Component({ diff --git a/packages/angular/src/lib/auth/screens/forgot-password-auth-screen/forgot-password-auth-screen.component.spec.ts b/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.spec.ts similarity index 98% rename from packages/angular/src/lib/auth/screens/forgot-password-auth-screen/forgot-password-auth-screen.component.spec.ts rename to packages/angular/src/lib/auth/screens/forgot-password-auth-screen.spec.ts index 9477b96a..6c5fa735 100644 --- a/packages/angular/src/lib/auth/screens/forgot-password-auth-screen/forgot-password-auth-screen.component.spec.ts +++ b/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.spec.ts @@ -17,14 +17,14 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { ForgotPasswordAuthScreenComponent } from "./forgot-password-auth-screen.component"; +import { ForgotPasswordAuthScreenComponent } from "./forgot-password-auth-screen"; import { CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent, CardContentComponent, -} from "../../../components/card/card.component"; +} from "../../components/card"; @Component({ selector: "fui-forgot-password-auth-form", diff --git a/packages/angular/src/lib/auth/screens/forgot-password-auth-screen/forgot-password-auth-screen.component.ts b/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.ts similarity index 84% rename from packages/angular/src/lib/auth/screens/forgot-password-auth-screen/forgot-password-auth-screen.component.ts rename to packages/angular/src/lib/auth/screens/forgot-password-auth-screen.ts index d3f00b32..d3a0bd65 100644 --- a/packages/angular/src/lib/auth/screens/forgot-password-auth-screen/forgot-password-auth-screen.component.ts +++ b/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.ts @@ -22,10 +22,10 @@ import { CardTitleComponent, CardSubtitleComponent, CardContentComponent, -} from "../../../components/card/card.component"; -import { injectTranslation } from "../../../provider"; -import { ForgotPasswordAuthFormComponent } from "../../forms/forgot-password-auth-form/forgot-password-auth-form.component"; -import { RedirectErrorComponent } from "../../../components/redirect-error/redirect-error.component"; +} from "../../components/card"; +import { injectTranslation } from "../../provider"; +import { ForgotPasswordAuthFormComponent } from "../forms/forgot-password-auth-form"; +import { RedirectErrorComponent } from "../../components/redirect-error"; @Component({ selector: "fui-forgot-password-auth-screen", diff --git a/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.spec.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.spec.ts similarity index 98% rename from packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.spec.ts rename to packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.spec.ts index 240b5837..d9a7bceb 100644 --- a/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.spec.ts +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.spec.ts @@ -16,14 +16,14 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { MultiFactorAuthEnrollmentScreenComponent } from "./multi-factor-auth-enrollment-screen.component"; +import { MultiFactorAuthEnrollmentScreenComponent } from "./multi-factor-auth-enrollment-screen"; import { CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent, CardContentComponent, -} from "../../../components/card/card.component"; +} from "../../components/card"; import { FactorId } from "firebase/auth"; @Component({ diff --git a/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.ts similarity index 80% rename from packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.ts rename to packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.ts index 148a4eb1..8d5c9eb7 100644 --- a/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen/multi-factor-auth-enrollment-screen.component.ts +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.ts @@ -17,16 +17,16 @@ import { Component, output, input } from "@angular/core"; import { CommonModule } from "@angular/common"; import { FactorId } from "firebase/auth"; -import { injectTranslation } from "../../../provider"; -import { MultiFactorAuthEnrollmentFormComponent } from "../../forms/multi-factor-auth-enrollment-form.component"; -import { RedirectErrorComponent } from "../../../components/redirect-error/redirect-error.component"; +import { injectTranslation } from "../../provider"; +import { MultiFactorAuthEnrollmentFormComponent } from "../forms/multi-factor-auth-enrollment-form"; +import { RedirectErrorComponent } from "../../components/redirect-error"; import { CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent, CardContentComponent, -} from "../../../components/card/card.component"; +} from "../../components/card"; type Hint = (typeof FactorId)[keyof typeof FactorId]; @@ -51,10 +51,7 @@ type Hint = (typeof FactorId)[keyof typeof FactorId]; {{ subtitleText() }} - + diff --git a/packages/angular/src/lib/auth/screens/oauth-screen/oauth-screen.component.spec.ts b/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts similarity index 97% rename from packages/angular/src/lib/auth/screens/oauth-screen/oauth-screen.component.spec.ts rename to packages/angular/src/lib/auth/screens/oauth-screen.spec.ts index d7daf609..ff1ab102 100644 --- a/packages/angular/src/lib/auth/screens/oauth-screen/oauth-screen.component.spec.ts +++ b/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts @@ -17,15 +17,15 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { OAuthScreenComponent } from "./oauth-screen.component"; +import { OAuthScreenComponent } from "./oauth-screen"; import { CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent, CardContentComponent, -} from "../../../components/card/card.component"; -import { ContentComponent } from "../../../components/content/content.component"; +} from "../../components/card"; +import { ContentComponent } from "../../components/content"; jest.mock("../../../provider", () => ({ injectTranslation: jest.fn(), diff --git a/packages/angular/src/lib/auth/screens/oauth-screen/oauth-screen.component.ts b/packages/angular/src/lib/auth/screens/oauth-screen.ts similarity index 82% rename from packages/angular/src/lib/auth/screens/oauth-screen/oauth-screen.component.ts rename to packages/angular/src/lib/auth/screens/oauth-screen.ts index e8a8e22d..0e15c32b 100644 --- a/packages/angular/src/lib/auth/screens/oauth-screen/oauth-screen.component.ts +++ b/packages/angular/src/lib/auth/screens/oauth-screen.ts @@ -22,11 +22,11 @@ import { CardTitleComponent, CardSubtitleComponent, CardContentComponent, -} from "../../../components/card/card.component"; -import { injectTranslation } from "../../../provider"; -import { PoliciesComponent } from "../../../components/policies/policies.component"; -import { ContentComponent } from "../../../components/content/content.component"; -import { RedirectErrorComponent } from "../../../components/redirect-error/redirect-error.component"; +} from "../../components/card"; +import { injectTranslation } from "../../provider"; +import { PoliciesComponent } from "../../components/policies"; +import { ContentComponent } from "../../components/content"; +import { RedirectErrorComponent } from "../../components/redirect-error"; @Component({ selector: "fui-oauth-screen", diff --git a/packages/angular/src/lib/auth/screens/phone-auth-screen/phone-auth-screen.component.spec.ts b/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts similarity index 97% rename from packages/angular/src/lib/auth/screens/phone-auth-screen/phone-auth-screen.component.spec.ts rename to packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts index 9ff1d563..e88341a6 100644 --- a/packages/angular/src/lib/auth/screens/phone-auth-screen/phone-auth-screen.component.spec.ts +++ b/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts @@ -17,14 +17,14 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { PhoneAuthScreenComponent } from "./phone-auth-screen.component"; +import { PhoneAuthScreenComponent } from "./phone-auth-screen"; import { CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent, CardContentComponent, -} from "../../../components/card/card.component"; +} from "../../components/card"; @Component({ selector: "fui-phone-auth-form", diff --git a/packages/angular/src/lib/auth/screens/phone-auth-screen/phone-auth-screen.component.ts b/packages/angular/src/lib/auth/screens/phone-auth-screen.ts similarity index 85% rename from packages/angular/src/lib/auth/screens/phone-auth-screen/phone-auth-screen.component.ts rename to packages/angular/src/lib/auth/screens/phone-auth-screen.ts index 8f8aabb9..3d873c07 100644 --- a/packages/angular/src/lib/auth/screens/phone-auth-screen/phone-auth-screen.component.ts +++ b/packages/angular/src/lib/auth/screens/phone-auth-screen.ts @@ -22,10 +22,10 @@ import { CardTitleComponent, CardSubtitleComponent, CardContentComponent, -} from "../../../components/card/card.component"; -import { injectTranslation } from "../../../provider"; -import { PhoneAuthFormComponent } from "../../forms/phone-auth-form/phone-auth-form.component"; -import { RedirectErrorComponent } from "../../../components/redirect-error/redirect-error.component"; +} from "../../components/card"; +import { injectTranslation } from "../../provider"; +import { PhoneAuthFormComponent } from "../forms/phone-auth-form"; +import { RedirectErrorComponent } from "../../components/redirect-error"; import { UserCredential } from "@angular/fire/auth"; @Component({ diff --git a/packages/angular/src/lib/auth/screens/sign-in-auth-screen/sign-in-auth-screen.component.spec.ts b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts similarity index 98% rename from packages/angular/src/lib/auth/screens/sign-in-auth-screen/sign-in-auth-screen.component.spec.ts rename to packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts index b0be7105..55e8e0b1 100644 --- a/packages/angular/src/lib/auth/screens/sign-in-auth-screen/sign-in-auth-screen.component.spec.ts +++ b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts @@ -17,14 +17,14 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { SignInAuthScreenComponent } from "./sign-in-auth-screen.component"; +import { SignInAuthScreenComponent } from "./sign-in-auth-screen"; import { CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent, CardContentComponent, -} from "../../../components/card/card.component"; +} from "../../components/card"; @Component({ selector: "fui-sign-in-auth-form", diff --git a/packages/angular/src/lib/auth/screens/sign-in-auth-screen/sign-in-auth-screen.component.ts b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts similarity index 86% rename from packages/angular/src/lib/auth/screens/sign-in-auth-screen/sign-in-auth-screen.component.ts rename to packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts index 7536d2b6..79bf5973 100644 --- a/packages/angular/src/lib/auth/screens/sign-in-auth-screen/sign-in-auth-screen.component.ts +++ b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts @@ -17,16 +17,16 @@ import { Component, output } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { injectTranslation } from "../../../provider"; -import { SignInAuthFormComponent } from "../../forms/sign-in-auth-form/sign-in-auth-form.component"; -import { RedirectErrorComponent } from "../../../components/redirect-error/redirect-error.component"; +import { injectTranslation } from "../../provider"; +import { SignInAuthFormComponent } from "../forms/sign-in-auth-form"; +import { RedirectErrorComponent } from "../../components/redirect-error"; import { CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent, CardContentComponent, -} from "../../../components/card/card.component"; +} from "../../components/card"; import { UserCredential } from "@angular/fire/auth"; @Component({ selector: "fui-sign-in-auth-screen", diff --git a/packages/angular/src/lib/auth/screens/sign-up-auth-screen/sign-up-auth-screen.component.spec.ts b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts similarity index 98% rename from packages/angular/src/lib/auth/screens/sign-up-auth-screen/sign-up-auth-screen.component.spec.ts rename to packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts index e818cfa7..4a7d5972 100644 --- a/packages/angular/src/lib/auth/screens/sign-up-auth-screen/sign-up-auth-screen.component.spec.ts +++ b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts @@ -17,14 +17,14 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { SignUpAuthScreenComponent } from "./sign-up-auth-screen.component"; +import { SignUpAuthScreenComponent } from "./sign-up-auth-screen"; import { CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent, CardContentComponent, -} from "../../../components/card/card.component"; +} from "../../components/card"; @Component({ selector: "fui-sign-up-auth-form", diff --git a/packages/angular/src/lib/auth/screens/sign-up-auth-screen/sign-up-auth-screen.component.ts b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts similarity index 85% rename from packages/angular/src/lib/auth/screens/sign-up-auth-screen/sign-up-auth-screen.component.ts rename to packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts index 4e3202e8..37173c7f 100644 --- a/packages/angular/src/lib/auth/screens/sign-up-auth-screen/sign-up-auth-screen.component.ts +++ b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts @@ -18,16 +18,16 @@ import { Component, output } from "@angular/core"; import { CommonModule } from "@angular/common"; import { UserCredential } from "@angular/fire/auth"; -import { injectTranslation } from "../../../provider"; -import { SignUpAuthFormComponent } from "../../forms/sign-up-auth-form/sign-up-auth-form.component"; -import { RedirectErrorComponent } from "../../../components/redirect-error/redirect-error.component"; +import { injectTranslation } from "../../provider"; +import { SignUpAuthFormComponent } from "../forms/sign-up-auth-form"; +import { RedirectErrorComponent } from "../../components/redirect-error"; import { CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent, CardContentComponent, -} from "../../../components/card/card.component"; +} from "../../components/card"; @Component({ selector: "fui-sign-up-auth-screen", diff --git a/packages/angular/src/lib/components/button/button.component.spec.ts b/packages/angular/src/lib/components/button.spec.ts similarity index 97% rename from packages/angular/src/lib/components/button/button.component.spec.ts rename to packages/angular/src/lib/components/button.spec.ts index 6e2f85b3..6b4925b6 100644 --- a/packages/angular/src/lib/components/button/button.component.spec.ts +++ b/packages/angular/src/lib/components/button.spec.ts @@ -16,7 +16,7 @@ import { render, screen, fireEvent } from "@testing-library/angular"; -import { ButtonComponent } from "./button.component"; +import { ButtonComponent } from "./button"; describe("
`, }) -export class MultiFactorAuthEnrollmentFormComponent { +export class MultiFactorAuthEnrollmentFormComponent implements OnInit { hints = input([FactorId.TOTP, FactorId.PHONE]); onEnrollment = output(); @@ -66,8 +66,8 @@ export class MultiFactorAuthEnrollmentFormComponent { smsVerificationLabel = injectTranslation("labels", "mfaSmsVerification"); totpVerificationLabel = injectTranslation("labels", "mfaTotpVerification"); - constructor() { - // If only a single hint is provided, select it by default to improve UX + ngOnInit() { + // Auto-select single hint after component initialization const hints = this.hints(); if (hints.length === 1) { this.selectedHint.set(hints[0]); diff --git a/packages/angular/src/lib/auth/forms/sign-in-auth-form.ts b/packages/angular/src/lib/auth/forms/sign-in-auth-form.ts index 9372d0a1..89bd9954 100644 --- a/packages/angular/src/lib/auth/forms/sign-in-auth-form.ts +++ b/packages/angular/src/lib/auth/forms/sign-in-auth-form.ts @@ -20,14 +20,14 @@ import { UserCredential } from "@angular/fire/auth"; import { injectForm, TanStackField, TanStackAppField, injectStore } from "@tanstack/angular-form"; import { FirebaseUIError, signInWithEmailAndPassword } from "@firebase-ui/core"; -import { injectSignInAuthFormSchema, injectTranslation, injectUI } from "../../../provider"; -import { PoliciesComponent } from "../../../components/policies/policies.component"; +import { injectSignInAuthFormSchema, injectTranslation, injectUI } from "../../provider"; +import { PoliciesComponent } from "../../components/policies"; import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent, FormActionComponent, -} from "../../../components/form/form.component"; +} from "../../components/form"; @Component({ selector: "fui-sign-in-auth-form", diff --git a/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.spec.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.spec.ts index d9a7bceb..abf32144 100644 --- a/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.spec.ts @@ -28,7 +28,7 @@ import { FactorId } from "firebase/auth"; @Component({ selector: "fui-multi-factor-auth-enrollment-form", - template: '
MFA Enrollment Form
', + template: '
MFA Enrollment Form
', standalone: true, }) class MockMultiFactorAuthEnrollmentFormComponent {} @@ -106,9 +106,9 @@ describe("", () => { ], }); - const form = screen.getByTestId("mfa-enrollment-form"); + const form = screen.getByRole("button", { name: "labels.mfaTotpVerification" }); expect(form).toBeInTheDocument(); - expect(form).toHaveTextContent("MFA Enrollment Form"); + expect(form.parentElement).toHaveTextContent("labels.mfaTotpVerification labels.mfaSmsVerification"); }); it("renders projected content when provided", async () => { 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 ff1ab102..18ab8f2a 100644 --- a/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts @@ -16,6 +16,7 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; import { OAuthScreenComponent } from "./oauth-screen"; import { @@ -25,12 +26,14 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; import { ContentComponent } from "../../components/content"; jest.mock("../../../provider", () => ({ injectTranslation: jest.fn(), injectPolicies: jest.fn(), injectRedirectError: jest.fn(), + injectUI: jest.fn(), })); @Component({ @@ -47,6 +50,13 @@ class MockPoliciesComponent {} }) class MockRedirectErrorComponent {} +@Component({ + selector: "fui-multi-factor-auth-assertion-form", + template: '
MFA Assertion Form
', + standalone: true, +}) +class MockMultiFactorAuthAssertionFormComponent {} + @Component({ template: ` @@ -79,7 +89,7 @@ class TestHostWithoutContentComponent {} describe("", () => { beforeEach(() => { - const { injectTranslation, injectPolicies, injectRedirectError } = require("../../../provider"); + const { injectTranslation, injectPolicies, injectRedirectError, injectUI } = require("../../../provider"); injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -100,6 +110,12 @@ describe("", () => { injectRedirectError.mockImplementation(() => { return () => undefined; }); + + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: null, + }); + }); }); it("renders with correct title and subtitle", async () => { @@ -108,6 +124,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -127,6 +144,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -146,6 +164,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -166,6 +185,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -190,6 +210,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -209,6 +230,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -233,6 +255,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -245,4 +268,72 @@ describe("", () => { expect(injectTranslation).toHaveBeenCalledWith("labels", "signIn"); expect(injectTranslation).toHaveBeenCalledWith("prompts", "signInToAccount"); }); + + it("renders MFA assertion form when multiFactorResolver is present", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { hints: [] }, + }); + }); + + // Override the real component with our mock + TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + set: { + template: '
MFA Assertion Form
', + }, + }); + + await render(TestHostWithoutContentComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); + expect(screen.queryByTestId("policies")).not.toBeInTheDocument(); + }); + + it("does not render Policies component when MFA resolver exists", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { hints: [] }, + }); + }); + + // Override the real component with our mock + TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + set: { + template: '
MFA Assertion Form
', + }, + }); + + await render(TestHostWithContentComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + expect(screen.queryByTestId("policies")).not.toBeInTheDocument(); + expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); + }); }); diff --git a/packages/angular/src/lib/auth/screens/oauth-screen.ts b/packages/angular/src/lib/auth/screens/oauth-screen.ts index 0e15c32b..99ab65c5 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 } from "@angular/core"; +import { Component, computed } from "@angular/core"; import { CommonModule } from "@angular/common"; import { CardComponent, @@ -23,9 +23,10 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; -import { injectTranslation } from "../../provider"; +import { injectTranslation, injectUI } from "../../provider"; import { PoliciesComponent } from "../../components/policies"; import { ContentComponent } from "../../components/content"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; import { RedirectErrorComponent } from "../../components/redirect-error"; @Component({ @@ -40,6 +41,7 @@ import { RedirectErrorComponent } from "../../components/redirect-error"; CardContentComponent, PoliciesComponent, ContentComponent, + MultiFactorAuthAssertionFormComponent, RedirectErrorComponent, ], template: ` @@ -50,17 +52,25 @@ import { RedirectErrorComponent } from "../../components/redirect-error"; {{ subtitleText() }} - - - - - + @if (mfaResolver()) { + + } @else { + + + + + + }
`, }) export class OAuthScreenComponent { + private ui = injectUI(); + + mfaResolver = computed(() => this.ui().multiFactorResolver); + titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); } diff --git a/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts index e88341a6..9793ec6f 100644 --- a/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts @@ -16,6 +16,7 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; import { PhoneAuthScreenComponent } from "./phone-auth-screen"; import { @@ -25,6 +26,7 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; @Component({ selector: "fui-phone-auth-form", @@ -40,6 +42,13 @@ class MockPhoneAuthFormComponent {} }) class MockRedirectErrorComponent {} +@Component({ + selector: "fui-multi-factor-auth-assertion-form", + template: '
MFA Assertion Form
', + standalone: true, +}) +class MockMultiFactorAuthAssertionFormComponent {} + @Component({ template: ` @@ -60,7 +69,7 @@ class TestHostWithoutContentComponent {} describe("", () => { beforeEach(() => { - const { injectTranslation } = require("../../../provider"); + const { injectTranslation, injectUI } = require("../../../provider"); injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -72,6 +81,12 @@ describe("", () => { }; return () => mockTranslations[category]?.[key] || `${category}.${key}`; }); + + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: null, + }); + }); }); it("renders with correct title and subtitle", async () => { @@ -80,6 +95,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -98,6 +114,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -118,6 +135,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -137,6 +155,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -155,6 +174,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -178,6 +198,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -189,4 +210,70 @@ describe("", () => { expect(injectTranslation).toHaveBeenCalledWith("labels", "signIn"); expect(injectTranslation).toHaveBeenCalledWith("prompts", "signInToAccount"); }); + + it("renders MFA assertion form when multiFactorResolver is present", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { hints: [] }, + }); + }); + + // Override the real component with our mock + TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + set: { + template: '
MFA Assertion Form
', + }, + }); + + await render(TestHostWithoutContentComponent, { + imports: [ + PhoneAuthScreenComponent, + MockPhoneAuthFormComponent, + MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); + expect(screen.queryByText("Phone Auth Form")).not.toBeInTheDocument(); + }); + + it("does not render PhoneAuthForm when MFA resolver exists", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { hints: [] }, + }); + }); + + // Override the real component with our mock + TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + set: { + template: '
MFA Assertion Form
', + }, + }); + + await render(TestHostWithContentComponent, { + imports: [ + PhoneAuthScreenComponent, + MockPhoneAuthFormComponent, + MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.queryByText("Phone Auth Form")).not.toBeInTheDocument(); + expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); + }); }); diff --git a/packages/angular/src/lib/auth/screens/phone-auth-screen.ts b/packages/angular/src/lib/auth/screens/phone-auth-screen.ts index 3d873c07..52c8830f 100644 --- a/packages/angular/src/lib/auth/screens/phone-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/phone-auth-screen.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, input, output } from "@angular/core"; +import { Component, input, output, computed } from "@angular/core"; import { CommonModule } from "@angular/common"; import { CardComponent, @@ -23,8 +23,9 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; -import { injectTranslation } from "../../provider"; +import { injectTranslation, injectUI } from "../../provider"; import { PhoneAuthFormComponent } from "../forms/phone-auth-form"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; import { RedirectErrorComponent } from "../../components/redirect-error"; import { UserCredential } from "@angular/fire/auth"; @@ -39,6 +40,7 @@ import { UserCredential } from "@angular/fire/auth"; CardSubtitleComponent, CardContentComponent, PhoneAuthFormComponent, + MultiFactorAuthAssertionFormComponent, RedirectErrorComponent, ], template: ` @@ -49,15 +51,23 @@ import { UserCredential } from "@angular/fire/auth"; {{ subtitleText() }} - - - + @if (mfaResolver()) { + + } @else { + + + + }
`, }) export class PhoneAuthScreenComponent { + private ui = injectUI(); + + mfaResolver = computed(() => this.ui().multiFactorResolver); + titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); 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 55e8e0b1..61247f90 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 @@ -16,6 +16,7 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; import { SignInAuthScreenComponent } from "./sign-in-auth-screen"; import { @@ -25,6 +26,7 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; @Component({ selector: "fui-sign-in-auth-form", @@ -40,6 +42,13 @@ class MockSignInAuthFormComponent {} }) class MockRedirectErrorComponent {} +@Component({ + selector: "fui-multi-factor-auth-assertion-form", + template: '
MFA Assertion Form
', + standalone: true, +}) +class MockMultiFactorAuthAssertionFormComponent {} + @Component({ template: ` @@ -60,7 +69,7 @@ class TestHostWithoutContentComponent {} describe("", () => { beforeEach(() => { - const { injectTranslation } = require("../../../provider"); + const { injectTranslation, injectUI } = require("../../../provider"); injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -72,6 +81,12 @@ describe("", () => { }; return () => mockTranslations[category]?.[key] || `${category}.${key}`; }); + + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: null, + }); + }); }); it("renders with correct title and subtitle", async () => { @@ -80,6 +95,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -98,6 +114,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -117,6 +134,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -136,6 +154,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -154,6 +173,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -177,6 +197,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -188,4 +209,70 @@ describe("", () => { expect(injectTranslation).toHaveBeenCalledWith("labels", "signIn"); expect(injectTranslation).toHaveBeenCalledWith("prompts", "signInToAccount"); }); + + it("renders MFA assertion form when multiFactorResolver is present", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { hints: [] }, + }); + }); + + // Override the real component with our mock + TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + set: { + template: '
MFA Assertion Form
', + }, + }); + + await render(TestHostWithoutContentComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Sign in" })).not.toBeInTheDocument(); + }); + + it("does not render SignInAuthForm when MFA resolver exists", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { hints: [] }, + }); + }); + + // Override the real component with our mock + TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + set: { + template: '
MFA Assertion Form
', + }, + }); + + await render(TestHostWithContentComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.queryByRole("button", { name: "Sign in" })).not.toBeInTheDocument(); + expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); + }); }); 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 79bf5973..ab4463fc 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,11 +14,12 @@ * limitations under the License. */ -import { Component, output } from "@angular/core"; +import { Component, output, computed } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { injectTranslation } from "../../provider"; +import { injectTranslation, injectUI } from "../../provider"; import { SignInAuthFormComponent } from "../forms/sign-in-auth-form"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; import { RedirectErrorComponent } from "../../components/redirect-error"; import { CardComponent, @@ -39,6 +40,7 @@ import { UserCredential } from "@angular/fire/auth"; CardSubtitleComponent, CardContentComponent, SignInAuthFormComponent, + MultiFactorAuthAssertionFormComponent, RedirectErrorComponent, ], template: ` @@ -49,19 +51,27 @@ import { UserCredential } from "@angular/fire/auth"; {{ subtitleText() }} - - - + @if (mfaResolver()) { + + } @else { + + + + }
`, }) export class SignInAuthScreenComponent { + private ui = injectUI(); + + mfaResolver = computed(() => this.ui().multiFactorResolver); + titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); diff --git a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts index 4a7d5972..840dea6b 100644 --- a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts @@ -16,6 +16,7 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; import { SignUpAuthScreenComponent } from "./sign-up-auth-screen"; import { @@ -25,6 +26,7 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; @Component({ selector: "fui-sign-up-auth-form", @@ -40,6 +42,13 @@ class MockSignUpAuthFormComponent {} }) class MockRedirectErrorComponent {} +@Component({ + selector: "fui-multi-factor-auth-assertion-form", + template: '
MFA Assertion Form
', + standalone: true, +}) +class MockMultiFactorAuthAssertionFormComponent {} + @Component({ template: ` @@ -60,7 +69,7 @@ class TestHostWithoutContentComponent {} describe("", () => { beforeEach(() => { - const { injectTranslation } = require("../../../provider"); + const { injectTranslation, injectUI } = require("../../../provider"); injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -72,6 +81,12 @@ describe("", () => { }; return () => mockTranslations[category]?.[key] || `${category}.${key}`; }); + + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: null, + }); + }); }); it("renders with correct title and subtitle", async () => { @@ -80,6 +95,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -98,6 +114,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -116,6 +133,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -135,6 +153,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -153,6 +172,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -176,6 +196,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -187,4 +208,70 @@ describe("", () => { expect(injectTranslation).toHaveBeenCalledWith("labels", "register"); expect(injectTranslation).toHaveBeenCalledWith("prompts", "enterDetailsToCreate"); }); + + it("renders MFA assertion form when multiFactorResolver is present", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { hints: [] }, + }); + }); + + // Override the real component with our mock + TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + set: { + template: '
MFA Assertion Form
', + }, + }); + + await render(TestHostWithoutContentComponent, { + imports: [ + SignUpAuthScreenComponent, + MockSignUpAuthFormComponent, + MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); + expect(screen.queryByText("Sign Up Form")).not.toBeInTheDocument(); + }); + + it("does not render SignUpAuthForm when MFA resolver exists", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { hints: [] }, + }); + }); + + // Override the real component with our mock + TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + set: { + template: '
MFA Assertion Form
', + }, + }); + + await render(TestHostWithContentComponent, { + imports: [ + SignUpAuthScreenComponent, + MockSignUpAuthFormComponent, + MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.queryByText("Sign Up Form")).not.toBeInTheDocument(); + expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); + }); }); diff --git a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts index 37173c7f..caa89fbd 100644 --- a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts @@ -14,12 +14,13 @@ * limitations under the License. */ -import { Component, output } from "@angular/core"; +import { Component, output, computed } from "@angular/core"; import { CommonModule } from "@angular/common"; import { UserCredential } from "@angular/fire/auth"; -import { injectTranslation } from "../../provider"; +import { injectTranslation, injectUI } from "../../provider"; import { SignUpAuthFormComponent } from "../forms/sign-up-auth-form"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; import { RedirectErrorComponent } from "../../components/redirect-error"; import { CardComponent, @@ -40,6 +41,7 @@ import { CardSubtitleComponent, CardContentComponent, SignUpAuthFormComponent, + MultiFactorAuthAssertionFormComponent, RedirectErrorComponent, ], template: ` @@ -50,15 +52,23 @@ import { {{ subtitleText() }} - - - + @if (mfaResolver()) { + + } @else { + + + + } `, }) export class SignUpAuthScreenComponent { + private ui = injectUI(); + + mfaResolver = computed(() => this.ui().multiFactorResolver); + titleText = injectTranslation("labels", "register"); subtitleText = injectTranslation("prompts", "enterDetailsToCreate"); diff --git a/packages/angular/src/lib/tests/test-helpers.ts b/packages/angular/src/lib/tests/test-helpers.ts index 279e76f9..f69bfff8 100644 --- a/packages/angular/src/lib/tests/test-helpers.ts +++ b/packages/angular/src/lib/tests/test-helpers.ts @@ -18,6 +18,9 @@ export const signInWithProvider = jest.fn(); export const verifyPhoneNumber = jest.fn(); export const confirmPhoneNumber = jest.fn(); export const formatPhoneNumber = jest.fn(); +export const generateTotpSecret = jest.fn(); +export const enrollWithMultiFactorAssertion = jest.fn(); +export const generateTotpQrCode = jest.fn(); export const countryData = [ { name: "United States", dialCode: "+1", code: "US", emoji: "πŸ‡ΊπŸ‡Έ" }, @@ -83,6 +86,9 @@ export const injectTranslation = jest.fn().mockImplementation((category: string, verifyCode: "Verify Code", displayName: "Display Name", createAccount: "Create Account", + generateQrCode: "Generate QR Code", + mfaSmsVerification: "SMS Verification", + mfaTotpVerification: "TOTP Verification", }, messages: { signInLinkSent: "Check your email for a sign in link", @@ -98,6 +104,9 @@ export const injectTranslation = jest.fn().mockImplementation((category: string, unknownError: "An unknown error occurred", invalidEmail: "Please enter a valid email address", invalidPassword: "Please enter a valid password", + userNotAuthenticated: "User must be authenticated to enroll with multi-factor authentication", + invalidPhoneNumber: "Invalid phone number", + invalidVerificationCode: "Invalid verification code", }, }; return () => mockTranslations[category]?.[key] || `${category}.${key}`; From e4de63ce3519bba1b0d8aa680363738ba4ed9e34 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 28 Oct 2025 13:38:45 +0000 Subject: [PATCH 05/12] feat(angular): Add MFA Assertion form (with inner stubs) --- .../mfa/sms-multi-factor-assertion-form.ts | 33 ++++ .../mfa/totp-multi-factor-assertion-form.ts | 33 ++++ .../multi-factor-auth-assertion-form.spec.ts | 152 ++++++++++++++++++ .../forms/multi-factor-auth-assertion-form.ts | 52 +++++- packages/angular/src/public-api.ts | 4 + 5 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts create mode 100644 packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts create mode 100644 packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts new file mode 100644 index 00000000..2aa4545d --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts @@ -0,0 +1,33 @@ +/** + * 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 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 { Component, input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { MultiFactorInfo } from "firebase/auth"; + +@Component({ + selector: "fui-sms-multi-factor-assertion-form", + standalone: true, + imports: [CommonModule], + template: ` +
+
SMS Multi-Factor Assertion Form (Stubbed)
+
Hint: {{ hint()?.displayName || 'No hint' }}
+
+ `, +}) +export class SmsMultiFactorAssertionFormComponent { + hint = input.required(); +} diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts new file mode 100644 index 00000000..9afc52c1 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts @@ -0,0 +1,33 @@ +/** + * 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 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 { Component, input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { MultiFactorInfo } from "firebase/auth"; + +@Component({ + selector: "fui-totp-multi-factor-assertion-form", + standalone: true, + imports: [CommonModule], + template: ` +
+
TOTP Multi-Factor Assertion Form (Stubbed)
+
Hint: {{ hint()?.displayName || 'No hint' }}
+
+ `, +}) +export class TotpMultiFactorAssertionFormComponent { + hint = input.required(); +} diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts new file mode 100644 index 00000000..d63e6c01 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts @@ -0,0 +1,152 @@ +/** + * 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 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 { TestBed } from "@angular/core/testing"; +import { PhoneMultiFactorGenerator, TotpMultiFactorGenerator } from "firebase/auth"; + +import { MultiFactorAuthAssertionFormComponent } from "./multi-factor-auth-assertion-form"; +import { SmsMultiFactorAssertionFormComponent } from "./mfa/sms-multi-factor-assertion-form"; +import { TotpMultiFactorAssertionFormComponent } from "./mfa/totp-multi-factor-assertion-form"; + +describe("", () => { + beforeEach(() => { + const { injectTranslation, injectUI } = require("../../../provider"); + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + mfaSmsVerification: "SMS Verification", + mfaTotpVerification: "TOTP Verification", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Phone", + }, + { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + }, + ], + }, + }); + }); + }); + + it("renders selection UI when multiple hints are available", async () => { + TestBed.overrideComponent(SmsMultiFactorAssertionFormComponent, { + set: { + template: '
SMS Assertion Form
', + }, + }); + TestBed.overrideComponent(TotpMultiFactorAssertionFormComponent, { + set: { + template: '
TOTP Assertion Form
', + }, + }); + + await render(MultiFactorAuthAssertionFormComponent, { + imports: [MultiFactorAuthAssertionFormComponent], + }); + + expect(screen.getByRole("button", { name: "SMS Verification" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "TOTP Verification" })).toBeInTheDocument(); + + expect(screen.queryByTestId("sms-assertion-form")).not.toBeInTheDocument(); + expect(screen.queryByTestId("totp-assertion-form")).not.toBeInTheDocument(); + }); + + it("auto-selects single hint when only one is available", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Phone", + }, + ], + }, + }); + }); + + TestBed.overrideComponent(SmsMultiFactorAssertionFormComponent, { + set: { + template: '
SMS Assertion Form
', + }, + }); + TestBed.overrideComponent(TotpMultiFactorAssertionFormComponent, { + set: { + template: '
TOTP Assertion Form
', + }, + }); + + await render(MultiFactorAuthAssertionFormComponent, { + imports: [MultiFactorAuthAssertionFormComponent], + }); + + expect(screen.getByTestId("sms-assertion-form")).toBeInTheDocument(); + + expect(screen.queryByRole("button", { name: "SMS Verification" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "TOTP Verification" })).not.toBeInTheDocument(); + }); + + it("switches to assertion form when selection button is clicked", async () => { + // Override the inner components with mocks + TestBed.overrideComponent(SmsMultiFactorAssertionFormComponent, { + set: { + template: '
SMS Assertion Form
', + }, + }); + TestBed.overrideComponent(TotpMultiFactorAssertionFormComponent, { + set: { + template: '
TOTP Assertion Form
', + }, + }); + + await render(MultiFactorAuthAssertionFormComponent, { + imports: [MultiFactorAuthAssertionFormComponent], + }); + + expect(screen.getByRole("button", { name: "SMS Verification" })).toBeInTheDocument(); + expect(screen.queryByTestId("sms-assertion-form")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "SMS Verification" })); + + expect(screen.getByTestId("sms-assertion-form")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "SMS Verification" })).not.toBeInTheDocument(); + }); + + it("throws error when no resolver is provided", () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: null, + }); + }); + + expect(() => { + new MultiFactorAuthAssertionFormComponent(); + }).toThrow("MultiFactorAuthAssertionForm requires a multi-factor resolver"); + }); +}); diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts index 2f12b396..dbdc9a1f 100644 --- a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts @@ -14,28 +14,70 @@ * limitations under the License. */ -import { Component, computed } from "@angular/core"; +import { Component, computed, signal } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { injectUI } from "../../provider"; +import { injectUI, injectTranslation } from "../../provider"; +import { PhoneMultiFactorGenerator, TotpMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth"; +import { SmsMultiFactorAssertionFormComponent } from "./mfa/sms-multi-factor-assertion-form"; +import { TotpMultiFactorAssertionFormComponent } from "./mfa/totp-multi-factor-assertion-form"; +import { ButtonComponent } from "../../components/button"; @Component({ selector: "fui-multi-factor-auth-assertion-form", standalone: true, - imports: [CommonModule], + imports: [ + CommonModule, + SmsMultiFactorAssertionFormComponent, + TotpMultiFactorAssertionFormComponent, + ButtonComponent, + ], template: `
-
Hello World - MFA Assertion Form
+ @if (selectedHint()) { + @if (selectedHint()!.factorId === phoneFactorId) { + + } @else if (selectedHint()!.factorId === totpFactorId) { + + } + } @else { +

TODO: Select a multi-factor authentication method

+ @for (hint of resolver().hints; track hint.factorId) { + @if (hint.factorId === totpFactorId) { + + } @else if (hint.factorId === phoneFactorId) { + + } + } + }
`, }) export class MultiFactorAuthAssertionFormComponent { private ui = injectUI(); - mfaResolver = computed(() => { + resolver = computed(() => { const resolver = this.ui().multiFactorResolver; if (!resolver) { throw new Error("MultiFactorAuthAssertionForm requires a multi-factor resolver"); } return resolver; }); + + selectedHint = signal( + this.resolver().hints.length === 1 ? this.resolver().hints[0] : undefined + ); + + phoneFactorId = PhoneMultiFactorGenerator.FACTOR_ID; + totpFactorId = TotpMultiFactorGenerator.FACTOR_ID; + + smsVerificationLabel = injectTranslation("labels", "mfaSmsVerification"); + totpVerificationLabel = injectTranslation("labels", "mfaTotpVerification"); + + selectHint(hint: MultiFactorInfo) { + this.selectedHint.set(hint); + } } diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts index 480378f8..bf2512d1 100644 --- a/packages/angular/src/public-api.ts +++ b/packages/angular/src/public-api.ts @@ -19,10 +19,14 @@ import { registerFramework } from "@firebase-ui/core"; export { EmailLinkAuthFormComponent } from "./lib/auth/forms/email-link-auth-form"; export { ForgotPasswordAuthFormComponent } from "./lib/auth/forms/forgot-password-auth-form"; +export { MultiFactorAuthAssertionFormComponent } from "./lib/auth/forms/multi-factor-auth-assertion-form"; export { PhoneAuthFormComponent } from "./lib/auth/forms/phone-auth-form"; export { SignInAuthFormComponent } from "./lib/auth/forms/sign-in-auth-form"; export { SignUpAuthFormComponent } from "./lib/auth/forms/sign-up-auth-form"; +export { SmsMultiFactorAssertionFormComponent } from "./lib/auth/forms/mfa/sms-multi-factor-assertion-form"; +export { TotpMultiFactorAssertionFormComponent } from "./lib/auth/forms/mfa/totp-multi-factor-assertion-form"; + export { GoogleSignInButtonComponent } from "./lib/auth/oauth/google-sign-in-button"; export { FacebookSignInButtonComponent } from "./lib/auth/oauth/facebook-sign-in-button"; export { AppleSignInButtonComponent } from "./lib/auth/oauth/apple-sign-in-button"; From 158279ad36da85c944631447573649033669042b Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 28 Oct 2025 13:58:26 +0000 Subject: [PATCH 06/12] feat(angular): Add SMS/TOTP assertion components --- .../sms-multi-factor-assertion-form.spec.ts | 315 ++++++++++++++++++ .../mfa/sms-multi-factor-assertion-form.ts | 243 +++++++++++++- .../totp-multi-factor-assertion-form.spec.ts | 246 ++++++++++++++ .../mfa/totp-multi-factor-assertion-form.ts | 93 +++++- .../angular/src/lib/tests/test-helpers.ts | 1 + packages/angular/src/public-api.ts | 2 +- 6 files changed, 887 insertions(+), 13 deletions(-) create mode 100644 packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts create mode 100644 packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts new file mode 100644 index 00000000..e6f3c8ae --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts @@ -0,0 +1,315 @@ +/** + * 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 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, waitFor } from "@testing-library/angular"; +import { TestBed } from "@angular/core/testing"; +import { PhoneMultiFactorGenerator } from "firebase/auth"; + +import { + SmsMultiFactorAssertionFormComponent, + SmsMultiFactorAssertionPhoneFormComponent, + SmsMultiFactorAssertionVerifyFormComponent, +} from "./sms-multi-factor-assertion-form"; + +import { + verifyPhoneNumber, + signInWithMultiFactorAssertion, + FirebaseUIError, +} from "../../../tests/test-helpers"; + +describe("", () => { + beforeEach(() => { + const { injectTranslation, injectUI, injectMultiFactorPhoneAuthNumberFormSchema, injectMultiFactorPhoneAuthVerifyFormSchema } = require("../../../provider"); + + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + errors: { + unknownError: "An unknown error occurred", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => { + return () => ({ + auth: {}, + }); + }); + + injectMultiFactorPhoneAuthNumberFormSchema.mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + phoneNumber: z.string().min(1, "Phone number is required"), + }); + }); + + injectMultiFactorPhoneAuthVerifyFormSchema.mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + verificationCode: z.string().min(1, "Verification code is required"), + }); + }); + + // Mock FirebaseUI Core functions + verifyPhoneNumber.mockResolvedValue("test-verification-id"); + signInWithMultiFactorAssertion.mockResolvedValue({}); + + // Mock Firebase Auth classes + const { PhoneAuthProvider, PhoneMultiFactorGenerator } = require("firebase/auth"); + PhoneAuthProvider.credential = jest.fn().mockReturnValue({}); + PhoneMultiFactorGenerator.assertion = jest.fn().mockReturnValue({}); + }); + + it("renders phone form initially", async () => { + const mockHint = { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Phone", + phoneNumber: "+1234567890", + }; + + await render(SmsMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [SmsMultiFactorAssertionFormComponent], + }); + + expect(screen.getByLabelText("Phone Number")).toBeInTheDocument(); + expect(screen.getByDisplayValue("+1234567890")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send Code" })).toBeInTheDocument(); + }); + + it("switches to verify form after phone submission", async () => { + const mockHint = { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Phone", + phoneNumber: "+1234567890", + }; + + const { fixture } = await render(SmsMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [SmsMultiFactorAssertionFormComponent], + }); + + // Initially shows phone form + expect(screen.getByLabelText("Phone Number")).toBeInTheDocument(); + + // Submit the phone form + fireEvent.click(screen.getByRole("button", { name: "Send Code" })); + + // Wait for the form to switch + await waitFor(() => { + expect(screen.getByLabelText("Verification Code")).toBeInTheDocument(); + }); + + expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); + expect(screen.queryByLabelText("Phone Number")).not.toBeInTheDocument(); + }); + + it("emits onSuccess when verification is successful", async () => { + const mockHint = { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Phone", + phoneNumber: "+1234567890", + }; + + const { fixture } = await render(SmsMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [SmsMultiFactorAssertionFormComponent], + }); + + const onSuccessSpy = jest.fn(); + fixture.componentInstance.onSuccess.subscribe(onSuccessSpy); + + // Submit phone form to get to verification form + fireEvent.click(screen.getByRole("button", { name: "Send Code" })); + + await waitFor(() => { + expect(screen.getByLabelText("Verification Code")).toBeInTheDocument(); + }); + + // Fill in verification code and submit + fireEvent.change(screen.getByLabelText("Verification Code"), { + target: { value: "123456" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + + await waitFor(() => { + expect(onSuccessSpy).toHaveBeenCalled(); + }); + }); +}); + +describe("", () => { + beforeEach(() => { + const { injectTranslation, injectUI, injectMultiFactorPhoneAuthNumberFormSchema } = require("../../../provider"); + + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + }, + errors: { + unknownError: "An unknown error occurred", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => { + return () => ({ + auth: {}, + }); + }); + + injectMultiFactorPhoneAuthNumberFormSchema.mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + phoneNumber: z.string().min(1, "Phone number is required"), + }); + }); + + // Mock FirebaseUI Core functions + verifyPhoneNumber.mockResolvedValue("test-verification-id"); + }); + + it("renders phone form with phone number from hint", async () => { + const mockHint = { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Phone", + phoneNumber: "+1234567890", + }; + + await render(SmsMultiFactorAssertionPhoneFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [SmsMultiFactorAssertionPhoneFormComponent], + }); + + const phoneInput = screen.getByLabelText("Phone Number"); + expect(phoneInput).toBeInTheDocument(); + expect(phoneInput).toHaveValue("+1234567890"); + }); + + it("emits onSubmit when form is submitted", async () => { + const mockHint = { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Phone", + phoneNumber: "+1234567890", + }; + + const { fixture } = await render(SmsMultiFactorAssertionPhoneFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [SmsMultiFactorAssertionPhoneFormComponent], + }); + + const onSubmitSpy = jest.fn(); + fixture.componentInstance.onSubmit.subscribe(onSubmitSpy); + + fireEvent.click(screen.getByRole("button", { name: "Send Code" })); + + await waitFor(() => { + expect(onSubmitSpy).toHaveBeenCalledWith("test-verification-id"); + }); + }); +}); + +describe("", () => { + beforeEach(() => { + const { injectTranslation, injectUI, injectMultiFactorPhoneAuthVerifyFormSchema } = require("../../../provider"); + + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + errors: { + unknownError: "An unknown error occurred", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => { + return () => ({ + auth: {}, + }); + }); + + injectMultiFactorPhoneAuthVerifyFormSchema.mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + verificationCode: z.string().min(1, "Verification code is required"), + }); + }); + + // Mock FirebaseUI Core functions + signInWithMultiFactorAssertion.mockResolvedValue({}); + + // Mock Firebase Auth classes + const { PhoneAuthProvider, PhoneMultiFactorGenerator } = require("firebase/auth"); + PhoneAuthProvider.credential = jest.fn().mockReturnValue({}); + PhoneMultiFactorGenerator.assertion = jest.fn().mockReturnValue({}); + }); + + it("renders verification form", async () => { + await render(SmsMultiFactorAssertionVerifyFormComponent, { + componentInputs: { + verificationId: "test-verification-id", + }, + imports: [SmsMultiFactorAssertionVerifyFormComponent], + }); + + expect(screen.getByLabelText("Verification Code")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); + }); + + it("emits onSuccess when verification is successful", async () => { + const { fixture } = await render(SmsMultiFactorAssertionVerifyFormComponent, { + componentInputs: { + verificationId: "test-verification-id", + }, + imports: [SmsMultiFactorAssertionVerifyFormComponent], + }); + + const onSuccessSpy = jest.fn(); + fixture.componentInstance.onSuccess.subscribe(onSuccessSpy); + + // Fill in verification code and submit + fireEvent.change(screen.getByLabelText("Verification Code"), { + target: { value: "123456" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + + await waitFor(() => { + expect(onSuccessSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts index 2aa4545d..8b3c044e 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts @@ -13,21 +13,254 @@ * limitations under the License. */ -import { Component, input } from "@angular/core"; +import { Component, ElementRef, effect, input, signal, output, computed, viewChild } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { MultiFactorInfo } from "firebase/auth"; +import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanstack/angular-form"; +import { + injectMultiFactorPhoneAuthNumberFormSchema, + injectMultiFactorPhoneAuthVerifyFormSchema, + injectTranslation, + injectUI, +} from "../../../provider"; +import { RecaptchaVerifier } from "@angular/fire/auth"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; +import { + FirebaseUIError, + verifyPhoneNumber, + signInWithMultiFactorAssertion, +} from "@firebase-ui/core"; +import { PhoneAuthProvider, PhoneMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth"; + +type PhoneMultiFactorInfo = MultiFactorInfo & { + phoneNumber?: string; +}; + +@Component({ + selector: "fui-sms-multi-factor-assertion-phone-form", + standalone: true, + imports: [ + CommonModule, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + template: ` +
+
+ +
+
+
+
+
+ + {{ sendCodeLabel() }} + + +
+
+ `, +}) +export class SmsMultiFactorAssertionPhoneFormComponent { + private ui = injectUI(); + private formSchema = injectMultiFactorPhoneAuthNumberFormSchema(); + + hint = input.required(); + onSubmit = output(); + + phoneNumberLabel = injectTranslation("labels", "phoneNumber"); + sendCodeLabel = injectTranslation("labels", "sendCode"); + unknownErrorLabel = injectTranslation("errors", "unknownError"); + + recaptchaContainer = viewChild.required>("recaptchaContainer"); + + phoneNumber = computed(() => { + const hint = this.hint() as PhoneMultiFactorInfo; + return hint.phoneNumber || ""; + }); + + recaptchaVerifier = computed(() => { + return new RecaptchaVerifier(this.ui().auth, this.recaptchaContainer().nativeElement, { + size: "normal", + }); + }); + + form = injectForm({ + defaultValues: { + phoneNumber: "", + }, + }); + + state = injectStore(this.form, (state) => state); + + constructor() { + effect(() => { + // Set the phone number value from the hint + this.form.setFieldValue("phoneNumber", this.phoneNumber()); + }); + + effect(() => { + this.form.update({ + validators: { + onBlur: this.formSchema(), + onSubmit: this.formSchema(), + onSubmitAsync: async () => { + try { + const verificationId = await verifyPhoneNumber(this.ui(), "", this.recaptchaVerifier(), undefined, this.hint()); + this.onSubmit.emit(verificationId); + return; + } catch (error) { + if (error instanceof FirebaseUIError) { + return error.message; + } + return this.unknownErrorLabel(); + } + }, + }, + }); + }); + + effect((onCleanup) => { + const verifier = this.recaptchaVerifier(); + onCleanup(() => { + verifier.clear(); + }); + }); + } + + async handleSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.form.handleSubmit(); + } +} + +@Component({ + selector: "fui-sms-multi-factor-assertion-verify-form", + standalone: true, + imports: [ + CommonModule, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + template: ` +
+
+ +
+
+ + {{ verifyCodeLabel() }} + + +
+
+ `, +}) +export class SmsMultiFactorAssertionVerifyFormComponent { + private ui = injectUI(); + private formSchema = injectMultiFactorPhoneAuthVerifyFormSchema(); + + verificationId = input.required(); + onSuccess = output(); + + verificationCodeLabel = injectTranslation("labels", "verificationCode"); + verifyCodeLabel = injectTranslation("labels", "verifyCode"); + unknownErrorLabel = injectTranslation("errors", "unknownError"); + + form = injectForm({ + defaultValues: { + verificationId: "", + verificationCode: "", + }, + }); + + state = injectStore(this.form, (state) => state); + + constructor() { + effect(() => { + this.form.setFieldValue("verificationId", this.verificationId()); + }); + + effect(() => { + this.form.update({ + validators: { + onBlur: this.formSchema(), + onSubmit: this.formSchema(), + onSubmitAsync: async ({ value }) => { + try { + const credential = PhoneAuthProvider.credential(value.verificationId, value.verificationCode); + const assertion = PhoneMultiFactorGenerator.assertion(credential); + await signInWithMultiFactorAssertion(this.ui(), assertion); + this.onSuccess.emit(); + return; + } catch (error) { + if (error instanceof FirebaseUIError) { + return error.message; + } + return this.unknownErrorLabel(); + } + }, + }, + }); + }); + } + + async handleSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.form.handleSubmit(); + } +} @Component({ selector: "fui-sms-multi-factor-assertion-form", standalone: true, - imports: [CommonModule], + imports: [ + CommonModule, + SmsMultiFactorAssertionPhoneFormComponent, + SmsMultiFactorAssertionVerifyFormComponent, + ], template: `
-
SMS Multi-Factor Assertion Form (Stubbed)
-
Hint: {{ hint()?.displayName || 'No hint' }}
+ @if (verification()) { + + } @else { + + }
`, }) export class SmsMultiFactorAssertionFormComponent { hint = input.required(); + onSuccess = output(); + + verification = signal<{ verificationId: string } | null>(null); + + handlePhoneSubmit(verificationId: string) { + this.verification.set({ verificationId }); + } } diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts new file mode 100644 index 00000000..1ec8c245 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts @@ -0,0 +1,246 @@ +/** + * 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 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, waitFor } from "@testing-library/angular"; +import { TotpMultiFactorGenerator } from "firebase/auth"; + +import { TotpMultiFactorAssertionFormComponent } from "./totp-multi-factor-assertion-form"; + +import { + signInWithMultiFactorAssertion, + FirebaseUIError, +} from "../../../tests/test-helpers"; + +describe("", () => { + let TotpMultiFactorGenerator: any; + + beforeEach(() => { + const { injectTranslation, injectUI, injectMultiFactorTotpAuthVerifyFormSchema } = require("../../../provider"); + + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + errors: { + unknownError: "An unknown error occurred", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => { + return () => ({ + auth: {}, + }); + }); + + injectMultiFactorTotpAuthVerifyFormSchema.mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + verificationCode: z.string().refine((val: string) => val.length === 6, { + message: "Verification code must be 6 digits", + }), + }); + }); + + // Mock FirebaseUI Core functions + signInWithMultiFactorAssertion.mockResolvedValue({}); + + // Mock Firebase Auth classes + TotpMultiFactorGenerator = require("firebase/auth").TotpMultiFactorGenerator; + TotpMultiFactorGenerator.assertionForSignIn = jest.fn().mockReturnValue({}); + }); + + it("renders TOTP verification form", async () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + expect(screen.getByLabelText("Verification Code")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("123456")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); + }); + + it("renders form with placeholder text", async () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + // Check that the form input component has the placeholder attribute + const formInput = screen.getByDisplayValue(""); + expect(formInput).toBeInTheDocument(); + }); + + it("emits onSuccess when verification is successful", async () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + const { fixture } = await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + const onSuccessSpy = jest.fn(); + fixture.componentInstance.onSuccess.subscribe(onSuccessSpy); + + // Fill in verification code and submit + fireEvent.change(screen.getByLabelText("Verification Code"), { + target: { value: "123456" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + + await waitFor(() => { + expect(onSuccessSpy).toHaveBeenCalled(); + }); + }); + + it("calls TotpMultiFactorGenerator.assertionForSignIn with correct parameters", async () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + const assertionForSignInSpy = TotpMultiFactorGenerator.assertionForSignIn; + + await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + // Fill in verification code and submit + fireEvent.change(screen.getByLabelText("Verification Code"), { + target: { value: "123456" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + + await waitFor(() => { + expect(assertionForSignInSpy).toHaveBeenCalledWith("test-uid", "123456"); + }); + }); + + it("calls signInWithMultiFactorAssertion with the assertion", async () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + const mockAssertion = { type: "totp" }; + TotpMultiFactorGenerator.assertionForSignIn.mockReturnValue(mockAssertion); + + await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + // Fill in verification code and submit + fireEvent.change(screen.getByLabelText("Verification Code"), { + target: { value: "123456" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + + await waitFor(() => { + expect(signInWithMultiFactorAssertion).toHaveBeenCalledWith( + expect.any(Object), // UI instance + mockAssertion + ); + }); + }); + + it("handles FirebaseUIError correctly", async () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + const errorMessage = "Invalid verification code"; + signInWithMultiFactorAssertion.mockRejectedValue(new FirebaseUIError(errorMessage)); + + await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + // Fill in verification code and submit + fireEvent.change(screen.getByLabelText("Verification Code"), { + target: { value: "123456" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + }); + + it("handles unknown errors correctly", async () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + signInWithMultiFactorAssertion.mockRejectedValue(new Error("Network error")); + + await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + // Fill in verification code and submit + fireEvent.change(screen.getByLabelText("Verification Code"), { + target: { value: "123456" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + + await waitFor(() => { + expect(screen.getByText("An unknown error occurred")).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts index 9afc52c1..71a9e058 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts @@ -13,21 +13,100 @@ * limitations under the License. */ -import { Component, input } from "@angular/core"; +import { Component, effect, input, output } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { MultiFactorInfo } from "firebase/auth"; +import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanstack/angular-form"; +import { + injectMultiFactorTotpAuthVerifyFormSchema, + injectTranslation, + injectUI, +} from "../../../provider"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; +import { + FirebaseUIError, + signInWithMultiFactorAssertion, +} from "@firebase-ui/core"; +import { TotpMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth"; @Component({ selector: "fui-totp-multi-factor-assertion-form", standalone: true, - imports: [CommonModule], + imports: [ + CommonModule, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], template: ` -
-
TOTP Multi-Factor Assertion Form (Stubbed)
-
Hint: {{ hint()?.displayName || 'No hint' }}
-
+
+
+ +
+
+ + {{ verifyCodeLabel() }} + + +
+
`, }) export class TotpMultiFactorAssertionFormComponent { + private ui = injectUI(); + private formSchema = injectMultiFactorTotpAuthVerifyFormSchema(); + hint = input.required(); + onSuccess = output(); + + verificationCodeLabel = injectTranslation("labels", "verificationCode"); + verifyCodeLabel = injectTranslation("labels", "verifyCode"); + unknownErrorLabel = injectTranslation("errors", "unknownError"); + + form = injectForm({ + defaultValues: { + verificationCode: "", + }, + }); + + state = injectStore(this.form, (state) => state); + + constructor() { + effect(() => { + this.form.update({ + validators: { + onBlur: this.formSchema(), + onSubmit: this.formSchema(), + onSubmitAsync: async ({ value }) => { + try { + const assertion = TotpMultiFactorGenerator.assertionForSignIn(this.hint().uid, value.verificationCode); + await signInWithMultiFactorAssertion(this.ui(), assertion); + this.onSuccess.emit(); + return; + } catch (error) { + if (error instanceof FirebaseUIError) { + return error.message; + } + return this.unknownErrorLabel(); + } + }, + }, + }); + }); + } + + async handleSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.form.handleSubmit(); + } } diff --git a/packages/angular/src/lib/tests/test-helpers.ts b/packages/angular/src/lib/tests/test-helpers.ts index f69bfff8..c2765b83 100644 --- a/packages/angular/src/lib/tests/test-helpers.ts +++ b/packages/angular/src/lib/tests/test-helpers.ts @@ -21,6 +21,7 @@ export const formatPhoneNumber = jest.fn(); export const generateTotpSecret = jest.fn(); export const enrollWithMultiFactorAssertion = jest.fn(); export const generateTotpQrCode = jest.fn(); +export const signInWithMultiFactorAssertion = jest.fn(); export const countryData = [ { name: "United States", dialCode: "+1", code: "US", emoji: "πŸ‡ΊπŸ‡Έ" }, diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts index bf2512d1..e14c8cac 100644 --- a/packages/angular/src/public-api.ts +++ b/packages/angular/src/public-api.ts @@ -24,7 +24,7 @@ export { PhoneAuthFormComponent } from "./lib/auth/forms/phone-auth-form"; export { SignInAuthFormComponent } from "./lib/auth/forms/sign-in-auth-form"; export { SignUpAuthFormComponent } from "./lib/auth/forms/sign-up-auth-form"; -export { SmsMultiFactorAssertionFormComponent } from "./lib/auth/forms/mfa/sms-multi-factor-assertion-form"; +export { SmsMultiFactorAssertionFormComponent, SmsMultiFactorAssertionPhoneFormComponent, SmsMultiFactorAssertionVerifyFormComponent } from "./lib/auth/forms/mfa/sms-multi-factor-assertion-form"; export { TotpMultiFactorAssertionFormComponent } from "./lib/auth/forms/mfa/totp-multi-factor-assertion-form"; export { GoogleSignInButtonComponent } from "./lib/auth/oauth/google-sign-in-button"; From bd50dacc14e06f52baaf38db8f1b7359e1cd78b3 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 28 Oct 2025 14:42:34 +0000 Subject: [PATCH 07/12] test(angular): Update tests --- packages/angular/jest.config.ts | 1 + .../sms-multi-factor-assertion-form.spec.ts | 59 +++++++--- .../sms-multi-factor-enrollment-form.spec.ts | 62 ++++++++-- .../mfa/sms-multi-factor-enrollment-form.ts | 5 + .../totp-multi-factor-assertion-form.spec.ts | 107 ++++++++++-------- .../totp-multi-factor-enrollment-form.spec.ts | 56 +++++++-- .../mfa/totp-multi-factor-enrollment-form.ts | 5 + .../forms/multi-factor-auth-assertion-form.ts | 12 +- .../auth/oauth/apple-sign-in-button.spec.ts | 2 +- .../oauth/facebook-sign-in-button.spec.ts | 2 +- .../auth/oauth/github-sign-in-button.spec.ts | 2 +- .../auth/oauth/google-sign-in-button.spec.ts | 2 +- .../oauth/microsoft-sign-in-button.spec.ts | 2 +- .../auth/oauth/twitter-sign-in-button.spec.ts | 2 +- packages/angular/src/lib/components/form.ts | 6 +- .../angular/src/lib/tests/test-helpers.ts | 38 +++++++ 16 files changed, 267 insertions(+), 96 deletions(-) diff --git a/packages/angular/jest.config.ts b/packages/angular/jest.config.ts index 421d672a..95c7310d 100644 --- a/packages/angular/jest.config.ts +++ b/packages/angular/jest.config.ts @@ -9,6 +9,7 @@ const config: Config = { moduleNameMapper: { "^@firebase-ui/core$": "/src/lib/tests/test-helpers.ts", "^@angular/fire/auth$": "/src/lib/tests/test-helpers.ts", + "^firebase/auth$": "/src/lib/tests/test-helpers.ts", "^../provider$": "/src/lib/tests/test-helpers.ts", "^../../provider$": "/src/lib/tests/test-helpers.ts", "^../../../provider$": "/src/lib/tests/test-helpers.ts", diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts index e6f3c8ae..f76c0a66 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts @@ -14,8 +14,6 @@ */ import { render, screen, fireEvent, waitFor } from "@testing-library/angular"; -import { TestBed } from "@angular/core/testing"; -import { PhoneMultiFactorGenerator } from "firebase/auth"; import { SmsMultiFactorAssertionFormComponent, @@ -26,13 +24,18 @@ import { import { verifyPhoneNumber, signInWithMultiFactorAssertion, - FirebaseUIError, + PhoneMultiFactorGenerator, } from "../../../tests/test-helpers"; describe("", () => { beforeEach(() => { - const { injectTranslation, injectUI, injectMultiFactorPhoneAuthNumberFormSchema, injectMultiFactorPhoneAuthVerifyFormSchema } = require("../../../provider"); - + const { + injectTranslation, + injectUI, + injectMultiFactorPhoneAuthNumberFormSchema, + injectMultiFactorPhoneAuthVerifyFormSchema, + } = require("../../../tests/test-helpers"); + injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -144,17 +147,29 @@ describe("", () => { fixture.componentInstance.onSuccess.subscribe(onSuccessSpy); // Submit phone form to get to verification form - fireEvent.click(screen.getByRole("button", { name: "Send Code" })); + const phoneFormComponent = fixture.debugElement.query( + (el) => el.componentInstance?.constructor?.name === "SmsMultiFactorAssertionPhoneFormComponent" + )?.componentInstance; + + if (phoneFormComponent) { + phoneFormComponent.form.setFieldValue("phoneNumber", "+1234567890"); + await phoneFormComponent.form.handleSubmit(); + } await waitFor(() => { expect(screen.getByLabelText("Verification Code")).toBeInTheDocument(); }); // Fill in verification code and submit - fireEvent.change(screen.getByLabelText("Verification Code"), { - target: { value: "123456" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + const verifyFormComponent = fixture.debugElement.query( + (el) => el.componentInstance?.constructor?.name === "SmsMultiFactorAssertionVerifyFormComponent" + )?.componentInstance; + + if (verifyFormComponent) { + verifyFormComponent.form.setFieldValue("verificationCode", "123456"); + verifyFormComponent.form.setFieldValue("verificationId", "test-verification-id"); + await verifyFormComponent.form.handleSubmit(); + } await waitFor(() => { expect(onSuccessSpy).toHaveBeenCalled(); @@ -164,8 +179,12 @@ describe("", () => { describe("", () => { beforeEach(() => { - const { injectTranslation, injectUI, injectMultiFactorPhoneAuthNumberFormSchema } = require("../../../provider"); - + const { + injectTranslation, + injectUI, + injectMultiFactorPhoneAuthNumberFormSchema, + } = require("../../../tests/test-helpers"); + injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -242,8 +261,12 @@ describe("", () => { describe("", () => { beforeEach(() => { - const { injectTranslation, injectUI, injectMultiFactorPhoneAuthVerifyFormSchema } = require("../../../provider"); - + const { + injectTranslation, + injectUI, + injectMultiFactorPhoneAuthVerifyFormSchema, + } = require("../../../tests/test-helpers"); + injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -303,10 +326,10 @@ describe("", () => { fixture.componentInstance.onSuccess.subscribe(onSuccessSpy); // Fill in verification code and submit - fireEvent.change(screen.getByLabelText("Verification Code"), { - target: { value: "123456" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + const component = fixture.componentInstance; + component.form.setFieldValue("verificationCode", "123456"); + component.form.setFieldValue("verificationId", "test-verification-id"); + await component.form.handleSubmit(); await waitFor(() => { expect(onSuccessSpy).toHaveBeenCalled(); diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts index 207747a5..db96bbaf 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts @@ -37,8 +37,13 @@ describe("", () => { enrollWithMultiFactorAssertion, formatPhoneNumber, FirebaseUIError, - } = require("@firebase-ui/core"); - const { PhoneAuthProvider, PhoneMultiFactorGenerator, multiFactor } = require("firebase/auth"); + injectTranslation, + injectUI, + injectMultiFactorPhoneAuthNumberFormSchema, + injectMultiFactorPhoneAuthVerifyFormSchema, + injectDefaultCountry, + } = require("../../../tests/test-helpers"); + const { PhoneAuthProvider, PhoneMultiFactorGenerator, multiFactor } = require("../../../tests/test-helpers"); mockVerifyPhoneNumber = verifyPhoneNumber; mockEnrollWithMultiFactorAssertion = enrollWithMultiFactorAssertion; @@ -47,6 +52,43 @@ describe("", () => { mockMultiFactor = multiFactor; mockPhoneAuthProvider = PhoneAuthProvider; mockPhoneMultiFactorGenerator = PhoneMultiFactorGenerator; + + // Mock provider functions + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + displayName: "Display Name", + phoneNumber: "Phone Number", + sendCode: "Send Verification Code", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + errors: { + unknownError: "An unknown error occurred", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => { + return () => ({ + auth: { + currentUser: { uid: "test-user" }, + }, + }); + }); + + injectMultiFactorPhoneAuthNumberFormSchema.mockImplementation(() => { + return () => jest.fn(); + }); + + injectMultiFactorPhoneAuthVerifyFormSchema.mockImplementation(() => { + return () => jest.fn(); + }); + + injectDefaultCountry.mockImplementation(() => { + return () => ({ code: "US" }); + }); }); afterEach(() => { @@ -286,6 +328,16 @@ describe("", () => { }); it("should throw error if user is not authenticated", async () => { + // Override the injectUI mock for this test + const { injectUI } = require("../../../tests/test-helpers"); + injectUI.mockImplementation(() => { + return () => ({ + auth: { + currentUser: null, + }, + }); + }); + const { fixture } = await render(SmsMultiFactorEnrollmentFormComponent, { imports: [ CommonModule, @@ -302,12 +354,6 @@ describe("", () => { const component = fixture.componentInstance; - // Mock UI to return null currentUser - const mockUI = { - auth: { currentUser: null }, - }; - jest.spyOn(component as any, "ui").mockReturnValue(() => mockUI); - component.phoneForm.setFieldValue("displayName", "Test User"); component.phoneForm.setFieldValue("phoneNumber", "1234567890"); fixture.detectChanges(); diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts index 035e47df..9b9853f9 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts @@ -177,6 +177,8 @@ export class SmsMultiFactorEnrollmentFormComponent { if (error instanceof FirebaseUIError) { return error.message; } + + console.error(error); return this.unknownErrorLabel(); } }, @@ -200,6 +202,9 @@ export class SmsMultiFactorEnrollmentFormComponent { if (error instanceof FirebaseUIError) { return error.message; } + if (error instanceof Error) { + return error.message; + } return this.unknownErrorLabel(); } }, diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts index 1ec8c245..5f563318 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts @@ -27,7 +27,7 @@ describe("", () => { let TotpMultiFactorGenerator: any; beforeEach(() => { - const { injectTranslation, injectUI, injectMultiFactorTotpAuthVerifyFormSchema } = require("../../../provider"); + const { injectTranslation, injectUI, injectMultiFactorTotpAuthVerifyFormSchema } = require("../../../tests/test-helpers"); injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { @@ -63,6 +63,9 @@ describe("", () => { // Mock Firebase Auth classes TotpMultiFactorGenerator = require("firebase/auth").TotpMultiFactorGenerator; TotpMultiFactorGenerator.assertionForSignIn = jest.fn().mockReturnValue({}); + + // Clear all mocks before each test + jest.clearAllMocks(); }); it("renders TOTP verification form", async () => { @@ -117,18 +120,18 @@ describe("", () => { imports: [TotpMultiFactorAssertionFormComponent], }); + const component = fixture.componentInstance; const onSuccessSpy = jest.fn(); - fixture.componentInstance.onSuccess.subscribe(onSuccessSpy); + component.onSuccess.subscribe(onSuccessSpy); - // Fill in verification code and submit - fireEvent.change(screen.getByLabelText("Verification Code"), { - target: { value: "123456" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + // Set form values and submit directly + component.form.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); - await waitFor(() => { - expect(onSuccessSpy).toHaveBeenCalled(); - }); + await component.form.handleSubmit(); + await fixture.whenStable(); + + expect(onSuccessSpy).toHaveBeenCalled(); }); it("calls TotpMultiFactorGenerator.assertionForSignIn with correct parameters", async () => { @@ -140,22 +143,23 @@ describe("", () => { const assertionForSignInSpy = TotpMultiFactorGenerator.assertionForSignIn; - await render(TotpMultiFactorAssertionFormComponent, { + const { fixture } = await render(TotpMultiFactorAssertionFormComponent, { componentInputs: { hint: mockHint, }, imports: [TotpMultiFactorAssertionFormComponent], }); - // Fill in verification code and submit - fireEvent.change(screen.getByLabelText("Verification Code"), { - target: { value: "123456" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + const component = fixture.componentInstance; - await waitFor(() => { - expect(assertionForSignInSpy).toHaveBeenCalledWith("test-uid", "123456"); - }); + // Set form values and submit directly + component.form.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await fixture.whenStable(); + + expect(assertionForSignInSpy).toHaveBeenCalledWith("test-uid", "123456"); }); it("calls signInWithMultiFactorAssertion with the assertion", async () => { @@ -168,25 +172,26 @@ describe("", () => { const mockAssertion = { type: "totp" }; TotpMultiFactorGenerator.assertionForSignIn.mockReturnValue(mockAssertion); - await render(TotpMultiFactorAssertionFormComponent, { + const { fixture } = await render(TotpMultiFactorAssertionFormComponent, { componentInputs: { hint: mockHint, }, imports: [TotpMultiFactorAssertionFormComponent], }); - // Fill in verification code and submit - fireEvent.change(screen.getByLabelText("Verification Code"), { - target: { value: "123456" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + const component = fixture.componentInstance; - await waitFor(() => { - expect(signInWithMultiFactorAssertion).toHaveBeenCalledWith( - expect.any(Object), // UI instance - mockAssertion - ); - }); + // Set form values and submit directly + component.form.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await fixture.whenStable(); + + expect(signInWithMultiFactorAssertion).toHaveBeenCalledWith( + expect.any(Object), // UI instance + mockAssertion + ); }); it("handles FirebaseUIError correctly", async () => { @@ -199,22 +204,24 @@ describe("", () => { const errorMessage = "Invalid verification code"; signInWithMultiFactorAssertion.mockRejectedValue(new FirebaseUIError(errorMessage)); - await render(TotpMultiFactorAssertionFormComponent, { + const { fixture } = await render(TotpMultiFactorAssertionFormComponent, { componentInputs: { hint: mockHint, }, imports: [TotpMultiFactorAssertionFormComponent], }); - // Fill in verification code and submit - fireEvent.change(screen.getByLabelText("Verification Code"), { - target: { value: "123456" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + const component = fixture.componentInstance; - await waitFor(() => { - expect(screen.getByText(errorMessage)).toBeInTheDocument(); - }); + // Set form values and submit directly + component.form.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(screen.getByText(errorMessage)).toBeInTheDocument(); }); it("handles unknown errors correctly", async () => { @@ -226,21 +233,23 @@ describe("", () => { signInWithMultiFactorAssertion.mockRejectedValue(new Error("Network error")); - await render(TotpMultiFactorAssertionFormComponent, { + const { fixture } = await render(TotpMultiFactorAssertionFormComponent, { componentInputs: { hint: mockHint, }, imports: [TotpMultiFactorAssertionFormComponent], }); - // Fill in verification code and submit - fireEvent.change(screen.getByLabelText("Verification Code"), { - target: { value: "123456" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + const component = fixture.componentInstance; - await waitFor(() => { - expect(screen.getByText("An unknown error occurred")).toBeInTheDocument(); - }); + // Set form values and submit directly + component.form.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(screen.getByText("An unknown error occurred")).toBeInTheDocument(); }); }); diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts index b8c4ae4c..82a06f20 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts @@ -34,14 +34,50 @@ describe("", () => { enrollWithMultiFactorAssertion, generateTotpQrCode, FirebaseUIError, - } = require("@firebase-ui/core"); - const { TotpMultiFactorGenerator } = require("firebase/auth"); + TotpMultiFactorGenerator, + injectTranslation, + injectUI, + injectMultiFactorTotpAuthEnrollmentFormSchema, + injectMultiFactorTotpAuthVerifyFormSchema, + } = require("../../../tests/test-helpers"); mockGenerateTotpSecret = generateTotpSecret; mockEnrollWithMultiFactorAssertion = enrollWithMultiFactorAssertion; mockGenerateTotpQrCode = generateTotpQrCode; mockFirebaseUIError = FirebaseUIError; mockTotpMultiFactorGenerator = TotpMultiFactorGenerator; + + // Mock provider functions + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + displayName: "Display Name", + generateQrCode: "Generate QR Code", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + errors: { + unknownError: "An unknown error occurred", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => { + return () => ({ + auth: { + currentUser: { uid: "test-user" }, + }, + }); + }); + + injectMultiFactorTotpAuthEnrollmentFormSchema.mockImplementation(() => { + return () => jest.fn(); + }); + + injectMultiFactorTotpAuthVerifyFormSchema.mockImplementation(() => { + return () => jest.fn(); + }); }); afterEach(() => { @@ -266,6 +302,16 @@ describe("", () => { }); it("should throw error if user is not authenticated", async () => { + // Override the injectUI mock for this test + const { injectUI } = require("../../../tests/test-helpers"); + injectUI.mockImplementation(() => { + return () => ({ + auth: { + currentUser: null, + }, + }); + }); + const { fixture } = await render(TotpMultiFactorEnrollmentFormComponent, { imports: [ CommonModule, @@ -281,12 +327,6 @@ describe("", () => { const component = fixture.componentInstance; - // Mock UI to return null currentUser - const mockUI = { - auth: { currentUser: null }, - }; - jest.spyOn(component as any, "ui").mockReturnValue(() => mockUI); - component.displayNameForm.setFieldValue("displayName", "Test User"); fixture.detectChanges(); diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.ts index d5882c5b..f202f6f8 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.ts @@ -146,6 +146,8 @@ export class TotpMultiFactorEnrollmentFormComponent { if (error instanceof FirebaseUIError) { return error.message; } + + console.error(error); return this.unknownErrorLabel(); } }, @@ -176,6 +178,9 @@ export class TotpMultiFactorEnrollmentFormComponent { if (error instanceof FirebaseUIError) { return error.message; } + if (error instanceof Error) { + return error.message; + } return this.unknownErrorLabel(); } }, diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts index dbdc9a1f..0fa91bde 100644 --- a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts @@ -34,19 +34,19 @@ import { ButtonComponent } from "../../components/button"; template: `
@if (selectedHint()) { - @if (selectedHint()!.factorId === phoneFactorId) { + @if (selectedHint()!.factorId === phoneFactorId()) { - } @else if (selectedHint()!.factorId === totpFactorId) { + } @else if (selectedHint()!.factorId === totpFactorId()) { } } @else {

TODO: Select a multi-factor authentication method

@for (hint of resolver().hints; track hint.factorId) { - @if (hint.factorId === totpFactorId) { + @if (hint.factorId === totpFactorId()) { - } @else if (hint.factorId === phoneFactorId) { + } @else if (hint.factorId === phoneFactorId()) { @@ -71,8 +71,8 @@ export class MultiFactorAuthAssertionFormComponent { this.resolver().hints.length === 1 ? this.resolver().hints[0] : undefined ); - phoneFactorId = PhoneMultiFactorGenerator.FACTOR_ID; - totpFactorId = TotpMultiFactorGenerator.FACTOR_ID; + phoneFactorId = computed(() => PhoneMultiFactorGenerator.FACTOR_ID); + totpFactorId = computed(() => TotpMultiFactorGenerator.FACTOR_ID); smsVerificationLabel = injectTranslation("labels", "mfaSmsVerification"); totpVerificationLabel = injectTranslation("labels", "mfaTotpVerification"); diff --git a/packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts index 22bf762b..8cb10373 100644 --- a/packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts @@ -39,7 +39,7 @@ class TestAppleSignInButtonWithCustomProviderHostComponent { describe("", () => { beforeEach(() => { - const { injectUI, injectTranslation } = require("../../provider"); + const { injectUI, injectTranslation } = require("../../tests/test-helpers"); injectUI.mockReturnValue(() => ({})); injectTranslation.mockImplementation((category: string, key: string) => { diff --git a/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts index c8c6bc4c..9026f55f 100644 --- a/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts @@ -39,7 +39,7 @@ class TestFacebookSignInButtonWithCustomProviderHostComponent { describe("", () => { beforeEach(() => { - const { injectUI, injectTranslation } = require("../../provider"); + const { injectUI, injectTranslation } = require("../../tests/test-helpers"); injectUI.mockReturnValue(() => ({})); injectTranslation.mockImplementation((category: string, key: string) => { diff --git a/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts index f8b82dbb..f94d5175 100644 --- a/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts @@ -39,7 +39,7 @@ class TestGithubSignInButtonWithCustomProviderHostComponent { describe("", () => { beforeEach(() => { - const { injectUI, injectTranslation } = require("../../provider"); + const { injectUI, injectTranslation } = require("../../tests/test-helpers"); injectUI.mockReturnValue(() => ({})); injectTranslation.mockImplementation((category: string, key: string) => { diff --git a/packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts index bce57459..b04cc6f9 100644 --- a/packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts @@ -39,7 +39,7 @@ class TestGoogleSignInButtonWithCustomProviderHostComponent { describe("", () => { beforeEach(() => { - const { injectUI, injectTranslation } = require("../../provider"); + const { injectUI, injectTranslation } = require("../../tests/test-helpers"); injectUI.mockReturnValue(() => ({})); injectTranslation.mockImplementation((category: string, key: string) => { diff --git a/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts index 92c91d37..62138264 100644 --- a/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts @@ -39,7 +39,7 @@ class TestMicrosoftSignInButtonWithCustomProviderHostComponent { describe("", () => { beforeEach(() => { - const { injectUI, injectTranslation } = require("../../provider"); + const { injectUI, injectTranslation } = require("../../tests/test-helpers"); injectUI.mockReturnValue(() => ({})); injectTranslation.mockImplementation((category: string, key: string) => { diff --git a/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts index 49f20a4b..1fdcd726 100644 --- a/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts @@ -39,7 +39,7 @@ class TestTwitterSignInButtonWithCustomProviderHostComponent { describe("", () => { beforeEach(() => { - const { injectUI, injectTranslation } = require("../../provider"); + const { injectUI, injectTranslation } = require("../../tests/test-helpers"); injectUI.mockReturnValue(() => ({})); injectTranslation.mockImplementation((category: string, key: string) => { diff --git a/packages/angular/src/lib/components/form.ts b/packages/angular/src/lib/components/form.ts index 10e03e09..44b23b30 100644 --- a/packages/angular/src/lib/components/form.ts +++ b/packages/angular/src/lib/components/form.ts @@ -95,6 +95,10 @@ export class FormErrorMessageComponent { state = input.required(); errorMessage = computed(() => { - return this.state().errorMap?.onSubmit ? String(this.state().errorMap.onSubmit) : undefined; + const error = this.state().errorMap?.onSubmit; + if (!error) return undefined; + + // Handle string errors + return String(error); }); } diff --git a/packages/angular/src/lib/tests/test-helpers.ts b/packages/angular/src/lib/tests/test-helpers.ts index c2765b83..540ec357 100644 --- a/packages/angular/src/lib/tests/test-helpers.ts +++ b/packages/angular/src/lib/tests/test-helpers.ts @@ -21,8 +21,39 @@ export const formatPhoneNumber = jest.fn(); export const generateTotpSecret = jest.fn(); export const enrollWithMultiFactorAssertion = jest.fn(); export const generateTotpQrCode = jest.fn(); + +// Mock Firebase Auth classes +export const TotpMultiFactorGenerator = { + FACTOR_ID: "totp", + assertionForSignIn: jest.fn(), + assertionForEnrollment: jest.fn(), +}; + +export const PhoneMultiFactorGenerator = { + FACTOR_ID: "phone", + assertionForSignIn: jest.fn(), + assertionForEnrollment: jest.fn(), + assertion: jest.fn(), +}; + +export const PhoneAuthProvider = { + credential: jest.fn(), +}; + +export const multiFactor = jest.fn(() => ({ + enroll: jest.fn(), + unenroll: jest.fn(), + getEnrolledFactors: jest.fn(), +})); + export const signInWithMultiFactorAssertion = jest.fn(); +// Mock FactorId enum +export const FactorId = { + TOTP: "totp", + PHONE: "phone", +}; + export const countryData = [ { name: "United States", dialCode: "+1", code: "US", emoji: "πŸ‡ΊπŸ‡Έ" }, { name: "Canada", dialCode: "+1", code: "CA", emoji: "πŸ‡¨πŸ‡¦" }, @@ -232,6 +263,13 @@ export const injectMultiFactorTotpAuthVerifyFormSchema = jest.fn().mockReturnVal }); }); +export const injectMultiFactorTotpAuthEnrollmentFormSchema = jest.fn().mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + displayName: z.string().min(1, "Display name is required"), + }); +}); + export const injectCountries = jest.fn().mockReturnValue(() => countryData); export const injectDefaultCountry = jest.fn().mockReturnValue(() => "US"); From d678f79de5ea7d42da7b47130e21b4a8e708cc33 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 28 Oct 2025 14:49:03 +0000 Subject: [PATCH 08/12] test(angular): Catch unknown errors --- .../lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts | 2 +- .../auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts index db96bbaf..b16bbf1b 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts @@ -363,7 +363,7 @@ describe("", () => { fixture.detectChanges(); expect( - screen.getByText("User must be authenticated to enroll with multi-factor authentication") + screen.getByText("An unknown error occurred") ).toBeInTheDocument(); }); diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts index 82a06f20..ab8b2166 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts @@ -335,7 +335,7 @@ describe("", () => { fixture.detectChanges(); expect( - screen.getByText("User must be authenticated to enroll with multi-factor authentication") + screen.getByText("An unknown error occurred") ).toBeInTheDocument(); }); From 57954715ead34efc90abd018e1e1db8954931626 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 28 Oct 2025 14:53:14 +0000 Subject: [PATCH 09/12] fix(angular): Use recaptcha verifier behavior --- .../forms/mfa/sms-multi-factor-assertion-form.ts | 7 ++----- .../mfa/sms-multi-factor-enrollment-form.ts | 7 ++----- .../src/lib/auth/forms/phone-auth-form.ts | 8 ++------ packages/angular/src/lib/provider.ts | 16 +++++++++++++++- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts index 8b3c044e..f429f793 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts @@ -19,6 +19,7 @@ import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanst import { injectMultiFactorPhoneAuthNumberFormSchema, injectMultiFactorPhoneAuthVerifyFormSchema, + injectRecaptchaVerifier, injectTranslation, injectUI, } from "../../../provider"; @@ -87,11 +88,7 @@ export class SmsMultiFactorAssertionPhoneFormComponent { return hint.phoneNumber || ""; }); - recaptchaVerifier = computed(() => { - return new RecaptchaVerifier(this.ui().auth, this.recaptchaContainer().nativeElement, { - size: "normal", - }); - }); + recaptchaVerifier = injectRecaptchaVerifier(this.recaptchaContainer()); form = injectForm({ defaultValues: { diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts index 9b9853f9..a3619a66 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts @@ -36,6 +36,7 @@ import { injectMultiFactorPhoneAuthNumberFormSchema, injectMultiFactorPhoneAuthVerifyFormSchema, injectDefaultCountry, + injectRecaptchaVerifier, } from "../../../provider"; @Component({ @@ -126,11 +127,7 @@ export class SmsMultiFactorEnrollmentFormComponent { recaptchaContainer = viewChild.required>("recaptchaContainer"); - recaptchaVerifier = computed(() => { - return new RecaptchaVerifier(this.ui().auth, this.recaptchaContainer().nativeElement, { - size: "normal", - }); - }); + recaptchaVerifier = injectRecaptchaVerifier(this.recaptchaContainer()); phoneForm = injectForm({ defaultValues: { diff --git a/packages/angular/src/lib/auth/forms/phone-auth-form.ts b/packages/angular/src/lib/auth/forms/phone-auth-form.ts index a05112d2..96e2768e 100644 --- a/packages/angular/src/lib/auth/forms/phone-auth-form.ts +++ b/packages/angular/src/lib/auth/forms/phone-auth-form.ts @@ -20,6 +20,7 @@ import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanst import { injectPhoneAuthFormSchema, injectPhoneAuthVerifyFormSchema, + injectRecaptchaVerifier, injectTranslation, injectUI, } from "../../provider"; @@ -85,12 +86,7 @@ export class PhoneNumberFormComponent { unknownErrorLabel = injectTranslation("errors", "unknownError"); recaptchaContainer = viewChild.required>("recaptchaContainer"); - - recaptchaVerifier = computed(() => { - return new RecaptchaVerifier(this.ui().auth, this.recaptchaContainer().nativeElement, { - size: "normal", // TODO(ehesp): Get this from the ui behavior - }); - }); + recaptchaVerifier = injectRecaptchaVerifier(this.recaptchaContainer()); form = injectForm({ defaultValues: { diff --git a/packages/angular/src/lib/provider.ts b/packages/angular/src/lib/provider.ts index 74ecdb64..7f24b8c5 100644 --- a/packages/angular/src/lib/provider.ts +++ b/packages/angular/src/lib/provider.ts @@ -24,6 +24,7 @@ import { computed, effect, Signal, + ElementRef, } from "@angular/core"; import { FirebaseApps } from "@angular/fire/app"; import { @@ -77,7 +78,6 @@ export function provideFirebaseUIPolicies(factory: () => PolicyConfig) { return makeEnvironmentProviders(providers); } -// Provides a signal with a subscription to the FirebaseUIStore export function injectUI() { const store = inject(FIREBASE_UI_STORE); const ui = signal(store.get()); @@ -89,6 +89,20 @@ export function injectUI() { return ui.asReadonly(); } +export function injectRecaptchaVerifier(element: ElementRef) { + const ui = injectUI(); + const verifier = computed(() => getBehavior(ui(), "recaptchaVerification")(ui(), element.nativeElement)); + + effect(() => { + if (verifier()) { + verifier().render(); + } + }); + + return verifier; + +} + export function injectTranslation(category: string, key: string) { const ui = injectUI(); return computed(() => getTranslation(ui(), category as any, key as any)); From 56afee352cb62f5538fc7fedf2ef743a976a7553 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 28 Oct 2025 14:53:43 +0000 Subject: [PATCH 10/12] chore: Formatting --- .../mfa/sms-multi-factor-assertion-form.ts | 25 ++++++++----------- .../sms-multi-factor-enrollment-form.spec.ts | 4 +-- .../totp-multi-factor-assertion-form.spec.ts | 15 +++++------ .../mfa/totp-multi-factor-assertion-form.ts | 11 ++------ .../totp-multi-factor-enrollment-form.spec.ts | 4 +-- .../forms/multi-factor-auth-assertion-form.ts | 9 ++----- .../multi-factor-auth-enrollment-form.spec.ts | 1 - .../src/lib/auth/screens/oauth-screen.ts | 4 +-- .../src/lib/auth/screens/phone-auth-screen.ts | 4 +-- .../lib/auth/screens/sign-in-auth-screen.ts | 4 +-- .../lib/auth/screens/sign-up-auth-screen.ts | 4 +-- packages/angular/src/lib/provider.ts | 1 - packages/angular/src/public-api.ts | 6 ++++- 13 files changed, 37 insertions(+), 55 deletions(-) diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts index f429f793..d0f76929 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts @@ -25,11 +25,7 @@ import { } from "../../../provider"; import { RecaptchaVerifier } from "@angular/fire/auth"; import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; -import { - FirebaseUIError, - verifyPhoneNumber, - signInWithMultiFactorAssertion, -} from "@firebase-ui/core"; +import { FirebaseUIError, verifyPhoneNumber, signInWithMultiFactorAssertion } from "@firebase-ui/core"; import { PhoneAuthProvider, PhoneMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth"; type PhoneMultiFactorInfo = MultiFactorInfo & { @@ -111,7 +107,13 @@ export class SmsMultiFactorAssertionPhoneFormComponent { onSubmit: this.formSchema(), onSubmitAsync: async () => { try { - const verificationId = await verifyPhoneNumber(this.ui(), "", this.recaptchaVerifier(), undefined, this.hint()); + const verificationId = await verifyPhoneNumber( + this.ui(), + "", + this.recaptchaVerifier(), + undefined, + this.hint() + ); this.onSubmit.emit(verificationId); return; } catch (error) { @@ -230,11 +232,7 @@ export class SmsMultiFactorAssertionVerifyFormComponent { @Component({ selector: "fui-sms-multi-factor-assertion-form", standalone: true, - imports: [ - CommonModule, - SmsMultiFactorAssertionPhoneFormComponent, - SmsMultiFactorAssertionVerifyFormComponent, - ], + imports: [CommonModule, SmsMultiFactorAssertionPhoneFormComponent, SmsMultiFactorAssertionVerifyFormComponent], template: `
@if (verification()) { @@ -243,10 +241,7 @@ export class SmsMultiFactorAssertionVerifyFormComponent { (onSuccess)="onSuccess.emit()" /> } @else { - + }
`, diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts index b16bbf1b..2dec58e6 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts @@ -362,9 +362,7 @@ describe("", () => { await fixture.whenStable(); fixture.detectChanges(); - expect( - screen.getByText("An unknown error occurred") - ).toBeInTheDocument(); + expect(screen.getByText("An unknown error occurred")).toBeInTheDocument(); }); it("should have correct CSS classes", async () => { diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts index 5f563318..2634a15d 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts @@ -18,17 +18,18 @@ import { TotpMultiFactorGenerator } from "firebase/auth"; import { TotpMultiFactorAssertionFormComponent } from "./totp-multi-factor-assertion-form"; -import { - signInWithMultiFactorAssertion, - FirebaseUIError, -} from "../../../tests/test-helpers"; +import { signInWithMultiFactorAssertion, FirebaseUIError } from "../../../tests/test-helpers"; describe("", () => { let TotpMultiFactorGenerator: any; beforeEach(() => { - const { injectTranslation, injectUI, injectMultiFactorTotpAuthVerifyFormSchema } = require("../../../tests/test-helpers"); - + const { + injectTranslation, + injectUI, + injectMultiFactorTotpAuthVerifyFormSchema, + } = require("../../../tests/test-helpers"); + injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -63,7 +64,7 @@ describe("", () => { // Mock Firebase Auth classes TotpMultiFactorGenerator = require("firebase/auth").TotpMultiFactorGenerator; TotpMultiFactorGenerator.assertionForSignIn = jest.fn().mockReturnValue({}); - + // Clear all mocks before each test jest.clearAllMocks(); }); diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts index 71a9e058..6e479bf8 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts @@ -16,16 +16,9 @@ import { Component, effect, input, output } from "@angular/core"; import { CommonModule } from "@angular/common"; import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanstack/angular-form"; -import { - injectMultiFactorTotpAuthVerifyFormSchema, - injectTranslation, - injectUI, -} from "../../../provider"; +import { injectMultiFactorTotpAuthVerifyFormSchema, injectTranslation, injectUI } from "../../../provider"; import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; -import { - FirebaseUIError, - signInWithMultiFactorAssertion, -} from "@firebase-ui/core"; +import { FirebaseUIError, signInWithMultiFactorAssertion } from "@firebase-ui/core"; import { TotpMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth"; @Component({ diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts index ab8b2166..bd7514b9 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts @@ -334,9 +334,7 @@ describe("", () => { await fixture.whenStable(); fixture.detectChanges(); - expect( - screen.getByText("An unknown error occurred") - ).toBeInTheDocument(); + expect(screen.getByText("An unknown error occurred")).toBeInTheDocument(); }); it("should generate QR code with correct parameters", async () => { diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts index 0fa91bde..848f7c7c 100644 --- a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts @@ -25,12 +25,7 @@ import { ButtonComponent } from "../../components/button"; @Component({ selector: "fui-multi-factor-auth-assertion-form", standalone: true, - imports: [ - CommonModule, - SmsMultiFactorAssertionFormComponent, - TotpMultiFactorAssertionFormComponent, - ButtonComponent, - ], + imports: [CommonModule, SmsMultiFactorAssertionFormComponent, TotpMultiFactorAssertionFormComponent, ButtonComponent], template: `
@if (selectedHint()) { @@ -58,7 +53,7 @@ import { ButtonComponent } from "../../components/button"; }) export class MultiFactorAuthAssertionFormComponent { private ui = injectUI(); - + resolver = computed(() => { const resolver = this.ui().multiFactorResolver; if (!resolver) { diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts index 85ff0957..dcaa9d20 100644 --- a/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts @@ -178,7 +178,6 @@ describe("", () => { expect(enrollmentSpy).toHaveBeenCalled(); }); - it("should have correct CSS classes", async () => { const { container } = await render(MultiFactorAuthEnrollmentFormComponent, { imports: [ diff --git a/packages/angular/src/lib/auth/screens/oauth-screen.ts b/packages/angular/src/lib/auth/screens/oauth-screen.ts index 99ab65c5..14d60dd0 100644 --- a/packages/angular/src/lib/auth/screens/oauth-screen.ts +++ b/packages/angular/src/lib/auth/screens/oauth-screen.ts @@ -68,9 +68,9 @@ import { RedirectErrorComponent } from "../../components/redirect-error"; }) export class OAuthScreenComponent { private ui = injectUI(); - + mfaResolver = computed(() => this.ui().multiFactorResolver); - + titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); } diff --git a/packages/angular/src/lib/auth/screens/phone-auth-screen.ts b/packages/angular/src/lib/auth/screens/phone-auth-screen.ts index 52c8830f..8c3f692e 100644 --- a/packages/angular/src/lib/auth/screens/phone-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/phone-auth-screen.ts @@ -65,9 +65,9 @@ import { UserCredential } from "@angular/fire/auth"; }) export class PhoneAuthScreenComponent { private ui = injectUI(); - + mfaResolver = computed(() => this.ui().multiFactorResolver); - + titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); 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 ab4463fc..7ff81dee 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 @@ -69,9 +69,9 @@ import { UserCredential } from "@angular/fire/auth"; }) export class SignInAuthScreenComponent { private ui = injectUI(); - + mfaResolver = computed(() => this.ui().multiFactorResolver); - + titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); diff --git a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts index caa89fbd..02291e77 100644 --- a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts @@ -66,9 +66,9 @@ import { }) export class SignUpAuthScreenComponent { private ui = injectUI(); - + mfaResolver = computed(() => this.ui().multiFactorResolver); - + titleText = injectTranslation("labels", "register"); subtitleText = injectTranslation("prompts", "enterDetailsToCreate"); diff --git a/packages/angular/src/lib/provider.ts b/packages/angular/src/lib/provider.ts index 7f24b8c5..2e404336 100644 --- a/packages/angular/src/lib/provider.ts +++ b/packages/angular/src/lib/provider.ts @@ -100,7 +100,6 @@ export function injectRecaptchaVerifier(element: ElementRef) { }); return verifier; - } export function injectTranslation(category: string, key: string) { diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts index e14c8cac..9c6581df 100644 --- a/packages/angular/src/public-api.ts +++ b/packages/angular/src/public-api.ts @@ -24,7 +24,11 @@ export { PhoneAuthFormComponent } from "./lib/auth/forms/phone-auth-form"; export { SignInAuthFormComponent } from "./lib/auth/forms/sign-in-auth-form"; export { SignUpAuthFormComponent } from "./lib/auth/forms/sign-up-auth-form"; -export { SmsMultiFactorAssertionFormComponent, SmsMultiFactorAssertionPhoneFormComponent, SmsMultiFactorAssertionVerifyFormComponent } from "./lib/auth/forms/mfa/sms-multi-factor-assertion-form"; +export { + SmsMultiFactorAssertionFormComponent, + SmsMultiFactorAssertionPhoneFormComponent, + SmsMultiFactorAssertionVerifyFormComponent, +} from "./lib/auth/forms/mfa/sms-multi-factor-assertion-form"; export { TotpMultiFactorAssertionFormComponent } from "./lib/auth/forms/mfa/totp-multi-factor-assertion-form"; export { GoogleSignInButtonComponent } from "./lib/auth/oauth/google-sign-in-button"; From e52ee10afb78b254bd52921b9f425a5da2511643 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Wed, 29 Oct 2025 09:07:00 +0000 Subject: [PATCH 11/12] chore: Cleanup tests --- .../lib/auth/forms/email-link-auth-form.spec.ts | 1 - .../auth/forms/forgot-password-auth-form.spec.ts | 5 ----- .../mfa/sms-multi-factor-assertion-form.spec.ts | 15 +++------------ .../mfa/sms-multi-factor-enrollment-form.spec.ts | 2 -- .../mfa/totp-multi-factor-assertion-form.spec.ts | 14 ++------------ .../mfa/totp-multi-factor-enrollment-form.spec.ts | 2 -- .../multi-factor-auth-assertion-form.spec.ts | 1 - .../multi-factor-auth-enrollment-form.spec.ts | 4 ---- .../src/lib/auth/forms/sign-up-auth-form.spec.ts | 1 - .../lib/auth/oauth/apple-sign-in-button.spec.ts | 2 -- .../auth/oauth/facebook-sign-in-button.spec.ts | 2 -- .../lib/auth/oauth/github-sign-in-button.spec.ts | 2 -- .../lib/auth/oauth/google-sign-in-button.spec.ts | 2 -- .../auth/oauth/microsoft-sign-in-button.spec.ts | 2 -- .../src/lib/auth/oauth/oauth-button.spec.ts | 11 +++-------- .../lib/auth/oauth/twitter-sign-in-button.spec.ts | 2 -- .../src/lib/auth/screens/oauth-screen.spec.ts | 2 -- .../lib/auth/screens/phone-auth-screen.spec.ts | 3 --- .../lib/auth/screens/sign-in-auth-screen.spec.ts | 2 -- .../lib/auth/screens/sign-up-auth-screen.spec.ts | 2 -- packages/angular/src/lib/components/form.spec.ts | 6 ------ .../angular/src/lib/components/policies.spec.ts | 1 - .../src/lib/components/redirect-error.spec.ts | 9 +++------ 23 files changed, 11 insertions(+), 82 deletions(-) diff --git a/packages/angular/src/lib/auth/forms/email-link-auth-form.spec.ts b/packages/angular/src/lib/auth/forms/email-link-auth-form.spec.ts index 62d2b983..107cb2e4 100644 --- a/packages/angular/src/lib/auth/forms/email-link-auth-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/email-link-auth-form.spec.ts @@ -309,7 +309,6 @@ describe("", () => { ], }); - // Wait for the async completeSignIn to be called await waitFor(() => { expect(mockCompleteEmailLinkSignIn).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/angular/src/lib/auth/forms/forgot-password-auth-form.spec.ts b/packages/angular/src/lib/auth/forms/forgot-password-auth-form.spec.ts index 7c30d7f8..c4f7e716 100644 --- a/packages/angular/src/lib/auth/forms/forgot-password-auth-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/forgot-password-auth-form.spec.ts @@ -277,13 +277,11 @@ describe("", () => { component.form.setFieldValue("email", "nonexistent@example.com"); fixture.detectChanges(); - // Trigger form submission await component.form.handleSubmit(); await fixture.whenStable(); fixture.detectChanges(); expect(component.emailSent()).toBe(false); - // The error should be in the form state expect(component.form.state.errors.length).toBeGreaterThan(0); }); @@ -309,13 +307,11 @@ describe("", () => { component.form.setFieldValue("email", "test@example.com"); fixture.detectChanges(); - // Trigger form submission await component.form.handleSubmit(); await fixture.whenStable(); fixture.detectChanges(); expect(component.emailSent()).toBe(false); - // The error should be in the form state expect(component.form.state.errors.length).toBeGreaterThan(0); }); @@ -344,7 +340,6 @@ describe("", () => { component.form.setFieldValue("email", "test@example.com"); fixture.detectChanges(); - // Should have no errors now expect(component.form.state.errors).toHaveLength(0); }); }); diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts index f76c0a66..39304854 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts @@ -71,11 +71,9 @@ describe("", () => { }); }); - // Mock FirebaseUI Core functions verifyPhoneNumber.mockResolvedValue("test-verification-id"); signInWithMultiFactorAssertion.mockResolvedValue({}); - // Mock Firebase Auth classes const { PhoneAuthProvider, PhoneMultiFactorGenerator } = require("firebase/auth"); PhoneAuthProvider.credential = jest.fn().mockReturnValue({}); PhoneMultiFactorGenerator.assertion = jest.fn().mockReturnValue({}); @@ -107,20 +105,17 @@ describe("", () => { phoneNumber: "+1234567890", }; - const { fixture } = await render(SmsMultiFactorAssertionFormComponent, { + await render(SmsMultiFactorAssertionFormComponent, { componentInputs: { hint: mockHint, }, imports: [SmsMultiFactorAssertionFormComponent], }); - // Initially shows phone form expect(screen.getByLabelText("Phone Number")).toBeInTheDocument(); - // Submit the phone form fireEvent.click(screen.getByRole("button", { name: "Send Code" })); - // Wait for the form to switch await waitFor(() => { expect(screen.getByLabelText("Verification Code")).toBeInTheDocument(); }); @@ -146,7 +141,6 @@ describe("", () => { const onSuccessSpy = jest.fn(); fixture.componentInstance.onSuccess.subscribe(onSuccessSpy); - // Submit phone form to get to verification form const phoneFormComponent = fixture.debugElement.query( (el) => el.componentInstance?.constructor?.name === "SmsMultiFactorAssertionPhoneFormComponent" )?.componentInstance; @@ -160,7 +154,6 @@ describe("", () => { expect(screen.getByLabelText("Verification Code")).toBeInTheDocument(); }); - // Fill in verification code and submit const verifyFormComponent = fixture.debugElement.query( (el) => el.componentInstance?.constructor?.name === "SmsMultiFactorAssertionVerifyFormComponent" )?.componentInstance; @@ -169,6 +162,8 @@ describe("", () => { verifyFormComponent.form.setFieldValue("verificationCode", "123456"); verifyFormComponent.form.setFieldValue("verificationId", "test-verification-id"); await verifyFormComponent.form.handleSubmit(); + } else { + fail("Verify form component not found"); } await waitFor(() => { @@ -211,7 +206,6 @@ describe("", () => { }); }); - // Mock FirebaseUI Core functions verifyPhoneNumber.mockResolvedValue("test-verification-id"); }); @@ -293,10 +287,8 @@ describe("", () => { }); }); - // Mock FirebaseUI Core functions signInWithMultiFactorAssertion.mockResolvedValue({}); - // Mock Firebase Auth classes const { PhoneAuthProvider, PhoneMultiFactorGenerator } = require("firebase/auth"); PhoneAuthProvider.credential = jest.fn().mockReturnValue({}); PhoneMultiFactorGenerator.assertion = jest.fn().mockReturnValue({}); @@ -325,7 +317,6 @@ describe("", () => { const onSuccessSpy = jest.fn(); fixture.componentInstance.onSuccess.subscribe(onSuccessSpy); - // Fill in verification code and submit const component = fixture.componentInstance; component.form.setFieldValue("verificationCode", "123456"); component.form.setFieldValue("verificationId", "test-verification-id"); diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts index 2dec58e6..0e393e89 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts @@ -53,7 +53,6 @@ describe("", () => { mockPhoneAuthProvider = PhoneAuthProvider; mockPhoneMultiFactorGenerator = PhoneMultiFactorGenerator; - // Mock provider functions injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -328,7 +327,6 @@ describe("", () => { }); it("should throw error if user is not authenticated", async () => { - // Override the injectUI mock for this test const { injectUI } = require("../../../tests/test-helpers"); injectUI.mockImplementation(() => { return () => ({ diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts index 2634a15d..cf1f101d 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts @@ -13,11 +13,9 @@ * limitations under the License. */ -import { render, screen, fireEvent, waitFor } from "@testing-library/angular"; -import { TotpMultiFactorGenerator } from "firebase/auth"; +import { render, screen } from "@testing-library/angular"; import { TotpMultiFactorAssertionFormComponent } from "./totp-multi-factor-assertion-form"; - import { signInWithMultiFactorAssertion, FirebaseUIError } from "../../../tests/test-helpers"; describe("", () => { @@ -58,14 +56,11 @@ describe("", () => { }); }); - // Mock FirebaseUI Core functions signInWithMultiFactorAssertion.mockResolvedValue({}); - // Mock Firebase Auth classes TotpMultiFactorGenerator = require("firebase/auth").TotpMultiFactorGenerator; TotpMultiFactorGenerator.assertionForSignIn = jest.fn().mockReturnValue({}); - // Clear all mocks before each test jest.clearAllMocks(); }); @@ -102,7 +97,7 @@ describe("", () => { imports: [TotpMultiFactorAssertionFormComponent], }); - // Check that the form input component has the placeholder attribute + // Verify the verification code input field starts empty (no pre-filled value) const formInput = screen.getByDisplayValue(""); expect(formInput).toBeInTheDocument(); }); @@ -125,7 +120,6 @@ describe("", () => { const onSuccessSpy = jest.fn(); component.onSuccess.subscribe(onSuccessSpy); - // Set form values and submit directly component.form.setFieldValue("verificationCode", "123456"); fixture.detectChanges(); @@ -153,7 +147,6 @@ describe("", () => { const component = fixture.componentInstance; - // Set form values and submit directly component.form.setFieldValue("verificationCode", "123456"); fixture.detectChanges(); @@ -182,7 +175,6 @@ describe("", () => { const component = fixture.componentInstance; - // Set form values and submit directly component.form.setFieldValue("verificationCode", "123456"); fixture.detectChanges(); @@ -214,7 +206,6 @@ describe("", () => { const component = fixture.componentInstance; - // Set form values and submit directly component.form.setFieldValue("verificationCode", "123456"); fixture.detectChanges(); @@ -243,7 +234,6 @@ describe("", () => { const component = fixture.componentInstance; - // Set form values and submit directly component.form.setFieldValue("verificationCode", "123456"); fixture.detectChanges(); diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts index bd7514b9..1e45292f 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts @@ -47,7 +47,6 @@ describe("", () => { mockFirebaseUIError = FirebaseUIError; mockTotpMultiFactorGenerator = TotpMultiFactorGenerator; - // Mock provider functions injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -302,7 +301,6 @@ describe("", () => { }); it("should throw error if user is not authenticated", async () => { - // Override the injectUI mock for this test const { injectUI } = require("../../../tests/test-helpers"); injectUI.mockImplementation(() => { return () => ({ diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts index d63e6c01..6be6e6bf 100644 --- a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts @@ -112,7 +112,6 @@ describe("", () => { }); it("switches to assertion form when selection button is clicked", async () => { - // Override the inner components with mocks TestBed.overrideComponent(SmsMultiFactorAssertionFormComponent, { set: { template: '
SMS Assertion Form
', diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts index dcaa9d20..8e5c6b05 100644 --- a/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts @@ -68,11 +68,9 @@ describe("", () => { }, }); - // Should not show selection buttons expect(screen.queryByRole("button", { name: "SMS Verification" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "TOTP Verification" })).not.toBeInTheDocument(); - // Should show SMS form directly expect(screen.getByLabelText("Display Name")).toBeInTheDocument(); expect(screen.getByLabelText("Phone Number")).toBeInTheDocument(); }); @@ -139,7 +137,6 @@ describe("", () => { const component = fixture.componentInstance; const enrollmentSpy = jest.spyOn(component.onEnrollment, "emit"); - // Get the SMS form component and emit enrollment const smsFormComponent = fixture.debugElement.query( (el) => el.componentInstance instanceof SmsMultiFactorEnrollmentFormComponent )?.componentInstance as SmsMultiFactorEnrollmentFormComponent; @@ -167,7 +164,6 @@ describe("", () => { const component = fixture.componentInstance; const enrollmentSpy = jest.spyOn(component.onEnrollment, "emit"); - // Get the TOTP form component and emit enrollment const totpFormComponent = fixture.debugElement.query( (el) => el.componentInstance instanceof TotpMultiFactorEnrollmentFormComponent )?.componentInstance as TotpMultiFactorEnrollmentFormComponent; diff --git a/packages/angular/src/lib/auth/forms/sign-up-auth-form.spec.ts b/packages/angular/src/lib/auth/forms/sign-up-auth-form.spec.ts index 356aa883..44c2065c 100644 --- a/packages/angular/src/lib/auth/forms/sign-up-auth-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/sign-up-auth-form.spec.ts @@ -151,7 +151,6 @@ describe("", () => { const component = fixture.componentInstance; expect(component.form.getFieldValue("email")).toBe(""); expect(component.form.getFieldValue("password")).toBe(""); - // displayName is undefined when hasBehavior returns false expect(component.form.getFieldValue("displayName")).toBeUndefined(); }); diff --git a/packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts index 8cb10373..acdb7796 100644 --- a/packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts @@ -19,8 +19,6 @@ import { Component } from "@angular/core"; import { AppleSignInButtonComponent } from "./apple-sign-in-button"; -// Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts - @Component({ template: ``, standalone: true, diff --git a/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts index 9026f55f..24a732cf 100644 --- a/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts @@ -19,8 +19,6 @@ import { Component } from "@angular/core"; import { FacebookSignInButtonComponent } from "./facebook-sign-in-button"; -// Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts - @Component({ template: ``, standalone: true, diff --git a/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts index f94d5175..955d06f6 100644 --- a/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts @@ -19,8 +19,6 @@ import { Component } from "@angular/core"; import { GithubSignInButtonComponent } from "./github-sign-in-button"; -// Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts - @Component({ template: ``, standalone: true, diff --git a/packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts index b04cc6f9..89267c09 100644 --- a/packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts @@ -19,8 +19,6 @@ import { Component, signal } from "@angular/core"; import { GoogleSignInButtonComponent } from "./google-sign-in-button"; -// Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts - @Component({ template: ``, standalone: true, diff --git a/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts index 62138264..512901ed 100644 --- a/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts @@ -19,8 +19,6 @@ import { Component } from "@angular/core"; import { MicrosoftSignInButtonComponent } from "./microsoft-sign-in-button"; -// Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts - @Component({ template: ``, standalone: true, diff --git a/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts b/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts index b3b63f0a..a3e46a7b 100644 --- a/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts @@ -17,11 +17,8 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/angular"; import { Component } from "@angular/core"; import { OAuthButtonComponent } from "./oauth-button"; -// ButtonComponent is imported by OAuthButtonComponent import { AuthProvider } from "@angular/fire/auth"; -// Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts - @Component({ template: ` Sign in with Google `, standalone: true, @@ -49,7 +46,6 @@ describe("", () => { mockSignInWithProvider = signInWithProvider; mockFirebaseUIError = FirebaseUIError; - // Reset mocks mockSignInWithProvider.mockClear(); }); @@ -154,22 +150,21 @@ describe("", () => { }); it("should clear error when sign-in is attempted again", async () => { - // First, trigger an error + // Throw an error to start mockSignInWithProvider.mockRejectedValueOnce(new mockFirebaseUIError("First error")); - const { fixture } = await render(TestOAuthButtonHostComponent, { + await render(TestOAuthButtonHostComponent, { imports: [OAuthButtonComponent], }); const button = screen.getByRole("button"); - // First click - should show error fireEvent.click(button); await waitFor(() => { expect(screen.getByText("First error")).toBeInTheDocument(); }); - // Second click - should clear error and attempt again + // Remove the error mockSignInWithProvider.mockResolvedValueOnce(undefined); fireEvent.click(button); diff --git a/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts index 1fdcd726..404ecd8c 100644 --- a/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts @@ -19,8 +19,6 @@ import { Component } from "@angular/core"; import { TwitterSignInButtonComponent } from "./twitter-sign-in-button"; -// Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts - @Component({ template: ``, standalone: true, 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 18ab8f2a..e9b2d4ac 100644 --- a/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts @@ -277,7 +277,6 @@ describe("", () => { }); }); - // Override the real component with our mock TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { set: { template: '
MFA Assertion Form
', @@ -311,7 +310,6 @@ describe("", () => { }); }); - // Override the real component with our mock TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { set: { template: '
MFA Assertion Form
', diff --git a/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts index 9793ec6f..11844a59 100644 --- a/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts @@ -123,7 +123,6 @@ describe("", () => { ], }); - // Look for form elements by class instead of role const form = document.querySelector(".fui-form"); expect(form).toBeInTheDocument(); expect(form).toHaveClass("fui-form"); @@ -219,7 +218,6 @@ describe("", () => { }); }); - // Override the real component with our mock TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { set: { template: '
MFA Assertion Form
', @@ -252,7 +250,6 @@ describe("", () => { }); }); - // Override the real component with our mock TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { set: { template: '
MFA Assertion Form
', 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 61247f90..2e2361a1 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 @@ -218,7 +218,6 @@ describe("", () => { }); }); - // Override the real component with our mock TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { set: { template: '
MFA Assertion Form
', @@ -251,7 +250,6 @@ describe("", () => { }); }); - // Override the real component with our mock TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { set: { template: '
MFA Assertion Form
', diff --git a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts index 840dea6b..2d0b0121 100644 --- a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts @@ -217,7 +217,6 @@ describe("", () => { }); }); - // Override the real component with our mock TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { set: { template: '
MFA Assertion Form
', @@ -250,7 +249,6 @@ describe("", () => { }); }); - // Override the real component with our mock TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { set: { template: '
MFA Assertion Form
', diff --git a/packages/angular/src/lib/components/form.spec.ts b/packages/angular/src/lib/components/form.spec.ts index 2c820cb9..3c1d60be 100644 --- a/packages/angular/src/lib/components/form.spec.ts +++ b/packages/angular/src/lib/components/form.spec.ts @@ -20,7 +20,6 @@ import { Component, signal } from "@angular/core"; import { FormMetadataComponent, FormActionComponent, FormSubmitComponent, FormErrorMessageComponent } from "./form"; import { ButtonComponent } from "./button"; -// Test host component for FormMetadataComponent @Component({ template: ``, standalone: true, @@ -37,7 +36,6 @@ class TestFormMetadataHostComponent { } as any); } -// Test host component for FormActionComponent @Component({ template: ``, standalone: true, @@ -45,7 +43,6 @@ class TestFormMetadataHostComponent { }) class TestFormActionHostComponent {} -// FormSubmitComponent test host component @Component({ template: `Submit`, standalone: true, @@ -58,7 +55,6 @@ class TestFormSubmitHostComponent { customClass = signal("custom-submit-class"); } -// FormErrorMessageComponent test host component @Component({ template: ``, standalone: true, @@ -87,7 +83,6 @@ describe("Form Components", () => { it("does not render error message when field has no errors", async () => { const component = await render(TestFormMetadataHostComponent); - // Update the field to have no errors component.fixture.componentInstance.field.set({ state: { meta: { @@ -105,7 +100,6 @@ describe("Form Components", () => { it("does not render error message when field is not touched", async () => { const component = await render(TestFormMetadataHostComponent); - // Update the field to not be touched component.fixture.componentInstance.field.set({ state: { meta: { diff --git a/packages/angular/src/lib/components/policies.spec.ts b/packages/angular/src/lib/components/policies.spec.ts index c8868b79..6a68df1b 100644 --- a/packages/angular/src/lib/components/policies.spec.ts +++ b/packages/angular/src/lib/components/policies.spec.ts @@ -180,7 +180,6 @@ describe("", () => { expect(privacyLink).toHaveAttribute("rel", "noopener noreferrer"); expect(privacyLink).toHaveTextContent("Privacy Policy"); - // Check that the template text is rendered const textContent = policiesContainer?.textContent; expect(textContent).toContain("By continuing, you agree to our"); }); diff --git a/packages/angular/src/lib/components/redirect-error.spec.ts b/packages/angular/src/lib/components/redirect-error.spec.ts index 5e19f9f9..c73d0662 100644 --- a/packages/angular/src/lib/components/redirect-error.spec.ts +++ b/packages/angular/src/lib/components/redirect-error.spec.ts @@ -30,10 +30,7 @@ describe("", () => { const errorMessage = "Authentication failed"; injectRedirectError.mockReturnValue(() => errorMessage); - const { container } = await render(TestHostComponent); - - // Debug: log the container HTML - console.log("Container HTML:", container.innerHTML); + await render(TestHostComponent); const errorElement = screen.getByText(errorMessage); expect(errorElement).toBeDefined(); @@ -66,7 +63,7 @@ describe("", () => { const errorMessage = "Custom error string"; injectRedirectError.mockReturnValue(() => errorMessage); - const { container } = await render(TestHostComponent); + await render(TestHostComponent); const errorElement = screen.getByText(errorMessage); expect(errorElement).toBeDefined(); @@ -78,7 +75,7 @@ describe("", () => { const errorMessage = "Test error"; injectRedirectError.mockReturnValue(() => errorMessage); - const { container } = await render(TestHostComponent); + await render(TestHostComponent); const errorElement = screen.getByText(errorMessage); expect(errorElement).toHaveClass("fui-form__error"); From 41a240e57b5330f6bc3ebc59eb264cbec2902503 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Wed, 29 Oct 2025 09:16:24 +0000 Subject: [PATCH 12/12] fix(angular): Ensure recaptcha container is deferred until view child is ready --- .../sms-multi-factor-assertion-form.spec.ts | 9 +++++++++ .../mfa/sms-multi-factor-assertion-form.ts | 19 ++++++++++--------- .../sms-multi-factor-enrollment-form.spec.ts | 9 +++++++++ .../mfa/sms-multi-factor-enrollment-form.ts | 14 +++++++------- .../lib/auth/forms/phone-auth-form.spec.ts | 9 +++++++++ .../src/lib/auth/forms/phone-auth-form.ts | 12 +++++++++--- packages/angular/src/lib/provider.ts | 15 +++++++++++---- .../angular/src/lib/tests/test-helpers.ts | 8 ++++++++ 8 files changed, 72 insertions(+), 23 deletions(-) diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts index 39304854..d7fbb79a 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts @@ -34,6 +34,7 @@ describe("", () => { injectUI, injectMultiFactorPhoneAuthNumberFormSchema, injectMultiFactorPhoneAuthVerifyFormSchema, + injectRecaptchaVerifier, } = require("../../../tests/test-helpers"); injectTranslation.mockImplementation((category: string, key: string) => { @@ -74,6 +75,14 @@ describe("", () => { verifyPhoneNumber.mockResolvedValue("test-verification-id"); signInWithMultiFactorAssertion.mockResolvedValue({}); + injectRecaptchaVerifier.mockImplementation(() => { + return () => ({ + clear: jest.fn(), + render: jest.fn(), + verify: jest.fn(), + }); + }); + const { PhoneAuthProvider, PhoneMultiFactorGenerator } = require("firebase/auth"); PhoneAuthProvider.credential = jest.fn().mockReturnValue({}); PhoneMultiFactorGenerator.assertion = jest.fn().mockReturnValue({}); diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts index d0f76929..8256dd57 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts @@ -84,7 +84,7 @@ export class SmsMultiFactorAssertionPhoneFormComponent { return hint.phoneNumber || ""; }); - recaptchaVerifier = injectRecaptchaVerifier(this.recaptchaContainer()); + recaptchaVerifier = injectRecaptchaVerifier(() => this.recaptchaContainer()); form = injectForm({ defaultValues: { @@ -107,13 +107,12 @@ export class SmsMultiFactorAssertionPhoneFormComponent { onSubmit: this.formSchema(), onSubmitAsync: async () => { try { - const verificationId = await verifyPhoneNumber( - this.ui(), - "", - this.recaptchaVerifier(), - undefined, - this.hint() - ); + const verifier = this.recaptchaVerifier(); + if (!verifier) { + return this.unknownErrorLabel(); + } + + const verificationId = await verifyPhoneNumber(this.ui(), "", verifier, undefined, this.hint()); this.onSubmit.emit(verificationId); return; } catch (error) { @@ -130,7 +129,9 @@ export class SmsMultiFactorAssertionPhoneFormComponent { effect((onCleanup) => { const verifier = this.recaptchaVerifier(); onCleanup(() => { - verifier.clear(); + if (verifier) { + verifier.clear(); + } }); }); } diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts index 0e393e89..a358cbf7 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts @@ -42,6 +42,7 @@ describe("", () => { injectMultiFactorPhoneAuthNumberFormSchema, injectMultiFactorPhoneAuthVerifyFormSchema, injectDefaultCountry, + injectRecaptchaVerifier, } = require("../../../tests/test-helpers"); const { PhoneAuthProvider, PhoneMultiFactorGenerator, multiFactor } = require("../../../tests/test-helpers"); @@ -88,6 +89,14 @@ describe("", () => { injectDefaultCountry.mockImplementation(() => { return () => ({ code: "US" }); }); + + injectRecaptchaVerifier.mockImplementation(() => { + return () => ({ + clear: jest.fn(), + render: jest.fn(), + verify: jest.fn(), + }); + }); }); afterEach(() => { diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts index a3619a66..2683759e 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts @@ -127,7 +127,7 @@ export class SmsMultiFactorEnrollmentFormComponent { recaptchaContainer = viewChild.required>("recaptchaContainer"); - recaptchaVerifier = injectRecaptchaVerifier(this.recaptchaContainer()); + recaptchaVerifier = injectRecaptchaVerifier(() => this.recaptchaContainer()); phoneForm = injectForm({ defaultValues: { @@ -158,14 +158,14 @@ export class SmsMultiFactorEnrollmentFormComponent { throw new Error("User must be authenticated to enroll with multi-factor authentication"); } + const verifier = this.recaptchaVerifier(); + if (!verifier) { + return this.unknownErrorLabel(); + } + const mfaUser = multiFactor(currentUser); const formattedPhoneNumber = formatPhoneNumber(value.phoneNumber, this.defaultCountry()); - const verificationId = await verifyPhoneNumber( - this.ui(), - formattedPhoneNumber, - this.recaptchaVerifier(), - mfaUser - ); + const verificationId = await verifyPhoneNumber(this.ui(), formattedPhoneNumber, verifier, mfaUser); this.displayName.set(value.displayName); this.verificationId.set(verificationId); diff --git a/packages/angular/src/lib/auth/forms/phone-auth-form.spec.ts b/packages/angular/src/lib/auth/forms/phone-auth-form.spec.ts index f8765e31..324320a2 100644 --- a/packages/angular/src/lib/auth/forms/phone-auth-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/phone-auth-form.spec.ts @@ -34,10 +34,19 @@ describe("", () => { beforeEach(() => { const { verifyPhoneNumber, confirmPhoneNumber, formatPhoneNumber, FirebaseUIError } = require("@firebase-ui/core"); + const { injectRecaptchaVerifier } = require("../../tests/test-helpers"); mockVerifyPhoneNumber = verifyPhoneNumber; mockConfirmPhoneNumber = confirmPhoneNumber; mockFormatPhoneNumber = formatPhoneNumber; mockFirebaseUIError = FirebaseUIError; + + injectRecaptchaVerifier.mockImplementation(() => { + return () => ({ + clear: jest.fn(), + render: jest.fn(), + verify: jest.fn(), + }); + }); }); afterEach(() => { diff --git a/packages/angular/src/lib/auth/forms/phone-auth-form.ts b/packages/angular/src/lib/auth/forms/phone-auth-form.ts index 96e2768e..72b189c5 100644 --- a/packages/angular/src/lib/auth/forms/phone-auth-form.ts +++ b/packages/angular/src/lib/auth/forms/phone-auth-form.ts @@ -86,7 +86,7 @@ export class PhoneNumberFormComponent { unknownErrorLabel = injectTranslation("errors", "unknownError"); recaptchaContainer = viewChild.required>("recaptchaContainer"); - recaptchaVerifier = injectRecaptchaVerifier(this.recaptchaContainer()); + recaptchaVerifier = injectRecaptchaVerifier(() => this.recaptchaContainer()); form = injectForm({ defaultValues: { @@ -107,7 +107,11 @@ export class PhoneNumberFormComponent { const formattedNumber = formatPhoneNumber(value.phoneNumber, selectedCountry!); try { - const verificationId = await verifyPhoneNumber(this.ui(), formattedNumber, this.recaptchaVerifier()); + const verifier = this.recaptchaVerifier(); + if (!verifier) { + return this.unknownErrorLabel(); + } + const verificationId = await verifyPhoneNumber(this.ui(), formattedNumber, verifier); this.onSubmit.emit({ verificationId, phoneNumber: formattedNumber }); return; } catch (error) { @@ -126,7 +130,9 @@ export class PhoneNumberFormComponent { const verifier = this.recaptchaVerifier(); onCleanup(() => { - verifier.clear(); + if (verifier) { + verifier.clear(); + } }); }); } diff --git a/packages/angular/src/lib/provider.ts b/packages/angular/src/lib/provider.ts index 2e404336..e0c47933 100644 --- a/packages/angular/src/lib/provider.ts +++ b/packages/angular/src/lib/provider.ts @@ -89,13 +89,20 @@ export function injectUI() { return ui.asReadonly(); } -export function injectRecaptchaVerifier(element: ElementRef) { +export function injectRecaptchaVerifier(element: () => ElementRef) { const ui = injectUI(); - const verifier = computed(() => getBehavior(ui(), "recaptchaVerification")(ui(), element.nativeElement)); + const verifier = computed(() => { + const elementRef = element(); + if (!elementRef) { + return null; + } + return getBehavior(ui(), "recaptchaVerification")(ui(), elementRef.nativeElement); + }); effect(() => { - if (verifier()) { - verifier().render(); + const verifierInstance = verifier(); + if (verifierInstance) { + verifierInstance.render(); } }); diff --git a/packages/angular/src/lib/tests/test-helpers.ts b/packages/angular/src/lib/tests/test-helpers.ts index 540ec357..85312555 100644 --- a/packages/angular/src/lib/tests/test-helpers.ts +++ b/packages/angular/src/lib/tests/test-helpers.ts @@ -273,6 +273,14 @@ export const injectMultiFactorTotpAuthEnrollmentFormSchema = jest.fn().mockRetur export const injectCountries = jest.fn().mockReturnValue(() => countryData); export const injectDefaultCountry = jest.fn().mockReturnValue(() => "US"); +export const injectRecaptchaVerifier = jest.fn().mockImplementation(() => { + return () => ({ + clear: jest.fn(), + render: jest.fn(), + verify: jest.fn(), + }); +}); + export const RecaptchaVerifier = jest.fn().mockImplementation(() => ({ clear: jest.fn(), render: jest.fn(),