Skip to content

Commit e4de63c

Browse files
committed
feat(angular): Add MFA Assertion form (with inner stubs)
1 parent 2e5807d commit e4de63c

File tree

5 files changed

+269
-5
lines changed

5 files changed

+269
-5
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
import { Component, input } from "@angular/core";
17+
import { CommonModule } from "@angular/common";
18+
import { MultiFactorInfo } from "firebase/auth";
19+
20+
@Component({
21+
selector: "fui-sms-multi-factor-assertion-form",
22+
standalone: true,
23+
imports: [CommonModule],
24+
template: `
25+
<div class="fui-content">
26+
<div>SMS Multi-Factor Assertion Form (Stubbed)</div>
27+
<div>Hint: {{ hint()?.displayName || 'No hint' }}</div>
28+
</div>
29+
`,
30+
})
31+
export class SmsMultiFactorAssertionFormComponent {
32+
hint = input.required<MultiFactorInfo>();
33+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
import { Component, input } from "@angular/core";
17+
import { CommonModule } from "@angular/common";
18+
import { MultiFactorInfo } from "firebase/auth";
19+
20+
@Component({
21+
selector: "fui-totp-multi-factor-assertion-form",
22+
standalone: true,
23+
imports: [CommonModule],
24+
template: `
25+
<div class="fui-content">
26+
<div>TOTP Multi-Factor Assertion Form (Stubbed)</div>
27+
<div>Hint: {{ hint()?.displayName || 'No hint' }}</div>
28+
</div>
29+
`,
30+
})
31+
export class TotpMultiFactorAssertionFormComponent {
32+
hint = input.required<MultiFactorInfo>();
33+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
import { render, screen, fireEvent } from "@testing-library/angular";
17+
import { TestBed } from "@angular/core/testing";
18+
import { PhoneMultiFactorGenerator, TotpMultiFactorGenerator } from "firebase/auth";
19+
20+
import { MultiFactorAuthAssertionFormComponent } from "./multi-factor-auth-assertion-form";
21+
import { SmsMultiFactorAssertionFormComponent } from "./mfa/sms-multi-factor-assertion-form";
22+
import { TotpMultiFactorAssertionFormComponent } from "./mfa/totp-multi-factor-assertion-form";
23+
24+
describe("<fui-multi-factor-auth-assertion-form>", () => {
25+
beforeEach(() => {
26+
const { injectTranslation, injectUI } = require("../../../provider");
27+
injectTranslation.mockImplementation((category: string, key: string) => {
28+
const mockTranslations: Record<string, Record<string, string>> = {
29+
labels: {
30+
mfaSmsVerification: "SMS Verification",
31+
mfaTotpVerification: "TOTP Verification",
32+
},
33+
};
34+
return () => mockTranslations[category]?.[key] || `${category}.${key}`;
35+
});
36+
37+
injectUI.mockImplementation(() => {
38+
return () => ({
39+
multiFactorResolver: {
40+
hints: [
41+
{
42+
factorId: PhoneMultiFactorGenerator.FACTOR_ID,
43+
displayName: "Phone",
44+
},
45+
{
46+
factorId: TotpMultiFactorGenerator.FACTOR_ID,
47+
displayName: "TOTP",
48+
},
49+
],
50+
},
51+
});
52+
});
53+
});
54+
55+
it("renders selection UI when multiple hints are available", async () => {
56+
TestBed.overrideComponent(SmsMultiFactorAssertionFormComponent, {
57+
set: {
58+
template: '<div data-testid="sms-assertion-form">SMS Assertion Form</div>',
59+
},
60+
});
61+
TestBed.overrideComponent(TotpMultiFactorAssertionFormComponent, {
62+
set: {
63+
template: '<div data-testid="totp-assertion-form">TOTP Assertion Form</div>',
64+
},
65+
});
66+
67+
await render(MultiFactorAuthAssertionFormComponent, {
68+
imports: [MultiFactorAuthAssertionFormComponent],
69+
});
70+
71+
expect(screen.getByRole("button", { name: "SMS Verification" })).toBeInTheDocument();
72+
expect(screen.getByRole("button", { name: "TOTP Verification" })).toBeInTheDocument();
73+
74+
expect(screen.queryByTestId("sms-assertion-form")).not.toBeInTheDocument();
75+
expect(screen.queryByTestId("totp-assertion-form")).not.toBeInTheDocument();
76+
});
77+
78+
it("auto-selects single hint when only one is available", async () => {
79+
const { injectUI } = require("../../../provider");
80+
injectUI.mockImplementation(() => {
81+
return () => ({
82+
multiFactorResolver: {
83+
hints: [
84+
{
85+
factorId: PhoneMultiFactorGenerator.FACTOR_ID,
86+
displayName: "Phone",
87+
},
88+
],
89+
},
90+
});
91+
});
92+
93+
TestBed.overrideComponent(SmsMultiFactorAssertionFormComponent, {
94+
set: {
95+
template: '<div data-testid="sms-assertion-form">SMS Assertion Form</div>',
96+
},
97+
});
98+
TestBed.overrideComponent(TotpMultiFactorAssertionFormComponent, {
99+
set: {
100+
template: '<div data-testid="totp-assertion-form">TOTP Assertion Form</div>',
101+
},
102+
});
103+
104+
await render(MultiFactorAuthAssertionFormComponent, {
105+
imports: [MultiFactorAuthAssertionFormComponent],
106+
});
107+
108+
expect(screen.getByTestId("sms-assertion-form")).toBeInTheDocument();
109+
110+
expect(screen.queryByRole("button", { name: "SMS Verification" })).not.toBeInTheDocument();
111+
expect(screen.queryByRole("button", { name: "TOTP Verification" })).not.toBeInTheDocument();
112+
});
113+
114+
it("switches to assertion form when selection button is clicked", async () => {
115+
// Override the inner components with mocks
116+
TestBed.overrideComponent(SmsMultiFactorAssertionFormComponent, {
117+
set: {
118+
template: '<div data-testid="sms-assertion-form">SMS Assertion Form</div>',
119+
},
120+
});
121+
TestBed.overrideComponent(TotpMultiFactorAssertionFormComponent, {
122+
set: {
123+
template: '<div data-testid="totp-assertion-form">TOTP Assertion Form</div>',
124+
},
125+
});
126+
127+
await render(MultiFactorAuthAssertionFormComponent, {
128+
imports: [MultiFactorAuthAssertionFormComponent],
129+
});
130+
131+
expect(screen.getByRole("button", { name: "SMS Verification" })).toBeInTheDocument();
132+
expect(screen.queryByTestId("sms-assertion-form")).not.toBeInTheDocument();
133+
134+
fireEvent.click(screen.getByRole("button", { name: "SMS Verification" }));
135+
136+
expect(screen.getByTestId("sms-assertion-form")).toBeInTheDocument();
137+
expect(screen.queryByRole("button", { name: "SMS Verification" })).not.toBeInTheDocument();
138+
});
139+
140+
it("throws error when no resolver is provided", () => {
141+
const { injectUI } = require("../../../provider");
142+
injectUI.mockImplementation(() => {
143+
return () => ({
144+
multiFactorResolver: null,
145+
});
146+
});
147+
148+
expect(() => {
149+
new MultiFactorAuthAssertionFormComponent();
150+
}).toThrow("MultiFactorAuthAssertionForm requires a multi-factor resolver");
151+
});
152+
});

packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,28 +14,70 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { Component, computed } from "@angular/core";
17+
import { Component, computed, signal } from "@angular/core";
1818
import { CommonModule } from "@angular/common";
19-
import { injectUI } from "../../provider";
19+
import { injectUI, injectTranslation } from "../../provider";
20+
import { PhoneMultiFactorGenerator, TotpMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth";
21+
import { SmsMultiFactorAssertionFormComponent } from "./mfa/sms-multi-factor-assertion-form";
22+
import { TotpMultiFactorAssertionFormComponent } from "./mfa/totp-multi-factor-assertion-form";
23+
import { ButtonComponent } from "../../components/button";
2024

2125
@Component({
2226
selector: "fui-multi-factor-auth-assertion-form",
2327
standalone: true,
24-
imports: [CommonModule],
28+
imports: [
29+
CommonModule,
30+
SmsMultiFactorAssertionFormComponent,
31+
TotpMultiFactorAssertionFormComponent,
32+
ButtonComponent,
33+
],
2534
template: `
2635
<div class="fui-content">
27-
<div>Hello World - MFA Assertion Form</div>
36+
@if (selectedHint()) {
37+
@if (selectedHint()!.factorId === phoneFactorId) {
38+
<fui-sms-multi-factor-assertion-form [hint]="selectedHint()!" />
39+
} @else if (selectedHint()!.factorId === totpFactorId) {
40+
<fui-totp-multi-factor-assertion-form [hint]="selectedHint()!" />
41+
}
42+
} @else {
43+
<p>TODO: Select a multi-factor authentication method</p>
44+
@for (hint of resolver().hints; track hint.factorId) {
45+
@if (hint.factorId === totpFactorId) {
46+
<button fui-button (click)="selectHint(hint)">
47+
{{ totpVerificationLabel() }}
48+
</button>
49+
} @else if (hint.factorId === phoneFactorId) {
50+
<button fui-button (click)="selectHint(hint)">
51+
{{ smsVerificationLabel() }}
52+
</button>
53+
}
54+
}
55+
}
2856
</div>
2957
`,
3058
})
3159
export class MultiFactorAuthAssertionFormComponent {
3260
private ui = injectUI();
3361

34-
mfaResolver = computed(() => {
62+
resolver = computed(() => {
3563
const resolver = this.ui().multiFactorResolver;
3664
if (!resolver) {
3765
throw new Error("MultiFactorAuthAssertionForm requires a multi-factor resolver");
3866
}
3967
return resolver;
4068
});
69+
70+
selectedHint = signal<MultiFactorInfo | undefined>(
71+
this.resolver().hints.length === 1 ? this.resolver().hints[0] : undefined
72+
);
73+
74+
phoneFactorId = PhoneMultiFactorGenerator.FACTOR_ID;
75+
totpFactorId = TotpMultiFactorGenerator.FACTOR_ID;
76+
77+
smsVerificationLabel = injectTranslation("labels", "mfaSmsVerification");
78+
totpVerificationLabel = injectTranslation("labels", "mfaTotpVerification");
79+
80+
selectHint(hint: MultiFactorInfo) {
81+
this.selectedHint.set(hint);
82+
}
4183
}

packages/angular/src/public-api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,14 @@ import { registerFramework } from "@firebase-ui/core";
1919

2020
export { EmailLinkAuthFormComponent } from "./lib/auth/forms/email-link-auth-form";
2121
export { ForgotPasswordAuthFormComponent } from "./lib/auth/forms/forgot-password-auth-form";
22+
export { MultiFactorAuthAssertionFormComponent } from "./lib/auth/forms/multi-factor-auth-assertion-form";
2223
export { PhoneAuthFormComponent } from "./lib/auth/forms/phone-auth-form";
2324
export { SignInAuthFormComponent } from "./lib/auth/forms/sign-in-auth-form";
2425
export { SignUpAuthFormComponent } from "./lib/auth/forms/sign-up-auth-form";
2526

27+
export { SmsMultiFactorAssertionFormComponent } from "./lib/auth/forms/mfa/sms-multi-factor-assertion-form";
28+
export { TotpMultiFactorAssertionFormComponent } from "./lib/auth/forms/mfa/totp-multi-factor-assertion-form";
29+
2630
export { GoogleSignInButtonComponent } from "./lib/auth/oauth/google-sign-in-button";
2731
export { FacebookSignInButtonComponent } from "./lib/auth/oauth/facebook-sign-in-button";
2832
export { AppleSignInButtonComponent } from "./lib/auth/oauth/apple-sign-in-button";

0 commit comments

Comments
 (0)