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/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..107cb2e4 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("", () => { @@ -313,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/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/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-auth-form/forgot-password-auth-form.component.spec.ts b/packages/angular/src/lib/auth/forms/forgot-password-auth-form.spec.ts similarity index 96% 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..c4f7e716 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; @@ -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/forgot-password-auth-form/forgot-password-auth-form.component.ts b/packages/angular/src/lib/auth/forms/forgot-password-auth-form.ts similarity index 95% 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 index 6fa7f3ce..f66226e9 100644 --- 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 @@ -24,9 +24,9 @@ import { FormSubmitComponent, FormErrorMessageComponent, FormActionComponent, -} from "../../../components/form/form.component"; -import { PoliciesComponent } from "../../../components/policies/policies.component"; -import { injectForgotPasswordAuthFormSchema, injectTranslation, injectUI } from "../../../provider"; +} from "../../components/form"; +import { PoliciesComponent } from "../../components/policies"; +import { injectForgotPasswordAuthFormSchema, injectTranslation, injectUI } from "../../provider"; @Component({ selector: "fui-forgot-password-auth-form", 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-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..d7fbb79a --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts @@ -0,0 +1,338 @@ +/** + * 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 { + SmsMultiFactorAssertionFormComponent, + SmsMultiFactorAssertionPhoneFormComponent, + SmsMultiFactorAssertionVerifyFormComponent, +} from "./sms-multi-factor-assertion-form"; + +import { + verifyPhoneNumber, + signInWithMultiFactorAssertion, + PhoneMultiFactorGenerator, +} from "../../../tests/test-helpers"; + +describe("", () => { + beforeEach(() => { + const { + injectTranslation, + injectUI, + injectMultiFactorPhoneAuthNumberFormSchema, + injectMultiFactorPhoneAuthVerifyFormSchema, + injectRecaptchaVerifier, + } = require("../../../tests/test-helpers"); + + 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"), + }); + }); + + 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({}); + }); + + 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", + }; + + await render(SmsMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [SmsMultiFactorAssertionFormComponent], + }); + + expect(screen.getByLabelText("Phone Number")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Send Code" })); + + 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); + + 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(); + }); + + 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(); + } else { + fail("Verify form component not found"); + } + + await waitFor(() => { + expect(onSuccessSpy).toHaveBeenCalled(); + }); + }); +}); + +describe("", () => { + beforeEach(() => { + const { + injectTranslation, + injectUI, + injectMultiFactorPhoneAuthNumberFormSchema, + } = require("../../../tests/test-helpers"); + + 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"), + }); + }); + + 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("../../../tests/test-helpers"); + + 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"), + }); + }); + + signInWithMultiFactorAssertion.mockResolvedValue({}); + + 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); + + 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-assertion-form.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts new file mode 100644 index 00000000..8256dd57 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts @@ -0,0 +1,259 @@ +/** + * 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, ElementRef, effect, input, signal, output, computed, viewChild } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanstack/angular-form"; +import { + injectMultiFactorPhoneAuthNumberFormSchema, + injectMultiFactorPhoneAuthVerifyFormSchema, + injectRecaptchaVerifier, + 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 = injectRecaptchaVerifier(() => this.recaptchaContainer()); + + 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 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) { + if (error instanceof FirebaseUIError) { + return error.message; + } + return this.unknownErrorLabel(); + } + }, + }, + }); + }); + + effect((onCleanup) => { + const verifier = this.recaptchaVerifier(); + onCleanup(() => { + if (verifier) { + 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, SmsMultiFactorAssertionPhoneFormComponent, SmsMultiFactorAssertionVerifyFormComponent], + template: ` +
+ @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/sms-multi-factor-enrollment-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts new file mode 100644 index 00000000..a358cbf7 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts @@ -0,0 +1,394 @@ +/** + * 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 { 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; + let mockEnrollWithMultiFactorAssertion: any; + let mockFormatPhoneNumber: any; + let mockFirebaseUIError: any; + let mockMultiFactor: any; + let mockPhoneAuthProvider: any; + let mockPhoneMultiFactorGenerator: any; + + beforeEach(() => { + const { + verifyPhoneNumber, + enrollWithMultiFactorAssertion, + formatPhoneNumber, + FirebaseUIError, + injectTranslation, + injectUI, + injectMultiFactorPhoneAuthNumberFormSchema, + injectMultiFactorPhoneAuthVerifyFormSchema, + injectDefaultCountry, + injectRecaptchaVerifier, + } = require("../../../tests/test-helpers"); + const { PhoneAuthProvider, PhoneMultiFactorGenerator, multiFactor } = require("../../../tests/test-helpers"); + + mockVerifyPhoneNumber = verifyPhoneNumber; + mockEnrollWithMultiFactorAssertion = enrollWithMultiFactorAssertion; + mockFormatPhoneNumber = formatPhoneNumber; + mockFirebaseUIError = FirebaseUIError; + mockMultiFactor = multiFactor; + mockPhoneAuthProvider = PhoneAuthProvider; + mockPhoneMultiFactorGenerator = PhoneMultiFactorGenerator; + + 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" }); + }); + + injectRecaptchaVerifier.mockImplementation(() => { + return () => ({ + clear: jest.fn(), + render: jest.fn(), + verify: jest.fn(), + }); + }); + }); + + 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 { injectUI } = require("../../../tests/test-helpers"); + injectUI.mockImplementation(() => { + return () => ({ + auth: { + currentUser: null, + }, + }); + }); + + 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("An unknown error occurred")).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.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts new file mode 100644 index 00000000..2683759e --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts @@ -0,0 +1,224 @@ +/** + * 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"; +import { CountrySelectorComponent } from "../../../components/country-selector"; +import { PoliciesComponent } from "../../../components/policies"; +import { + injectUI, + injectTranslation, + injectMultiFactorPhoneAuthNumberFormSchema, + injectMultiFactorPhoneAuthVerifyFormSchema, + injectDefaultCountry, + injectRecaptchaVerifier, +} 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 = injectMultiFactorPhoneAuthNumberFormSchema(); + private verificationFormSchema = injectMultiFactorPhoneAuthVerifyFormSchema(); + 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 = injectRecaptchaVerifier(() => this.recaptchaContainer()); + + 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 { + const currentUser = this.ui().auth.currentUser; + if (!currentUser) { + 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, verifier, mfaUser); + + this.displayName.set(value.displayName); + this.verificationId.set(verificationId); + return; + } catch (error) { + if (error instanceof FirebaseUIError) { + return error.message; + } + + console.error(error); + 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; + } + if (error instanceof Error) { + 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-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..cf1f101d --- /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 } from "@testing-library/angular"; + +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("../../../tests/test-helpers"); + + 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", + }), + }); + }); + + signInWithMultiFactorAssertion.mockResolvedValue({}); + + TotpMultiFactorGenerator = require("firebase/auth").TotpMultiFactorGenerator; + TotpMultiFactorGenerator.assertionForSignIn = jest.fn().mockReturnValue({}); + + jest.clearAllMocks(); + }); + + 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], + }); + + // Verify the verification code input field starts empty (no pre-filled value) + 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 component = fixture.componentInstance; + const onSuccessSpy = jest.fn(); + component.onSuccess.subscribe(onSuccessSpy); + + component.form.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await fixture.whenStable(); + + 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; + + const { fixture } = await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + const component = fixture.componentInstance; + + 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 () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + const mockAssertion = { type: "totp" }; + TotpMultiFactorGenerator.assertionForSignIn.mockReturnValue(mockAssertion); + + const { fixture } = await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + const component = fixture.componentInstance; + + 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 () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + const errorMessage = "Invalid verification code"; + signInWithMultiFactorAssertion.mockRejectedValue(new FirebaseUIError(errorMessage)); + + const { fixture } = await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + const component = fixture.componentInstance; + + 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 () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + signInWithMultiFactorAssertion.mockRejectedValue(new Error("Network error")); + + const { fixture } = await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + const component = fixture.componentInstance; + + 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-assertion-form.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts new file mode 100644 index 00000000..6e479bf8 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts @@ -0,0 +1,105 @@ +/** + * 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, 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 { 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, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + template: ` +
+
+ +
+
+ + {{ 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/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 new file mode 100644 index 00000000..1e45292f --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts @@ -0,0 +1,390 @@ +/** + * 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"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; +import { PoliciesComponent } from "../../../components/policies"; + +describe("", () => { + let mockGenerateTotpSecret: any; + let mockEnrollWithMultiFactorAssertion: any; + let mockGenerateTotpQrCode: any; + let mockFirebaseUIError: any; + let mockTotpMultiFactorGenerator: any; + + beforeEach(() => { + const { + generateTotpSecret, + enrollWithMultiFactorAssertion, + generateTotpQrCode, + FirebaseUIError, + TotpMultiFactorGenerator, + injectTranslation, + injectUI, + injectMultiFactorTotpAuthEnrollmentFormSchema, + injectMultiFactorTotpAuthVerifyFormSchema, + } = require("../../../tests/test-helpers"); + + mockGenerateTotpSecret = generateTotpSecret; + mockEnrollWithMultiFactorAssertion = enrollWithMultiFactorAssertion; + mockGenerateTotpQrCode = generateTotpQrCode; + mockFirebaseUIError = FirebaseUIError; + mockTotpMultiFactorGenerator = TotpMultiFactorGenerator; + + 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(() => { + 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 { injectUI } = require("../../../tests/test-helpers"); + injectUI.mockImplementation(() => { + return () => ({ + auth: { + currentUser: null, + }, + }); + }); + + 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("An unknown error occurred")).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.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.ts new file mode 100644 index 00000000..f202f6f8 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.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 { 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 { + enrollWithMultiFactorAssertion, + generateTotpSecret, + generateTotpQrCode, + FirebaseUIError, +} from "@firebase-ui/core"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; +import { PoliciesComponent } from "../../../components/policies"; +import { + injectUI, + injectTranslation, + injectMultiFactorTotpAuthNumberFormSchema, + injectMultiFactorTotpAuthVerifyFormSchema, +} 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(); + private displayNameFormSchema = injectMultiFactorTotpAuthNumberFormSchema(); + private verificationFormSchema = injectMultiFactorTotpAuthVerifyFormSchema(); + + 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: this.displayNameFormSchema(), + onSubmit: this.displayNameFormSchema(), + 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; + } + + console.error(error); + return this.unknownErrorLabel(); + } + }, + }, + }); + }); + + effect(() => { + this.verificationForm.update({ + validators: { + onBlur: this.verificationFormSchema(), + onSubmit: this.verificationFormSchema(), + 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; + } + if (error instanceof Error) { + 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-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts new file mode 100644 index 00000000..6be6e6bf --- /dev/null +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts @@ -0,0 +1,151 @@ +/** + * 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 () => { + 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 new file mode 100644 index 00000000..848f7c7c --- /dev/null +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts @@ -0,0 +1,78 @@ +/** + * 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, computed, signal } from "@angular/core"; +import { CommonModule } from "@angular/common"; +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, SmsMultiFactorAssertionFormComponent, TotpMultiFactorAssertionFormComponent, ButtonComponent], + template: ` +
+ @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(); + + 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 = computed(() => PhoneMultiFactorGenerator.FACTOR_ID); + totpFactorId = computed(() => TotpMultiFactorGenerator.FACTOR_ID); + + smsVerificationLabel = injectTranslation("labels", "mfaSmsVerification"); + totpVerificationLabel = injectTranslation("labels", "mfaTotpVerification"); + + selectHint(hint: MultiFactorInfo) { + this.selectedHint.set(hint); + } +} 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 new file mode 100644 index 00000000..8e5c6b05 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts @@ -0,0 +1,193 @@ +/** + * 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"; +import { SmsMultiFactorEnrollmentFormComponent } from "./mfa/sms-multi-factor-enrollment-form"; +import { TotpMultiFactorEnrollmentFormComponent } from "./mfa/totp-multi-factor-enrollment-form"; +import { ButtonComponent } from "../../components/button"; +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], + }, + }); + + expect(screen.queryByRole("button", { name: "SMS Verification" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "TOTP Verification" })).not.toBeInTheDocument(); + + 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"); + + 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"); + + const totpFormComponent = fixture.debugElement.query( + (el) => el.componentInstance instanceof TotpMultiFactorEnrollmentFormComponent + )?.componentInstance as TotpMultiFactorEnrollmentFormComponent; + + expect(totpFormComponent).toBeTruthy(); + totpFormComponent.onEnrollment.emit(); + + expect(enrollmentSpy).toHaveBeenCalled(); + }); + + 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.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.ts new file mode 100644 index 00000000..527116b2 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.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, OnInit } 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"; +import { TotpMultiFactorEnrollmentFormComponent } from "./mfa/totp-multi-factor-enrollment-form"; +import { ButtonComponent } from "../../components/button"; + +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 implements OnInit { + hints = input([FactorId.TOTP, FactorId.PHONE]); + onEnrollment = output(); + + selectedHint = signal(undefined); + + smsVerificationLabel = injectTranslation("labels", "mfaSmsVerification"); + totpVerificationLabel = injectTranslation("labels", "mfaTotpVerification"); + + ngOnInit() { + // Auto-select single hint after component initialization + 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/forms/phone-auth-form/phone-auth-form.component.spec.ts b/packages/angular/src/lib/auth/forms/phone-auth-form.spec.ts similarity index 96% 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..324320a2 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("", () => { @@ -38,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/phone-auth-form.component.ts b/packages/angular/src/lib/auth/forms/phone-auth-form.ts similarity index 91% 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..72b189c5 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 @@ -20,17 +20,14 @@ import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanst import { injectPhoneAuthFormSchema, injectPhoneAuthVerifyFormSchema, + injectRecaptchaVerifier, 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, @@ -89,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: { @@ -115,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) { @@ -134,7 +130,9 @@ export class PhoneNumberFormComponent { const verifier = this.recaptchaVerifier(); onCleanup(() => { - verifier.clear(); + if (verifier) { + verifier.clear(); + } }); }); } 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/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 96% 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 index 9372d0a1..89bd9954 100644 --- 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 @@ -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/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 97% 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..44c2065c 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("", () => { @@ -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/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 95% 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..acdb7796 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,9 +17,7 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { AppleSignInButtonComponent } from "./apple-sign-in-button.component"; - -// Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts +import { AppleSignInButtonComponent } from "./apple-sign-in-button"; @Component({ template: ``, @@ -39,7 +37,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/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 95% 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..24a732cf 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,9 +17,7 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { FacebookSignInButtonComponent } from "./facebook-sign-in-button.component"; - -// Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts +import { FacebookSignInButtonComponent } from "./facebook-sign-in-button"; @Component({ template: ``, @@ -39,7 +37,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/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 95% 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..955d06f6 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,9 +17,7 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { GithubSignInButtonComponent } from "./github-sign-in-button.component"; - -// Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts +import { GithubSignInButtonComponent } from "./github-sign-in-button"; @Component({ template: ``, @@ -39,7 +37,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/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 95% 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..89267c09 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,9 +17,7 @@ import { render, screen } from "@testing-library/angular"; import { Component, signal } from "@angular/core"; -import { GoogleSignInButtonComponent } from "./google-sign-in-button.component"; - -// Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts +import { GoogleSignInButtonComponent } from "./google-sign-in-button"; @Component({ template: ``, @@ -39,7 +37,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/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 95% 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..512901ed 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,9 +17,7 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { MicrosoftSignInButtonComponent } from "./microsoft-sign-in-button.component"; - -// Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts +import { MicrosoftSignInButtonComponent } from "./microsoft-sign-in-button"; @Component({ template: ``, @@ -39,7 +37,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/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 93% 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..a3e46a7b 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,12 +16,9 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { OAuthButtonComponent } from "./oauth-button.component"; -// ButtonComponent is imported by OAuthButtonComponent +import { OAuthButtonComponent } from "./oauth-button"; 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/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 95% 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..404ecd8c 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,9 +17,7 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { TwitterSignInButtonComponent } from "./twitter-sign-in-button.component"; - -// Mocks are handled by jest.config.ts moduleNameMapper and test-helpers.ts +import { TwitterSignInButtonComponent } from "./twitter-sign-in-button"; @Component({ template: ``, @@ -39,7 +37,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/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.spec.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.spec.ts new file mode 100644 index 00000000..abf32144 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.spec.ts @@ -0,0 +1,233 @@ +/** + * 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"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; +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.getByRole("button", { name: "labels.mfaTotpVerification" }); + expect(form).toBeInTheDocument(); + expect(form.parentElement).toHaveTextContent("labels.mfaTotpVerification labels.mfaSmsVerification"); + }); + + 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.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.ts new file mode 100644 index 00000000..8d5c9eb7 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.ts @@ -0,0 +1,68 @@ +/** + * 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 "../forms/multi-factor-auth-enrollment-form"; +import { RedirectErrorComponent } from "../../components/redirect-error"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; + +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"); +} 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 70% 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..e9b2d4ac 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 @@ -16,21 +16,24 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; -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 { 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,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: [] }, + }); + }); + + 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: [] }, + }); + }); + + 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/oauth-screen.component.ts b/packages/angular/src/lib/auth/screens/oauth-screen.ts similarity index 64% 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..14d60dd0 100644 --- a/packages/angular/src/lib/auth/screens/oauth-screen/oauth-screen.component.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, @@ -22,11 +22,12 @@ 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, 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({ selector: "fui-oauth-screen", @@ -40,6 +41,7 @@ import { RedirectErrorComponent } from "../../../components/redirect-error/redir CardContentComponent, PoliciesComponent, ContentComponent, + MultiFactorAuthAssertionFormComponent, RedirectErrorComponent, ], template: ` @@ -50,17 +52,25 @@ import { RedirectErrorComponent } from "../../../components/redirect-error/redir {{ 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/phone-auth-screen.component.spec.ts b/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts similarity index 66% 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..11844a59 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 @@ -16,15 +16,17 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; -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"; +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, @@ -106,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"); @@ -118,6 +134,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -137,6 +154,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -155,6 +173,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -178,6 +197,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, + MockMultiFactorAuthAssertionFormComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -189,4 +209,68 @@ 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: [] }, + }); + }); + + 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: [] }, + }); + }); + + 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/phone-auth-screen.component.ts b/packages/angular/src/lib/auth/screens/phone-auth-screen.ts similarity index 68% 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..8c3f692e 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 @@ -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, @@ -22,10 +22,11 @@ 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, 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"; @Component({ @@ -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/sign-in-auth-screen.component.spec.ts b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts similarity index 67% 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..2e2361a1 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 @@ -16,15 +16,17 @@ 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.component"; +import { SignInAuthScreenComponent } from "./sign-in-auth-screen"; import { CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent, CardContentComponent, -} from "../../../components/card/card.component"; +} 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,68 @@ 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: [] }, + }); + }); + + 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: [] }, + }); + }); + + 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/sign-in-auth-screen.component.ts b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts similarity index 65% 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..7ff81dee 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 @@ -14,19 +14,20 @@ * 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 { SignInAuthFormComponent } from "../../forms/sign-in-auth-form/sign-in-auth-form.component"; -import { RedirectErrorComponent } from "../../../components/redirect-error/redirect-error.component"; +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, 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", @@ -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/sign-up-auth-screen.component.spec.ts b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts similarity index 67% 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..2d0b0121 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 @@ -16,15 +16,17 @@ 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.component"; +import { SignUpAuthScreenComponent } from "./sign-up-auth-screen"; import { CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent, CardContentComponent, -} from "../../../components/card/card.component"; +} 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,68 @@ 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: [] }, + }); + }); + + 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: [] }, + }); + }); + + 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/sign-up-auth-screen.component.ts b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts similarity index 67% 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..02291e77 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 @@ -14,20 +14,21 @@ * 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 { SignUpAuthFormComponent } from "../../forms/sign-up-auth-form/sign-up-auth-form.component"; -import { RedirectErrorComponent } from "../../../components/redirect-error/redirect-error.component"; +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, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent, CardContentComponent, -} from "../../../components/card/card.component"; +} from "../../components/card"; @Component({ selector: "fui-sign-up-auth-screen", @@ -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/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("`, standalone: true, @@ -50,7 +43,6 @@ class TestFormMetadataHostComponent { }) class TestFormActionHostComponent {} -// FormSubmitComponent test host component @Component({ template: `Submit`, standalone: true, @@ -63,7 +55,6 @@ class TestFormSubmitHostComponent { customClass = signal("custom-submit-class"); } -// FormErrorMessageComponent test host component @Component({ template: ``, standalone: true, @@ -92,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: { @@ -110,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/form/form.component.ts b/packages/angular/src/lib/components/form.ts similarity index 92% rename from packages/angular/src/lib/components/form/form.component.ts rename to packages/angular/src/lib/components/form.ts index 5428492a..44b23b30 100644 --- a/packages/angular/src/lib/components/form/form.component.ts +++ b/packages/angular/src/lib/components/form.ts @@ -1,6 +1,6 @@ import { Component, computed, input } from "@angular/core"; import { AnyFieldApi, AnyFormState, injectField } from "@tanstack/angular-form"; -import { ButtonComponent } from "../button/button.component"; +import { ButtonComponent } from "./button"; @Component({ selector: "fui-form-metadata", @@ -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/components/policies/policies.component.spec.ts b/packages/angular/src/lib/components/policies.spec.ts similarity index 97% rename from packages/angular/src/lib/components/policies/policies.component.spec.ts rename to packages/angular/src/lib/components/policies.spec.ts index 18150be2..6a68df1b 100644 --- a/packages/angular/src/lib/components/policies/policies.component.spec.ts +++ b/packages/angular/src/lib/components/policies.spec.ts @@ -14,11 +14,10 @@ * limitations under the License. */ -import { render, screen } from "@testing-library/angular"; +import { render } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { BehaviorSubject } from "rxjs"; -import { PoliciesComponent } from "./policies.component"; +import { PoliciesComponent } from "./policies"; jest.mock("../../provider", () => ({ injectUI: jest.fn(), @@ -181,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/policies/policies.component.ts b/packages/angular/src/lib/components/policies.ts similarity index 97% rename from packages/angular/src/lib/components/policies/policies.component.ts rename to packages/angular/src/lib/components/policies.ts index 2a2871e9..feaef45f 100644 --- a/packages/angular/src/lib/components/policies/policies.component.ts +++ b/packages/angular/src/lib/components/policies.ts @@ -16,7 +16,7 @@ import { Component, computed, Signal } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { injectPolicies, injectTranslation } from "../../provider"; +import { injectPolicies, injectTranslation } from "../provider"; type PolicyPart = | { type: "tos"; url: string; text: string } diff --git a/packages/angular/src/lib/components/redirect-error/redirect-error.component.spec.ts b/packages/angular/src/lib/components/redirect-error.spec.ts similarity index 90% rename from packages/angular/src/lib/components/redirect-error/redirect-error.component.spec.ts rename to packages/angular/src/lib/components/redirect-error.spec.ts index 38c0a870..c73d0662 100644 --- a/packages/angular/src/lib/components/redirect-error/redirect-error.component.spec.ts +++ b/packages/angular/src/lib/components/redirect-error.spec.ts @@ -15,7 +15,7 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { RedirectErrorComponent } from "./redirect-error.component"; +import { RedirectErrorComponent } from "./redirect-error"; @Component({ template: ``, @@ -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"); diff --git a/packages/angular/src/lib/components/redirect-error/redirect-error.component.ts b/packages/angular/src/lib/components/redirect-error.ts similarity index 94% rename from packages/angular/src/lib/components/redirect-error/redirect-error.component.ts rename to packages/angular/src/lib/components/redirect-error.ts index 5b29a948..ae18eca4 100644 --- a/packages/angular/src/lib/components/redirect-error/redirect-error.component.ts +++ b/packages/angular/src/lib/components/redirect-error.ts @@ -15,7 +15,7 @@ import { Component } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { injectRedirectError } from "../../provider"; +import { injectRedirectError } from "../provider"; @Component({ selector: "fui-redirect-error", diff --git a/packages/angular/src/lib/provider.ts b/packages/angular/src/lib/provider.ts index 56eea72b..e0c47933 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 { @@ -33,6 +34,10 @@ import { createPhoneAuthVerifyFormSchema, createSignInAuthFormSchema, createSignUpAuthFormSchema, + createMultiFactorPhoneAuthNumberFormSchema, + createMultiFactorPhoneAuthVerifyFormSchema, + createMultiFactorTotpAuthNumberFormSchema, + createMultiFactorTotpAuthVerifyFormSchema, FirebaseUIStore, type FirebaseUI as FirebaseUIType, getTranslation, @@ -73,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()); @@ -85,6 +89,26 @@ export function injectUI() { return ui.asReadonly(); } +export function injectRecaptchaVerifier(element: () => ElementRef) { + const ui = injectUI(); + const verifier = computed(() => { + const elementRef = element(); + if (!elementRef) { + return null; + } + return getBehavior(ui(), "recaptchaVerification")(ui(), elementRef.nativeElement); + }); + + effect(() => { + const verifierInstance = verifier(); + if (verifierInstance) { + verifierInstance.render(); + } + }); + + return verifier; +} + export function injectTranslation(category: string, key: string) { const ui = injectUI(); return computed(() => getTranslation(ui(), category as any, key as any)); @@ -120,6 +144,34 @@ export function injectPhoneAuthVerifyFormSchema(): Signal createPhoneAuthVerifyFormSchema(ui())); } +export function injectMultiFactorPhoneAuthNumberFormSchema(): Signal< + ReturnType +> { + const ui = injectUI(); + return computed(() => createMultiFactorPhoneAuthNumberFormSchema(ui())); +} + +export function injectMultiFactorPhoneAuthVerifyFormSchema(): Signal< + ReturnType +> { + const ui = injectUI(); + return computed(() => createMultiFactorPhoneAuthVerifyFormSchema(ui())); +} + +export function injectMultiFactorTotpAuthNumberFormSchema(): Signal< + ReturnType +> { + const ui = injectUI(); + return computed(() => createMultiFactorTotpAuthNumberFormSchema(ui())); +} + +export function injectMultiFactorTotpAuthVerifyFormSchema(): Signal< + ReturnType +> { + 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..85312555 100644 --- a/packages/angular/src/lib/tests/test-helpers.ts +++ b/packages/angular/src/lib/tests/test-helpers.ts @@ -18,6 +18,41 @@ 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(); + +// 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: "🇺🇸" }, @@ -83,6 +118,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 +136,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}`; @@ -191,9 +232,55 @@ 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 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"); +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(), diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts index 3354c554..9c6581df 100644 --- a/packages/angular/src/public-api.ts +++ b/packages/angular/src/public-api.ts @@ -17,34 +17,42 @@ import { isDevMode } from "@angular/core"; import { registerFramework } from "@firebase-ui/core"; -export { EmailLinkAuthFormComponent } from "./lib/auth/forms/email-link-auth-form/email-link-auth-form.component"; -export { ForgotPasswordAuthFormComponent } from "./lib/auth/forms/forgot-password-auth-form/forgot-password-auth-form.component"; -export { PhoneAuthFormComponent } from "./lib/auth/forms/phone-auth-form/phone-auth-form.component"; -export { SignInAuthFormComponent } from "./lib/auth/forms/sign-in-auth-form/sign-in-auth-form.component"; -export { SignUpAuthFormComponent } from "./lib/auth/forms/sign-up-auth-form/sign-up-auth-form.component"; - -export { GoogleSignInButtonComponent } from "./lib/auth/oauth/google-sign-in-button.component"; -export { FacebookSignInButtonComponent } from "./lib/auth/oauth/facebook-sign-in-button.component"; -export { AppleSignInButtonComponent } from "./lib/auth/oauth/apple-sign-in-button.component"; -export { MicrosoftSignInButtonComponent } from "./lib/auth/oauth/microsoft-sign-in-button.component"; -export { TwitterSignInButtonComponent } from "./lib/auth/oauth/twitter-sign-in-button.component"; -export { GithubSignInButtonComponent } from "./lib/auth/oauth/github-sign-in-button.component"; -export { OAuthButtonComponent } from "./lib/auth/oauth/oauth-button.component"; - -export { EmailLinkAuthScreenComponent } from "./lib/auth/screens/email-link-auth-screen/email-link-auth-screen.component"; -export { ForgotPasswordAuthScreenComponent } from "./lib/auth/screens/forgot-password-auth-screen/forgot-password-auth-screen.component"; -export { OAuthScreenComponent } from "./lib/auth/screens/oauth-screen/oauth-screen.component"; -export { PhoneAuthScreenComponent } from "./lib/auth/screens/phone-auth-screen/phone-auth-screen.component"; -export { SignInAuthScreenComponent } from "./lib/auth/screens/sign-in-auth-screen/sign-in-auth-screen.component"; -export { SignUpAuthScreenComponent } from "./lib/auth/screens/sign-up-auth-screen/sign-up-auth-screen.component"; - -export { ButtonComponent } from "./lib/components/button/button.component"; -export { CardComponent } from "./lib/components/card/card.component"; -export { CountrySelectorComponent } from "./lib/components/country-selector/country-selector.component"; -export { DividerComponent } from "./lib/components/divider/divider.component"; -export { PoliciesComponent } from "./lib/components/policies/policies.component"; -export { ContentComponent } from "./lib/components/content/content.component"; -export { RedirectErrorComponent } from "./lib/components/redirect-error/redirect-error.component"; +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, + 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"; +export { FacebookSignInButtonComponent } from "./lib/auth/oauth/facebook-sign-in-button"; +export { AppleSignInButtonComponent } from "./lib/auth/oauth/apple-sign-in-button"; +export { MicrosoftSignInButtonComponent } from "./lib/auth/oauth/microsoft-sign-in-button"; +export { TwitterSignInButtonComponent } from "./lib/auth/oauth/twitter-sign-in-button"; +export { GithubSignInButtonComponent } from "./lib/auth/oauth/github-sign-in-button"; +export { OAuthButtonComponent } from "./lib/auth/oauth/oauth-button"; + +export { EmailLinkAuthScreenComponent } from "./lib/auth/screens/email-link-auth-screen"; +export { ForgotPasswordAuthScreenComponent } from "./lib/auth/screens/forgot-password-auth-screen"; +export { OAuthScreenComponent } from "./lib/auth/screens/oauth-screen"; +export { PhoneAuthScreenComponent } from "./lib/auth/screens/phone-auth-screen"; +export { SignInAuthScreenComponent } from "./lib/auth/screens/sign-in-auth-screen"; +export { SignUpAuthScreenComponent } from "./lib/auth/screens/sign-up-auth-screen"; + +export { ButtonComponent } from "./lib/components/button"; +export { CardComponent } from "./lib/components/card"; +export { CountrySelectorComponent } from "./lib/components/country-selector"; +export { DividerComponent } from "./lib/components/divider"; +export { PoliciesComponent } from "./lib/components/policies"; +export { ContentComponent } from "./lib/components/content"; +export { RedirectErrorComponent } from "./lib/components/redirect-error"; // Provider export * from "./lib/provider";