diff --git a/examples/angular/package.json b/examples/angular/package.json index 1e12ca98b..1c116b2e5 100644 --- a/examples/angular/package.json +++ b/examples/angular/package.json @@ -32,10 +32,10 @@ "@angular/platform-server": "^20.2.2", "@angular/router": "^20.2.2", "@angular/ssr": "^20.2.2", - "@invertase/firebaseui-angular": "0.0.3", - "@invertase/firebaseui-core": "0.0.3", - "@invertase/firebaseui-styles": "0.0.7", - "@invertase/firebaseui-translations": "0.0.4", + "@invertase/firebaseui-angular": "workspace:*", + "@invertase/firebaseui-core": "workspace:*", + "@invertase/firebaseui-styles": "workspace:*", + "@invertase/firebaseui-translations": "workspace:*", "@tailwindcss/postcss": "^4.0.6", "express": "^4.18.2", "postcss": "^8.5.2", diff --git a/examples/angular/public/firebase-logo-inverted.png b/examples/angular/public/firebase-logo-inverted.png new file mode 100644 index 000000000..b6f4ef80a Binary files /dev/null and b/examples/angular/public/firebase-logo-inverted.png differ diff --git a/examples/angular/public/firebase-logo.png b/examples/angular/public/firebase-logo.png new file mode 100644 index 000000000..1cf731440 Binary files /dev/null and b/examples/angular/public/firebase-logo.png differ diff --git a/examples/angular/src/app/app.component.html b/examples/angular/src/app/app.component.html index a057c5171..a166cf8f1 100644 --- a/examples/angular/src/app/app.component.html +++ b/examples/angular/src/app/app.component.html @@ -14,6 +14,6 @@ limitations under the License. --> -
- -
\ No newline at end of file + + + \ No newline at end of file diff --git a/examples/angular/src/app/app.component.ts b/examples/angular/src/app/app.component.ts index 4c071815b..acd98aab5 100644 --- a/examples/angular/src/app/app.component.ts +++ b/examples/angular/src/app/app.component.ts @@ -14,43 +14,125 @@ * limitations under the License. */ -import { Component } from "@angular/core"; -import { RouterOutlet } from "@angular/router"; +import { Component, computed, inject, input } from "@angular/core"; +import { RouterModule, Router } from "@angular/router"; import { CommonModule } from "@angular/common"; -import { HeaderComponent } from "./components/header"; +import { Auth, multiFactor, sendEmailVerification, signOut, type User } from "@angular/fire/auth"; +import { routes } from "./routes"; +import { ThemeToggleComponent } from "./components/theme-toggle/theme-toggle.component"; +import { PirateToggleComponent } from "./components/pirate-toggle/pirate-toggle.component"; +import { MultiFactorAuthAssertionScreenComponent } from "@invertase/firebaseui-angular"; +import { injectUI } from "@invertase/firebaseui-angular"; @Component({ - selector: "app-root", + selector: "app-unauthenticated", + standalone: true, + imports: [CommonModule, RouterModule, MultiFactorAuthAssertionScreenComponent], + template: ` + @if (mfaResolver()) { + + } @else { +
+
+ + Firebase UI +

+ Welcome to Firebase UI, choose an example screen below to get started! +

+
+
+ @for (route of routes; track route.path) { + +
+

{{ route.name }}

+

{{ route.description }}

+
+
+ +
+
+ } +
+
+ } + `, +}) +export class UnauthenticatedAppComponent { + ui = injectUI(); + routes = routes; + + mfaResolver = computed(() => this.ui().multiFactorResolver); +} + +@Component({ + selector: "app-authenticated", standalone: true, - imports: [CommonModule, RouterOutlet, HeaderComponent], + imports: [CommonModule, RouterModule], template: ` - -
- +
+
+

Welcome, {{ user().displayName || user().email || user().phoneNumber }}

+ @if (user().email) { + @if (user().emailVerified) { +
Email verified
+ } @else { + + } + } +
+

Multi-factor Authentication

+ @for (factor of mfaFactors(); track factor.factorId) { +
{{ factor.factorId }} - {{ factor.displayName }}
+ } + +
+ +
`, - styles: [ - ` - .app-container { - max-width: 1200px; - margin: 0 auto; - } - - :host { - display: block; - min-height: 100vh; - background-color: #f9fafb; - font-family: - system-ui, - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Roboto, - sans-serif; - } - `, - ], }) -export class AppComponent { - title = "Firebase UI Angular Example"; +export class AuthenticatedAppComponent { + user = input.required(); + private auth = inject(Auth); + private router = inject(Router); + + mfaFactors = computed(() => { + const mfa = multiFactor(this.user()); + return mfa.enrolledFactors; + }); + + async verifyEmail() { + try { + await sendEmailVerification(this.user()); + alert("Email verification sent, please check your email"); + } catch (error) { + console.error(error); + alert("Error sending email verification, check console"); + } + } + + navigateToMfa() { + this.router.navigate(["/screens/mfa-enrollment-screen"]); + } + + async signOut() { + await signOut(this.auth); + } } + +@Component({ + selector: "app-root", + standalone: true, + imports: [CommonModule, RouterModule, ThemeToggleComponent, PirateToggleComponent], + templateUrl: "./app.component.html", +}) +export class AppComponent {} diff --git a/examples/angular/src/app/app.routes.server.ts b/examples/angular/src/app/app.routes.server.ts index a75f17913..4d2d69b8a 100644 --- a/examples/angular/src/app/app.routes.server.ts +++ b/examples/angular/src/app/app.routes.server.ts @@ -24,47 +24,63 @@ export const serverRoutes: ServerRoute[] = [ }, /** Static auth demos - good for SSG as they showcase Firebase UI components */ { - path: "sign-in", + path: "screens/sign-in-auth-screen", renderMode: RenderMode.Prerender, }, { - path: "oauth", + path: "screens/oauth-screen", renderMode: RenderMode.Prerender, }, /** Interactive auth routes - better as CSR for user interaction */ { - path: "sign-up", + path: "screens/sign-up-auth-screen", renderMode: RenderMode.Client, }, { - path: "forgot-password", + path: "screens/forgot-password-auth-screen", renderMode: RenderMode.Client, }, /** Dynamic auth routes - good for SSR as they may need server-side data */ { - path: "email-link", + path: "screens/email-link-auth-screen", renderMode: RenderMode.Server, }, { - path: "email-link-oauth", + path: "screens/email-link-auth-screen-w-oauth", renderMode: RenderMode.Server, }, { - path: "phone", + path: "screens/phone-auth-screen", renderMode: RenderMode.Server, }, { - path: "phone-oauth", + path: "screens/phone-auth-screen-w-oauth", renderMode: RenderMode.Server, }, { - path: "sign-in-oauth", + path: "screens/sign-in-auth-screen-w-oauth", renderMode: RenderMode.Server, }, { - path: "sign-up-oauth", + path: "screens/sign-up-auth-screen-w-oauth", renderMode: RenderMode.Server, }, + { + path: "screens/sign-in-auth-screen-w-handlers", + renderMode: RenderMode.Client, + }, + { + path: "screens/sign-up-auth-screen-w-handlers", + renderMode: RenderMode.Client, + }, + { + path: "screens/forgot-password-auth-screen-w-handlers", + renderMode: RenderMode.Client, + }, + { + path: "screens/mfa-enrollment-screen", + renderMode: RenderMode.Client, + }, /** All other routes will be rendered on the server (SSR) */ { path: "**", diff --git a/examples/angular/src/app/app.routes.ts b/examples/angular/src/app/app.routes.ts index 81960a6f7..0902873c8 100644 --- a/examples/angular/src/app/app.routes.ts +++ b/examples/angular/src/app/app.routes.ts @@ -15,6 +15,10 @@ */ import { type Routes } from "@angular/router"; +import { routes as routeConfigs, hiddenRoutes } from "./routes"; +import { ScreenRouteLayoutComponent } from "./components/screen-route-layout/screen-route-layout.component"; + +const allRoutes = [...routeConfigs, ...hiddenRoutes]; export const routes: Routes = [ { @@ -22,44 +26,12 @@ export const routes: Routes = [ loadComponent: () => import("./home").then((m) => m.HomeComponent), }, { - path: "email-link", - loadComponent: () => import("./auth/email-link").then((m) => m.EmailLinkComponent), - }, - { - path: "email-link-oauth", - loadComponent: () => import("./auth/email-link-oauth").then((m) => m.EmailLinkOAuthComponent), - }, - { - path: "forgot-password", - loadComponent: () => import("./auth/forgot-password").then((m) => m.ForgotPasswordComponent), - }, - { - path: "oauth", - loadComponent: () => import("./auth/oauth").then((m) => m.OAuthComponent), - }, - { - path: "phone", - loadComponent: () => import("./auth/phone").then((m) => m.PhoneComponent), - }, - { - path: "phone-oauth", - loadComponent: () => import("./auth/phone-oauth").then((m) => m.PhoneOAuthComponent), - }, - { - path: "sign-in", - loadComponent: () => import("./auth/sign-in").then((m) => m.SignInComponent), - }, - { - path: "sign-in-oauth", - loadComponent: () => import("./auth/sign-in-oauth").then((m) => m.SignInOAuthComponent), - }, - { - path: "sign-up", - loadComponent: () => import("./auth/sign-up").then((m) => m.SignUpComponent), - }, - { - path: "sign-up-oauth", - loadComponent: () => import("./auth/sign-up-oauth").then((m) => m.SignUpOAuthComponent), + path: "screens", + component: ScreenRouteLayoutComponent, + children: allRoutes.map((route) => ({ + path: route.path.replace(/^\/screens\//, ""), + loadComponent: route.loadComponent, + })), }, { path: "**", diff --git a/examples/angular/src/app/components/header/header.component.ts b/examples/angular/src/app/components/header/header.component.ts deleted file mode 100644 index 54f31be69..000000000 --- a/examples/angular/src/app/components/header/header.component.ts +++ /dev/null @@ -1,117 +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 } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { RouterModule } from "@angular/router"; -import { Auth, type User, authState, signOut } from "@angular/fire/auth"; -import { Router } from "@angular/router"; -import { type Observable } from "rxjs"; - -@Component({ - selector: "app-header", - standalone: true, - imports: [CommonModule, RouterModule], - template: ` -
-
- -
- -
-
-
- `, - styles: [ - ` - .border-b { - border-bottom-width: 1px; - } - .border-gray-200 { - border-color: #e5e7eb; - } - .max-w-6xl { - max-width: 72rem; - } - .mx-auto { - margin-left: auto; - margin-right: auto; - } - .h-12 { - height: 3rem; - } - .px-4 { - padding-left: 1rem; - padding-right: 1rem; - } - .flex { - display: flex; - } - .items-center { - align-items: center; - } - .font-bold { - font-weight: 700; - } - .flex-grow { - flex-grow: 1; - } - .justify-end { - justify-content: flex-end; - } - .text-sm { - font-size: 0.875rem; - line-height: 1.25rem; - } - .gap-6 { - gap: 1.5rem; - } - button { - background: none; - border: none; - cursor: pointer; - font: inherit; - color: inherit; - } - a { - text-decoration: none; - color: inherit; - } - *:hover { - opacity: 0.75; - } - `, - ], -}) -export class HeaderComponent { - private auth = inject(Auth); - private router = inject(Router); - user$: Observable = authState(this.auth); - - async onSignOut() { - await signOut(this.auth); - this.router.navigate(["/auth/sign-in"]); - } -} diff --git a/examples/angular/src/app/components/pirate-toggle/pirate-toggle.component.ts b/examples/angular/src/app/components/pirate-toggle/pirate-toggle.component.ts new file mode 100644 index 000000000..77401b370 --- /dev/null +++ b/examples/angular/src/app/components/pirate-toggle/pirate-toggle.component.ts @@ -0,0 +1,51 @@ +/** + * 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 } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { injectUI } from "@invertase/firebaseui-angular"; +import { enUs } from "@invertase/firebaseui-translations"; +import { pirate } from "../../pirate"; + +@Component({ + selector: "app-pirate-toggle", + standalone: true, + imports: [CommonModule], + template: ` + + `, + styles: [], +}) +export class PirateToggleComponent { + private ui = injectUI(); + + isPirate = computed(() => this.ui().locale.locale === "pirate"); + + toggleLocale() { + const currentUI = this.ui(); + if (this.isPirate()) { + currentUI.setLocale(enUs); + } else { + currentUI.setLocale(pirate); + } + } +} diff --git a/examples/angular/src/app/components/screen-route-layout/screen-route-layout.component.ts b/examples/angular/src/app/components/screen-route-layout/screen-route-layout.component.ts new file mode 100644 index 000000000..ec6b361b1 --- /dev/null +++ b/examples/angular/src/app/components/screen-route-layout/screen-route-layout.component.ts @@ -0,0 +1,40 @@ +/** + * 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 } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { RouterModule } from "@angular/router"; + +@Component({ + selector: "app-screen-route-layout", + standalone: true, + imports: [CommonModule, RouterModule], + template: ` + + `, + styles: [], +}) +export class ScreenRouteLayoutComponent {} diff --git a/examples/angular/src/app/components/theme-toggle/theme-toggle.component.ts b/examples/angular/src/app/components/theme-toggle/theme-toggle.component.ts new file mode 100644 index 000000000..dbfe6ed8e --- /dev/null +++ b/examples/angular/src/app/components/theme-toggle/theme-toggle.component.ts @@ -0,0 +1,62 @@ +/** + * 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 } from "@angular/core"; +import { CommonModule } from "@angular/common"; + +@Component({ + selector: "app-theme-toggle", + standalone: true, + imports: [CommonModule], + template: ` + + `, + styles: [], +}) +export class ThemeToggleComponent { + toggleTheme() { + const htmlElement = document.documentElement; + const isDark = htmlElement.classList.contains("dark"); + htmlElement.classList.toggle("dark", !isDark); + localStorage["theme"] = htmlElement.classList.contains("dark") ? "dark" : "light"; + } +} diff --git a/examples/angular/src/app/home/home.component.ts b/examples/angular/src/app/home/home.component.ts index addba1d04..71e2037d8 100644 --- a/examples/angular/src/app/home/home.component.ts +++ b/examples/angular/src/app/home/home.component.ts @@ -16,69 +16,24 @@ import { Component, inject } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { RouterModule } from "@angular/router"; -import { Auth, type User, authState } from "@angular/fire/auth"; -import { type Observable } from "rxjs"; +import { AsyncPipe } from "@angular/common"; +import { UserService } from "../services/user.service"; +import { UnauthenticatedAppComponent } from "../app.component"; +import { AuthenticatedAppComponent } from "../app.component"; @Component({ selector: "app-home", standalone: true, - imports: [CommonModule, RouterModule], + imports: [CommonModule, AsyncPipe, UnauthenticatedAppComponent, AuthenticatedAppComponent], template: ` - + @if (user$ | async; as user) { + + } @else { + + } `, - styles: [], }) export class HomeComponent { - private auth = inject(Auth); - user$: Observable = authState(this.auth); - - signOut() { - this.auth.signOut(); - } + private userService = inject(UserService); + user$ = this.userService.getUser(); } diff --git a/examples/angular/src/app/home/index.ts b/examples/angular/src/app/home/index.ts index 099bc938e..d7102cdb5 100644 --- a/examples/angular/src/app/home/index.ts +++ b/examples/angular/src/app/home/index.ts @@ -1,17 +1 @@ -/** - * 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. - */ - export * from "./home.component"; diff --git a/examples/angular/src/app/pirate.ts b/examples/angular/src/app/pirate.ts new file mode 100644 index 000000000..aa92433ce --- /dev/null +++ b/examples/angular/src/app/pirate.ts @@ -0,0 +1,95 @@ +import { registerLocale } from "@invertase/firebaseui-translations"; + +export const pirate = registerLocale("pirate", { + errors: { + userNotFound: "Arrr! No account found with this email address, matey", + wrongPassword: "Arrr! Incorrect password, ye scallywag", + invalidEmail: "Avast! Enter a valid email address, ye bilge rat", + userDisabled: "This account has been marooned, arrr!", + networkRequestFailed: "Can't connect to the server, ye land lubber! Check yer internet connection", + tooManyRequests: "Too many failed attempts, ye scurvy dog! Try again later", + missingVerificationCode: "Enter the verification code, ye scallywag", + emailAlreadyInUse: "An account already exists with this email, arrr!", + invalidCredential: "The credentials ye provided be invalid, matey", + weakPassword: "Ye password ain't long enough! It should be at least 8 characters", + unverifiedEmail: "Verify yer email address to continue, ye scallywag", + operationNotAllowed: "This operation ain't allowed, arrr! Contact support, matey", + invalidPhoneNumber: "The phone number be invalid, ye bilge rat", + missingPhoneNumber: "Provide a phone number, ye scallywag", + quotaExceeded: "SMS quota exceeded, arrr! Try again later, matey", + codeExpired: "The verification code has expired, ye scurvy dog", + captchaCheckFailed: "reCAPTCHA verification failed, arrr! Try again, matey", + missingVerificationId: "Complete the reCAPTCHA verification first, ye scallywag", + missingEmail: "Provide an email address, ye bilge rat", + invalidActionCode: "The password reset link be invalid or has expired, arrr!", + credentialAlreadyInUse: "An account already exists with this email, arrr! Sign in with that account, matey", + requiresRecentLogin: "This operation requires a recent login, ye scallywag! Sign in again", + providerAlreadyLinked: "This phone number be already linked to another account, arrr!", + invalidVerificationCode: "Invalid verification code, ye scurvy dog! Try again", + unknownError: "An unexpected error occurred, arrr!", + popupClosed: "The sign-in popup was closed, ye scallywag! Try again", + accountExistsWithDifferentCredential: + "An account already exists with this email, arrr! Sign in with the original provider, matey", + displayNameRequired: "Provide a display name, ye bilge rat", + secondFactorAlreadyInUse: "This phone number be already enrolled with this account, arrr!", + }, + messages: { + passwordResetEmailSent: "Password reset email sent successfully, arrr!", + signInLinkSent: "Sign-in link sent successfully, matey!", + verificationCodeFirst: "Request a verification code first, ye scallywag", + checkEmailForReset: "Check yer email for password reset instructions, ye bilge rat", + dividerOr: "or", + termsAndPrivacy: "By continuing, ye agree to our {tos} and {privacy}, arrr!", + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process, matey.", + }, + labels: { + emailAddress: "Email Address, ye bilge rat", + password: "Password, ye scallywag", + displayName: "Display Name, ye bilge rat", + forgotPassword: "Forgot Password, ye scallywag?", + signUp: "Sign Up, Matey", + signIn: "Sign In, Matey", + resetPassword: "Reset Password, ye scallywag", + createAccount: "Create Account, ye bilge rat", + backToSignIn: "Back to Sign In, ye scallywag", + signInWithPhone: "Sign in with Phone, ye scallywag", + phoneNumber: "Phone Number, ye bilge rat", + verificationCode: "Verification Code, ye scallywag", + sendCode: "Send Code, ye scallywag", + verifyCode: "Verify Code, ye scallywag", + signInWithGoogle: "Sign in with ye Google Account", + signInWithFacebook: "Sign in with ye Facebook Account", + signInWithApple: "Sign in with ye Apple Account", + signInWithMicrosoft: "Sign in with ye Microsoft Account", + signInWithGitHub: "Sign in with ye GitHub Account", + signInWithTwitter: "Sign in with ye X Account", + signInWithEmailLink: "Sign in with Email Link", + sendSignInLink: "Send Sign-in Link", + termsOfService: "Terms of Service", + privacyPolicy: "Privacy Policy", + resendCode: "Resend ye Code", + sending: "Firing...", + multiFactorEnrollment: "Multi-factor Enrrrrrrollment!", + multiFactorAssertion: "Multi-factor Authentication, arrr!", + mfaTotpVerification: "TOTP Verification, arrr!", + mfaSmsVerification: "SMS Verification, arrr!", + generateQrCode: "Generate ye QR Code", + }, + prompts: { + noAccount: "Don't have an account, ye scallywag?", + haveAccount: "Already have an account, matey?", + enterEmailToReset: "Enter yer email address to reset yer password, ye bilge rat", + signInToAccount: "Sign in to yer account, matey", + smsVerificationPrompt: "Enter the verification code sent to yer phone number, ye scallywag", + enterDetailsToCreate: "Enter yer details to create a new account, ye bilge rat", + enterPhoneNumber: "Enter yer phone number, matey", + enterVerificationCode: "Enter the verification code, ye scallywag", + enterEmailForLink: "Enter yer email to receive a sign-in link, ye bilge rat", + mfaEnrollmentPrompt: "Select a new multi-factor enrollment method, arrr!", + mfaAssertionPrompt: "Complete the multi-factor authentication process, ye scallywag", + mfaAssertionFactorPrompt: "Choose a multi-factor authentication method, matey", + mfaTotpQrCodePrompt: "Scan this QR code with yer authenticator app, ye bilge rat", + mfaTotpEnrollmentVerificationPrompt: "Add the code generated by yer authenticator app, arrr!", + }, +}); diff --git a/examples/angular/src/app/routes.ts b/examples/angular/src/app/routes.ts new file mode 100644 index 000000000..fcafffd4a --- /dev/null +++ b/examples/angular/src/app/routes.ts @@ -0,0 +1,123 @@ +/** + * 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 type { Type } from "@angular/core"; + +export interface RouteConfig { + name: string; + description: string; + path: string; + loadComponent: () => Promise<{ default: Type } | Type>; +} + +export const routes: RouteConfig[] = [ + { + name: "Sign In Screen", + description: "A sign in screen with email and password.", + path: "/screens/sign-in-auth-screen", + loadComponent: () => import("./screens/sign-in-auth-screen").then((m) => m.SignInAuthScreenWrapperComponent), + }, + { + name: "Sign In Screen (with handlers)", + description: "A sign in screen with email and password, with forgot password and register handlers.", + path: "/screens/sign-in-auth-screen-w-handlers", + loadComponent: () => + import("./screens/sign-in-auth-screen-w-handlers").then((m) => m.SignInAuthScreenWithHandlersComponent), + }, + { + name: "Sign In Screen (with OAuth)", + description: "A sign in screen with email and password, with oAuth buttons.", + path: "/screens/sign-in-auth-screen-w-oauth", + loadComponent: () => + import("./screens/sign-in-auth-screen-w-oauth").then((m) => m.SignInAuthScreenWithOAuthComponent), + }, + { + name: "Sign Up Screen", + description: "A sign up screen with email and password.", + path: "/screens/sign-up-auth-screen", + loadComponent: () => import("./screens/sign-up-auth-screen").then((m) => m.SignUpAuthScreenWrapperComponent), + }, + { + name: "Sign Up Screen (with handlers)", + description: "A sign up screen with email and password, sign in handlers.", + path: "/screens/sign-up-auth-screen-w-handlers", + loadComponent: () => + import("./screens/sign-up-auth-screen-w-handlers").then((m) => m.SignUpAuthScreenWithHandlersComponent), + }, + { + name: "Sign Up Screen (with OAuth)", + description: "A sign in screen with email and password, with oAuth buttons.", + path: "/screens/sign-up-auth-screen-w-oauth", + loadComponent: () => + import("./screens/sign-up-auth-screen-w-oauth").then((m) => m.SignUpAuthScreenWithOAuthComponent), + }, + { + name: "Email Link Auth Screen", + description: "A screen allowing a user to send an email link for sign in.", + path: "/screens/email-link-auth-screen", + loadComponent: () => import("./screens/email-link-auth-screen").then((m) => m.EmailLinkAuthScreenWrapperComponent), + }, + { + name: "Email Link Auth Screen (with OAuth)", + description: "A screen allowing a user to send an email link for sign in, with oAuth buttons.", + path: "/screens/email-link-auth-screen-w-oauth", + loadComponent: () => + import("./screens/email-link-auth-screen-w-oauth").then((m) => m.EmailLinkAuthScreenWithOAuthComponent), + }, + { + name: "Forgot Password Screen", + description: "A screen allowing a user to reset their password.", + path: "/screens/forgot-password-auth-screen", + loadComponent: () => + import("./screens/forgot-password-auth-screen").then((m) => m.ForgotPasswordAuthScreenWrapperComponent), + }, + { + name: "Forgot Password Screen (with handlers)", + description: "A screen allowing a user to reset their password, with forgot password and register handlers.", + path: "/screens/forgot-password-auth-screen-w-handlers", + loadComponent: () => + import("./screens/forgot-password-auth-screen-w-handlers").then( + (m) => m.ForgotPasswordAuthScreenWithHandlersComponent + ), + }, + { + name: "OAuth Screen", + description: "A screen which allows a user to sign in with OAuth only.", + path: "/screens/oauth-screen", + loadComponent: () => import("./screens/oauth-screen").then((m) => m.OAuthScreenWrapperComponent), + }, + { + name: "Phone Auth Screen", + description: "A screen allowing a user to sign in with a phone number.", + path: "/screens/phone-auth-screen", + loadComponent: () => import("./screens/phone-auth-screen").then((m) => m.PhoneAuthScreenWrapperComponent), + }, + { + name: "Phone Auth Screen (with OAuth)", + description: "A screen allowing a user to sign in with a phone number, with oAuth buttons.", + path: "/screens/phone-auth-screen-w-oauth", + loadComponent: () => import("./screens/phone-auth-screen-w-oauth").then((m) => m.PhoneAuthScreenWithOAuthComponent), + }, +] as const; + +export const hiddenRoutes: RouteConfig[] = [ + { + name: "MFA Enrollment Screen", + description: "A screen allowing a user to enroll in multi-factor authentication.", + path: "/screens/mfa-enrollment-screen", + loadComponent: () => import("./screens/mfa-enrollment-screen").then((m) => m.MfaEnrollmentScreenComponent), + }, +] as const; diff --git a/examples/angular/src/app/screens/email-link-auth-screen-w-oauth/email-link-auth-screen-w-oauth.component.ts b/examples/angular/src/app/screens/email-link-auth-screen-w-oauth/email-link-auth-screen-w-oauth.component.ts new file mode 100644 index 000000000..331551c38 --- /dev/null +++ b/examples/angular/src/app/screens/email-link-auth-screen-w-oauth/email-link-auth-screen-w-oauth.component.ts @@ -0,0 +1,45 @@ +/** + * 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 } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { EmailLinkAuthScreenComponent, GoogleSignInButtonComponent } from "@invertase/firebaseui-angular"; +import type { UserCredential } from "firebase/auth"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-email-link-auth-screen-w-oauth", + standalone: true, + imports: [CommonModule, EmailLinkAuthScreenComponent, GoogleSignInButtonComponent], + template: ` + + + + `, + styles: [], +}) +export class EmailLinkAuthScreenWithOAuthComponent { + private router = inject(Router); + + onEmailSent() { + alert("email sent - please check your email"); + } + + onSignIn(credential: UserCredential) { + console.log("sign in", credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/email-link-auth-screen-w-oauth/index.ts b/examples/angular/src/app/screens/email-link-auth-screen-w-oauth/index.ts new file mode 100644 index 000000000..13f2e186d --- /dev/null +++ b/examples/angular/src/app/screens/email-link-auth-screen-w-oauth/index.ts @@ -0,0 +1 @@ +export * from "./email-link-auth-screen-w-oauth.component"; diff --git a/examples/angular/src/app/screens/email-link-auth-screen/email-link-auth-screen.component.ts b/examples/angular/src/app/screens/email-link-auth-screen/email-link-auth-screen.component.ts index 6489ce99e..55dcefb66 100644 --- a/examples/angular/src/app/screens/email-link-auth-screen/email-link-auth-screen.component.ts +++ b/examples/angular/src/app/screens/email-link-auth-screen/email-link-auth-screen.component.ts @@ -14,15 +14,28 @@ * limitations under the License. */ -import { Component } from "@angular/core"; +import { Component, inject } from "@angular/core"; import { CommonModule } from "@angular/common"; import { EmailLinkAuthScreenComponent } from "@invertase/firebaseui-angular"; +import type { UserCredential } from "firebase/auth"; +import { Router } from "@angular/router"; @Component({ selector: "app-email-link-auth-screen", standalone: true, imports: [CommonModule, EmailLinkAuthScreenComponent], - template: ` `, + template: ` `, styles: [], }) -export class EmailLinkAuthScreenWrapperComponent {} +export class EmailLinkAuthScreenWrapperComponent { + private router = inject(Router); + + onEmailSent() { + alert("email sent - please check your email"); + } + + onSignIn(credential: UserCredential) { + console.log("sign in", credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/forgot-password-auth-screen-w-handlers.component.ts b/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/forgot-password-auth-screen-w-handlers.component.ts new file mode 100644 index 000000000..e8c09a3ca --- /dev/null +++ b/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/forgot-password-auth-screen-w-handlers.component.ts @@ -0,0 +1,49 @@ +/** + * 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 } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router } from "@angular/router"; +import { ForgotPasswordAuthScreenComponent } from "@invertase/firebaseui-angular"; + +@Component({ + selector: "app-forgot-password-auth-screen-w-handlers", + standalone: true, + imports: [CommonModule, ForgotPasswordAuthScreenComponent], + template: ` + + `, + styles: [], +}) +export class ForgotPasswordAuthScreenWithHandlersComponent { + private router = inject(Router); + + goToSignIn() { + this.router.navigate(["/screens/sign-in-auth-screen"]); + } + + goToForgotPassword() { + this.router.navigate(["/screens/forgot-password-auth-screen"]); + } + + goToSignUp() { + this.router.navigate(["/screens/sign-up-auth-screen"]); + } +} diff --git a/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/index.ts b/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/index.ts new file mode 100644 index 000000000..227203450 --- /dev/null +++ b/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/index.ts @@ -0,0 +1 @@ +export * from "./forgot-password-auth-screen-w-handlers.component"; diff --git a/examples/angular/src/app/app.component.css b/examples/angular/src/app/screens/forgot-password-auth-screen/forgot-password-auth-screen.component.ts similarity index 51% rename from examples/angular/src/app/app.component.css rename to examples/angular/src/app/screens/forgot-password-auth-screen/forgot-password-auth-screen.component.ts index 2ef08e386..ceb20bc35 100644 --- a/examples/angular/src/app/app.component.css +++ b/examples/angular/src/app/screens/forgot-password-auth-screen/forgot-password-auth-screen.component.ts @@ -13,3 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { Component } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { ForgotPasswordAuthScreenComponent } from "@invertase/firebaseui-angular"; + +@Component({ + selector: "app-forgot-password-auth-screen", + standalone: true, + imports: [CommonModule, ForgotPasswordAuthScreenComponent], + template: ` `, + styles: [], +}) +export class ForgotPasswordAuthScreenWrapperComponent { + onPasswordSent() { + alert("password reset email sent - please check your email"); + } +} diff --git a/examples/angular/src/app/screens/forgot-password-auth-screen/index.ts b/examples/angular/src/app/screens/forgot-password-auth-screen/index.ts new file mode 100644 index 000000000..6cc32654d --- /dev/null +++ b/examples/angular/src/app/screens/forgot-password-auth-screen/index.ts @@ -0,0 +1 @@ +export * from "./forgot-password-auth-screen.component"; diff --git a/examples/angular/src/app/screens/mfa-enrollment-screen/index.ts b/examples/angular/src/app/screens/mfa-enrollment-screen/index.ts new file mode 100644 index 000000000..1402c2ecd --- /dev/null +++ b/examples/angular/src/app/screens/mfa-enrollment-screen/index.ts @@ -0,0 +1 @@ +export * from "./mfa-enrollment-screen.component"; diff --git a/examples/angular/src/app/screens/mfa-enrollment-screen/mfa-enrollment-screen.component.ts b/examples/angular/src/app/screens/mfa-enrollment-screen/mfa-enrollment-screen.component.ts new file mode 100644 index 000000000..4e7c7fd8e --- /dev/null +++ b/examples/angular/src/app/screens/mfa-enrollment-screen/mfa-enrollment-screen.component.ts @@ -0,0 +1,42 @@ +/** + * 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 } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router } from "@angular/router"; +import { MultiFactorAuthEnrollmentScreenComponent } from "@invertase/firebaseui-angular"; +import { FactorId } from "firebase/auth"; + +@Component({ + selector: "app-mfa-enrollment-screen", + standalone: true, + imports: [CommonModule, MultiFactorAuthEnrollmentScreenComponent], + template: ` + + `, + styles: [], +}) +export class MfaEnrollmentScreenComponent { + FactorId = FactorId; + private router = inject(Router); + + onEnrollment() { + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/oauth-screen/oauth-screen.component.ts b/examples/angular/src/app/screens/oauth-screen/oauth-screen.component.ts index a952ec62f..80690c2dd 100644 --- a/examples/angular/src/app/screens/oauth-screen/oauth-screen.component.ts +++ b/examples/angular/src/app/screens/oauth-screen/oauth-screen.component.ts @@ -14,15 +14,57 @@ * limitations under the License. */ -import { Component } from "@angular/core"; +import { Component, inject, signal } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { OAuthScreenComponent } from "@invertase/firebaseui-angular"; +import { + OAuthScreenComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, +} from "@invertase/firebaseui-angular"; +import type { UserCredential } from "firebase/auth"; +import { Router } from "@angular/router"; @Component({ selector: "app-oauth-screen", standalone: true, - imports: [CommonModule, OAuthScreenComponent], - template: ` `, + imports: [ + CommonModule, + OAuthScreenComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, + ], + template: ` + + + + + + + + +
+ +
+ `, styles: [], }) -export class OAuthScreenWrapperComponent {} +export class OAuthScreenWrapperComponent { + themed = signal(false); + private router = inject(Router); + + onSignIn(credential: UserCredential) { + console.log("sign in", credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/phone-auth-screen-w-oauth/index.ts b/examples/angular/src/app/screens/phone-auth-screen-w-oauth/index.ts new file mode 100644 index 000000000..3d0a30e0d --- /dev/null +++ b/examples/angular/src/app/screens/phone-auth-screen-w-oauth/index.ts @@ -0,0 +1 @@ +export * from "./phone-auth-screen-w-oauth.component"; diff --git a/examples/angular/src/app/screens/phone-auth-screen-w-oauth/phone-auth-screen-w-oauth.component.ts b/examples/angular/src/app/screens/phone-auth-screen-w-oauth/phone-auth-screen-w-oauth.component.ts new file mode 100644 index 000000000..9fac00203 --- /dev/null +++ b/examples/angular/src/app/screens/phone-auth-screen-w-oauth/phone-auth-screen-w-oauth.component.ts @@ -0,0 +1,43 @@ +/** + * 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 } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { PhoneAuthScreenComponent, GoogleSignInButtonComponent, ContentComponent } from "@invertase/firebaseui-angular"; +import type { UserCredential } from "firebase/auth"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-phone-auth-screen-w-oauth", + standalone: true, + imports: [CommonModule, PhoneAuthScreenComponent, GoogleSignInButtonComponent, ContentComponent], + template: ` + + + + + + `, + styles: [], +}) +export class PhoneAuthScreenWithOAuthComponent { + private router = inject(Router); + + onSignIn(credential: UserCredential) { + console.log("sign in", credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/phone-auth-screen/phone-auth-screen.component.ts b/examples/angular/src/app/screens/phone-auth-screen/phone-auth-screen.component.ts index 2dd4fab2c..add17ca3f 100644 --- a/examples/angular/src/app/screens/phone-auth-screen/phone-auth-screen.component.ts +++ b/examples/angular/src/app/screens/phone-auth-screen/phone-auth-screen.component.ts @@ -14,15 +14,24 @@ * limitations under the License. */ -import { Component } from "@angular/core"; +import { Component, inject } from "@angular/core"; import { CommonModule } from "@angular/common"; import { PhoneAuthScreenComponent } from "@invertase/firebaseui-angular"; +import type { UserCredential } from "firebase/auth"; +import { Router } from "@angular/router"; @Component({ selector: "app-phone-auth-screen", standalone: true, imports: [CommonModule, PhoneAuthScreenComponent], - template: ` `, + template: ` `, styles: [], }) -export class PhoneAuthScreenWrapperComponent {} +export class PhoneAuthScreenWrapperComponent { + private router = inject(Router); + + onSignIn(credential: UserCredential) { + console.log("sign in", credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/index.ts b/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/index.ts new file mode 100644 index 000000000..d498e8f0b --- /dev/null +++ b/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/index.ts @@ -0,0 +1 @@ +export * from "./sign-in-auth-screen-w-handlers.component"; diff --git a/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/sign-in-auth-screen-w-handlers.component.ts b/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/sign-in-auth-screen-w-handlers.component.ts new file mode 100644 index 000000000..ce7a7d296 --- /dev/null +++ b/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/sign-in-auth-screen-w-handlers.component.ts @@ -0,0 +1,50 @@ +/** + * 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 } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router } from "@angular/router"; +import { SignInAuthScreenComponent } from "@invertase/firebaseui-angular"; + +@Component({ + selector: "app-sign-in-auth-screen-w-handlers", + standalone: true, + imports: [CommonModule, SignInAuthScreenComponent], + template: ` + + `, + styles: [], +}) +export class SignInAuthScreenWithHandlersComponent { + private router = inject(Router); + + goToForgotPassword() { + this.router.navigate(["/screens/forgot-password-auth-screen"]); + } + + goToSignUp() { + this.router.navigate(["/screens/sign-up-auth-screen"]); + } + + onSignIn(credential: unknown) { + console.log(credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/index.ts b/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/index.ts new file mode 100644 index 000000000..2697e1510 --- /dev/null +++ b/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/index.ts @@ -0,0 +1 @@ +export * from "./sign-in-auth-screen-w-oauth.component"; diff --git a/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/sign-in-auth-screen-w-oauth.component.ts b/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/sign-in-auth-screen-w-oauth.component.ts new file mode 100644 index 000000000..dd0048828 --- /dev/null +++ b/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/sign-in-auth-screen-w-oauth.component.ts @@ -0,0 +1,67 @@ +/** + * 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 } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + SignInAuthScreenComponent, + ContentComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, +} from "@invertase/firebaseui-angular"; +import type { UserCredential } from "firebase/auth"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-sign-in-auth-screen-w-oauth", + standalone: true, + imports: [ + CommonModule, + SignInAuthScreenComponent, + ContentComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, + ], + template: ` + + + + + + + + + + + `, + styles: [], +}) +export class SignInAuthScreenWithOAuthComponent { + private router = inject(Router); + + onSignIn(credential: UserCredential) { + console.log("sign in", credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/sign-in-auth-screen/sign-in-auth-screen.component.ts b/examples/angular/src/app/screens/sign-in-auth-screen/sign-in-auth-screen.component.ts index 8b1e7d458..2475549dd 100644 --- a/examples/angular/src/app/screens/sign-in-auth-screen/sign-in-auth-screen.component.ts +++ b/examples/angular/src/app/screens/sign-in-auth-screen/sign-in-auth-screen.component.ts @@ -14,15 +14,24 @@ * limitations under the License. */ -import { Component } from "@angular/core"; +import { Component, inject } from "@angular/core"; import { CommonModule } from "@angular/common"; import { SignInAuthScreenComponent } from "@invertase/firebaseui-angular"; +import type { UserCredential } from "firebase/auth"; +import { Router } from "@angular/router"; @Component({ selector: "app-sign-in-auth-screen", standalone: true, imports: [CommonModule, SignInAuthScreenComponent], - template: ` `, + template: ` `, styles: [], }) -export class SignInAuthScreenWrapperComponent {} +export class SignInAuthScreenWrapperComponent { + private router = inject(Router); + + onSignIn(credential: UserCredential) { + console.log("sign in", credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/index.ts b/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/index.ts new file mode 100644 index 000000000..64db728c0 --- /dev/null +++ b/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/index.ts @@ -0,0 +1 @@ +export * from "./sign-up-auth-screen-w-handlers.component"; diff --git a/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/sign-up-auth-screen-w-handlers.component.ts b/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/sign-up-auth-screen-w-handlers.component.ts new file mode 100644 index 000000000..8aa94af46 --- /dev/null +++ b/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/sign-up-auth-screen-w-handlers.component.ts @@ -0,0 +1,40 @@ +/** + * 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 } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router } from "@angular/router"; +import { SignUpAuthScreenComponent } from "@invertase/firebaseui-angular"; + +@Component({ + selector: "app-sign-up-auth-screen-w-handlers", + standalone: true, + imports: [CommonModule, SignUpAuthScreenComponent], + template: ` `, + styles: [], +}) +export class SignUpAuthScreenWithHandlersComponent { + private router = inject(Router); + + goToSignIn() { + this.router.navigate(["/screens/sign-in-auth-screen"]); + } + + onSignUp(credential: unknown) { + console.log(credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/index.ts b/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/index.ts new file mode 100644 index 000000000..cc1567d01 --- /dev/null +++ b/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/index.ts @@ -0,0 +1 @@ +export * from "./sign-up-auth-screen-w-oauth.component"; diff --git a/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/sign-up-auth-screen-w-oauth.component.ts b/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/sign-up-auth-screen-w-oauth.component.ts new file mode 100644 index 000000000..c172ead67 --- /dev/null +++ b/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/sign-up-auth-screen-w-oauth.component.ts @@ -0,0 +1,67 @@ +/** + * 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 } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + SignUpAuthScreenComponent, + ContentComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, +} from "@invertase/firebaseui-angular"; +import type { UserCredential } from "firebase/auth"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-sign-up-auth-screen-w-oauth", + standalone: true, + imports: [ + CommonModule, + SignUpAuthScreenComponent, + ContentComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, + ], + template: ` + + + + + + + + + + + `, + styles: [], +}) +export class SignUpAuthScreenWithOAuthComponent { + private router = inject(Router); + + onSignUp(credential: UserCredential) { + console.log("sign up", credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/sign-up-auth-screen/sign-up-auth-screen.component.ts b/examples/angular/src/app/screens/sign-up-auth-screen/sign-up-auth-screen.component.ts index 4f9b43493..6671ecba7 100644 --- a/examples/angular/src/app/screens/sign-up-auth-screen/sign-up-auth-screen.component.ts +++ b/examples/angular/src/app/screens/sign-up-auth-screen/sign-up-auth-screen.component.ts @@ -14,15 +14,24 @@ * limitations under the License. */ -import { Component } from "@angular/core"; +import { Component, inject } from "@angular/core"; import { CommonModule } from "@angular/common"; import { SignUpAuthScreenComponent } from "@invertase/firebaseui-angular"; +import { Router } from "@angular/router"; +import type { UserCredential } from "firebase/auth"; @Component({ selector: "app-sign-up-auth-screen", standalone: true, imports: [CommonModule, SignUpAuthScreenComponent], - template: ` `, + template: ` `, styles: [], }) -export class SignUpAuthScreenWrapperComponent {} +export class SignUpAuthScreenWrapperComponent { + private router = inject(Router); + + onSignUp(credential: UserCredential) { + console.log("sign up", credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/components/header/index.ts b/examples/angular/src/app/services/user.service.ts similarity index 64% rename from examples/angular/src/app/components/header/index.ts rename to examples/angular/src/app/services/user.service.ts index 7df717022..50548a8b1 100644 --- a/examples/angular/src/app/components/header/index.ts +++ b/examples/angular/src/app/services/user.service.ts @@ -14,4 +14,17 @@ * limitations under the License. */ -export * from "./header.component"; +import { Injectable, inject } from "@angular/core"; +import { Auth, type User, authState } from "@angular/fire/auth"; +import type { Observable } from "rxjs"; + +@Injectable({ + providedIn: "root", +}) +export class UserService { + private auth = inject(Auth); + + getUser(): Observable { + return authState(this.auth); + } +} diff --git a/examples/angular/src/index.html b/examples/angular/src/index.html index 781564eb6..e53fbd415 100644 --- a/examples/angular/src/index.html +++ b/examples/angular/src/index.html @@ -18,12 +18,15 @@ - AngularSsr + Firebase UI for Angular - + + diff --git a/examples/angular/src/styles.css b/examples/angular/src/styles.css index 5e23eb6d6..c0f242db4 100644 --- a/examples/angular/src/styles.css +++ b/examples/angular/src/styles.css @@ -16,4 +16,5 @@ /* You can add global styles to this file, and also import other style files */ @import "tailwindcss"; +@custom-variant dark (&:where(.dark, .dark *)); @import "@invertase/firebaseui-styles/tailwind"; diff --git a/packages/angular/package.json b/packages/angular/package.json index 9d95db2ab..770160ba2 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -5,13 +5,13 @@ "dist" ], "type": "module", - "main": "./dist/fesm2022/firebase-ui-angular.mjs", - "module": "./dist/fesm2022/firebase-ui-angular.mjs", + "main": "./dist/fesm2022/invertase-firebaseui-angular.mjs", + "module": "./dist/fesm2022/invertase-firebaseui-angular.mjs", "typings": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", - "default": "./dist/fesm2022/firebase-ui-angular.mjs" + "default": "./dist/fesm2022/invertase-firebaseui-angular.mjs" } }, "scripts": { diff --git a/packages/angular/src/lib/auth/forms/email-link-auth-form.spec.ts b/packages/angular/src/lib/auth/forms/email-link-auth-form.spec.ts index 57673edaa..a1c9a5edc 100644 --- a/packages/angular/src/lib/auth/forms/email-link-auth-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/email-link-auth-form.spec.ts @@ -224,7 +224,9 @@ describe("", () => { component.emailSentState.set(true); fixture.detectChanges(); - expect(screen.getByText("Check your email for a sign in link")).toBeInTheDocument(); + const successMessage = screen.getByText("Check your email for a sign in link"); + expect(successMessage).toBeInTheDocument(); + expect(successMessage).toHaveClass("fui-success"); }); it("should handle FirebaseUIError and display error message", async () => { diff --git a/packages/angular/src/lib/auth/forms/email-link-auth-form.ts b/packages/angular/src/lib/auth/forms/email-link-auth-form.ts index 320e7b028..8be54e5c9 100644 --- a/packages/angular/src/lib/auth/forms/email-link-auth-form.ts +++ b/packages/angular/src/lib/auth/forms/email-link-auth-form.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, effect, output, signal } from "@angular/core"; +import { Component, effect, Output, EventEmitter, signal } from "@angular/core"; import { CommonModule } from "@angular/common"; import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanstack/angular-form"; import { UserCredential } from "@angular/fire/auth"; @@ -27,6 +27,9 @@ import { injectEmailLinkAuthFormSchema, injectTranslation, injectUI } from "../. @Component({ selector: "fui-email-link-auth-form", standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, TanStackField, @@ -38,7 +41,7 @@ import { injectEmailLinkAuthFormSchema, injectTranslation, injectUI } from "../. ], template: ` @if (emailSentState()) { -
+
{{ emailSentMessage() }}
} @@ -72,8 +75,8 @@ export class EmailLinkAuthFormComponent { emailSentMessage = injectTranslation("messages", "signInLinkSent"); unknownErrorLabel = injectTranslation("errors", "unknownError"); - emailSent = output(); - signIn = output(); + @Output() emailSent = new EventEmitter(); + @Output() signIn = new EventEmitter(); form = injectForm({ defaultValues: { @@ -100,13 +103,14 @@ export class EmailLinkAuthFormComponent { try { await sendSignInLinkToEmail(this.ui(), value.email); this.emailSentState.set(true); - this.emailSent?.emit(); + this.emailSent.emit(); return; } catch (error) { if (error instanceof FirebaseUIError) { return error.message; } + console.error(error); return this.unknownErrorLabel(); } }, @@ -119,7 +123,7 @@ export class EmailLinkAuthFormComponent { const credential = await completeEmailLinkSignIn(this.ui(), window.location.href); if (credential) { - this.signIn?.emit(credential); + this.signIn.emit(credential); } } } diff --git a/packages/angular/src/lib/auth/forms/forgot-password-auth-form.spec.ts b/packages/angular/src/lib/auth/forms/forgot-password-auth-form.spec.ts index 753a83651..71517c7b0 100644 --- a/packages/angular/src/lib/auth/forms/forgot-password-auth-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/forgot-password-auth-form.spec.ts @@ -16,6 +16,7 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/angular"; import { CommonModule } from "@angular/common"; +import { EventEmitter } from "@angular/core"; import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; import { ForgotPasswordAuthFormComponent } from "./forgot-password-auth-form"; import { @@ -73,7 +74,10 @@ describe("", () => { }); it("should render the form initially", async () => { - await render(ForgotPasswordAuthFormComponent, { + const backToSignInEmitter = new EventEmitter(); + backToSignInEmitter.subscribe(() => {}); + + const { fixture } = await render(ForgotPasswordAuthFormComponent, { imports: [ CommonModule, ForgotPasswordAuthFormComponent, @@ -85,7 +89,11 @@ describe("", () => { FormActionComponent, PoliciesComponent, ], + componentInputs: { + backToSignIn: backToSignInEmitter, + }, }); + fixture.detectChanges(); expect(screen.getByLabelText("Email Address")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Reset Password" })).toBeInTheDocument(); @@ -153,6 +161,9 @@ describe("", () => { }); it("should emit backToSignIn when back button is clicked", async () => { + const backToSignInEmitter = new EventEmitter(); + backToSignInEmitter.subscribe(() => {}); + const { fixture } = await render(ForgotPasswordAuthFormComponent, { imports: [ CommonModule, @@ -165,9 +176,12 @@ describe("", () => { FormActionComponent, PoliciesComponent, ], + componentInputs: { + backToSignIn: backToSignInEmitter, + }, }); - const component = fixture.componentInstance; - const backToSignInSpy = jest.spyOn(component.backToSignIn, "emit"); + fixture.detectChanges(); + const backToSignInSpy = jest.spyOn(backToSignInEmitter, "emit"); const backButton = screen.getByRole("button", { name: "Back to Sign In β†’" }); fireEvent.click(backButton); @@ -226,6 +240,9 @@ describe("", () => { }); const component = fixture.componentInstance; + // Access the getter to initialize EventEmitter (simulating template binding) + component.passwordSent.subscribe(() => {}); + fixture.detectChanges(); const passwordSentSpy = jest.spyOn(component.passwordSent, "emit"); const mockUI = { app: {}, auth: {} }; @@ -266,7 +283,9 @@ describe("", () => { component.emailSent.set(true); fixture.detectChanges(); - expect(screen.getByText("Check your email for a password reset link")).toBeInTheDocument(); + const successMessage = screen.getByText("Check your email for a password reset link"); + expect(successMessage).toBeInTheDocument(); + expect(successMessage).toHaveClass("fui-success"); }); it("should handle FirebaseUIError and display error message", async () => { diff --git a/packages/angular/src/lib/auth/forms/forgot-password-auth-form.ts b/packages/angular/src/lib/auth/forms/forgot-password-auth-form.ts index d91f13e37..fa72689eb 100644 --- a/packages/angular/src/lib/auth/forms/forgot-password-auth-form.ts +++ b/packages/angular/src/lib/auth/forms/forgot-password-auth-form.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, effect, output, signal } from "@angular/core"; +import { Component, effect, Output, EventEmitter, input, signal } from "@angular/core"; import { CommonModule } from "@angular/common"; import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanstack/angular-form"; import { FirebaseUIError, sendPasswordResetEmail } from "@invertase/firebaseui-core"; @@ -31,6 +31,9 @@ import { injectForgotPasswordAuthFormSchema, injectTranslation, injectUI } from @Component({ selector: "fui-forgot-password-auth-form", standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, TanStackField, @@ -43,7 +46,7 @@ import { injectForgotPasswordAuthFormSchema, injectTranslation, injectUI } from ], template: ` @if (emailSent()) { -
+
{{ checkEmailForResetMessage() }}
} @@ -63,8 +66,8 @@ import { injectForgotPasswordAuthFormSchema, injectTranslation, injectUI } from - @if (backToSignIn) { - + @if (backToSignIn()?.observed) { + } } @@ -82,8 +85,9 @@ export class ForgotPasswordAuthFormComponent { checkEmailForResetMessage = injectTranslation("messages", "checkEmailForReset"); unknownErrorLabel = injectTranslation("errors", "unknownError"); - passwordSent = output(); - backToSignIn = output(); + backToSignIn = input>(); + + @Output() passwordSent = new EventEmitter(); form = injectForm({ defaultValues: { @@ -108,13 +112,14 @@ export class ForgotPasswordAuthFormComponent { try { await sendPasswordResetEmail(this.ui(), value.email); this.emailSent.set(true); - this.passwordSent?.emit(); + this.passwordSent.emit(); return; } catch (error) { if (error instanceof FirebaseUIError) { return error.message; } + console.error(error); return this.unknownErrorLabel(); } }, diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts index 5a7915d52..4221d1f73 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts @@ -37,6 +37,32 @@ describe("", () => { injectRecaptchaVerifier, } = require("../../../tests/test-helpers"); + const { getTranslation } = require("@invertase/firebaseui-core"); + getTranslation.mockImplementation((ui: any, category: string, key: string, params?: any) => { + if (category === "messages" && key === "mfaSmsAssertionPrompt" && params) { + return `A verification code will be sent to ${params.phoneNumber} to complete the authentication process.`; + } + const mockTranslations: Record> = { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, + prompts: { + smsVerificationPrompt: "Enter the verification code sent to your phone number", + }, + errors: { + unknownError: "An unknown error occurred", + }, + }; + return mockTranslations[category]?.[key] || `${category}.${key}`; + }); + injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -45,6 +71,13 @@ describe("", () => { verificationCode: "Verification Code", verifyCode: "Verify Code", }, + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, + prompts: { + smsVerificationPrompt: "Enter the verification code sent to your phone number", + }, errors: { unknownError: "An unknown error occurred", }, @@ -102,8 +135,7 @@ describe("", () => { imports: [SmsMultiFactorAssertionFormComponent], }); - expect(screen.getByLabelText("Phone Number")).toBeInTheDocument(); - expect(screen.getByDisplayValue("+1234567890")).toBeInTheDocument(); + expect(screen.getByText(/A verification code will be sent to \+1234567890/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Send Code" })).toBeInTheDocument(); }); @@ -121,16 +153,16 @@ describe("", () => { imports: [SmsMultiFactorAssertionFormComponent], }); - expect(screen.getByLabelText("Phone Number")).toBeInTheDocument(); + expect(screen.getByText(/A verification code will be sent to \+1234567890/)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Send Code" })); await waitFor(() => { - expect(screen.getByLabelText("Verification Code")).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /Verification Code/i })).toBeInTheDocument(); }); expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); - expect(screen.queryByLabelText("Phone Number")).not.toBeInTheDocument(); + expect(screen.queryByText(/A verification code will be sent to/)).not.toBeInTheDocument(); }); it("emits onSuccess when verification is successful", async () => { @@ -155,12 +187,11 @@ describe("", () => { )?.componentInstance; if (phoneFormComponent) { - phoneFormComponent.form.setFieldValue("phoneNumber", "+1234567890"); await phoneFormComponent.form.handleSubmit(); } await waitFor(() => { - expect(screen.getByLabelText("Verification Code")).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /Verification Code/i })).toBeInTheDocument(); }); const verifyFormComponent = fixture.debugElement.query( @@ -189,12 +220,37 @@ describe("", () => { injectMultiFactorPhoneAuthAssertionFormSchema, } = require("../../../tests/test-helpers"); + const { getTranslation } = require("@invertase/firebaseui-core"); + getTranslation.mockImplementation((ui: any, category: string, key: string, params?: any) => { + if (category === "messages" && key === "mfaSmsAssertionPrompt" && params) { + return `A verification code will be sent to ${params.phoneNumber} to complete the authentication process.`; + } + const mockTranslations: Record> = { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + }, + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, + errors: { + unknownError: "An unknown error occurred", + }, + }; + return mockTranslations[category]?.[key] || `${category}.${key}`; + }); + injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { phoneNumber: "Phone Number", sendCode: "Send Code", }, + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, errors: { unknownError: "An unknown error occurred", }, @@ -218,7 +274,7 @@ describe("", () => { verifyPhoneNumber.mockResolvedValue("test-verification-id"); }); - it("renders phone form with phone number from hint", async () => { + it("renders phone form with message showing phone number from hint", async () => { const mockHint = { factorId: PhoneMultiFactorGenerator.FACTOR_ID, displayName: "Phone", @@ -232,9 +288,8 @@ describe("", () => { imports: [SmsMultiFactorAssertionPhoneFormComponent], }); - const phoneInput = screen.getByLabelText("Phone Number"); - expect(phoneInput).toBeInTheDocument(); - expect(phoneInput).toHaveValue("+1234567890"); + expect(screen.getByText(/A verification code will be sent to \+1234567890/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send Code" })).toBeInTheDocument(); }); it("emits onSubmit when form is submitted", async () => { @@ -276,6 +331,9 @@ describe("", () => { verificationCode: "Verification Code", verifyCode: "Verify Code", }, + prompts: { + smsVerificationPrompt: "Enter the verification code sent to your phone number", + }, errors: { unknownError: "An unknown error occurred", }, @@ -311,7 +369,9 @@ describe("", () => { imports: [SmsMultiFactorAssertionVerifyFormComponent], }); - expect(screen.getByLabelText("Verification Code")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole("textbox", { name: /Verification Code/i })).toBeInTheDocument(); + }); expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); }); diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts index cb90db425..353b816c7 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts @@ -13,18 +13,22 @@ * limitations under the License. */ -import { Component, ElementRef, effect, input, signal, output, computed, viewChild } from "@angular/core"; +import { Component, ElementRef, effect, input, signal, Output, EventEmitter, computed, viewChild } from "@angular/core"; import { CommonModule } from "@angular/common"; import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanstack/angular-form"; import { - injectMultiFactorPhoneAuthAssertionFormSchema, injectMultiFactorPhoneAuthVerifyFormSchema, injectRecaptchaVerifier, injectTranslation, injectUI, } from "../../../provider"; import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; -import { FirebaseUIError, verifyPhoneNumber, signInWithMultiFactorAssertion } from "@invertase/firebaseui-core"; +import { + FirebaseUIError, + verifyPhoneNumber, + signInWithMultiFactorAssertion, + getTranslation, +} from "@invertase/firebaseui-core"; import { PhoneAuthProvider, PhoneMultiFactorGenerator, type MultiFactorInfo, type UserCredential } from "firebase/auth"; type PhoneMultiFactorInfo = MultiFactorInfo & { @@ -34,24 +38,18 @@ type PhoneMultiFactorInfo = MultiFactorInfo & { @Component({ selector: "fui-sms-multi-factor-assertion-phone-form", standalone: true, - imports: [ - CommonModule, - TanStackField, - TanStackAppField, - FormInputComponent, - FormSubmitComponent, - FormErrorMessageComponent, - ], + imports: [CommonModule, FormSubmitComponent, FormErrorMessageComponent], + host: { + style: "display: block;", + }, template: `
- +
@@ -67,14 +65,11 @@ type PhoneMultiFactorInfo = MultiFactorInfo & { }) export class SmsMultiFactorAssertionPhoneFormComponent { private ui = injectUI(); - private formSchema = injectMultiFactorPhoneAuthAssertionFormSchema(); hint = input.required(); - onSubmit = output(); + @Output() onSubmit = new EventEmitter(); - phoneNumberLabel = injectTranslation("labels", "phoneNumber"); sendCodeLabel = injectTranslation("labels", "sendCode"); - unknownErrorLabel = injectTranslation("errors", "unknownError"); recaptchaContainer = viewChild.required>("recaptchaContainer"); @@ -83,42 +78,34 @@ export class SmsMultiFactorAssertionPhoneFormComponent { return hint.phoneNumber || ""; }); + mfaSmsAssertionPrompt = computed(() => { + return getTranslation(this.ui(), "messages", "mfaSmsAssertionPrompt", { phoneNumber: this.phoneNumber() }); + }); + recaptchaVerifier = injectRecaptchaVerifier(() => this.recaptchaContainer()); form = injectForm({ - defaultValues: { - phoneNumber: "", - }, + defaultValues: {}, }); 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(); + return "Recaptcha verifier not available"; } 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(); + return error instanceof FirebaseUIError ? error.message : String(error); } }, }, @@ -145,6 +132,9 @@ export class SmsMultiFactorAssertionPhoneFormComponent { @Component({ selector: "fui-sms-multi-factor-assertion-verify-form", standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, TanStackField, @@ -160,7 +150,8 @@ export class SmsMultiFactorAssertionPhoneFormComponent { name="verificationCode" tanstack-app-field [tanstackField]="form" - label="{{ verificationCodeLabel() }}" + [label]="verificationCodeLabel()" + [description]="smsVerificationPrompt()" type="text" >
@@ -178,10 +169,11 @@ export class SmsMultiFactorAssertionVerifyFormComponent { private formSchema = injectMultiFactorPhoneAuthVerifyFormSchema(); verificationId = input.required(); - onSuccess = output(); + @Output() onSuccess = new EventEmitter(); verificationCodeLabel = injectTranslation("labels", "verificationCode"); verifyCodeLabel = injectTranslation("labels", "verifyCode"); + smsVerificationPrompt = injectTranslation("prompts", "smsVerificationPrompt"); unknownErrorLabel = injectTranslation("errors", "unknownError"); form = injectForm({ @@ -211,10 +203,7 @@ export class SmsMultiFactorAssertionVerifyFormComponent { this.onSuccess.emit(result); return; } catch (error) { - if (error instanceof FirebaseUIError) { - return error.message; - } - return this.unknownErrorLabel(); + return error instanceof FirebaseUIError ? error.message : String(error); } }, }, @@ -233,6 +222,9 @@ export class SmsMultiFactorAssertionVerifyFormComponent { selector: "fui-sms-multi-factor-assertion-form", standalone: true, imports: [CommonModule, SmsMultiFactorAssertionPhoneFormComponent, SmsMultiFactorAssertionVerifyFormComponent], + host: { + style: "display: block;", + }, template: `
@if (verification()) { @@ -248,7 +240,7 @@ export class SmsMultiFactorAssertionVerifyFormComponent { }) export class SmsMultiFactorAssertionFormComponent { hint = input.required(); - onSuccess = output(); + @Output() onSuccess = new EventEmitter(); verification = signal<{ verificationId: string } | null>(null); diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts index e192cfba9..5819cbd47 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts @@ -20,7 +20,6 @@ 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"; jest.mock("@invertase/firebaseui-core", () => { const originalModule = jest.requireActual("@invertase/firebaseui-core"); @@ -94,6 +93,9 @@ describe("", () => { verificationCode: "Verification Code", verifyCode: "Verify Code", }, + prompts: { + smsVerificationPrompt: "Enter the verification code sent to your phone number", + }, errors: { unknownError: "An unknown error occurred", }, @@ -145,14 +147,13 @@ describe("", () => { FormSubmitComponent, FormErrorMessageComponent, CountrySelectorComponent, - PoliciesComponent, ], }); expect(fixture.componentInstance).toBeTruthy(); }); it("should render phone number form initially", async () => { - await render(SmsMultiFactorEnrollmentFormComponent, { + const { container } = await render(SmsMultiFactorEnrollmentFormComponent, { imports: [ CommonModule, SmsMultiFactorEnrollmentFormComponent, @@ -162,12 +163,11 @@ describe("", () => { FormSubmitComponent, FormErrorMessageComponent, CountrySelectorComponent, - PoliciesComponent, ], }); expect(screen.getByLabelText("Display Name")).toBeInTheDocument(); - expect(screen.getByLabelText("Phone Number")).toBeInTheDocument(); + expect(container.querySelector('input[name="phoneNumber"]')).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Send Verification Code" })).toBeInTheDocument(); }); @@ -185,7 +185,6 @@ describe("", () => { FormSubmitComponent, FormErrorMessageComponent, CountrySelectorComponent, - PoliciesComponent, ], }); @@ -193,7 +192,9 @@ describe("", () => { component.verificationId.set(mockVerificationId); fixture.detectChanges(); - expect(screen.getByLabelText("Verification Code")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole("textbox", { name: /Verification Code/i })).toBeInTheDocument(); + }); expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); }); @@ -211,7 +212,6 @@ describe("", () => { FormSubmitComponent, FormErrorMessageComponent, CountrySelectorComponent, - PoliciesComponent, ], }); @@ -243,7 +243,6 @@ describe("", () => { FormSubmitComponent, FormErrorMessageComponent, CountrySelectorComponent, - PoliciesComponent, ], }); @@ -277,7 +276,6 @@ describe("", () => { FormSubmitComponent, FormErrorMessageComponent, CountrySelectorComponent, - PoliciesComponent, ], }); @@ -309,7 +307,6 @@ describe("", () => { FormSubmitComponent, FormErrorMessageComponent, CountrySelectorComponent, - PoliciesComponent, ], }); @@ -342,7 +339,6 @@ describe("", () => { FormSubmitComponent, FormErrorMessageComponent, CountrySelectorComponent, - PoliciesComponent, ], }); @@ -375,31 +371,20 @@ describe("", () => { }); }); - 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(); + await expect( + render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + ], + }) + ).rejects.toThrow("User must be authenticated to enroll with multi-factor authentication"); }); it("should have correct CSS classes", async () => { @@ -413,7 +398,6 @@ describe("", () => { FormSubmitComponent, FormErrorMessageComponent, CountrySelectorComponent, - PoliciesComponent, ], }); diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts index e0d2de369..7f65985ce 100644 --- a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, signal, effect, viewChild, computed, output } from "@angular/core"; +import { Component, signal, effect, viewChild, computed, Output, EventEmitter } from "@angular/core"; import { CommonModule } from "@angular/common"; import { TanStackField, TanStackAppField, injectForm, injectStore } from "@tanstack/angular-form"; import { ElementRef } from "@angular/core"; @@ -42,6 +42,9 @@ import { @Component({ selector: "fui-sms-multi-factor-enrollment-form", standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, TanStackField, @@ -50,7 +53,6 @@ import { FormSubmitComponent, FormErrorMessageComponent, CountrySelectorComponent, - PoliciesComponent, ], template: `
@@ -61,22 +63,24 @@ import { name="displayName" tanstack-app-field [tanstackField]="phoneForm" - label="{{ displayNameLabel() }}" - > + [label]="displayNameLabel()" + type="text" + />
- + [label]="phoneNumberLabel()" + type="tel" + > + +
-
{{ sendCodeLabel() }} @@ -91,10 +95,11 @@ import { name="verificationCode" tanstack-app-field [tanstackField]="verificationForm" - label="{{ verificationCodeLabel() }}" + [label]="verificationCodeLabel()" + [description]="smsVerificationPrompt()" + type="text" >
-
{{ verifyCodeLabel() }} @@ -121,9 +126,9 @@ export class SmsMultiFactorEnrollmentFormComponent { sendCodeLabel = injectTranslation("labels", "sendCode"); verificationCodeLabel = injectTranslation("labels", "verificationCode"); verifyCodeLabel = injectTranslation("labels", "verifyCode"); - unknownErrorLabel = injectTranslation("errors", "unknownError"); + smsVerificationPrompt = injectTranslation("prompts", "smsVerificationPrompt"); - onEnrollment = output(); + @Output() onEnrollment = new EventEmitter(); recaptchaContainer = viewChild.required>("recaptchaContainer"); @@ -146,6 +151,10 @@ export class SmsMultiFactorEnrollmentFormComponent { verificationState = injectStore(this.verificationForm, (state) => state); constructor() { + if (!this.ui().auth.currentUser) { + throw new Error("User must be authenticated to enroll with multi-factor authentication"); + } + effect(() => { this.phoneForm.update({ validators: { @@ -153,16 +162,12 @@ export class SmsMultiFactorEnrollmentFormComponent { 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(); + return "Recaptcha verifier not available"; } + const currentUser = this.ui().auth.currentUser!; const mfaUser = multiFactor(currentUser); const formattedPhoneNumber = formatPhoneNumber(value.phoneNumber, this.defaultCountry()); const verificationId = await verifyPhoneNumber(this.ui(), formattedPhoneNumber, verifier, mfaUser); @@ -171,12 +176,7 @@ export class SmsMultiFactorEnrollmentFormComponent { this.verificationId.set(verificationId); return; } catch (error) { - if (error instanceof FirebaseUIError) { - return error.message; - } - - console.error(error); - return this.unknownErrorLabel(); + return error instanceof FirebaseUIError ? error.message : String(error); } }, }, @@ -196,13 +196,7 @@ export class SmsMultiFactorEnrollmentFormComponent { this.onEnrollment.emit(); return; } catch (error) { - if (error instanceof FirebaseUIError) { - return error.message; - } - if (error instanceof Error) { - return error.message; - } - return this.unknownErrorLabel(); + return error instanceof FirebaseUIError ? error.message : String(error); } }, }, diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts index 71750a236..a1df6dc81 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts @@ -50,6 +50,9 @@ describe("", () => { verificationCode: "Verification Code", verifyCode: "Verify Code", }, + prompts: { + enterVerificationCode: "Enter the verification code", + }, errors: { unknownError: "An unknown error occurred", }, @@ -94,7 +97,9 @@ describe("", () => { imports: [TotpMultiFactorAssertionFormComponent], }); - expect(screen.getByLabelText("Verification Code")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole("textbox", { name: /Verification Code/i })).toBeInTheDocument(); + }); expect(screen.getByPlaceholderText("123456")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); }); @@ -268,7 +273,8 @@ describe("", () => { uid: "test-uid", }; - signInWithMultiFactorAssertion.mockRejectedValue(new Error("Network error")); + const errorMessage = "Network error"; + signInWithMultiFactorAssertion.mockRejectedValue(new Error(errorMessage)); const { fixture } = await render(TotpMultiFactorAssertionFormComponent, { componentInputs: { @@ -284,7 +290,7 @@ describe("", () => { await component.form.handleSubmit(); await waitFor(() => { - expect(screen.getByText("An unknown error occurred")).toBeInTheDocument(); + expect(screen.getByText(new RegExp(errorMessage))).toBeInTheDocument(); }); }); }); diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts index 4045a849b..4471e66da 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts @@ -13,7 +13,7 @@ * limitations under the License. */ -import { Component, effect, input, output } from "@angular/core"; +import { Component, effect, input, Output, EventEmitter } from "@angular/core"; import { CommonModule } from "@angular/common"; import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanstack/angular-form"; import { injectMultiFactorTotpAuthVerifyFormSchema, injectTranslation, injectUI } from "../../../provider"; @@ -24,6 +24,9 @@ import { TotpMultiFactorGenerator, type MultiFactorInfo, type UserCredential } f @Component({ selector: "fui-totp-multi-factor-assertion-form", standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, TanStackField, @@ -39,7 +42,8 @@ import { TotpMultiFactorGenerator, type MultiFactorInfo, type UserCredential } f name="verificationCode" tanstack-app-field [tanstackField]="form" - label="{{ verificationCodeLabel() }}" + [label]="verificationCodeLabel()" + [description]="enterVerificationCodePrompt()" type="text" placeholder="123456" maxlength="6" @@ -59,11 +63,11 @@ export class TotpMultiFactorAssertionFormComponent { private formSchema = injectMultiFactorTotpAuthVerifyFormSchema(); hint = input.required(); - onSuccess = output(); + @Output() onSuccess = new EventEmitter(); verificationCodeLabel = injectTranslation("labels", "verificationCode"); verifyCodeLabel = injectTranslation("labels", "verifyCode"); - unknownErrorLabel = injectTranslation("errors", "unknownError"); + enterVerificationCodePrompt = injectTranslation("prompts", "enterVerificationCode"); form = injectForm({ defaultValues: { @@ -85,10 +89,7 @@ export class TotpMultiFactorAssertionFormComponent { this.onSuccess.emit(result); return; } catch (error) { - if (error instanceof FirebaseUIError) { - return error.message; - } - return this.unknownErrorLabel(); + return error instanceof FirebaseUIError ? error.message : String(error); } }, }, diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts index 6a107e9f8..4145d4add 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts @@ -14,12 +14,11 @@ * limitations under the License. */ -import { render, screen } from "@testing-library/angular"; +import { render, screen, waitFor } 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; @@ -57,6 +56,7 @@ describe("", () => { }, prompts: { mfaTotpQrCodePrompt: "Scan this QR code with your authenticator app", + mfaTotpEnrollmentVerificationPrompt: "Add the code generated by your authenticator app", }, errors: { unknownError: "An unknown error occurred", @@ -96,7 +96,6 @@ describe("", () => { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent, - PoliciesComponent, ], }); expect(fixture.componentInstance).toBeTruthy(); @@ -112,7 +111,6 @@ describe("", () => { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent, - PoliciesComponent, ], }); @@ -142,7 +140,6 @@ describe("", () => { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent, - PoliciesComponent, ], }); @@ -152,7 +149,9 @@ describe("", () => { expect(screen.getByAltText("TOTP QR Code")).toBeInTheDocument(); expect(screen.getByText("Scan this QR code with your authenticator app")).toBeInTheDocument(); - expect(screen.getByLabelText("Verification Code")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole("textbox", { name: /Verification Code/i })).toBeInTheDocument(); + }); expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); }); @@ -177,7 +176,6 @@ describe("", () => { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent, - PoliciesComponent, ], }); @@ -211,7 +209,6 @@ describe("", () => { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent, - PoliciesComponent, ], }); @@ -240,7 +237,6 @@ describe("", () => { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent, - PoliciesComponent, ], }); @@ -264,7 +260,6 @@ describe("", () => { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent, - PoliciesComponent, ], }); @@ -296,24 +291,19 @@ describe("", () => { }); }); - const { fixture } = await render(TotpMultiFactorEnrollmentFormComponent, { - imports: [ - CommonModule, - TotpMultiFactorEnrollmentFormComponent, - TanStackField, - TanStackAppField, - FormInputComponent, - FormSubmitComponent, - FormErrorMessageComponent, - PoliciesComponent, - ], - }); - - const component = fixture.componentInstance; - - // Since the parent component doesn't have direct access to the child form, - // we test that the enrollment state remains null when user is not authenticated - expect(component.enrollment()).toBeNull(); + await expect( + render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + }) + ).rejects.toThrow("User must be authenticated to enroll with multi-factor authentication"); }); it("should generate QR code with correct parameters", async () => { @@ -339,7 +329,6 @@ describe("", () => { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent, - PoliciesComponent, ], }); @@ -362,7 +351,6 @@ describe("", () => { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent, - PoliciesComponent, ], }); diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.ts index 00100d50d..e96732791 100644 --- a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.ts +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, signal, effect, output, computed, input } from "@angular/core"; +import { Component, signal, effect, Output, EventEmitter, computed, input } from "@angular/core"; import { CommonModule } from "@angular/common"; import { TanStackField, TanStackAppField, injectForm, injectStore } from "@tanstack/angular-form"; import { TotpMultiFactorGenerator, type TotpSecret } from "firebase/auth"; @@ -25,7 +25,6 @@ import { FirebaseUIError, } from "@invertase/firebaseui-core"; import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; -import { PoliciesComponent } from "../../../components/policies"; import { injectUI, injectTranslation, @@ -36,6 +35,9 @@ import { @Component({ selector: "fui-totp-multi-factor-secret-generation-form", standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, TanStackField, @@ -43,7 +45,6 @@ import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent, - PoliciesComponent, ], template: ` @@ -52,10 +53,10 @@ import { name="displayName" tanstack-app-field [tanstackField]="form" - label="{{ displayNameLabel() }}" + [label]="displayNameLabel()" + type="text" >
-
{{ generateQrCodeLabel() }} @@ -69,11 +70,10 @@ export class TotpMultiFactorSecretGenerationFormComponent { private ui = injectUI(); private formSchema = injectMultiFactorTotpAuthNumberFormSchema(); - onSubmit = output<{ secret: TotpSecret; displayName: string }>(); + @Output() onSubmit = new EventEmitter<{ secret: TotpSecret; displayName: string }>(); displayNameLabel = injectTranslation("labels", "displayName"); generateQrCodeLabel = injectTranslation("labels", "generateQrCode"); - unknownErrorLabel = injectTranslation("errors", "unknownError"); form = injectForm({ defaultValues: { @@ -91,20 +91,11 @@ export class TotpMultiFactorSecretGenerationFormComponent { onSubmit: this.formSchema(), 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.onSubmit.emit({ secret, displayName: value.displayName }); return; } catch (error) { - if (error instanceof FirebaseUIError) { - return error.message; - } - - console.error(error); - return this.unknownErrorLabel(); + return error instanceof FirebaseUIError ? error.message : String(error); } }, }, @@ -122,6 +113,9 @@ export class TotpMultiFactorSecretGenerationFormComponent { @Component({ selector: "fui-totp-multi-factor-verification-form", standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, TanStackField, @@ -129,11 +123,11 @@ export class TotpMultiFactorSecretGenerationFormComponent { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent, - PoliciesComponent, ], template: `
TOTP QR Code + {{ secret().secretKey.toString() }}

{{ mfaTotpQrCodePrompt() }}

@@ -142,10 +136,11 @@ export class TotpMultiFactorSecretGenerationFormComponent { name="verificationCode" tanstack-app-field [tanstackField]="form" - label="{{ verificationCodeLabel() }}" + [label]="verificationCodeLabel()" + [description]="mfaTotpEnrollmentVerificationPrompt()" + type="text" >
-
{{ verifyCodeLabel() }} @@ -161,12 +156,12 @@ export class TotpMultiFactorVerificationFormComponent { secret = input.required(); displayName = input.required(); - onEnrollment = output(); + @Output() onEnrollment = new EventEmitter(); verificationCodeLabel = injectTranslation("labels", "verificationCode"); verifyCodeLabel = injectTranslation("labels", "verifyCode"); mfaTotpQrCodePrompt = injectTranslation("prompts", "mfaTotpQrCodePrompt"); - unknownErrorLabel = injectTranslation("errors", "unknownError"); + mfaTotpEnrollmentVerificationPrompt = injectTranslation("prompts", "mfaTotpEnrollmentVerificationPrompt"); form = injectForm({ defaultValues: { @@ -193,13 +188,7 @@ export class TotpMultiFactorVerificationFormComponent { this.onEnrollment.emit(); return; } catch (error) { - if (error instanceof FirebaseUIError) { - return error.message; - } - if (error instanceof Error) { - return error.message; - } - return this.unknownErrorLabel(); + return error instanceof FirebaseUIError ? error.message : String(error); } }, }, @@ -218,6 +207,9 @@ export class TotpMultiFactorVerificationFormComponent { selector: "fui-totp-multi-factor-enrollment-form", standalone: true, imports: [CommonModule, TotpMultiFactorSecretGenerationFormComponent, TotpMultiFactorVerificationFormComponent], + host: { + style: "display: block;", + }, template: `
@if (!enrollment()) { @@ -233,8 +225,16 @@ export class TotpMultiFactorVerificationFormComponent { `, }) export class TotpMultiFactorEnrollmentFormComponent { + private ui = injectUI(); + enrollment = signal<{ secret: TotpSecret; displayName: string } | null>(null); - onEnrollment = output(); + @Output() onEnrollment = new EventEmitter(); + + constructor() { + if (!this.ui().auth.currentUser) { + throw new Error("User must be authenticated to enroll with multi-factor authentication"); + } + } handleSecretGeneration(data: { secret: TotpSecret; displayName: string }) { this.enrollment.set(data); diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts index d37d323f5..246e0170d 100644 --- a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts @@ -51,6 +51,7 @@ describe("", () => { }, ], }, + setMultiFactorResolver: jest.fn(), }); }); }); @@ -91,6 +92,7 @@ describe("", () => { }, ], }, + setMultiFactorResolver: jest.fn(), }); }); @@ -145,6 +147,7 @@ describe("", () => { injectUI.mockImplementation(() => { return () => ({ multiFactorResolver: null, + setMultiFactorResolver: jest.fn(), }); }); @@ -154,4 +157,79 @@ describe("", () => { }) ).rejects.toThrow("MultiFactorAuthAssertionForm requires a multi-factor resolver"); }); + + it("calls setMultiFactorResolver on component destruction", async () => { + const { injectUI } = require("../../../provider"); + const setMultiFactorResolverSpy = jest.fn(); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Phone", + }, + ], + }, + setMultiFactorResolver: setMultiFactorResolverSpy, + }); + }); + + TestBed.overrideComponent(SmsMultiFactorAssertionFormComponent, { + set: { + template: '
SMS Assertion Form
', + }, + }); + + const { fixture } = await render(MultiFactorAuthAssertionFormComponent, { + imports: [MultiFactorAuthAssertionFormComponent], + }); + + expect(setMultiFactorResolverSpy).not.toHaveBeenCalled(); + + fixture.destroy(); + + expect(setMultiFactorResolverSpy).toHaveBeenCalledTimes(1); + }); + + it("clears multiFactorResolver when component is destroyed", async () => { + const { injectUI } = require("../../../provider"); + const mockResolver = { + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Phone", + }, + ], + }; + let currentResolver: any = mockResolver; + const setMultiFactorResolverSpy = jest.fn((value?: any) => { + currentResolver = value; + }); + const uiMock = () => ({ + get multiFactorResolver() { + return currentResolver; + }, + setMultiFactorResolver: setMultiFactorResolverSpy, + }); + + injectUI.mockImplementation(() => uiMock); + + TestBed.overrideComponent(SmsMultiFactorAssertionFormComponent, { + set: { + template: '
SMS Assertion Form
', + }, + }); + + const { fixture } = await render(MultiFactorAuthAssertionFormComponent, { + imports: [MultiFactorAuthAssertionFormComponent], + }); + + expect(uiMock().multiFactorResolver).toBe(mockResolver); + + fixture.destroy(); + + expect(setMultiFactorResolverSpy).toHaveBeenCalledTimes(1); + expect(uiMock().multiFactorResolver).toBeUndefined(); + }); }); diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts index ac59f54cb..55fcc1b33 100644 --- a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, computed, output, signal } from "@angular/core"; +import { Component, computed, effect, Output, EventEmitter, signal } from "@angular/core"; import { CommonModule } from "@angular/common"; import { injectUI, injectTranslation } from "../../provider"; import { @@ -31,22 +31,25 @@ import { ButtonComponent } from "../../components/button"; selector: "fui-multi-factor-auth-assertion-form", standalone: true, imports: [CommonModule, SmsMultiFactorAssertionFormComponent, TotpMultiFactorAssertionFormComponent, ButtonComponent], + host: { + style: "display: block;", + }, template: `
@if (selectedHint()) { - @if (selectedHint()!.factorId === phoneFactorId()) { + @if (selectedHint()!.factorId === phoneFactorId) { - } @else if (selectedHint()!.factorId === totpFactorId()) { + } @else if (selectedHint()!.factorId === totpFactorId) { } } @else {

{{ mfaAssertionFactorPrompt() }}

@for (hint of resolver().hints; track hint.factorId) { - @if (hint.factorId === totpFactorId()) { + @if (hint.factorId === totpFactorId) { - } @else if (hint.factorId === phoneFactorId()) { + } @else if (hint.factorId === phoneFactorId) { @@ -59,7 +62,16 @@ import { ButtonComponent } from "../../components/button"; export class MultiFactorAuthAssertionFormComponent { private ui = injectUI(); - onSuccess = output(); + constructor() { + effect((onCleanup) => { + // Cleanup the multi-factor resolver when the component unmounts. + onCleanup(() => { + this.ui().setMultiFactorResolver(); + }); + }); + } + + @Output() onSuccess = new EventEmitter(); resolver = computed(() => { const resolver = this.ui().multiFactorResolver; @@ -73,8 +85,8 @@ export class MultiFactorAuthAssertionFormComponent { this.resolver().hints.length === 1 ? this.resolver().hints[0] : undefined ); - phoneFactorId = computed(() => PhoneMultiFactorGenerator.FACTOR_ID); - totpFactorId = computed(() => TotpMultiFactorGenerator.FACTOR_ID); + phoneFactorId = PhoneMultiFactorGenerator.FACTOR_ID; + totpFactorId = TotpMultiFactorGenerator.FACTOR_ID; smsVerificationLabel = injectTranslation("labels", "mfaSmsVerification"); totpVerificationLabel = injectTranslation("labels", "mfaTotpVerification"); diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts index 8e5c6b05f..6b2f32292 100644 --- a/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts @@ -23,6 +23,17 @@ import { ButtonComponent } from "../../components/button"; import { FactorId } from "firebase/auth"; describe("", () => { + beforeEach(() => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + auth: { + currentUser: { uid: "test-user" }, + }, + }); + }); + }); + it("should create", async () => { const { fixture } = await render(MultiFactorAuthEnrollmentFormComponent, { imports: [ @@ -55,7 +66,7 @@ describe("", () => { }); it("should auto-select single hint when only one is provided", async () => { - await render(MultiFactorAuthEnrollmentFormComponent, { + const { container } = await render(MultiFactorAuthEnrollmentFormComponent, { imports: [ CommonModule, MultiFactorAuthEnrollmentFormComponent, @@ -72,11 +83,11 @@ describe("", () => { expect(screen.queryByRole("button", { name: "TOTP Verification" })).not.toBeInTheDocument(); expect(screen.getByLabelText("Display Name")).toBeInTheDocument(); - expect(screen.getByLabelText("Phone Number")).toBeInTheDocument(); + expect(container.querySelector('input[name="phoneNumber"]')).toBeInTheDocument(); }); it("should show SMS form when SMS hint is selected", async () => { - const { fixture } = await render(MultiFactorAuthEnrollmentFormComponent, { + const { fixture, container } = await render(MultiFactorAuthEnrollmentFormComponent, { imports: [ CommonModule, MultiFactorAuthEnrollmentFormComponent, @@ -94,7 +105,7 @@ describe("", () => { fixture.detectChanges(); expect(screen.getByLabelText("Display Name")).toBeInTheDocument(); - expect(screen.getByLabelText("Phone Number")).toBeInTheDocument(); + expect(container.querySelector('input[name="phoneNumber"]')).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Send Verification Code" })).toBeInTheDocument(); }); @@ -190,4 +201,40 @@ describe("", () => { expect(container.querySelector(".fui-content")).toBeInTheDocument(); }); + + it("should throw error when hints array is empty", async () => { + await expect( + render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + componentInputs: { + hints: [], + }, + }) + ).rejects.toThrow("MultiFactorAuthEnrollmentForm must have at least one hint"); + }); + + it("should throw error for unknown hint type", async () => { + const unknownHint = "unknown" as any; + + await expect( + render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + componentInputs: { + hints: [unknownHint], + }, + }) + ).rejects.toThrow("Unknown multi-factor enrollment type: unknown"); + }); }); 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 index 527116b21..30ee40402 100644 --- 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 @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, signal, input, output, OnInit } from "@angular/core"; +import { Component, signal, input, Output, EventEmitter, OnInit, computed } from "@angular/core"; import { CommonModule } from "@angular/common"; import { FactorId } from "firebase/auth"; import { injectTranslation } from "../../provider"; @@ -27,6 +27,9 @@ type Hint = (typeof FactorId)[keyof typeof FactorId]; @Component({ selector: "fui-multi-factor-auth-enrollment-form", standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, SmsMultiFactorEnrollmentFormComponent, @@ -35,22 +38,22 @@ type Hint = (typeof FactorId)[keyof typeof FactorId]; ], template: `
- @if (selectedHint()) { - @if (selectedHint() === "phone") { + @if (validatedHint()) { + @if (validatedHint() === phoneFactorId) { - } @else if (selectedHint() === "totp") { + } @else if (validatedHint() === totpFactorId) { } } @else { @for (hint of hints(); track hint) { - @if (hint === "phone") { - - } @else if (hint === "totp") { - + } @else if (hint === phoneFactorId) { + } } } @@ -59,20 +62,32 @@ type Hint = (typeof FactorId)[keyof typeof FactorId]; }) export class MultiFactorAuthEnrollmentFormComponent implements OnInit { hints = input([FactorId.TOTP, FactorId.PHONE]); - onEnrollment = output(); + @Output() onEnrollment = new EventEmitter(); selectedHint = signal(undefined); + phoneFactorId = FactorId.PHONE; + totpFactorId = FactorId.TOTP; + smsVerificationLabel = injectTranslation("labels", "mfaSmsVerification"); totpVerificationLabel = injectTranslation("labels", "mfaTotpVerification"); + validatedHint = computed(() => { + const hint = this.selectedHint(); + if (hint && hint !== this.phoneFactorId && hint !== this.totpFactorId) { + throw new Error(`Unknown multi-factor enrollment type: ${hint}`); + } + return hint; + }); + ngOnInit() { - // Auto-select single hint after component initialization const hints = this.hints(); + if (hints.length === 0) { + throw new Error("MultiFactorAuthEnrollmentForm must have at least one hint"); + } + // Auto-select single hint after component initialization if (hints.length === 1) { this.selectedHint.set(hints[0]); - } else if (hints.length === 0) { - throw new Error("MultiFactorAuthEnrollmentForm must have at least one hint"); } } diff --git a/packages/angular/src/lib/auth/forms/phone-auth-form.spec.ts b/packages/angular/src/lib/auth/forms/phone-auth-form.spec.ts index 88d984bdf..0f0d15c1e 100644 --- a/packages/angular/src/lib/auth/forms/phone-auth-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/phone-auth-form.spec.ts @@ -91,7 +91,7 @@ describe("", () => { }); it("should render phone number form initially", async () => { - await render(PhoneAuthFormComponent, { + const { container } = await render(PhoneAuthFormComponent, { imports: [ CommonModule, PhoneAuthFormComponent, @@ -104,7 +104,7 @@ describe("", () => { ], }); - expect(screen.getByLabelText("Phone Number")).toBeInTheDocument(); + expect(container.querySelector('input[name="phoneNumber"]')).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Send Verification Code" })).toBeInTheDocument(); }); @@ -126,7 +126,9 @@ describe("", () => { component.verificationId.set("test-verification-id"); fixture.detectChanges(); - expect(screen.getByLabelText("Verification Code")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole("textbox", { name: /Verification Code/i })).toBeInTheDocument(); + }); expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); }); @@ -299,7 +301,7 @@ describe("", () => { }); it("should reset form when going back to phone number step", async () => { - const { fixture } = await render(PhoneAuthFormComponent, { + const { fixture, container } = await render(PhoneAuthFormComponent, { imports: [ CommonModule, PhoneAuthFormComponent, @@ -319,7 +321,7 @@ describe("", () => { component.verificationId.set(null); fixture.detectChanges(); - expect(screen.getByLabelText("Phone Number")).toBeInTheDocument(); + expect(container.querySelector('input[name="phoneNumber"]')).toBeInTheDocument(); expect(screen.queryByLabelText("Verification Code")).toBeNull(); }); }); diff --git a/packages/angular/src/lib/auth/forms/phone-auth-form.ts b/packages/angular/src/lib/auth/forms/phone-auth-form.ts index 916590896..acd988c2c 100644 --- a/packages/angular/src/lib/auth/forms/phone-auth-form.ts +++ b/packages/angular/src/lib/auth/forms/phone-auth-form.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, ElementRef, effect, input, signal, output, computed, viewChild } from "@angular/core"; +import { Component, ElementRef, effect, input, signal, Output, EventEmitter, computed, viewChild } from "@angular/core"; import { CommonModule } from "@angular/common"; import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanstack/angular-form"; import { @@ -40,6 +40,9 @@ import { @Component({ selector: "fui-phone-number-form", standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, TanStackField, @@ -53,13 +56,15 @@ import { template: `
- + [label]="phoneNumberLabel()" + type="tel" + > + +
@@ -78,7 +83,7 @@ export class PhoneNumberFormComponent { private ui = injectUI(); private formSchema = injectPhoneAuthFormSchema(); - onSubmit = output<{ verificationId: string; phoneNumber: string }>(); + @Output() onSubmit = new EventEmitter<{ verificationId: string; phoneNumber: string }>(); country = signal(countryData[0].code); phoneNumberLabel = injectTranslation("labels", "phoneNumber"); @@ -146,6 +151,9 @@ export class PhoneNumberFormComponent { @Component({ selector: "fui-verification-form", standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, TanStackField, @@ -162,7 +170,9 @@ export class PhoneNumberFormComponent { name="verificationCode" tanstack-app-field [tanstackField]="form" - label="{{ verificationCodeLabel() }}" + [label]="verificationCodeLabel()" + [description]="smsVerificationPrompt()" + type="text" >
@@ -182,10 +192,11 @@ export class VerificationFormComponent { private formSchema = injectPhoneAuthVerifyFormSchema(); verificationId = input.required(); - signIn = output(); + @Output() signIn = new EventEmitter(); verificationCodeLabel = injectTranslation("labels", "verificationCode"); verifyCodeLabel = injectTranslation("labels", "verifyCode"); + smsVerificationPrompt = injectTranslation("prompts", "smsVerificationPrompt"); unknownErrorLabel = injectTranslation("errors", "unknownError"); form = injectForm({ @@ -235,6 +246,9 @@ export class VerificationFormComponent { selector: "fui-phone-auth-form", standalone: true, imports: [CommonModule, PhoneNumberFormComponent, VerificationFormComponent], + host: { + style: "display: block;", + }, template: `
@if (verificationId()) { @@ -247,7 +261,7 @@ export class VerificationFormComponent { }) export class PhoneAuthFormComponent { verificationId = signal(null); - signIn = output(); + @Output() signIn = new EventEmitter(); handlePhoneSubmit(data: { verificationId: string; phoneNumber: string }) { this.verificationId.set(data.verificationId); diff --git a/packages/angular/src/lib/auth/forms/sign-in-auth-form.spec.ts b/packages/angular/src/lib/auth/forms/sign-in-auth-form.spec.ts index 98ff2fa23..616bb4cc1 100644 --- a/packages/angular/src/lib/auth/forms/sign-in-auth-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/sign-in-auth-form.spec.ts @@ -16,6 +16,7 @@ import { render, screen, fireEvent } from "@testing-library/angular"; import { CommonModule } from "@angular/common"; +import { EventEmitter } from "@angular/core"; import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; import { SignInAuthFormComponent } from "./sign-in-auth-form"; import { @@ -59,7 +60,12 @@ describe("", () => { }); it("should render the form initially", async () => { - await render(SignInAuthFormComponent, { + const forgotPasswordEmitter = new EventEmitter(); + const signUpEmitter = new EventEmitter(); + forgotPasswordEmitter.subscribe(() => {}); + signUpEmitter.subscribe(() => {}); + + const { fixture } = await render(SignInAuthFormComponent, { imports: [ CommonModule, SignInAuthFormComponent, @@ -71,7 +77,12 @@ describe("", () => { FormActionComponent, PoliciesComponent, ], + componentInputs: { + forgotPassword: forgotPasswordEmitter, + signUp: signUpEmitter, + }, }); + fixture.detectChanges(); expect(screen.getByLabelText("Email Address")).toBeInTheDocument(); expect(screen.getByLabelText("Password", { selector: "input" })).toBeInTheDocument(); @@ -81,6 +92,76 @@ describe("", () => { expect(screen.getByRole("button", { name: "Don't have an account? Sign Up" })).toBeInTheDocument(); }); + it("should not render forgot password button when output is not bound", async () => { + const { fixture } = await render(SignInAuthFormComponent, { + imports: [ + CommonModule, + SignInAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + fixture.detectChanges(); + + expect(screen.getByLabelText("Email Address")).toBeInTheDocument(); + expect(screen.getByLabelText("Password", { selector: "input" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Sign In" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Forgot Password" })).not.toBeInTheDocument(); + }); + + it("should not render sign up button when output is not bound", async () => { + const { fixture } = await render(SignInAuthFormComponent, { + imports: [ + CommonModule, + SignInAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + fixture.detectChanges(); + + expect(screen.getByLabelText("Email Address")).toBeInTheDocument(); + expect(screen.getByLabelText("Password", { selector: "input" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Sign In" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Don't have an account? Sign Up" })).not.toBeInTheDocument(); + }); + + it("should conditionally render buttons based on which outputs are bound", async () => { + const forgotPasswordEmitter = new EventEmitter(); + forgotPasswordEmitter.subscribe(() => {}); + + const { fixture } = await render(SignInAuthFormComponent, { + imports: [ + CommonModule, + SignInAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + componentInputs: { + forgotPassword: forgotPasswordEmitter, + }, + }); + fixture.detectChanges(); + + expect(screen.getByRole("button", { name: "Forgot Password" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Don't have an account? Sign Up" })).not.toBeInTheDocument(); + }); + it("should have correct translation labels", async () => { const { fixture } = await render(SignInAuthFormComponent, { imports: [ @@ -126,6 +207,9 @@ describe("", () => { }); it("should emit forgotPassword when forgot password button is clicked", async () => { + const forgotPasswordEmitter = new EventEmitter(); + forgotPasswordEmitter.subscribe(() => {}); + const { fixture } = await render(SignInAuthFormComponent, { imports: [ CommonModule, @@ -138,9 +222,12 @@ describe("", () => { FormActionComponent, PoliciesComponent, ], + componentInputs: { + forgotPassword: forgotPasswordEmitter, + }, }); - const component = fixture.componentInstance; - const forgotPasswordSpy = jest.spyOn(component.forgotPassword, "emit"); + fixture.detectChanges(); + const forgotPasswordSpy = jest.spyOn(forgotPasswordEmitter, "emit"); const forgotPasswordButton = screen.getByRole("button", { name: "Forgot Password" }); fireEvent.click(forgotPasswordButton); @@ -148,6 +235,9 @@ describe("", () => { }); it("should emit signUp when sign up button is clicked", async () => { + const signUpEmitter = new EventEmitter(); + signUpEmitter.subscribe(() => {}); + const { fixture } = await render(SignInAuthFormComponent, { imports: [ CommonModule, @@ -160,9 +250,12 @@ describe("", () => { FormActionComponent, PoliciesComponent, ], + componentInputs: { + signUp: signUpEmitter, + }, }); - const component = fixture.componentInstance; - const signUpSpy = jest.spyOn(component.signUp, "emit"); + fixture.detectChanges(); + const signUpSpy = jest.spyOn(signUpEmitter, "emit"); const signUpButton = screen.getByRole("button", { name: "Don't have an account? Sign Up" }); fireEvent.click(signUpButton); diff --git a/packages/angular/src/lib/auth/forms/sign-in-auth-form.ts b/packages/angular/src/lib/auth/forms/sign-in-auth-form.ts index 2760c6b86..94ba33aaa 100644 --- a/packages/angular/src/lib/auth/forms/sign-in-auth-form.ts +++ b/packages/angular/src/lib/auth/forms/sign-in-auth-form.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, output, effect } from "@angular/core"; +import { Component, Output, EventEmitter, input, effect } from "@angular/core"; import { CommonModule } from "@angular/common"; import { UserCredential } from "@angular/fire/auth"; import { injectForm, TanStackField, TanStackAppField, injectStore } from "@tanstack/angular-form"; @@ -32,6 +32,9 @@ import { @Component({ selector: "fui-sign-in-auth-form", standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, TanStackField, @@ -49,7 +52,8 @@ import { name="email" tanstack-app-field [tanstackField]="form" - label="{{ emailLabel() }}" + [label]="emailLabel()" + type="email" >
@@ -57,11 +61,11 @@ import { name="password" tanstack-app-field [tanstackField]="form" - label="{{ passwordLabel() }}" + [label]="passwordLabel()" type="password" > - @if (forgotPassword) { - } @@ -77,8 +81,8 @@ import {
- @if (signUp) { - + @if (signUp()?.observed) { + } `, @@ -95,9 +99,10 @@ export class SignInAuthFormComponent { signUpLabel = injectTranslation("labels", "signUp"); unknownErrorLabel = injectTranslation("errors", "unknownError"); - forgotPassword = output(); - signUp = output(); - signIn = output(); + forgotPassword = input>(); + signUp = input>(); + + @Output() signIn = new EventEmitter(); form = injectForm({ defaultValues: { @@ -122,13 +127,14 @@ export class SignInAuthFormComponent { onSubmitAsync: async ({ value }) => { try { const credential = await signInWithEmailAndPassword(this.ui(), value.email, value.password); - this.signIn?.emit(credential); + this.signIn.emit(credential); return; } catch (error) { if (error instanceof FirebaseUIError) { return error.message; } + console.error(error); return this.unknownErrorLabel(); } }, diff --git a/packages/angular/src/lib/auth/forms/sign-up-auth-form.spec.ts b/packages/angular/src/lib/auth/forms/sign-up-auth-form.spec.ts index b6c1b840f..57161b17c 100644 --- a/packages/angular/src/lib/auth/forms/sign-up-auth-form.spec.ts +++ b/packages/angular/src/lib/auth/forms/sign-up-auth-form.spec.ts @@ -16,6 +16,7 @@ import { render, screen, fireEvent } from "@testing-library/angular"; import { CommonModule } from "@angular/common"; +import { EventEmitter } from "@angular/core"; import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; import { SignUpAuthFormComponent } from "./sign-up-auth-form"; import { @@ -64,7 +65,10 @@ describe("", () => { }); it("should render the form initially without display name field", async () => { - await render(SignUpAuthFormComponent, { + const signInEmitter = new EventEmitter(); + signInEmitter.subscribe(() => {}); + + const { fixture } = await render(SignUpAuthFormComponent, { imports: [ CommonModule, SignUpAuthFormComponent, @@ -76,14 +80,18 @@ describe("", () => { FormActionComponent, PoliciesComponent, ], + componentInputs: { + signIn: signInEmitter, + }, }); + fixture.detectChanges(); expect(screen.getByLabelText("Email Address")).toBeInTheDocument(); expect(screen.getByLabelText("Password")).toBeInTheDocument(); expect(screen.queryByLabelText("Display Name")).toBeNull(); expect(screen.getByRole("button", { name: "Create Account" })).toBeInTheDocument(); expect(screen.getByText("By continuing, you agree to our")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Already have an account? Sign In β†’" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Already have an account? Sign In" })).toBeInTheDocument(); }); it("should render display name field when hasBehavior returns true", async () => { @@ -155,6 +163,9 @@ describe("", () => { }); it("should emit signIn when sign in button is clicked", async () => { + const signInEmitter = new EventEmitter(); + signInEmitter.subscribe(() => {}); + const { fixture } = await render(SignUpAuthFormComponent, { imports: [ CommonModule, @@ -167,11 +178,14 @@ describe("", () => { FormActionComponent, PoliciesComponent, ], + componentInputs: { + signIn: signInEmitter, + }, }); - const component = fixture.componentInstance; - const signInSpy = jest.spyOn(component.signIn, "emit"); + fixture.detectChanges(); + const signInSpy = jest.spyOn(signInEmitter, "emit"); - const signInButton = screen.getByRole("button", { name: "Already have an account? Sign In β†’" }); + const signInButton = screen.getByRole("button", { name: "Already have an account? Sign In" }); fireEvent.click(signInButton); expect(signInSpy).toHaveBeenCalled(); }); diff --git a/packages/angular/src/lib/auth/forms/sign-up-auth-form.ts b/packages/angular/src/lib/auth/forms/sign-up-auth-form.ts index 7e2f884c2..5c3a9d35a 100644 --- a/packages/angular/src/lib/auth/forms/sign-up-auth-form.ts +++ b/packages/angular/src/lib/auth/forms/sign-up-auth-form.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, output, effect, computed } from "@angular/core"; +import { Component, Output, EventEmitter, input, effect, computed } from "@angular/core"; import { CommonModule } from "@angular/common"; import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanstack/angular-form"; import { FirebaseUIError, createUserWithEmailAndPassword, hasBehavior } from "@invertase/firebaseui-core"; @@ -31,6 +31,10 @@ import { @Component({ selector: "fui-sign-up-auth-form", + standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, TanStackField, @@ -45,23 +49,18 @@ import {
@if (requireDisplayNameField()) {
- +
}
- +
@@ -73,12 +72,11 @@ import { - @if (signIn) { - + @if (signIn()?.observed) { + } `, - standalone: true, }) export class SignUpAuthFormComponent { private ui = injectUI(); @@ -96,8 +94,9 @@ export class SignUpAuthFormComponent { signInLabel = injectTranslation("labels", "signIn"); unknownErrorLabel = injectTranslation("errors", "unknownError"); - signUp = output(); - signIn = output(); + signIn = input>(); + + @Output() signUp = new EventEmitter(); form = injectForm({ defaultValues: { @@ -128,13 +127,14 @@ export class SignUpAuthFormComponent { value.password, value.displayName ); - this.signUp?.emit(credential); + this.signUp.emit(credential); return; } catch (error) { if (error instanceof FirebaseUIError) { return error.message; } + console.error(error); return this.unknownErrorLabel(); } }, diff --git a/packages/angular/src/lib/auth/oauth/apple-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.ts index a9e358f6e..8681b5765 100644 --- a/packages/angular/src/lib/auth/oauth/apple-sign-in-button.ts +++ b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.ts @@ -25,8 +25,11 @@ import { AppleLogoComponent } from "../../components/logos/apple"; selector: "fui-apple-sign-in-button", standalone: true, imports: [CommonModule, OAuthButtonComponent, AppleLogoComponent], + host: { + style: "display: block;", + }, template: ` - + {{ signInWithAppleLabel() }} @@ -35,6 +38,7 @@ import { AppleLogoComponent } from "../../components/logos/apple"; export class AppleSignInButtonComponent { ui = injectUI(); signInWithAppleLabel = injectTranslation("labels", "signInWithApple"); + themed = input(false); private defaultProvider = new OAuthProvider("apple.com"); diff --git a/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts index 8a1be2f17..06443abe2 100644 --- a/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts +++ b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts @@ -25,8 +25,11 @@ import { FacebookLogoComponent } from "../../components/logos/facebook"; selector: "fui-facebook-sign-in-button", standalone: true, imports: [CommonModule, OAuthButtonComponent, FacebookLogoComponent], + host: { + style: "display: block;", + }, template: ` - + {{ signInWithFacebookLabel() }} @@ -35,6 +38,7 @@ import { FacebookLogoComponent } from "../../components/logos/facebook"; export class FacebookSignInButtonComponent { ui = injectUI(); signInWithFacebookLabel = injectTranslation("labels", "signInWithFacebook"); + themed = input(false); private defaultProvider = new FacebookAuthProvider(); diff --git a/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts index 955d06f68..061e15e9a 100644 --- a/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts @@ -17,19 +17,19 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; -import { GithubSignInButtonComponent } from "./github-sign-in-button"; +import { GitHubSignInButtonComponent } from "./github-sign-in-button"; @Component({ template: ``, standalone: true, - imports: [GithubSignInButtonComponent], + imports: [GitHubSignInButtonComponent], }) class TestGithubSignInButtonHostComponent {} @Component({ template: ``, standalone: true, - imports: [GithubSignInButtonComponent], + imports: [GitHubSignInButtonComponent], }) class TestGithubSignInButtonWithCustomProviderHostComponent { customProvider = { providerId: "custom.github.com" }; @@ -43,7 +43,7 @@ describe("", () => { injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { - signInWithGithub: "Sign in with GitHub", + signInWithGitHub: "Sign in with GitHub", }, }; return () => mockTranslations[category]?.[key] || `${category}.${key}`; diff --git a/packages/angular/src/lib/auth/oauth/github-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/github-sign-in-button.ts index d76cf686e..6e253e175 100644 --- a/packages/angular/src/lib/auth/oauth/github-sign-in-button.ts +++ b/packages/angular/src/lib/auth/oauth/github-sign-in-button.ts @@ -25,15 +25,19 @@ import { GithubLogoComponent } from "../../components/logos/github"; selector: "fui-github-sign-in-button", standalone: true, imports: [CommonModule, OAuthButtonComponent, GithubLogoComponent], + host: { + style: "display: block;", + }, template: ` - + - {{ signInWithGithubLabel() }} + {{ signInWithGitHubLabel() }} `, }) -export class GithubSignInButtonComponent { - signInWithGithubLabel = injectTranslation("labels", "signInWithGithub"); +export class GitHubSignInButtonComponent { + signInWithGitHubLabel = injectTranslation("labels", "signInWithGitHub"); + themed = input(false); private defaultProvider = new GithubAuthProvider(); diff --git a/packages/angular/src/lib/auth/oauth/google-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/google-sign-in-button.ts index cece30b49..a8201b0a6 100644 --- a/packages/angular/src/lib/auth/oauth/google-sign-in-button.ts +++ b/packages/angular/src/lib/auth/oauth/google-sign-in-button.ts @@ -25,8 +25,11 @@ import { GoogleLogoComponent } from "../../components/logos/google"; selector: "fui-google-sign-in-button", standalone: true, imports: [CommonModule, OAuthButtonComponent, GoogleLogoComponent], + host: { + style: "display: block;", + }, template: ` - + {{ signInWithGoogleLabel() }} @@ -35,6 +38,7 @@ import { GoogleLogoComponent } from "../../components/logos/google"; export class GoogleSignInButtonComponent { ui = injectUI(); signInWithGoogleLabel = injectTranslation("labels", "signInWithGoogle"); + themed = input(false); private defaultProvider = new GoogleAuthProvider(); diff --git a/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.ts index e2b05039e..641aeeb98 100644 --- a/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.ts +++ b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.ts @@ -25,8 +25,11 @@ import { MicrosoftLogoComponent } from "../../components/logos/microsoft"; selector: "fui-microsoft-sign-in-button", standalone: true, imports: [CommonModule, OAuthButtonComponent, MicrosoftLogoComponent], + host: { + style: "display: block;", + }, template: ` - + {{ signInWithMicrosoftLabel() }} @@ -34,6 +37,7 @@ import { MicrosoftLogoComponent } from "../../components/logos/microsoft"; }) export class MicrosoftSignInButtonComponent { signInWithMicrosoftLabel = injectTranslation("labels", "signInWithMicrosoft"); + themed = input(false); private defaultProvider = new OAuthProvider("microsoft.com"); diff --git a/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts b/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts index bd749f6d5..32856f17e 100644 --- a/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts +++ b/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts @@ -40,13 +40,21 @@ class TestOAuthButtonWithCustomProviderHostComponent { describe("", () => { let mockSignInWithProvider: any; let mockFirebaseUIError: any; + let mockGetTranslation: any; beforeEach(() => { - const { signInWithProvider, FirebaseUIError } = require("@invertase/firebaseui-core"); + const { signInWithProvider, FirebaseUIError, getTranslation } = require("@invertase/firebaseui-core"); mockSignInWithProvider = signInWithProvider; mockFirebaseUIError = FirebaseUIError; + mockGetTranslation = getTranslation; mockSignInWithProvider.mockClear(); + mockGetTranslation.mockImplementation((ui: any, category: string, key: string) => { + if (category === "errors" && key === "unknownError") { + return "An unknown error occurred"; + } + return `${category}.${key}`; + }); }); it("should create", async () => { diff --git a/packages/angular/src/lib/auth/oauth/oauth-button.ts b/packages/angular/src/lib/auth/oauth/oauth-button.ts index 185b07cf2..727c2c5f0 100644 --- a/packages/angular/src/lib/auth/oauth/oauth-button.ts +++ b/packages/angular/src/lib/auth/oauth/oauth-button.ts @@ -14,23 +14,28 @@ * limitations under the License. */ -import { Component, input, signal } from "@angular/core"; +import { Component, input, signal, computed } from "@angular/core"; import { CommonModule } from "@angular/common"; import { ButtonComponent } from "../../components/button"; -import { injectTranslation, injectUI } from "../../provider"; +import { injectUI } from "../../provider"; import { AuthProvider } from "@angular/fire/auth"; -import { FirebaseUIError, signInWithProvider } from "@invertase/firebaseui-core"; +import { FirebaseUIError, signInWithProvider, getTranslation } from "@invertase/firebaseui-core"; @Component({ selector: "fui-oauth-button", standalone: true, imports: [CommonModule, ButtonComponent], + host: { + style: "display: block;", + }, template: `
@@ -51,7 +51,7 @@ class MockRedirectErrorComponent {} standalone: true, outputs: ["onSuccess"], }) -class MockMultiFactorAuthAssertionFormComponent { +class MockMultiFactorAuthAssertionScreenComponent { onSuccess = new EventEmitter(); } @@ -90,6 +90,7 @@ describe("", () => { injectUI.mockImplementation(() => () => ({ multiFactorResolver: null, + setMultiFactorResolver: jest.fn(), })); }); @@ -212,13 +213,14 @@ describe("", () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => () => ({ multiFactorResolver: { auth: {}, session: null, hints: [] }, + setMultiFactorResolver: jest.fn(), })); const { container } = await render(TestHostWithoutContentComponent, { imports: [ EmailLinkAuthScreenComponent, MockEmailLinkAuthFormComponent, - MockMultiFactorAuthAssertionFormComponent, + MockMultiFactorAuthAssertionScreenComponent, MockRedirectErrorComponent, CardComponent, CardHeaderComponent, @@ -228,45 +230,22 @@ describe("", () => { ], }); - // Check for the MFA form element by its selector - expect(container.querySelector("fui-multi-factor-auth-assertion-form")).toBeInTheDocument(); - }); - - it("does not render RedirectError when MFA resolver is present", async () => { - const { injectUI } = require("../../../provider"); - injectUI.mockImplementation(() => () => ({ - multiFactorResolver: { auth: {}, session: null, hints: [] }, - })); - - const { container } = await render(TestHostWithContentComponent, { - imports: [ - EmailLinkAuthScreenComponent, - MockEmailLinkAuthFormComponent, - MockMultiFactorAuthAssertionFormComponent, - MockRedirectErrorComponent, - CardComponent, - CardHeaderComponent, - CardTitleComponent, - CardSubtitleComponent, - CardContentComponent, - ], - }); - - expect(container.querySelector("fui-redirect-error")).toBeNull(); - expect(container.querySelector("fui-multi-factor-auth-assertion-form")).toBeInTheDocument(); + // Check for the MFA screen element by its selector + expect(container.querySelector("fui-multi-factor-auth-assertion-screen")).toBeInTheDocument(); }); it("calls signIn output when MFA flow succeeds", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => () => ({ multiFactorResolver: { auth: {}, session: null, hints: [] }, + setMultiFactorResolver: jest.fn(), })); const { fixture } = await render(TestHostWithoutContentComponent, { imports: [ EmailLinkAuthScreenComponent, MockEmailLinkAuthFormComponent, - MockMultiFactorAuthAssertionFormComponent, + MockMultiFactorAuthAssertionScreenComponent, MockRedirectErrorComponent, CardComponent, CardHeaderComponent, @@ -281,7 +260,7 @@ describe("", () => { // Simulate MFA success by directly calling the onSuccess handler const mfaComponent = fixture.debugElement.query( - (el) => el.name === "fui-multi-factor-auth-assertion-form" + (el) => el.name === "fui-multi-factor-auth-assertion-screen" ).componentInstance; mfaComponent.onSuccess.emit({ user: { uid: "mfa-user" } }); diff --git a/packages/angular/src/lib/auth/screens/email-link-auth-screen.ts b/packages/angular/src/lib/auth/screens/email-link-auth-screen.ts index 87106720c..343ebad09 100644 --- a/packages/angular/src/lib/auth/screens/email-link-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/email-link-auth-screen.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, output, computed } from "@angular/core"; +import { Component, Output, EventEmitter, computed } from "@angular/core"; import { CommonModule } from "@angular/common"; import { CardComponent, @@ -25,13 +25,16 @@ import { } from "../../components/card"; import { injectTranslation, injectUI } from "../../provider"; import { EmailLinkAuthFormComponent } from "../forms/email-link-auth-form"; -import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; +import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; import { UserCredential } from "@angular/fire/auth"; @Component({ selector: "fui-email-link-auth-screen", standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, CardComponent, @@ -40,27 +43,27 @@ import { UserCredential } from "@angular/fire/auth"; CardSubtitleComponent, CardContentComponent, EmailLinkAuthFormComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, RedirectErrorComponent, ], template: ` -
- - - {{ titleText() }} - {{ subtitleText() }} - - - @if (mfaResolver()) { - - } @else { + @if (mfaResolver()) { + + } @else { +
+ + + {{ titleText() }} + {{ subtitleText() }} + + - - } - - -
+ +
+
+
+ } `, }) export class EmailLinkAuthScreenComponent { @@ -71,6 +74,6 @@ export class EmailLinkAuthScreenComponent { titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); - emailSent = output(); - signIn = output(); + @Output() emailSent = new EventEmitter(); + @Output() signIn = new EventEmitter(); } diff --git a/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.spec.ts index 6c5fa7352..fbff9cdab 100644 --- a/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.spec.ts @@ -33,13 +33,6 @@ import { }) class MockForgotPasswordAuthFormComponent {} -@Component({ - selector: "fui-redirect-error", - template: '
Redirect Error
', - standalone: true, -}) -class MockRedirectErrorComponent {} - describe("", () => { beforeEach(() => { const { injectTranslation } = require("../../../provider"); @@ -61,7 +54,6 @@ describe("", () => { imports: [ ForgotPasswordAuthScreenComponent, MockForgotPasswordAuthFormComponent, - MockRedirectErrorComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -79,7 +71,6 @@ describe("", () => { imports: [ ForgotPasswordAuthScreenComponent, MockForgotPasswordAuthFormComponent, - MockRedirectErrorComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -92,30 +83,11 @@ describe("", () => { expect(form).toBeInTheDocument(); }); - it("renders RedirectError component in children section when no MFA resolver", async () => { - const { container } = await render(ForgotPasswordAuthScreenComponent, { - imports: [ - ForgotPasswordAuthScreenComponent, - MockForgotPasswordAuthFormComponent, - 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(ForgotPasswordAuthScreenComponent, { imports: [ ForgotPasswordAuthScreenComponent, MockForgotPasswordAuthFormComponent, - MockRedirectErrorComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -138,7 +110,6 @@ describe("", () => { imports: [ ForgotPasswordAuthScreenComponent, MockForgotPasswordAuthFormComponent, - MockRedirectErrorComponent, CardComponent, CardHeaderComponent, CardTitleComponent, diff --git a/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.ts b/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.ts index d3a0bd650..93248d871 100644 --- a/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, output } from "@angular/core"; +import { Component, Output, EventEmitter } from "@angular/core"; import { CommonModule } from "@angular/common"; import { CardComponent, @@ -25,11 +25,13 @@ import { } 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", standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, CardComponent, @@ -38,7 +40,6 @@ import { RedirectErrorComponent } from "../../components/redirect-error"; CardSubtitleComponent, CardContentComponent, ForgotPasswordAuthFormComponent, - RedirectErrorComponent, ], template: `
@@ -48,8 +49,7 @@ import { RedirectErrorComponent } from "../../components/redirect-error"; {{ subtitleText() }} - - +
@@ -59,6 +59,6 @@ export class ForgotPasswordAuthScreenComponent { titleText = injectTranslation("labels", "resetPassword"); subtitleText = injectTranslation("prompts", "enterEmailToReset"); - passwordSent = output(); - backToSignIn = output(); + @Output() passwordSent = new EventEmitter(); + @Output() backToSignIn = new EventEmitter(); } diff --git a/packages/angular/src/lib/auth/screens/multi-factor-auth-assertion-screen.spec.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-assertion-screen.spec.ts new file mode 100644 index 000000000..2efac64f7 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-assertion-screen.spec.ts @@ -0,0 +1,171 @@ +/** + * 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 { TestBed } from "@angular/core/testing"; +import { MultiFactorAuthAssertionScreenComponent } from "./multi-factor-auth-assertion-screen"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; + +@Component({ + template: ``, + standalone: true, + imports: [MultiFactorAuthAssertionScreenComponent], +}) +class TestHostWithoutContentComponent {} + +describe("", () => { + beforeEach(() => { + const { injectTranslation, injectUI } = require("../../../provider"); + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + multiFactorAssertion: "Multi-Factor Assertion", + }, + prompts: { + mfaAssertionPrompt: "Verify your multi-factor authentication", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => () => ({ + multiFactorResolver: { + auth: {}, + session: null, + hints: [], + }, + setMultiFactorResolver: jest.fn(), + })); + }); + + it("renders with correct title and subtitle", async () => { + await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.getByRole("heading", { name: "Multi-Factor Assertion" })).toBeInTheDocument(); + expect(screen.getByText("Verify your multi-factor authentication")).toBeInTheDocument(); + }); + + it("includes the MultiFactorAuthAssertionForm component", async () => { + TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + set: { + template: '
MFA Assertion Form
', + }, + }); + + await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const form = screen.getByTestId("mfa-assertion-form"); + expect(form).toBeInTheDocument(); + expect(form).toHaveTextContent("MFA Assertion Form"); + }); + + it("has correct CSS classes", async () => { + const { container } = await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthAssertionScreenComponent, + 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: [ + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(injectTranslation).toHaveBeenCalledWith("labels", "multiFactorAssertion"); + expect(injectTranslation).toHaveBeenCalledWith("prompts", "mfaAssertionPrompt"); + }); + + it("emits onSuccess event when form emits onSuccess", async () => { + TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + set: { + template: '
MFA Assertion Form
', + }, + }); + + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query( + (el) => el.name === "fui-multi-factor-auth-assertion-screen" + ).componentInstance; + const onSuccessSpy = jest.spyOn(component.onSuccess, "emit"); + + const formComponent = fixture.debugElement.query( + (el) => el.name === "fui-multi-factor-auth-assertion-form" + ).componentInstance; + formComponent.onSuccess.emit({ user: { uid: "mfa-user" } }); + + expect(onSuccessSpy).toHaveBeenCalledTimes(1); + expect(onSuccessSpy).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) }) + ); + }); +}); diff --git a/packages/angular/src/lib/auth/screens/multi-factor-auth-assertion-screen.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-assertion-screen.ts new file mode 100644 index 000000000..038244804 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-assertion-screen.ts @@ -0,0 +1,64 @@ +/** + * 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, EventEmitter } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { UserCredential } from "@angular/fire/auth"; +import { injectTranslation } from "../../provider"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; + +@Component({ + selector: "fui-multi-factor-auth-assertion-screen", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + MultiFactorAuthAssertionFormComponent, + ], + template: ` +
+ + + {{ titleText() }} + {{ subtitleText() }} + + + + + +
+ `, +}) +export class MultiFactorAuthAssertionScreenComponent { + @Output() onSuccess = new EventEmitter(); + + titleText = injectTranslation("labels", "multiFactorAssertion"); + subtitleText = injectTranslation("prompts", "mfaAssertionPrompt"); +} diff --git a/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.spec.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.spec.ts index abf321446..abe68f671 100644 --- a/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.spec.ts @@ -33,24 +33,6 @@ import { FactorId } from "firebase/auth"; }) 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, @@ -79,7 +61,6 @@ describe("", () => { imports: [ MultiFactorAuthEnrollmentScreenComponent, MockMultiFactorAuthEnrollmentFormComponent, - MockRedirectErrorComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -97,7 +78,6 @@ describe("", () => { imports: [ MultiFactorAuthEnrollmentScreenComponent, MockMultiFactorAuthEnrollmentFormComponent, - MockRedirectErrorComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -111,49 +91,11 @@ describe("", () => { 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, @@ -176,7 +118,6 @@ describe("", () => { imports: [ MultiFactorAuthEnrollmentScreenComponent, MockMultiFactorAuthEnrollmentFormComponent, - MockRedirectErrorComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -194,7 +135,6 @@ describe("", () => { imports: [ MultiFactorAuthEnrollmentScreenComponent, MockMultiFactorAuthEnrollmentFormComponent, - MockRedirectErrorComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -215,7 +155,6 @@ describe("", () => { imports: [ MultiFactorAuthEnrollmentScreenComponent, MockMultiFactorAuthEnrollmentFormComponent, - MockRedirectErrorComponent, CardComponent, CardHeaderComponent, CardTitleComponent, 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 index 8d5c9eb77..69faaac2a 100644 --- 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 @@ -14,12 +14,11 @@ * limitations under the License. */ -import { Component, output, input } from "@angular/core"; +import { Component, Output, EventEmitter, 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, @@ -33,6 +32,9 @@ type Hint = (typeof FactorId)[keyof typeof FactorId]; @Component({ selector: "fui-multi-factor-auth-enrollment-screen", standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, CardComponent, @@ -41,7 +43,6 @@ type Hint = (typeof FactorId)[keyof typeof FactorId]; CardSubtitleComponent, CardContentComponent, MultiFactorAuthEnrollmentFormComponent, - RedirectErrorComponent, ], template: `
@@ -52,8 +53,6 @@ type Hint = (typeof FactorId)[keyof typeof FactorId]; - -
@@ -61,7 +60,7 @@ type Hint = (typeof FactorId)[keyof typeof FactorId]; }) export class MultiFactorAuthEnrollmentScreenComponent { hints = input([FactorId.TOTP, FactorId.PHONE]); - onEnrollment = output(); + @Output() onEnrollment = new EventEmitter(); titleText = injectTranslation("labels", "multiFactorEnrollment"); subtitleText = injectTranslation("prompts", "mfaEnrollmentPrompt"); diff --git a/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts b/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts index 49a57e09b..1b87973fa 100644 --- a/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts @@ -26,6 +26,7 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; +import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; import { ContentComponent } from "../../components/content"; @@ -81,11 +82,12 @@ class TestHostWithMultipleProvidersComponent {} class TestHostWithoutContentComponent {} @Component({ - selector: "fui-multi-factor-auth-assertion-form", - template: '
MFA Assertion Form
', + selector: "fui-multi-factor-auth-assertion-screen", + template: '
MFA Assertion Screen
', standalone: true, + outputs: ["onSuccess"], }) -class MockMultiFactorAuthAssertionFormComponent { +class MockMultiFactorAuthAssertionScreenComponent { onSuccess = new EventEmitter(); } @@ -126,7 +128,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -146,7 +148,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -166,7 +168,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -187,7 +189,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -212,7 +214,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -232,7 +234,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -257,7 +259,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -271,17 +273,17 @@ describe("", () => { expect(injectTranslation).toHaveBeenCalledWith("prompts", "signInToAccount"); }); - it("renders MFA assertion form when multiFactorResolver is present", async () => { + it("renders MFA assertion screen when multiFactorResolver is present", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ - multiFactorResolver: { hints: [] }, + multiFactorResolver: { auth: {}, session: null, hints: [] }, }); }); - TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { set: { - template: '
MFA Assertion Form
', + template: '
MFA Assertion Screen
', }, }); @@ -290,7 +292,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -300,7 +302,7 @@ describe("", () => { ], }); - expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); + expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); expect(screen.queryByTestId("policies")).not.toBeInTheDocument(); }); @@ -308,13 +310,13 @@ describe("", () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ - multiFactorResolver: { hints: [] }, + multiFactorResolver: { auth: {}, session: null, hints: [] }, }); }); - TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { set: { - template: '
MFA Assertion Form
', + template: '
MFA Assertion Screen
', }, }); @@ -323,7 +325,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -334,41 +336,29 @@ describe("", () => { }); expect(screen.queryByTestId("policies")).not.toBeInTheDocument(); - expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); + expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); }); it("emits onSignIn with credential when MFA flow succeeds", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ - multiFactorResolver: { hints: [{ factorId: "totp", uid: "test" }] }, + multiFactorResolver: { auth: {}, session: null, hints: [{ factorId: "totp", uid: "test" }] }, }); }); - TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { set: { - template: - '
MFA Assertion Form
', + template: '
MFA Assertion Screen
', }, }); - const onSignInHandler = jest.fn(); - - @Component({ - template: ``, - standalone: true, - imports: [OAuthScreenComponent], - }) - class HostCaptureComponent { - onSignIn = onSignInHandler; - } - - await render(HostCaptureComponent, { + const { fixture } = await render(TestHostWithoutContentComponent, { imports: [ OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -378,11 +368,16 @@ describe("", () => { ], }); - const trigger = screen.getByTestId("mfa-on-success"); - trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const component = fixture.debugElement.query((el) => el.name === "fui-oauth-screen").componentInstance; + const onSignInSpy = jest.spyOn(component.onSignIn, "emit"); + + const mfaScreenComponent = fixture.debugElement.query( + (el) => el.name === "fui-multi-factor-auth-assertion-screen" + ).componentInstance; + mfaScreenComponent.onSuccess.emit({ user: { uid: "angular-oauth-mfa-user" } }); - expect(onSignInHandler).toHaveBeenCalled(); - expect(onSignInHandler).toHaveBeenCalledWith( + expect(onSignInSpy).toHaveBeenCalledTimes(1); + expect(onSignInSpy).toHaveBeenCalledWith( expect.objectContaining({ user: expect.objectContaining({ uid: "angular-oauth-mfa-user" }) }) ); }); diff --git a/packages/angular/src/lib/auth/screens/oauth-screen.ts b/packages/angular/src/lib/auth/screens/oauth-screen.ts index 04b4e2794..cb30c6c9d 100644 --- a/packages/angular/src/lib/auth/screens/oauth-screen.ts +++ b/packages/angular/src/lib/auth/screens/oauth-screen.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, computed, output } from "@angular/core"; +import { Component, computed, Output, EventEmitter } from "@angular/core"; import { CommonModule } from "@angular/common"; import { CardComponent, @@ -25,14 +25,16 @@ import { } 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 { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; import { type UserCredential } from "firebase/auth"; @Component({ selector: "fui-oauth-screen", standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, CardComponent, @@ -41,30 +43,29 @@ import { type UserCredential } from "firebase/auth"; CardSubtitleComponent, CardContentComponent, PoliciesComponent, - ContentComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, RedirectErrorComponent, ], template: ` -
- - - {{ titleText() }} - {{ subtitleText() }} - - - @if (mfaResolver()) { - - } @else { - + @if (mfaResolver()) { + + } @else { +
+ + + {{ titleText() }} + {{ subtitleText() }} + + +
- - - - } - - -
+ + +
+
+
+
+ } `, }) export class OAuthScreenComponent { @@ -75,5 +76,5 @@ export class OAuthScreenComponent { titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); - onSignIn = output(); + @Output() onSignIn = new EventEmitter(); } diff --git a/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts index 45b937727..138ea477d 100644 --- a/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts @@ -26,6 +26,7 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; +import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; import { TotpMultiFactorAssertionFormComponent } from "../forms/mfa/totp-multi-factor-assertion-form"; import { TotpMultiFactorGenerator } from "firebase/auth"; @@ -90,7 +91,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -109,7 +110,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -129,7 +130,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -149,7 +150,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -168,7 +169,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -192,7 +193,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -205,17 +206,17 @@ describe("", () => { expect(injectTranslation).toHaveBeenCalledWith("prompts", "signInToAccount"); }); - it("renders MFA assertion form when multiFactorResolver is present", async () => { + it("renders MFA assertion screen when multiFactorResolver is present", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ - multiFactorResolver: { hints: [] }, + multiFactorResolver: { auth: {}, session: null, hints: [] }, }); }); - TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { set: { - template: '
MFA Assertion Form
', + template: '
MFA Assertion Screen
', }, }); @@ -224,7 +225,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -233,7 +234,7 @@ describe("", () => { ], }); - expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); + expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); expect(screen.queryByText("Phone Auth Form")).not.toBeInTheDocument(); }); @@ -241,13 +242,13 @@ describe("", () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ - multiFactorResolver: { hints: [] }, + multiFactorResolver: { auth: {}, session: null, hints: [] }, }); }); - TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { set: { - template: '
MFA Assertion Form
', + template: '
MFA Assertion Screen
', }, }); @@ -256,7 +257,7 @@ describe("", () => { PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -266,41 +267,33 @@ describe("", () => { }); expect(screen.queryByText("Phone Auth Form")).not.toBeInTheDocument(); - expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); + expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); }); it("emits signIn with credential when MFA flow succeeds", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ - multiFactorResolver: { hints: [{ factorId: TotpMultiFactorGenerator.FACTOR_ID, uid: "test" }] }, + multiFactorResolver: { + auth: {}, + session: null, + hints: [{ factorId: TotpMultiFactorGenerator.FACTOR_ID, uid: "test" }], + }, }); }); - TestBed.overrideComponent(TotpMultiFactorAssertionFormComponent, { + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { set: { - template: - '
TOTP
', + template: '
MFA Assertion Screen
', }, }); - const signInHandler = jest.fn(); - - @Component({ - template: ``, - standalone: true, - imports: [PhoneAuthScreenComponent], - }) - class HostCaptureComponent { - onSignIn = signInHandler; - } - - await render(HostCaptureComponent, { + const { fixture } = await render(TestHostWithoutContentComponent, { imports: [ PhoneAuthScreenComponent, MockPhoneAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, // Using real component + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -309,11 +302,16 @@ describe("", () => { ], }); - const trigger = screen.getByTestId("mfa-on-success"); - trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const component = fixture.debugElement.query((el) => el.name === "fui-phone-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + const mfaScreenComponent = fixture.debugElement.query( + (el) => el.name === "fui-multi-factor-auth-assertion-screen" + ).componentInstance; + mfaScreenComponent.onSuccess.emit({ user: { uid: "angular-phone-mfa-user" } }); - expect(signInHandler).toHaveBeenCalled(); - expect(signInHandler).toHaveBeenCalledWith( + expect(signInSpy).toHaveBeenCalledTimes(1); + expect(signInSpy).toHaveBeenCalledWith( expect.objectContaining({ user: expect.objectContaining({ uid: "angular-phone-mfa-user" }) }) ); }); diff --git a/packages/angular/src/lib/auth/screens/phone-auth-screen.ts b/packages/angular/src/lib/auth/screens/phone-auth-screen.ts index fb12e057e..64ed89364 100644 --- a/packages/angular/src/lib/auth/screens/phone-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/phone-auth-screen.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, input, output, computed } from "@angular/core"; +import { Component, input, Output, EventEmitter, computed } from "@angular/core"; import { CommonModule } from "@angular/common"; import { CardComponent, @@ -25,13 +25,16 @@ import { } 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 { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; import { UserCredential } from "@angular/fire/auth"; @Component({ selector: "fui-phone-auth-screen", standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, CardComponent, @@ -40,27 +43,27 @@ import { UserCredential } from "@angular/fire/auth"; CardSubtitleComponent, CardContentComponent, PhoneAuthFormComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, RedirectErrorComponent, ], template: ` -
- - - {{ titleText() }} - {{ subtitleText() }} - - - @if (mfaResolver()) { - - } @else { + @if (mfaResolver()) { + + } @else { +
+ + + {{ titleText() }} + {{ subtitleText() }} + + - - } - - -
+ +
+
+
+ } `, }) export class PhoneAuthScreenComponent { @@ -71,6 +74,5 @@ export class PhoneAuthScreenComponent { titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); - resendDelay = input(30); - signIn = output(); + @Output() signIn = new EventEmitter(); } diff --git a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts index d87a65e61..77e1c0d28 100644 --- a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts @@ -26,6 +26,7 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; +import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; import { TotpMultiFactorAssertionFormComponent } from "../forms/mfa/totp-multi-factor-assertion-form"; import { TotpMultiFactorGenerator } from "firebase/auth"; @@ -90,7 +91,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -109,7 +110,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -129,7 +130,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -149,7 +150,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -168,7 +169,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -192,7 +193,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -205,17 +206,17 @@ describe("", () => { expect(injectTranslation).toHaveBeenCalledWith("prompts", "signInToAccount"); }); - it("renders MFA assertion form when multiFactorResolver is present", async () => { + it("renders MFA assertion screen when multiFactorResolver is present", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ - multiFactorResolver: { hints: [] }, + multiFactorResolver: { auth: {}, session: null, hints: [] }, }); }); - TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { set: { - template: '
MFA Assertion Form
', + template: '
MFA Assertion Screen
', }, }); @@ -224,7 +225,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -233,7 +234,7 @@ describe("", () => { ], }); - expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); + expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Sign in" })).not.toBeInTheDocument(); }); @@ -241,13 +242,13 @@ describe("", () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ - multiFactorResolver: { hints: [] }, + multiFactorResolver: { auth: {}, session: null, hints: [] }, }); }); - TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { set: { - template: '
MFA Assertion Form
', + template: '
MFA Assertion Screen
', }, }); @@ -256,7 +257,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -266,41 +267,33 @@ describe("", () => { }); expect(screen.queryByRole("button", { name: "Sign in" })).not.toBeInTheDocument(); - expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); + expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); }); it("emits signIn with credential when MFA flow succeeds", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ - multiFactorResolver: { hints: [{ factorId: TotpMultiFactorGenerator.FACTOR_ID, uid: "test" }] }, + multiFactorResolver: { + auth: {}, + session: null, + hints: [{ factorId: TotpMultiFactorGenerator.FACTOR_ID, uid: "test" }], + }, }); }); - TestBed.overrideComponent(TotpMultiFactorAssertionFormComponent, { + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { set: { - template: - '
TOTP
', + template: '
MFA Assertion Screen
', }, }); - const signInHandler = jest.fn(); - - @Component({ - template: ``, - standalone: true, - imports: [SignInAuthScreenComponent], - }) - class HostCaptureComponent { - onSignIn = signInHandler; - } - - await render(HostCaptureComponent, { + const { fixture } = await render(TestHostWithoutContentComponent, { imports: [ SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -309,11 +302,16 @@ describe("", () => { ], }); - const trigger = screen.getByTestId("mfa-on-success"); - trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const component = fixture.debugElement.query((el) => el.name === "fui-sign-in-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + const mfaScreenComponent = fixture.debugElement.query( + (el) => el.name === "fui-multi-factor-auth-assertion-screen" + ).componentInstance; + mfaScreenComponent.onSuccess.emit({ user: { uid: "angular-mfa-user" } }); - expect(signInHandler).toHaveBeenCalled(); - expect(signInHandler).toHaveBeenCalledWith( + expect(signInSpy).toHaveBeenCalledTimes(1); + expect(signInSpy).toHaveBeenCalledWith( expect.objectContaining({ user: expect.objectContaining({ uid: "angular-mfa-user" }) }) ); }); diff --git a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts index abf254812..02efd57b2 100644 --- a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts @@ -14,12 +14,12 @@ * limitations under the License. */ -import { Component, output, computed } from "@angular/core"; +import { Component, Output, EventEmitter, computed } from "@angular/core"; import { CommonModule } from "@angular/common"; import { injectTranslation, injectUI } from "../../provider"; import { SignInAuthFormComponent } from "../forms/sign-in-auth-form"; -import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; +import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; import { CardComponent, @@ -32,6 +32,9 @@ import { UserCredential } from "@angular/fire/auth"; @Component({ selector: "fui-sign-in-auth-screen", standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, CardComponent, @@ -40,31 +43,27 @@ import { UserCredential } from "@angular/fire/auth"; CardSubtitleComponent, CardContentComponent, SignInAuthFormComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, RedirectErrorComponent, ], template: ` -
- - - {{ titleText() }} - {{ subtitleText() }} - - - @if (mfaResolver()) { - - } @else { - - + @if (mfaResolver()) { + + } @else { +
+ + + {{ titleText() }} + {{ subtitleText() }} + + + - } - - -
+ +
+
+
+ } `, }) export class SignInAuthScreenComponent { @@ -75,7 +74,7 @@ export class SignInAuthScreenComponent { titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); - forgotPassword = output(); - signUp = output(); - signIn = output(); + @Output() forgotPassword = new EventEmitter(); + @Output() signUp = new EventEmitter(); + @Output() signIn = new EventEmitter(); } diff --git a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts index f7879f315..02c113fc9 100644 --- a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts @@ -26,6 +26,7 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; +import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; import { TotpMultiFactorAssertionFormComponent } from "../forms/mfa/totp-multi-factor-assertion-form"; import { TotpMultiFactorGenerator } from "firebase/auth"; @@ -90,7 +91,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -109,7 +110,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -128,7 +129,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -148,7 +149,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -167,7 +168,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -191,7 +192,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -204,17 +205,17 @@ describe("", () => { expect(injectTranslation).toHaveBeenCalledWith("prompts", "enterDetailsToCreate"); }); - it("renders MFA assertion form when multiFactorResolver is present", async () => { + it("renders MFA assertion screen when multiFactorResolver is present", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ - multiFactorResolver: { hints: [] }, + multiFactorResolver: { auth: {}, session: null, hints: [] }, }); }); - TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { set: { - template: '
MFA Assertion Form
', + template: '
MFA Assertion Screen
', }, }); @@ -223,7 +224,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -232,7 +233,7 @@ describe("", () => { ], }); - expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); + expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); expect(screen.queryByText("Sign Up Form")).not.toBeInTheDocument(); }); @@ -240,13 +241,13 @@ describe("", () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ - multiFactorResolver: { hints: [] }, + multiFactorResolver: { auth: {}, session: null, hints: [] }, }); }); - TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { set: { - template: '
MFA Assertion Form
', + template: '
MFA Assertion Screen
', }, }); @@ -255,7 +256,7 @@ describe("", () => { SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -265,41 +266,33 @@ describe("", () => { }); expect(screen.queryByText("Sign Up Form")).not.toBeInTheDocument(); - expect(screen.getByTestId("mfa-assertion-form")).toBeInTheDocument(); + expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); }); it("emits signUp with credential when MFA flow succeeds", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ - multiFactorResolver: { hints: [{ factorId: TotpMultiFactorGenerator.FACTOR_ID, uid: "test" }] }, + multiFactorResolver: { + auth: {}, + session: null, + hints: [{ factorId: TotpMultiFactorGenerator.FACTOR_ID, uid: "test" }], + }, }); }); - TestBed.overrideComponent(TotpMultiFactorAssertionFormComponent, { + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { set: { - template: - '
TOTP
', + template: '
MFA Assertion Screen
', }, }); - const signUpHandler = jest.fn(); - - @Component({ - template: ``, - standalone: true, - imports: [SignUpAuthScreenComponent], - }) - class HostCaptureComponent { - onSignUp = signUpHandler; - } - - await render(HostCaptureComponent, { + const { fixture } = await render(TestHostWithoutContentComponent, { imports: [ SignUpAuthScreenComponent, MockSignUpAuthFormComponent, MockRedirectErrorComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -308,11 +301,16 @@ describe("", () => { ], }); - const trigger = screen.getByTestId("mfa-on-success"); - trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const component = fixture.debugElement.query((el) => el.name === "fui-sign-up-auth-screen").componentInstance; + const signUpSpy = jest.spyOn(component.signUp, "emit"); + + const mfaScreenComponent = fixture.debugElement.query( + (el) => el.name === "fui-multi-factor-auth-assertion-screen" + ).componentInstance; + mfaScreenComponent.onSuccess.emit({ user: { uid: "angular-signup-mfa-user" } }); - expect(signUpHandler).toHaveBeenCalled(); - expect(signUpHandler).toHaveBeenCalledWith( + expect(signUpSpy).toHaveBeenCalledTimes(1); + expect(signUpSpy).toHaveBeenCalledWith( expect.objectContaining({ user: expect.objectContaining({ uid: "angular-signup-mfa-user" }) }) ); }); diff --git a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts index ce206f4a1..48e16a14e 100644 --- a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts @@ -14,13 +14,13 @@ * limitations under the License. */ -import { Component, output, computed } from "@angular/core"; +import { Component, Output, EventEmitter, computed } from "@angular/core"; import { CommonModule } from "@angular/common"; import { UserCredential } from "@angular/fire/auth"; import { injectTranslation, injectUI } from "../../provider"; import { SignUpAuthFormComponent } from "../forms/sign-up-auth-form"; -import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; +import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; import { CardComponent, @@ -33,6 +33,9 @@ import { @Component({ selector: "fui-sign-up-auth-screen", standalone: true, + host: { + style: "display: block;", + }, imports: [ CommonModule, CardComponent, @@ -41,27 +44,27 @@ import { CardSubtitleComponent, CardContentComponent, SignUpAuthFormComponent, - MultiFactorAuthAssertionFormComponent, + MultiFactorAuthAssertionScreenComponent, RedirectErrorComponent, ], template: ` -
- - - {{ titleText() }} - {{ subtitleText() }} - - - @if (mfaResolver()) { - - } @else { - - + @if (mfaResolver()) { + + } @else { +
+ + + {{ titleText() }} + {{ subtitleText() }} + + + - } - - -
+ +
+
+
+ } `, }) export class SignUpAuthScreenComponent { @@ -72,6 +75,6 @@ export class SignUpAuthScreenComponent { titleText = injectTranslation("labels", "signUp"); subtitleText = injectTranslation("prompts", "enterDetailsToCreate"); - signUp = output(); - signIn = output(); + @Output() signUp = new EventEmitter(); + @Output() signIn = new EventEmitter(); } diff --git a/packages/angular/src/lib/components/card.spec.ts b/packages/angular/src/lib/components/card.spec.ts index bd2eac6a5..022b230f5 100644 --- a/packages/angular/src/lib/components/card.spec.ts +++ b/packages/angular/src/lib/components/card.spec.ts @@ -30,10 +30,9 @@ describe("", () => { imports: [CardComponent, CardContentComponent], }); const card = screen.getByTestId("test-card"); - const cardDiv = card.querySelector(".fui-card"); - expect(cardDiv).toHaveClass("fui-card"); - expect(cardDiv).toHaveTextContent("Card content"); + expect(card).toHaveClass("fui-card"); + expect(card).toHaveTextContent("Card content"); }); it("applies custom class", async () => { @@ -42,9 +41,8 @@ describe("", () => { { imports: [CardComponent, CardContentComponent] } ); const card = screen.getByTestId("test-card"); - const cardDiv = card.querySelector(".fui-card"); - expect(cardDiv).toHaveClass("fui-card"); + expect(card).toHaveClass("fui-card"); expect(card).toHaveClass("custom-class"); }); @@ -54,9 +52,8 @@ describe("", () => { { imports: [CardComponent, CardContentComponent] } ); const card = screen.getByTestId("test-card"); - const cardDiv = card.querySelector(".fui-card"); - expect(cardDiv).toHaveClass("fui-card"); + expect(card).toHaveClass("fui-card"); expect(card).toHaveAttribute("aria-label", "card"); }); @@ -65,8 +62,8 @@ describe("", () => { ` - Card Title - Card Subtitle + Card Title + Card Subtitle
Card Body Content
@@ -80,14 +77,16 @@ describe("", () => { const card = screen.getByTestId("complete-card"); const header = screen.getByTestId("complete-header"); + const titleHost = screen.getByTestId("complete-title"); + const subtitleHost = screen.getByTestId("complete-subtitle"); const title = screen.getByRole("heading", { name: "Card Title" }); const subtitle = screen.getByText("Card Subtitle"); const content = screen.getByText("Card Body Content"); - expect(card.querySelector(".fui-card")).toHaveClass("fui-card"); - expect(title).toHaveClass("fui-card__title"); - expect(subtitle).toHaveClass("fui-card__subtitle"); - expect(header.querySelector(".fui-card__header")).toHaveClass("fui-card__header"); + expect(card).toHaveClass("fui-card"); + expect(titleHost).toHaveClass("fui-card__title"); + expect(subtitleHost).toHaveClass("fui-card__subtitle"); + expect(header).toHaveClass("fui-card__header"); expect(content).toBeTruthy(); expect(header).toContainElement(title); @@ -103,10 +102,9 @@ describe("", () => { { imports: [CardHeaderComponent, CardTitleComponent] } ); const header = screen.getByTestId("test-header"); - const headerDiv = header.querySelector(".fui-card__header"); - expect(headerDiv).toHaveClass("fui-card__header"); - expect(headerDiv).toHaveTextContent("Header content"); + expect(header).toHaveClass("fui-card__header"); + expect(header).toHaveTextContent("Header content"); }); it("applies custom className", async () => { @@ -115,19 +113,21 @@ describe("", () => { { imports: [CardHeaderComponent, CardTitleComponent] } ); const header = screen.getByTestId("test-header"); - const headerDiv = header.querySelector(".fui-card__header"); - expect(headerDiv).toHaveClass("fui-card__header"); + expect(header).toHaveClass("fui-card__header"); expect(header).toHaveClass("custom-header"); }); }); describe("", () => { it("renders a card title with children", async () => { - await render(`Title content`, { imports: [CardTitleComponent] }); + await render(`Title content`, { + imports: [CardTitleComponent], + }); + const titleHost = screen.getByTestId("title-host"); const title = screen.getByRole("heading", { name: "Title content" }); - expect(title).toHaveClass("fui-card__title"); + expect(titleHost).toHaveClass("fui-card__title"); expect(title.tagName).toBe("H2"); }); @@ -135,20 +135,22 @@ describe("", () => { await render(`Title content`, { imports: [CardTitleComponent], }); - const title = screen.getByRole("heading", { name: "Title content" }); const titleHost = screen.getByTestId("title-host"); - expect(title).toHaveClass("fui-card__title"); + expect(titleHost).toHaveClass("fui-card__title"); expect(titleHost).toHaveClass("custom-title"); }); }); describe("", () => { it("renders a card subtitle with children", async () => { - await render(`Subtitle content`, { imports: [CardSubtitleComponent] }); + await render(`Subtitle content`, { + imports: [CardSubtitleComponent], + }); + const subtitleHost = screen.getByTestId("subtitle-host"); const subtitle = screen.getByText("Subtitle content"); - expect(subtitle).toHaveClass("fui-card__subtitle"); + expect(subtitleHost).toHaveClass("fui-card__subtitle"); expect(subtitle.tagName).toBe("P"); }); @@ -157,31 +159,30 @@ describe("", () => { `Subtitle content`, { imports: [CardSubtitleComponent] } ); - const subtitle = screen.getByText("Subtitle content"); const subtitleHost = screen.getByTestId("subtitle-host"); - expect(subtitle).toHaveClass("fui-card__subtitle"); + expect(subtitleHost).toHaveClass("fui-card__subtitle"); expect(subtitleHost).toHaveClass("custom-subtitle"); }); }); describe("", () => { it("renders a card content with children", async () => { - await render(`Content content`, { imports: [CardContentComponent] }); - const content = screen.getByText("Content content"); + await render(`Content content`, { + imports: [CardContentComponent], + }); + const content = screen.getByTestId("test-content"); expect(content).toHaveClass("fui-card__content"); - expect(content.tagName).toBe("DIV"); }); it("applies custom className", async () => { await render(`Content`, { imports: [CardContentComponent], }); - const content = screen.getByText("Content"); const contentHost = screen.getByTestId("content-host"); - expect(content).toHaveClass("fui-card__content"); + expect(contentHost).toHaveClass("fui-card__content"); expect(contentHost).toHaveClass("custom-content"); }); }); diff --git a/packages/angular/src/lib/components/card.ts b/packages/angular/src/lib/components/card.ts index 0e5cf2933..48fe36cac 100644 --- a/packages/angular/src/lib/components/card.ts +++ b/packages/angular/src/lib/components/card.ts @@ -21,11 +21,13 @@ import { CommonModule } from "@angular/common"; selector: "fui-card", standalone: true, imports: [], + host: { + class: "fui-card", + style: "display: block;", + }, template: ` -
- - -
+ + `, }) export class CardComponent {} @@ -34,11 +36,13 @@ export class CardComponent {} selector: "fui-card-header", standalone: true, imports: [CommonModule], + host: { + class: "fui-card__header", + style: "display: block;", + }, template: ` -
- - -
+ + `, }) export class CardHeaderComponent {} @@ -47,8 +51,12 @@ export class CardHeaderComponent {} selector: "fui-card-title", standalone: true, imports: [CommonModule], + host: { + class: "fui-card__title", + style: "display: block;", + }, template: ` -

+

`, @@ -59,8 +67,12 @@ export class CardTitleComponent {} selector: "fui-card-subtitle", standalone: true, imports: [CommonModule], + host: { + class: "fui-card__subtitle", + style: "display: block;", + }, template: ` -

+

`, @@ -71,10 +83,10 @@ export class CardSubtitleComponent {} selector: "fui-card-content", standalone: true, imports: [CommonModule], - template: ` -
- -
- `, + host: { + class: "fui-card__content", + style: "display: block;", + }, + template: ` `, }) export class CardContentComponent {} diff --git a/packages/angular/src/lib/components/content.ts b/packages/angular/src/lib/components/content.ts index 0b9d1d0f2..ce3ddc4b9 100644 --- a/packages/angular/src/lib/components/content.ts +++ b/packages/angular/src/lib/components/content.ts @@ -22,6 +22,9 @@ import { injectTranslation } from "../provider"; selector: "fui-content", standalone: true, imports: [DividerComponent], + host: { + style: "display: block;", + }, template: `
diff --git a/packages/angular/src/lib/components/country-selector.ts b/packages/angular/src/lib/components/country-selector.ts index 8146461d9..4ac72704c 100644 --- a/packages/angular/src/lib/components/country-selector.ts +++ b/packages/angular/src/lib/components/country-selector.ts @@ -24,6 +24,9 @@ import { injectCountries, injectDefaultCountry } from "../provider"; selector: "fui-country-selector", standalone: true, imports: [CommonModule, FormsModule], + host: { + style: "display: block;", + }, template: `
@@ -35,7 +38,7 @@ import { injectCountries, injectDefaultCountry } from "../provider"; [ngModel]="selected().code" (ngModelChange)="handleCountryChange($event)" > - @for (country of countries(); track country.code) { + @for (country of countries(); track $index) { } diff --git a/packages/angular/src/lib/components/divider.ts b/packages/angular/src/lib/components/divider.ts index 0bd736276..40b3e43db 100644 --- a/packages/angular/src/lib/components/divider.ts +++ b/packages/angular/src/lib/components/divider.ts @@ -21,6 +21,9 @@ import { CommonModule } from "@angular/common"; selector: "fui-divider", standalone: true, imports: [CommonModule], + host: { + style: "display: block;", + }, template: `
diff --git a/packages/angular/src/lib/components/form.spec.ts b/packages/angular/src/lib/components/form.spec.ts index b6845b2db..34372808e 100644 --- a/packages/angular/src/lib/components/form.spec.ts +++ b/packages/angular/src/lib/components/form.spec.ts @@ -189,6 +189,28 @@ describe("Form Components", () => { }); } + @Component({ + template: ` + + `, + standalone: true, + imports: [FormInputComponent, TanStackAppField], + }) + class TestFormInputWithDescriptionHostComponent { + form = injectForm({ + defaultValues: { + test: "", + }, + }); + description = signal(undefined); + } + it("renders action content when provided", async () => { await render(TestFormInputHostComponent, { imports: [TestFormInputHostComponent], @@ -198,6 +220,45 @@ describe("Form Components", () => { expect(actionButton).toBeTruthy(); expect(actionButton).toHaveTextContent("Action"); }); + + it("renders description when provided", async () => { + const component = await render(TestFormInputWithDescriptionHostComponent, { + imports: [TestFormInputWithDescriptionHostComponent], + }); + + component.fixture.componentInstance.description.set("Test description text"); + component.fixture.detectChanges(); + + const descriptionElement = screen.getByText("Test description text"); + expect(descriptionElement).toBeTruthy(); + expect(descriptionElement).toHaveAttribute("data-input-description"); + }); + + it("does not render description when not provided", async () => { + const { container } = await render(TestFormInputWithDescriptionHostComponent, { + imports: [TestFormInputWithDescriptionHostComponent], + }); + + const descriptionElement = container.querySelector("[data-input-description]"); + expect(descriptionElement).toBeFalsy(); + }); + + it("updates description when input changes", async () => { + const component = await render(TestFormInputWithDescriptionHostComponent, { + imports: [TestFormInputWithDescriptionHostComponent], + }); + + component.fixture.componentInstance.description.set("Initial description"); + component.fixture.detectChanges(); + + expect(screen.getByText("Initial description")).toBeTruthy(); + + component.fixture.componentInstance.description.set("Updated description"); + component.fixture.detectChanges(); + + expect(screen.queryByText("Initial description")).toBeFalsy(); + expect(screen.getByText("Updated description")).toBeTruthy(); + }); }); describe("", () => { diff --git a/packages/angular/src/lib/components/form.ts b/packages/angular/src/lib/components/form.ts index dbf9fc638..034cb860c 100644 --- a/packages/angular/src/lib/components/form.ts +++ b/packages/angular/src/lib/components/form.ts @@ -5,6 +5,9 @@ import { ButtonComponent } from "./button"; @Component({ selector: "fui-form-metadata", standalone: true, + host: { + style: "display: block;", + }, template: ` @if (field().state.meta.isTouched && errors().length > 0) {
@@ -28,12 +31,18 @@ export class FormMetadataComponent { selector: "fui-form-input", standalone: true, imports: [FormMetadataComponent], + host: { + style: "display: block;", + }, template: `