Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -335,4 +335,28 @@ describe("<fui-sms-multi-factor-assertion-verify-form>", () => {
expect(onSuccessSpy).toHaveBeenCalled();
});
});

it("emits onSuccess with credential after successful verification", async () => {
const mockCredential = { user: { uid: "sms-verify-user" } };
signInWithMultiFactorAssertion.mockResolvedValue(mockCredential);

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).toHaveBeenCalledWith(mockCredential);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,9 @@ import {
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";
import { PhoneAuthProvider, PhoneMultiFactorGenerator, type UserCredential, type MultiFactorInfo } from "firebase/auth";

type PhoneMultiFactorInfo = MultiFactorInfo & {
phoneNumber?: string;
Expand Down Expand Up @@ -179,7 +178,7 @@ export class SmsMultiFactorAssertionVerifyFormComponent {
private formSchema = injectMultiFactorPhoneAuthVerifyFormSchema();

verificationId = input.required<string>();
onSuccess = output<void>();
onSuccess = output<UserCredential>();

verificationCodeLabel = injectTranslation("labels", "verificationCode");
verifyCodeLabel = injectTranslation("labels", "verifyCode");
Expand Down Expand Up @@ -208,8 +207,8 @@ export class SmsMultiFactorAssertionVerifyFormComponent {
try {
const credential = PhoneAuthProvider.credential(value.verificationId, value.verificationCode);
const assertion = PhoneMultiFactorGenerator.assertion(credential);
await signInWithMultiFactorAssertion(this.ui(), assertion);
this.onSuccess.emit();
const result = await signInWithMultiFactorAssertion(this.ui(), assertion);
this.onSuccess.emit(result);
return;
} catch (error) {
if (error instanceof FirebaseUIError) {
Expand Down Expand Up @@ -239,7 +238,7 @@ export class SmsMultiFactorAssertionVerifyFormComponent {
@if (verification()) {
<fui-sms-multi-factor-assertion-verify-form
[verificationId]="verification()!.verificationId"
(onSuccess)="onSuccess.emit()"
(onSuccess)="onSuccess.emit($event)"
/>
} @else {
<fui-sms-multi-factor-assertion-phone-form [hint]="hint()" (onSubmit)="handlePhoneSubmit($event)" />
Expand All @@ -249,7 +248,7 @@ export class SmsMultiFactorAssertionVerifyFormComponent {
})
export class SmsMultiFactorAssertionFormComponent {
hint = input.required<MultiFactorInfo>();
onSuccess = output<void>();
onSuccess = output<UserCredential>();

verification = signal<{ verificationId: string } | null>(null);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,36 @@ describe("<fui-totp-multi-factor-assertion-form>", () => {
});
});

it("emits onSuccess with credential after successful verification", async () => {
const mockHint = {
factorId: TotpMultiFactorGenerator.FACTOR_ID,
displayName: "TOTP",
uid: "test-uid",
};

const mockCredential = { user: { uid: "totp-verify-user" } };
signInWithMultiFactorAssertion.mockResolvedValue(mockCredential);

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 waitFor(() => {
expect(onSuccessSpy).toHaveBeenCalledWith(mockCredential);
});
});

it("calls TotpMultiFactorGenerator.assertionForSignIn with correct parameters", async () => {
const mockHint = {
factorId: TotpMultiFactorGenerator.FACTOR_ID,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanst
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";
import { TotpMultiFactorGenerator, type UserCredential, type MultiFactorInfo } from "firebase/auth";

@Component({
selector: "fui-totp-multi-factor-assertion-form",
Expand Down Expand Up @@ -59,7 +59,7 @@ export class TotpMultiFactorAssertionFormComponent {
private formSchema = injectMultiFactorTotpAuthVerifyFormSchema();

hint = input.required<MultiFactorInfo>();
onSuccess = output<void>();
onSuccess = output<UserCredential>();

verificationCodeLabel = injectTranslation("labels", "verificationCode");
verifyCodeLabel = injectTranslation("labels", "verifyCode");
Expand All @@ -82,8 +82,8 @@ export class TotpMultiFactorAssertionFormComponent {
onSubmitAsync: async ({ value }) => {
try {
const assertion = TotpMultiFactorGenerator.assertionForSignIn(this.hint().uid, value.verificationCode);
await signInWithMultiFactorAssertion(this.ui(), assertion);
this.onSuccess.emit();
const result = await signInWithMultiFactorAssertion(this.ui(), assertion);
this.onSuccess.emit(result);
return;
} catch (error) {
if (error instanceof FirebaseUIError) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,16 +136,18 @@ describe("<fui-multi-factor-auth-assertion-form>", () => {
expect(screen.queryByRole("button", { name: "SMS Verification" })).not.toBeInTheDocument();
});

it("throws error when no resolver is provided", () => {
it("throws error when no resolver is provided", async () => {
const { injectUI } = require("../../../provider");
injectUI.mockImplementation(() => {
return () => ({
multiFactorResolver: null,
});
});

expect(() => {
new MultiFactorAuthAssertionFormComponent();
}).toThrow("MultiFactorAuthAssertionForm requires a multi-factor resolver");
await expect(
render(MultiFactorAuthAssertionFormComponent, {
imports: [MultiFactorAuthAssertionFormComponent],
})
).rejects.toThrow("MultiFactorAuthAssertionForm requires a multi-factor resolver");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@
* limitations under the License.
*/

import { Component, computed, signal } from "@angular/core";
import { Component, computed, output, signal } from "@angular/core";
import { CommonModule } from "@angular/common";
import { injectUI, injectTranslation } from "../../provider";
import { PhoneMultiFactorGenerator, TotpMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth";
import {
PhoneMultiFactorGenerator,
TotpMultiFactorGenerator,
type UserCredential,
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";
Expand All @@ -30,9 +35,9 @@ import { ButtonComponent } from "../../components/button";
<div class="fui-content">
@if (selectedHint()) {
@if (selectedHint()!.factorId === phoneFactorId()) {
<fui-sms-multi-factor-assertion-form [hint]="selectedHint()!" />
<fui-sms-multi-factor-assertion-form [hint]="selectedHint()!" (onSuccess)="onSuccess.emit($event)" />
} @else if (selectedHint()!.factorId === totpFactorId()) {
<fui-totp-multi-factor-assertion-form [hint]="selectedHint()!" />
<fui-totp-multi-factor-assertion-form [hint]="selectedHint()!" (onSuccess)="onSuccess.emit($event)" />
}
} @else {
<p>TODO: Select a multi-factor authentication method</p>
Expand All @@ -54,6 +59,8 @@ import { ButtonComponent } from "../../components/button";
export class MultiFactorAuthAssertionFormComponent {
private ui = injectUI();

onSuccess = output<UserCredential>();

resolver = computed(() => {
const resolver = this.ui().multiFactorResolver;
if (!resolver) {
Expand Down
86 changes: 69 additions & 17 deletions packages/angular/src/lib/auth/screens/oauth-screen.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/

import { render, screen } from "@testing-library/angular";
import { Component } from "@angular/core";
import { Component, EventEmitter } from "@angular/core";
import { TestBed } from "@angular/core/testing";

import { OAuthScreenComponent } from "./oauth-screen";
Expand Down Expand Up @@ -50,13 +50,6 @@ class MockPoliciesComponent {}
})
class MockRedirectErrorComponent {}

@Component({
selector: "fui-multi-factor-auth-assertion-form",
template: '<div data-testid="mfa-assertion-form">MFA Assertion Form</div>',
standalone: true,
})
class MockMultiFactorAuthAssertionFormComponent {}

@Component({
template: `
<fui-oauth-screen>
Expand Down Expand Up @@ -87,6 +80,15 @@ class TestHostWithMultipleProvidersComponent {}
})
class TestHostWithoutContentComponent {}

@Component({
selector: "fui-multi-factor-auth-assertion-form",
template: '<div data-testid="mfa-assertion-form">MFA Assertion Form</div>',
standalone: true,
})
class MockMultiFactorAuthAssertionFormComponent {
onSuccess = new EventEmitter();
}

describe("<fui-oauth-screen>", () => {
beforeEach(() => {
const { injectTranslation, injectPolicies, injectRedirectError, injectUI } = require("../../../provider");
Expand Down Expand Up @@ -124,7 +126,7 @@ describe("<fui-oauth-screen>", () => {
OAuthScreenComponent,
MockPoliciesComponent,
MockRedirectErrorComponent,
MockMultiFactorAuthAssertionFormComponent,
MultiFactorAuthAssertionFormComponent,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
Expand All @@ -144,7 +146,7 @@ describe("<fui-oauth-screen>", () => {
OAuthScreenComponent,
MockPoliciesComponent,
MockRedirectErrorComponent,
MockMultiFactorAuthAssertionFormComponent,
MultiFactorAuthAssertionFormComponent,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
Expand All @@ -164,7 +166,7 @@ describe("<fui-oauth-screen>", () => {
OAuthScreenComponent,
MockPoliciesComponent,
MockRedirectErrorComponent,
MockMultiFactorAuthAssertionFormComponent,
MultiFactorAuthAssertionFormComponent,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
Expand All @@ -185,7 +187,7 @@ describe("<fui-oauth-screen>", () => {
OAuthScreenComponent,
MockPoliciesComponent,
MockRedirectErrorComponent,
MockMultiFactorAuthAssertionFormComponent,
MultiFactorAuthAssertionFormComponent,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
Expand All @@ -210,7 +212,7 @@ describe("<fui-oauth-screen>", () => {
OAuthScreenComponent,
MockPoliciesComponent,
MockRedirectErrorComponent,
MockMultiFactorAuthAssertionFormComponent,
MultiFactorAuthAssertionFormComponent,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
Expand All @@ -230,7 +232,7 @@ describe("<fui-oauth-screen>", () => {
OAuthScreenComponent,
MockPoliciesComponent,
MockRedirectErrorComponent,
MockMultiFactorAuthAssertionFormComponent,
MultiFactorAuthAssertionFormComponent,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
Expand All @@ -255,7 +257,7 @@ describe("<fui-oauth-screen>", () => {
OAuthScreenComponent,
MockPoliciesComponent,
MockRedirectErrorComponent,
MockMultiFactorAuthAssertionFormComponent,
MultiFactorAuthAssertionFormComponent,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
Expand Down Expand Up @@ -288,7 +290,7 @@ describe("<fui-oauth-screen>", () => {
OAuthScreenComponent,
MockPoliciesComponent,
MockRedirectErrorComponent,
MockMultiFactorAuthAssertionFormComponent,
MultiFactorAuthAssertionFormComponent,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
Expand Down Expand Up @@ -321,7 +323,7 @@ describe("<fui-oauth-screen>", () => {
OAuthScreenComponent,
MockPoliciesComponent,
MockRedirectErrorComponent,
MockMultiFactorAuthAssertionFormComponent,
MultiFactorAuthAssertionFormComponent,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
Expand All @@ -334,4 +336,54 @@ describe("<fui-oauth-screen>", () => {
expect(screen.queryByTestId("policies")).not.toBeInTheDocument();
expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument();
});

it("emits onSignIn with credential when MFA flow succeeds", async () => {
const { injectUI } = require("../../../provider");
injectUI.mockImplementation(() => {
return () => ({
multiFactorResolver: { hints: [{ factorId: "totp", uid: "test" }] },
});
});

TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, {
set: {
template:
'<div data-testid="mfa-assertion-form">MFA Assertion Form</div><button data-testid="mfa-on-success" (click)="onSuccess.emit({ user: { uid: \'angular-oauth-mfa-user\' } })">Trigger</button>',
},
});

const onSignInHandler = jest.fn();

@Component({
template: `<fui-oauth-screen (onSignIn)="onSignIn($event)"></fui-oauth-screen>`,
standalone: true,
imports: [OAuthScreenComponent],
})
class HostCaptureComponent {
onSignIn = onSignInHandler;
}

await render(HostCaptureComponent, {
imports: [
OAuthScreenComponent,
MockPoliciesComponent,
MockRedirectErrorComponent,
MultiFactorAuthAssertionFormComponent,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardSubtitleComponent,
CardContentComponent,
ContentComponent,
],
});

const trigger = screen.getByTestId("mfa-on-success");
trigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));

expect(onSignInHandler).toHaveBeenCalled();
expect(onSignInHandler).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.objectContaining({ uid: "angular-oauth-mfa-user" }) })
);
});
});
Loading