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.ts similarity index 89% rename from examples/angular/src/app/screens/email-link-auth-screen-w-oauth/email-link-auth-screen-w-oauth.component.ts rename to examples/angular/src/app/screens/email-link-auth-screen-w-oauth.ts index 331551c38..8fb5c0263 100644 --- 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.ts @@ -17,7 +17,6 @@ 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({ @@ -25,7 +24,7 @@ import { Router } from "@angular/router"; standalone: true, imports: [CommonModule, EmailLinkAuthScreenComponent, GoogleSignInButtonComponent], template: ` - + `, @@ -38,8 +37,7 @@ export class EmailLinkAuthScreenWithOAuthComponent { alert("email sent - please check your email"); } - onSignIn(credential: UserCredential) { - console.log("sign in", credential); + onSignIn() { 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 deleted file mode 100644 index 13f2e186d..000000000 --- a/examples/angular/src/app/screens/email-link-auth-screen-w-oauth/index.ts +++ /dev/null @@ -1 +0,0 @@ -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.ts similarity index 87% rename from examples/angular/src/app/screens/email-link-auth-screen/email-link-auth-screen.component.ts rename to examples/angular/src/app/screens/email-link-auth-screen.ts index 55dcefb66..3a702d5d5 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.ts @@ -17,14 +17,13 @@ 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 { @@ -34,8 +33,7 @@ export class EmailLinkAuthScreenWrapperComponent { alert("email sent - please check your email"); } - onSignIn(credential: UserCredential) { - console.log("sign in", credential); + onSignIn() { this.router.navigate(["/"]); } } diff --git a/examples/angular/src/app/screens/email-link-auth-screen/index.ts b/examples/angular/src/app/screens/email-link-auth-screen/index.ts deleted file mode 100644 index 3d995dddc..000000000 --- a/examples/angular/src/app/screens/email-link-auth-screen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./email-link-auth-screen.component"; 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.ts similarity index 100% rename from examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/forgot-password-auth-screen-w-handlers.component.ts rename to examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers.ts 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 deleted file mode 100644 index 227203450..000000000 --- a/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./forgot-password-auth-screen-w-handlers.component"; diff --git a/examples/angular/src/app/screens/forgot-password-auth-screen/forgot-password-auth-screen.component.ts b/examples/angular/src/app/screens/forgot-password-auth-screen.ts similarity index 100% rename from examples/angular/src/app/screens/forgot-password-auth-screen/forgot-password-auth-screen.component.ts rename to examples/angular/src/app/screens/forgot-password-auth-screen.ts 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 deleted file mode 100644 index 6cc32654d..000000000 --- a/examples/angular/src/app/screens/forgot-password-auth-screen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./forgot-password-auth-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.ts similarity index 100% rename from examples/angular/src/app/screens/mfa-enrollment-screen/mfa-enrollment-screen.component.ts rename to examples/angular/src/app/screens/mfa-enrollment-screen.ts diff --git a/examples/angular/src/app/screens/mfa-enrollment-screen/index.ts b/examples/angular/src/app/screens/mfa-enrollment-screen/index.ts deleted file mode 100644 index 1402c2ecd..000000000 --- a/examples/angular/src/app/screens/mfa-enrollment-screen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./mfa-enrollment-screen.component"; diff --git a/examples/angular/src/app/screens/oauth-screen/oauth-screen.component.ts b/examples/angular/src/app/screens/oauth-screen.ts similarity index 91% rename from examples/angular/src/app/screens/oauth-screen/oauth-screen.component.ts rename to examples/angular/src/app/screens/oauth-screen.ts index 80690c2dd..1424a5e70 100644 --- a/examples/angular/src/app/screens/oauth-screen/oauth-screen.component.ts +++ b/examples/angular/src/app/screens/oauth-screen.ts @@ -25,7 +25,6 @@ import { MicrosoftSignInButtonComponent, TwitterSignInButtonComponent, } from "@invertase/firebaseui-angular"; -import type { UserCredential } from "firebase/auth"; import { Router } from "@angular/router"; @Component({ @@ -42,7 +41,7 @@ import { Router } from "@angular/router"; TwitterSignInButtonComponent, ], template: ` - + @@ -63,8 +62,7 @@ export class OAuthScreenWrapperComponent { themed = signal(false); private router = inject(Router); - onSignIn(credential: UserCredential) { - console.log("sign in", credential); + onSignIn() { this.router.navigate(["/"]); } } diff --git a/examples/angular/src/app/screens/oauth-screen/index.ts b/examples/angular/src/app/screens/oauth-screen/index.ts deleted file mode 100644 index 6fed7e762..000000000 --- a/examples/angular/src/app/screens/oauth-screen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./oauth-screen.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.ts similarity index 83% rename from examples/angular/src/app/screens/phone-auth-screen-w-oauth/phone-auth-screen-w-oauth.component.ts rename to examples/angular/src/app/screens/phone-auth-screen-w-oauth.ts index 9fac00203..f5e50ef68 100644 --- 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.ts @@ -17,7 +17,6 @@ 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({ @@ -25,9 +24,9 @@ import { Router } from "@angular/router"; standalone: true, imports: [CommonModule, PhoneAuthScreenComponent, GoogleSignInButtonComponent, ContentComponent], template: ` - + - + `, @@ -36,8 +35,7 @@ import { Router } from "@angular/router"; export class PhoneAuthScreenWithOAuthComponent { private router = inject(Router); - onSignIn(credential: UserCredential) { - console.log("sign in", credential); + onSignIn() { 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 deleted file mode 100644 index 3d0a30e0d..000000000 --- a/examples/angular/src/app/screens/phone-auth-screen-w-oauth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./phone-auth-screen-w-oauth.component"; 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.ts similarity index 84% rename from examples/angular/src/app/screens/phone-auth-screen/phone-auth-screen.component.ts rename to examples/angular/src/app/screens/phone-auth-screen.ts index add17ca3f..e0ff32d21 100644 --- a/examples/angular/src/app/screens/phone-auth-screen/phone-auth-screen.component.ts +++ b/examples/angular/src/app/screens/phone-auth-screen.ts @@ -17,21 +17,19 @@ 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 { private router = inject(Router); - onSignIn(credential: UserCredential) { - console.log("sign in", credential); + onSignIn() { this.router.navigate(["/"]); } } diff --git a/examples/angular/src/app/screens/phone-auth-screen/index.ts b/examples/angular/src/app/screens/phone-auth-screen/index.ts deleted file mode 100644 index ae65a8ce7..000000000 --- a/examples/angular/src/app/screens/phone-auth-screen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./phone-auth-screen.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.ts similarity index 84% rename from examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/sign-in-auth-screen-w-handlers.component.ts rename to examples/angular/src/app/screens/sign-in-auth-screen-w-handlers.ts index ce7a7d296..063c52c62 100644 --- 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.ts @@ -24,11 +24,7 @@ import { SignInAuthScreenComponent } from "@invertase/firebaseui-angular"; standalone: true, imports: [CommonModule, SignInAuthScreenComponent], template: ` - + `, styles: [], }) @@ -43,8 +39,7 @@ export class SignInAuthScreenWithHandlersComponent { this.router.navigate(["/screens/sign-up-auth-screen"]); } - onSignIn(credential: unknown) { - console.log(credential); + onSignIn() { 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 deleted file mode 100644 index d498e8f0b..000000000 --- a/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./sign-in-auth-screen-w-handlers.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.ts similarity index 90% rename from examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/sign-in-auth-screen-w-oauth.component.ts rename to examples/angular/src/app/screens/sign-in-auth-screen-w-oauth.ts index dd0048828..56cf346ba 100644 --- 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.ts @@ -26,7 +26,6 @@ import { MicrosoftSignInButtonComponent, TwitterSignInButtonComponent, } from "@invertase/firebaseui-angular"; -import type { UserCredential } from "firebase/auth"; import { Router } from "@angular/router"; @Component({ @@ -44,7 +43,7 @@ import { Router } from "@angular/router"; TwitterSignInButtonComponent, ], template: ` - + @@ -60,8 +59,7 @@ import { Router } from "@angular/router"; export class SignInAuthScreenWithOAuthComponent { private router = inject(Router); - onSignIn(credential: UserCredential) { - console.log("sign in", credential); + onSignIn() { 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 deleted file mode 100644 index 2697e1510..000000000 --- a/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./sign-in-auth-screen-w-oauth.component"; 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.ts similarity index 83% rename from examples/angular/src/app/screens/sign-in-auth-screen/sign-in-auth-screen.component.ts rename to examples/angular/src/app/screens/sign-in-auth-screen.ts index 2475549dd..b87cee771 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.ts @@ -17,21 +17,19 @@ 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 { private router = inject(Router); - onSignIn(credential: UserCredential) { - console.log("sign in", credential); + onSignIn() { this.router.navigate(["/"]); } } diff --git a/examples/angular/src/app/screens/sign-in-auth-screen/index.ts b/examples/angular/src/app/screens/sign-in-auth-screen/index.ts deleted file mode 100644 index 744e4f844..000000000 --- a/examples/angular/src/app/screens/sign-in-auth-screen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./sign-in-auth-screen.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.ts similarity index 86% rename from examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/sign-up-auth-screen-w-handlers.component.ts rename to examples/angular/src/app/screens/sign-up-auth-screen-w-handlers.ts index 8aa94af46..f3b0b73d9 100644 --- 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.ts @@ -23,7 +23,7 @@ import { SignUpAuthScreenComponent } from "@invertase/firebaseui-angular"; selector: "app-sign-up-auth-screen-w-handlers", standalone: true, imports: [CommonModule, SignUpAuthScreenComponent], - template: ` `, + template: ``, styles: [], }) export class SignUpAuthScreenWithHandlersComponent { @@ -33,8 +33,7 @@ export class SignUpAuthScreenWithHandlersComponent { this.router.navigate(["/screens/sign-in-auth-screen"]); } - onSignUp(credential: unknown) { - console.log(credential); + onSignUp() { 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 deleted file mode 100644 index 64db728c0..000000000 --- a/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./sign-up-auth-screen-w-handlers.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.ts similarity index 90% rename from examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/sign-up-auth-screen-w-oauth.component.ts rename to examples/angular/src/app/screens/sign-up-auth-screen-w-oauth.ts index c172ead67..450120e07 100644 --- 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.ts @@ -26,7 +26,6 @@ import { MicrosoftSignInButtonComponent, TwitterSignInButtonComponent, } from "@invertase/firebaseui-angular"; -import type { UserCredential } from "firebase/auth"; import { Router } from "@angular/router"; @Component({ @@ -44,7 +43,7 @@ import { Router } from "@angular/router"; TwitterSignInButtonComponent, ], template: ` - + @@ -60,8 +59,7 @@ import { Router } from "@angular/router"; export class SignUpAuthScreenWithOAuthComponent { private router = inject(Router); - onSignUp(credential: UserCredential) { - console.log("sign up", credential); + onSignUp() { 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 deleted file mode 100644 index cc1567d01..000000000 --- a/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./sign-up-auth-screen-w-oauth.component"; 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.ts similarity index 83% rename from examples/angular/src/app/screens/sign-up-auth-screen/sign-up-auth-screen.component.ts rename to examples/angular/src/app/screens/sign-up-auth-screen.ts index 6671ecba7..d235c6800 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.ts @@ -18,20 +18,18 @@ 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 { private router = inject(Router); - onSignUp(credential: UserCredential) { - console.log("sign up", credential); + onSignUp() { this.router.navigate(["/"]); } } diff --git a/examples/angular/src/app/screens/sign-up-auth-screen/index.ts b/examples/angular/src/app/screens/sign-up-auth-screen/index.ts deleted file mode 100644 index 41aa28348..000000000 --- a/examples/angular/src/app/screens/sign-up-auth-screen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./sign-up-auth-screen.component"; diff --git a/examples/react/src/routes.ts b/examples/react/src/routes.ts index 90af4a284..f3b958586 100644 --- a/examples/react/src/routes.ts +++ b/examples/react/src/routes.ts @@ -67,12 +67,6 @@ export const routes = [ path: "/screens/forgot-password-auth-screen", component: ForgotPasswordAuthScreenPage, }, - { - 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", - component: ForgotPasswordAuthScreenPage, - }, { name: "OAuth Screen", description: "A screen which allows a user to sign in with OAuth only.", diff --git a/examples/react/src/screens/email-link-auth-screen-w-oauth.tsx b/examples/react/src/screens/email-link-auth-screen-w-oauth.tsx index 5725e66ae..b918a5a0d 100644 --- a/examples/react/src/screens/email-link-auth-screen-w-oauth.tsx +++ b/examples/react/src/screens/email-link-auth-screen-w-oauth.tsx @@ -35,8 +35,7 @@ export default function EmailLinkAuthScreenWithOAuthPage() { onEmailSent={() => { alert("Email has been sent - please check your email"); }} - onSignIn={(credential) => { - console.log(credential); + onSignIn={() => { navigate("/"); }} > diff --git a/examples/react/src/screens/email-link-auth-screen.tsx b/examples/react/src/screens/email-link-auth-screen.tsx index b803ea13e..1a7d7a1cf 100644 --- a/examples/react/src/screens/email-link-auth-screen.tsx +++ b/examples/react/src/screens/email-link-auth-screen.tsx @@ -27,8 +27,7 @@ export default function EmailLinkAuthScreenPage() { onEmailSent={() => { alert("Email has been sent"); }} - onSignIn={(credential) => { - console.log(credential); + onSignIn={() => { navigate("/"); }} /> diff --git a/examples/react/src/screens/forgot-password-auth-screen-w-handlers.tsx b/examples/react/src/screens/forgot-password-auth-screen-w-handlers.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/react/src/screens/oauth-screen.tsx b/examples/react/src/screens/oauth-screen.tsx index 6fb8521fc..b85a1fb30 100644 --- a/examples/react/src/screens/oauth-screen.tsx +++ b/examples/react/src/screens/oauth-screen.tsx @@ -26,13 +26,19 @@ import { OAuthScreen, TwitterSignInButton, } from "@invertase/firebaseui-react"; +import { useNavigate } from "react-router"; export default function OAuthScreenPage() { const [themed, setThemed] = useState(false); + const navigate = useNavigate(); return ( <> - + { + navigate("/"); + }} + > diff --git a/examples/react/src/screens/phone-auth-screen-w-oauth.tsx b/examples/react/src/screens/phone-auth-screen-w-oauth.tsx index 3d765569e..a8f892732 100644 --- a/examples/react/src/screens/phone-auth-screen-w-oauth.tsx +++ b/examples/react/src/screens/phone-auth-screen-w-oauth.tsx @@ -32,8 +32,7 @@ export default function PhoneAuthScreenWithOAuthPage() { return ( { - console.log(credential); + onSignIn={() => { navigate("/"); }} > diff --git a/examples/react/src/screens/phone-auth-screen.tsx b/examples/react/src/screens/phone-auth-screen.tsx index 4bd364a66..c244f99f6 100644 --- a/examples/react/src/screens/phone-auth-screen.tsx +++ b/examples/react/src/screens/phone-auth-screen.tsx @@ -24,8 +24,7 @@ export default function PhoneAuthScreenPage() { return ( { - console.log(credential); + onSignIn={() => { navigate("/"); }} /> diff --git a/examples/react/src/screens/sign-in-auth-screen-w-handlers.tsx b/examples/react/src/screens/sign-in-auth-screen-w-handlers.tsx index 7999a07d2..a881d1f3e 100644 --- a/examples/react/src/screens/sign-in-auth-screen-w-handlers.tsx +++ b/examples/react/src/screens/sign-in-auth-screen-w-handlers.tsx @@ -21,6 +21,7 @@ import { useNavigate } from "react-router"; export default function SignInAuthScreenWithHandlersPage() { const navigate = useNavigate(); + return ( { @@ -29,6 +30,9 @@ export default function SignInAuthScreenWithHandlersPage() { onSignUpClick={() => { navigate("/screens/sign-up-auth-screen"); }} + onSignIn={() => { + navigate("/"); + }} /> ); } diff --git a/examples/react/src/screens/sign-in-auth-screen-w-oauth.tsx b/examples/react/src/screens/sign-in-auth-screen-w-oauth.tsx index 4909fcaf3..d3c8a9c28 100644 --- a/examples/react/src/screens/sign-in-auth-screen-w-oauth.tsx +++ b/examples/react/src/screens/sign-in-auth-screen-w-oauth.tsx @@ -30,8 +30,7 @@ export default function SignInAuthScreenWithOAuthPage() { return ( { - console.log(credential); + onSignIn={() => { navigate("/"); }} > diff --git a/examples/react/src/screens/sign-in-auth-screen.tsx b/examples/react/src/screens/sign-in-auth-screen.tsx index af5df82b5..3392f9a1b 100644 --- a/examples/react/src/screens/sign-in-auth-screen.tsx +++ b/examples/react/src/screens/sign-in-auth-screen.tsx @@ -22,8 +22,7 @@ export default function SignInAuthScreenPage() { return ( { - console.log(credential); + onSignIn={() => { navigate("/"); }} /> diff --git a/examples/react/src/screens/sign-up-auth-screen-w-handlers.tsx b/examples/react/src/screens/sign-up-auth-screen-w-handlers.tsx index fbe456b66..2f8d3ddab 100644 --- a/examples/react/src/screens/sign-up-auth-screen-w-handlers.tsx +++ b/examples/react/src/screens/sign-up-auth-screen-w-handlers.tsx @@ -27,8 +27,7 @@ export default function SignUpAuthScreenWithHandlersPage() { onSignInClick={() => { navigate("/screens/sign-in-auth-screen"); }} - onSignUp={(credential) => { - console.log(credential); + onSignUp={() => { navigate("/"); }} /> diff --git a/examples/react/src/screens/sign-up-auth-screen-w-oauth.tsx b/examples/react/src/screens/sign-up-auth-screen-w-oauth.tsx index 3762afd90..9497c778d 100644 --- a/examples/react/src/screens/sign-up-auth-screen-w-oauth.tsx +++ b/examples/react/src/screens/sign-up-auth-screen-w-oauth.tsx @@ -32,8 +32,7 @@ export default function SignUpAuthScreenWithOAuthPage() { return ( { - console.log(credential); + onSignUp={() => { navigate("/"); }} > diff --git a/examples/react/src/screens/sign-up-auth-screen.tsx b/examples/react/src/screens/sign-up-auth-screen.tsx index f284aebc5..d0a902c79 100644 --- a/examples/react/src/screens/sign-up-auth-screen.tsx +++ b/examples/react/src/screens/sign-up-auth-screen.tsx @@ -24,8 +24,7 @@ export default function SignUpAuthScreenPage() { return ( { - console.log(credential); + onSignUp={() => { navigate("/"); }} /> diff --git a/examples/shadcn/src/components/email-link-auth-screen.tsx b/examples/shadcn/src/components/email-link-auth-screen.tsx index 88828712c..171a55de7 100644 --- a/examples/shadcn/src/components/email-link-auth-screen.tsx +++ b/examples/shadcn/src/components/email-link-auth-screen.tsx @@ -1,7 +1,7 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { useUI, type EmailLinkAuthScreenProps } from "@invertase/firebaseui-react"; +import { useUI, type EmailLinkAuthScreenProps, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; @@ -11,15 +11,16 @@ import { RedirectError } from "@/components/redirect-error"; export type { EmailLinkAuthScreenProps }; -export function EmailLinkAuthScreen({ children, ...props }: EmailLinkAuthScreenProps) { +export function EmailLinkAuthScreen({ children, onSignIn, ...props }: EmailLinkAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - const mfaResolver = ui.multiFactorResolver; - if (mfaResolver) { - return ; + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/examples/shadcn/src/components/oauth-screen.tsx b/examples/shadcn/src/components/oauth-screen.tsx index 1281d74e1..a586527d2 100644 --- a/examples/shadcn/src/components/oauth-screen.tsx +++ b/examples/shadcn/src/components/oauth-screen.tsx @@ -1,16 +1,16 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { type UserCredential } from "firebase/auth"; +import { type User } from "firebase/auth"; import { type PropsWithChildren } from "react"; -import { useUI } from "@invertase/firebaseui-react"; +import { useUI, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardHeader, CardDescription, CardTitle } from "@/components/ui/card"; import { Policies } from "@/components/policies"; import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen"; import { RedirectError } from "@/components/redirect-error"; export type OAuthScreenProps = PropsWithChildren<{ - onSignIn?: (credential: UserCredential) => void; + onSignIn?: (user: User) => void; }>; export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { @@ -18,10 +18,11 @@ export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - const mfaResolver = ui.multiFactorResolver; - if (mfaResolver) { - return ; + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/examples/shadcn/src/components/phone-auth-screen.tsx b/examples/shadcn/src/components/phone-auth-screen.tsx index ad31fae73..7908c58c8 100644 --- a/examples/shadcn/src/components/phone-auth-screen.tsx +++ b/examples/shadcn/src/components/phone-auth-screen.tsx @@ -2,24 +2,28 @@ import type { PropsWithChildren } from "react"; import { getTranslation } from "@invertase/firebaseui-core"; -import { useUI } from "@invertase/firebaseui-react"; +import { useUI, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardHeader, CardDescription, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; -import { PhoneAuthForm, type PhoneAuthFormProps } from "@/components/phone-auth-form"; +import { PhoneAuthForm } from "@/components/phone-auth-form"; import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen"; import { RedirectError } from "@/components/redirect-error"; +import type { User } from "firebase/auth"; -export type PhoneAuthScreenProps = PropsWithChildren; +export type PhoneAuthScreenProps = PropsWithChildren<{ + onSignIn?: (user: User) => void; +}>; -export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { +export function PhoneAuthScreen({ children, onSignIn }: PhoneAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - const mfaResolver = ui.multiFactorResolver; - if (mfaResolver) { - return ; + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; } return ( @@ -30,7 +34,7 @@ export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { {subtitleText} - + {children ? ( <> diff --git a/examples/shadcn/src/components/sign-in-auth-screen.tsx b/examples/shadcn/src/components/sign-in-auth-screen.tsx index 322c34848..3397fac0d 100644 --- a/examples/shadcn/src/components/sign-in-auth-screen.tsx +++ b/examples/shadcn/src/components/sign-in-auth-screen.tsx @@ -1,7 +1,7 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { useUI, type SignInAuthScreenProps } from "@invertase/firebaseui-react"; +import { useUI, type SignInAuthScreenProps, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; @@ -10,16 +10,16 @@ import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-a export type { SignInAuthScreenProps }; -export function SignInAuthScreen({ children, ...props }: SignInAuthScreenProps) { +export function SignInAuthScreen({ children, onSignIn, ...props }: SignInAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - const mfaResolver = ui.multiFactorResolver; + useOnUserAuthenticated(onSignIn); - if (mfaResolver) { - return ; + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/examples/shadcn/src/components/sign-up-auth-screen.tsx b/examples/shadcn/src/components/sign-up-auth-screen.tsx index 23838f3f7..f358a163e 100644 --- a/examples/shadcn/src/components/sign-up-auth-screen.tsx +++ b/examples/shadcn/src/components/sign-up-auth-screen.tsx @@ -1,7 +1,7 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { useUI, type SignUpAuthScreenProps } from "@invertase/firebaseui-react"; +import { useUI, type SignUpAuthScreenProps, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; @@ -10,15 +10,16 @@ import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-a export type { SignUpAuthScreenProps }; -export function SignUpAuthScreen({ children, ...props }: SignUpAuthScreenProps) { +export function SignUpAuthScreen({ children, onSignUp, ...props }: SignUpAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signUp"); const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate"); - const mfaResolver = ui.multiFactorResolver; - if (mfaResolver) { - return ; + useOnUserAuthenticated(onSignUp); + + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/examples/shadcn/src/index.css b/examples/shadcn/src/index.css index cdd7d07ac..ca3abb1f8 100644 --- a/examples/shadcn/src/index.css +++ b/examples/shadcn/src/index.css @@ -160,7 +160,7 @@ --primary: var(--line-primary); --primary-foreground: var(--color-white); } - + } @variant dark { diff --git a/examples/shadcn/src/screens/email-link-auth-screen-w-oauth.tsx b/examples/shadcn/src/screens/email-link-auth-screen-w-oauth.tsx index f6435c3c2..905b82c8d 100644 --- a/examples/shadcn/src/screens/email-link-auth-screen-w-oauth.tsx +++ b/examples/shadcn/src/screens/email-link-auth-screen-w-oauth.tsx @@ -22,10 +22,17 @@ import { GitHubSignInButton } from "@/components/github-sign-in-button"; import { MicrosoftSignInButton } from "@/components/microsoft-sign-in-button"; import { TwitterSignInButton } from "@/components/twitter-sign-in-button"; import { EmailLinkAuthScreen } from "@/components/email-link-auth-screen"; +import { useNavigate } from "react-router"; export default function EmailLinkAuthScreenWithOAuthPage() { + const navigate = useNavigate(); + return ( - + { + navigate("/"); + }} + > diff --git a/examples/shadcn/src/screens/email-link-auth-screen.tsx b/examples/shadcn/src/screens/email-link-auth-screen.tsx index fd1bf5837..35d5a83e2 100644 --- a/examples/shadcn/src/screens/email-link-auth-screen.tsx +++ b/examples/shadcn/src/screens/email-link-auth-screen.tsx @@ -16,7 +16,19 @@ "use client"; import { EmailLinkAuthScreen } from "@/components/email-link-auth-screen"; +import { useNavigate } from "react-router"; export default function EmailLinkAuthScreenPage() { - return ; + const navigate = useNavigate(); + + return ( + { + alert("Email has been sent"); + }} + onSignIn={() => { + navigate("/"); + }} + /> + ); } diff --git a/examples/shadcn/src/screens/oauth-screen.tsx b/examples/shadcn/src/screens/oauth-screen.tsx index 4258fc9be..bf65531ae 100644 --- a/examples/shadcn/src/screens/oauth-screen.tsx +++ b/examples/shadcn/src/screens/oauth-screen.tsx @@ -25,13 +25,19 @@ import { GitHubSignInButton } from "@/components/github-sign-in-button"; import { MicrosoftSignInButton } from "@/components/microsoft-sign-in-button"; import { TwitterSignInButton } from "@/components/twitter-sign-in-button"; import { OAuthScreen } from "@/components/oauth-screen"; +import { useNavigate } from "react-router"; export default function OAuthScreenPage() { const [themed, setThemed] = useState(false); + const navigate = useNavigate(); return ( <> - + { + navigate("/"); + }} + > diff --git a/examples/shadcn/src/screens/phone-auth-screen-w-oauth.tsx b/examples/shadcn/src/screens/phone-auth-screen-w-oauth.tsx index 75fef1faf..c36ffebe2 100644 --- a/examples/shadcn/src/screens/phone-auth-screen-w-oauth.tsx +++ b/examples/shadcn/src/screens/phone-auth-screen-w-oauth.tsx @@ -29,8 +29,7 @@ export default function PhoneAuthScreenWithOAuthPage() { return ( { - console.log(credential); + onSignIn={() => { navigate("/"); }} > diff --git a/examples/shadcn/src/screens/phone-auth-screen.tsx b/examples/shadcn/src/screens/phone-auth-screen.tsx index d85868e42..c7d6e043b 100644 --- a/examples/shadcn/src/screens/phone-auth-screen.tsx +++ b/examples/shadcn/src/screens/phone-auth-screen.tsx @@ -23,8 +23,7 @@ export default function PhoneAuthScreenPage() { return ( { - console.log(credential); + onSignIn={() => { navigate("/"); }} /> diff --git a/examples/shadcn/src/screens/sign-up-auth-screen-w-handlers.tsx b/examples/shadcn/src/screens/sign-up-auth-screen-w-handlers.tsx index 2b53baf1c..bfd9404c2 100644 --- a/examples/shadcn/src/screens/sign-up-auth-screen-w-handlers.tsx +++ b/examples/shadcn/src/screens/sign-up-auth-screen-w-handlers.tsx @@ -22,7 +22,7 @@ export default function SignUpAuthScreenWithHandlersPage() { const navigate = useNavigate(); return ( { + onSignInClick={() => { navigate("/screens/sign-in-auth-screen"); }} onSignUp={(credential) => { diff --git a/examples/shadcn/src/screens/sign-up-auth-screen-w-oauth.tsx b/examples/shadcn/src/screens/sign-up-auth-screen-w-oauth.tsx index 22a179269..f91867f90 100644 --- a/examples/shadcn/src/screens/sign-up-auth-screen-w-oauth.tsx +++ b/examples/shadcn/src/screens/sign-up-auth-screen-w-oauth.tsx @@ -29,8 +29,7 @@ export default function SignUpAuthScreenWithOAuthPage() { return ( { - console.log(credential); + onSignUp={() => { navigate("/"); }} > diff --git a/packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts index ef9e77d37..512282042 100644 --- a/packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts @@ -16,6 +16,8 @@ import { render, screen, fireEvent } from "@testing-library/angular"; import { Component, EventEmitter } from "@angular/core"; +import { Subject } from "rxjs"; +import { User } from "@angular/fire/auth"; import { EmailLinkAuthScreenComponent } from "./email-link-auth-screen"; import { @@ -74,8 +76,25 @@ class TestHostWithContentComponent {} class TestHostWithoutContentComponent {} describe("", () => { + let authStateSubject: Subject; + let userAuthenticatedCallback: ((user: User) => void) | null = null; + beforeEach(() => { - const { injectTranslation, injectUI } = require("../../../provider"); + authStateSubject = new Subject(); + + const { injectTranslation, injectUI, injectUserAuthenticated } = require("../../../provider"); + + // Mock injectUserAuthenticated to store the callback and set up subscription + injectUserAuthenticated.mockImplementation((callback: (user: User) => void) => { + userAuthenticatedCallback = callback; + const subscription = authStateSubject.subscribe((user) => { + if (user && !user.isAnonymous && userAuthenticatedCallback) { + userAuthenticatedCallback(user); + } + }); + return subscription; + }); + injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -94,6 +113,13 @@ describe("", () => { })); }); + afterEach(() => { + userAuthenticatedCallback = null; + authStateSubject.complete(); + authStateSubject = new Subject(); + jest.clearAllMocks(); + }); + it("renders with correct title and subtitle", async () => { await render(TestHostWithoutContentComponent, { imports: [ @@ -234,7 +260,7 @@ describe("", () => { expect(container.querySelector("fui-multi-factor-auth-assertion-screen")).toBeInTheDocument(); }); - it("calls signIn output when MFA flow succeeds", async () => { + it("emits signIn when MFA flow succeeds and user authenticates", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => () => ({ multiFactorResolver: { auth: {}, session: null, hints: [] }, @@ -258,15 +284,116 @@ describe("", () => { const component = fixture.debugElement.query((el) => el.name === "fui-email-link-auth-screen").componentInstance; const signInSpy = jest.spyOn(component.signIn, "emit"); - // Simulate MFA success by directly calling the onSuccess handler - const mfaComponent = fixture.debugElement.query( - (el) => el.name === "fui-multi-factor-auth-assertion-screen" - ).componentInstance; - mfaComponent.onSuccess.emit({ user: { uid: "mfa-user" } }); + // Simulate user authenticating after MFA flow succeeds + const mockUser = { + uid: "mfa-user", + email: "email-link@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable (simulating auth state change after MFA) + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signInSpy).toHaveBeenCalledTimes(1); + expect(signInSpy).toHaveBeenCalledWith(mockUser); + }); + + it("emits signIn when a non-anonymous user authenticates", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + EmailLinkAuthScreenComponent, + MockEmailLinkAuthFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-email-link-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Simulate a user authenticating + const mockUser = { + uid: "test-user-123", + email: "test@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); expect(signInSpy).toHaveBeenCalledTimes(1); - expect(signInSpy).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) }) - ); + expect(signInSpy).toHaveBeenCalledWith(mockUser); + }); + + it("does not emit signIn for anonymous users", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + EmailLinkAuthScreenComponent, + MockEmailLinkAuthFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-email-link-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Simulate an anonymous user authenticating + const mockAnonymousUser = { + uid: "anonymous-user-123", + isAnonymous: true, + } as User; + + // Emit the anonymous user through the authState observable + authStateSubject.next(mockAnonymousUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signInSpy).not.toHaveBeenCalled(); + }); + + it("does not emit signIn when user is null", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + EmailLinkAuthScreenComponent, + MockEmailLinkAuthFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-email-link-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Emit null (no user) through the authState observable + authStateSubject.next(null); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signInSpy).not.toHaveBeenCalled(); }); }); 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 343ebad09..f0a744659 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 @@ -23,11 +23,11 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; -import { injectTranslation, injectUI } from "../../provider"; +import { injectTranslation, injectUI, injectUserAuthenticated } from "../../provider"; import { EmailLinkAuthFormComponent } from "../forms/email-link-auth-form"; import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; -import { UserCredential } from "@angular/fire/auth"; +import { User } from "@angular/fire/auth"; @Component({ selector: "fui-email-link-auth-screen", @@ -48,7 +48,7 @@ import { UserCredential } from "@angular/fire/auth"; ], template: ` @if (mfaResolver()) { - + } @else {
@@ -57,7 +57,7 @@ import { UserCredential } from "@angular/fire/auth"; {{ subtitleText() }} - + @@ -74,6 +74,12 @@ export class EmailLinkAuthScreenComponent { titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); + constructor() { + injectUserAuthenticated((user) => { + this.signIn.emit(user); + }); + } + @Output() emailSent = new EventEmitter(); - @Output() signIn = new EventEmitter(); + @Output() signIn = new EventEmitter(); } 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 1b87973fa..9a9578775 100644 --- a/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts @@ -17,6 +17,8 @@ import { render, screen } from "@testing-library/angular"; import { Component, EventEmitter } from "@angular/core"; import { TestBed } from "@angular/core/testing"; +import { Subject } from "rxjs"; +import { User } from "@angular/fire/auth"; import { OAuthScreenComponent } from "./oauth-screen"; import { @@ -35,6 +37,7 @@ jest.mock("../../../provider", () => ({ injectPolicies: jest.fn(), injectRedirectError: jest.fn(), injectUI: jest.fn(), + injectUserAuthenticated: jest.fn(), })); @Component({ @@ -92,8 +95,31 @@ class MockMultiFactorAuthAssertionScreenComponent { } describe("", () => { + let authStateSubject: Subject; + let userAuthenticatedCallback: ((user: User) => void) | null = null; + beforeEach(() => { - const { injectTranslation, injectPolicies, injectRedirectError, injectUI } = require("../../../provider"); + authStateSubject = new Subject(); + + const { + injectTranslation, + injectPolicies, + injectRedirectError, + injectUI, + injectUserAuthenticated, + } = require("../../../provider"); + + // Mock injectUserAuthenticated to store the callback and set up subscription + injectUserAuthenticated.mockImplementation((callback: (user: User) => void) => { + userAuthenticatedCallback = callback; + const subscription = authStateSubject.subscribe((user) => { + if (user && !user.isAnonymous && userAuthenticatedCallback) { + userAuthenticatedCallback(user); + } + }); + return subscription; + }); + injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -122,6 +148,13 @@ describe("", () => { }); }); + afterEach(() => { + userAuthenticatedCallback = null; + authStateSubject.complete(); + authStateSubject = new Subject(); + jest.clearAllMocks(); + }); + it("renders with correct title and subtitle", async () => { await render(TestHostWithoutContentComponent, { imports: [ @@ -339,7 +372,7 @@ describe("", () => { expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); }); - it("emits onSignIn with credential when MFA flow succeeds", async () => { + it("emits onSignIn when MFA flow succeeds and user authenticates", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ @@ -371,14 +404,122 @@ describe("", () => { 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" } }); + // Simulate user authenticating after MFA flow succeeds + const mockUser = { + uid: "angular-oauth-mfa-user", + email: "oauth@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable (simulating auth state change after MFA) + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); expect(onSignInSpy).toHaveBeenCalledTimes(1); - expect(onSignInSpy).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "angular-oauth-mfa-user" }) }) - ); + expect(onSignInSpy).toHaveBeenCalledWith(mockUser); + }); + + it("emits onSignIn when a non-anonymous user authenticates", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-oauth-screen").componentInstance; + const onSignInSpy = jest.spyOn(component.onSignIn, "emit"); + + // Simulate a user authenticating + const mockUser = { + uid: "test-user-123", + email: "test@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(onSignInSpy).toHaveBeenCalledTimes(1); + expect(onSignInSpy).toHaveBeenCalledWith(mockUser); + }); + + it("does not emit onSignIn for anonymous users", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-oauth-screen").componentInstance; + const onSignInSpy = jest.spyOn(component.onSignIn, "emit"); + + // Simulate an anonymous user authenticating + const mockAnonymousUser = { + uid: "anonymous-user-123", + isAnonymous: true, + } as User; + + // Emit the anonymous user through the authState observable + authStateSubject.next(mockAnonymousUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(onSignInSpy).not.toHaveBeenCalled(); + }); + + it("does not emit onSignIn when user is null", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-oauth-screen").componentInstance; + const onSignInSpy = jest.spyOn(component.onSignIn, "emit"); + + // Emit null (no user) through the authState observable + authStateSubject.next(null); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(onSignInSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/angular/src/lib/auth/screens/oauth-screen.ts b/packages/angular/src/lib/auth/screens/oauth-screen.ts index cb30c6c9d..3fa0a9808 100644 --- a/packages/angular/src/lib/auth/screens/oauth-screen.ts +++ b/packages/angular/src/lib/auth/screens/oauth-screen.ts @@ -23,11 +23,11 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; -import { injectTranslation, injectUI } from "../../provider"; +import { injectTranslation, injectUI, injectUserAuthenticated } from "../../provider"; import { PoliciesComponent } from "../../components/policies"; import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; -import { type UserCredential } from "firebase/auth"; +import { type User } from "@angular/fire/auth"; @Component({ selector: "fui-oauth-screen", @@ -48,7 +48,7 @@ import { type UserCredential } from "firebase/auth"; ], template: ` @if (mfaResolver()) { - + } @else {
@@ -76,5 +76,11 @@ export class OAuthScreenComponent { titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); - @Output() onSignIn = new EventEmitter(); + constructor() { + injectUserAuthenticated((user) => { + this.onSignIn.emit(user); + }); + } + + @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 138ea477d..ab5e1d8df 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 @@ -17,6 +17,8 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; import { TestBed } from "@angular/core/testing"; +import { Subject } from "rxjs"; +import { User } from "@angular/fire/auth"; import { PhoneAuthScreenComponent } from "./phone-auth-screen"; import { @@ -64,8 +66,25 @@ class TestHostWithContentComponent {} class TestHostWithoutContentComponent {} describe("", () => { + let authStateSubject: Subject; + let userAuthenticatedCallback: ((user: User) => void) | null = null; + beforeEach(() => { - const { injectTranslation, injectUI } = require("../../../provider"); + authStateSubject = new Subject(); + + const { injectTranslation, injectUI, injectUserAuthenticated } = require("../../../provider"); + + // Mock injectUserAuthenticated to store the callback and set up subscription + injectUserAuthenticated.mockImplementation((callback: (user: User) => void) => { + userAuthenticatedCallback = callback; + const subscription = authStateSubject.subscribe((user) => { + if (user && !user.isAnonymous && userAuthenticatedCallback) { + userAuthenticatedCallback(user); + } + }); + return subscription; + }); + injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -85,6 +104,13 @@ describe("", () => { }); }); + afterEach(() => { + userAuthenticatedCallback = null; + authStateSubject.complete(); + authStateSubject = new Subject(); + jest.clearAllMocks(); + }); + it("renders with correct title and subtitle", async () => { await render(TestHostWithoutContentComponent, { imports: [ @@ -270,7 +296,7 @@ describe("", () => { expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); }); - it("emits signIn with credential when MFA flow succeeds", async () => { + it("emits signIn when MFA flow succeeds and user authenticates", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ @@ -305,14 +331,119 @@ describe("", () => { 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" } }); + // Simulate user authenticating after MFA flow succeeds + const mockUser = { + uid: "angular-phone-mfa-user", + email: "phone@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable (simulating auth state change after MFA) + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signInSpy).toHaveBeenCalledTimes(1); + expect(signInSpy).toHaveBeenCalledWith(mockUser); + }); + + it("emits signIn when a non-anonymous user authenticates", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + PhoneAuthScreenComponent, + MockPhoneAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-phone-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Simulate a user authenticating + const mockUser = { + uid: "test-user-123", + email: "test@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); expect(signInSpy).toHaveBeenCalledTimes(1); - expect(signInSpy).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "angular-phone-mfa-user" }) }) - ); + expect(signInSpy).toHaveBeenCalledWith(mockUser); + }); + + it("does not emit signIn for anonymous users", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + PhoneAuthScreenComponent, + MockPhoneAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-phone-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Simulate an anonymous user authenticating + const mockAnonymousUser = { + uid: "anonymous-user-123", + isAnonymous: true, + } as User; + + // Emit the anonymous user through the authState observable + authStateSubject.next(mockAnonymousUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signInSpy).not.toHaveBeenCalled(); + }); + + it("does not emit signIn when user is null", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + PhoneAuthScreenComponent, + MockPhoneAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-phone-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Emit null (no user) through the authState observable + authStateSubject.next(null); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signInSpy).not.toHaveBeenCalled(); }); }); 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 64ed89364..96a5bde86 100644 --- a/packages/angular/src/lib/auth/screens/phone-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/phone-auth-screen.ts @@ -23,11 +23,11 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; -import { injectTranslation, injectUI } from "../../provider"; +import { injectTranslation, injectUI, injectUserAuthenticated } from "../../provider"; import { PhoneAuthFormComponent } from "../forms/phone-auth-form"; import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; -import { UserCredential } from "@angular/fire/auth"; +import { User } from "@angular/fire/auth"; @Component({ selector: "fui-phone-auth-screen", @@ -48,7 +48,7 @@ import { UserCredential } from "@angular/fire/auth"; ], template: ` @if (mfaResolver()) { - + } @else {
@@ -57,7 +57,7 @@ import { UserCredential } from "@angular/fire/auth"; {{ subtitleText() }} - + @@ -74,5 +74,11 @@ export class PhoneAuthScreenComponent { titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); - @Output() signIn = new EventEmitter(); + constructor() { + injectUserAuthenticated((user) => { + this.signIn.emit(user); + }); + } + + @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 77e1c0d28..bed61fbfe 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 @@ -17,6 +17,8 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; import { TestBed } from "@angular/core/testing"; +import { Subject } from "rxjs"; +import { User } from "@angular/fire/auth"; import { SignInAuthScreenComponent } from "./sign-in-auth-screen"; import { @@ -64,8 +66,29 @@ class TestHostWithContentComponent {} class TestHostWithoutContentComponent {} describe("", () => { + let authStateSubject: Subject; + let userAuthenticatedCallback: ((user: User) => void) | null = null; + beforeEach(() => { - const { injectTranslation, injectUI } = require("../../../provider"); + authStateSubject = new Subject(); + + // Store the callback so we can trigger it later + const { injectTranslation, injectUI, injectUserAuthenticated } = require("../../../provider"); + + // Mock injectUserAuthenticated to store the callback and set up subscription + injectUserAuthenticated.mockImplementation((callback: (user: User) => void) => { + userAuthenticatedCallback = callback; + // Set up subscription similar to the real implementation + const subscription = authStateSubject.subscribe((user) => { + if (user && !user.isAnonymous && userAuthenticatedCallback) { + userAuthenticatedCallback(user); + } + }); + // Note: In the real implementation, this is cleaned up in an effect's onCleanup + // For testing, we'll manage it manually + return subscription; + }); + injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -85,6 +108,13 @@ describe("", () => { }); }); + afterEach(() => { + userAuthenticatedCallback = null; + authStateSubject.complete(); + authStateSubject = new Subject(); + jest.clearAllMocks(); + }); + it("renders with correct title and subtitle", async () => { await render(TestHostWithoutContentComponent, { imports: [ @@ -270,7 +300,7 @@ describe("", () => { expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); }); - it("emits signIn with credential when MFA flow succeeds", async () => { + it("emits signIn when MFA flow succeeds and user authenticates", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ @@ -305,14 +335,119 @@ describe("", () => { 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" } }); + // Simulate user authenticating after MFA flow succeeds + const mockUser = { + uid: "angular-mfa-user", + email: "mfa@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable (simulating auth state change after MFA) + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); expect(signInSpy).toHaveBeenCalledTimes(1); - expect(signInSpy).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "angular-mfa-user" }) }) - ); + expect(signInSpy).toHaveBeenCalledWith(mockUser); + }); + + it("emits signIn when a non-anonymous user authenticates", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-sign-in-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Simulate a user authenticating + const mockUser = { + uid: "test-user-123", + email: "test@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signInSpy).toHaveBeenCalledTimes(1); + expect(signInSpy).toHaveBeenCalledWith(mockUser); + }); + + it("does not emit signIn for anonymous users", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-sign-in-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Simulate an anonymous user authenticating + const mockAnonymousUser = { + uid: "anonymous-user-123", + isAnonymous: true, + } as User; + + // Emit the anonymous user through the authState observable + authStateSubject.next(mockAnonymousUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signInSpy).not.toHaveBeenCalled(); + }); + + it("does not emit signIn when user is null", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-sign-in-auth-screen").componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Emit null (no user) through the authState observable + authStateSubject.next(null); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signInSpy).not.toHaveBeenCalled(); }); }); 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 02efd57b2..14b0dc36d 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,10 +14,10 @@ * limitations under the License. */ -import { Component, Output, EventEmitter, computed } from "@angular/core"; +import { Component, Output, EventEmitter, computed, inject, effect } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { injectTranslation, injectUI } from "../../provider"; +import { injectTranslation, injectUI, injectUserAuthenticated } from "../../provider"; import { SignInAuthFormComponent } from "../forms/sign-in-auth-form"; import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; @@ -28,7 +28,7 @@ import { CardSubtitleComponent, CardContentComponent, } from "../../components/card"; -import { UserCredential } from "@angular/fire/auth"; +import { Auth, authState, User, UserCredential } from "@angular/fire/auth"; @Component({ selector: "fui-sign-in-auth-screen", standalone: true, @@ -48,7 +48,7 @@ import { UserCredential } from "@angular/fire/auth"; ], template: ` @if (mfaResolver()) { - + } @else {
@@ -57,7 +57,7 @@ import { UserCredential } from "@angular/fire/auth"; {{ subtitleText() }} - + @@ -70,11 +70,16 @@ export class SignInAuthScreenComponent { private ui = injectUI(); mfaResolver = computed(() => this.ui().multiFactorResolver); - titleText = injectTranslation("labels", "signIn"); subtitleText = injectTranslation("prompts", "signInToAccount"); + constructor() { + injectUserAuthenticated((user) => { + this.signIn.emit(user); + }); + } + @Output() forgotPassword = new EventEmitter(); @Output() signUp = new EventEmitter(); - @Output() signIn = 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 02c113fc9..82b394e86 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 @@ -17,6 +17,8 @@ import { render, screen } from "@testing-library/angular"; import { Component } from "@angular/core"; import { TestBed } from "@angular/core/testing"; +import { Subject } from "rxjs"; +import { User } from "@angular/fire/auth"; import { SignUpAuthScreenComponent } from "./sign-up-auth-screen"; import { @@ -64,8 +66,25 @@ class TestHostWithContentComponent {} class TestHostWithoutContentComponent {} describe("", () => { + let authStateSubject: Subject; + let userAuthenticatedCallback: ((user: User) => void) | null = null; + beforeEach(() => { - const { injectTranslation, injectUI } = require("../../../provider"); + authStateSubject = new Subject(); + + const { injectTranslation, injectUI, injectUserAuthenticated } = require("../../../provider"); + + // Mock injectUserAuthenticated to store the callback and set up subscription + injectUserAuthenticated.mockImplementation((callback: (user: User) => void) => { + userAuthenticatedCallback = callback; + const subscription = authStateSubject.subscribe((user) => { + if (user && !user.isAnonymous && userAuthenticatedCallback) { + userAuthenticatedCallback(user); + } + }); + return subscription; + }); + injectTranslation.mockImplementation((category: string, key: string) => { const mockTranslations: Record> = { labels: { @@ -85,6 +104,13 @@ describe("", () => { }); }); + afterEach(() => { + userAuthenticatedCallback = null; + authStateSubject.complete(); + authStateSubject = new Subject(); + jest.clearAllMocks(); + }); + it("renders with correct title and subtitle", async () => { await render(TestHostWithoutContentComponent, { imports: [ @@ -269,7 +295,7 @@ describe("", () => { expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); }); - it("emits signUp with credential when MFA flow succeeds", async () => { + it("emits signUp when MFA flow succeeds and user authenticates", async () => { const { injectUI } = require("../../../provider"); injectUI.mockImplementation(() => { return () => ({ @@ -304,14 +330,119 @@ describe("", () => { 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" } }); + // Simulate user authenticating after MFA flow succeeds + const mockUser = { + uid: "angular-signup-mfa-user", + email: "signup@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable (simulating auth state change after MFA) + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signUpSpy).toHaveBeenCalledTimes(1); + expect(signUpSpy).toHaveBeenCalledWith(mockUser); + }); + + it("emits signUp when a non-anonymous user authenticates", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + SignUpAuthScreenComponent, + MockSignUpAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-sign-up-auth-screen").componentInstance; + const signUpSpy = jest.spyOn(component.signUp, "emit"); + + // Simulate a user authenticating + const mockUser = { + uid: "test-user-123", + email: "test@example.com", + isAnonymous: false, + } as User; + + // Emit the user through the authState observable + authStateSubject.next(mockUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); expect(signUpSpy).toHaveBeenCalledTimes(1); - expect(signUpSpy).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "angular-signup-mfa-user" }) }) - ); + expect(signUpSpy).toHaveBeenCalledWith(mockUser); + }); + + it("does not emit signUp for anonymous users", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + SignUpAuthScreenComponent, + MockSignUpAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-sign-up-auth-screen").componentInstance; + const signUpSpy = jest.spyOn(component.signUp, "emit"); + + // Simulate an anonymous user authenticating + const mockAnonymousUser = { + uid: "anonymous-user-123", + isAnonymous: true, + } as User; + + // Emit the anonymous user through the authState observable + authStateSubject.next(mockAnonymousUser); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signUpSpy).not.toHaveBeenCalled(); + }); + + it("does not emit signUp when user is null", async () => { + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + SignUpAuthScreenComponent, + MockSignUpAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query((el) => el.name === "fui-sign-up-auth-screen").componentInstance; + const signUpSpy = jest.spyOn(component.signUp, "emit"); + + // Emit null (no user) through the authState observable + authStateSubject.next(null); + + // Wait for Angular's change detection and effect to run + fixture.detectChanges(); + await fixture.whenStable(); + + expect(signUpSpy).not.toHaveBeenCalled(); }); }); 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 48e16a14e..f479917a1 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 @@ -16,9 +16,9 @@ import { Component, Output, EventEmitter, computed } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { UserCredential } from "@angular/fire/auth"; +import { User } from "@angular/fire/auth"; -import { injectTranslation, injectUI } from "../../provider"; +import { injectTranslation, injectUI, injectUserAuthenticated } from "../../provider"; import { SignUpAuthFormComponent } from "../forms/sign-up-auth-form"; import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; @@ -49,7 +49,7 @@ import { ], template: ` @if (mfaResolver()) { - + } @else {
@@ -58,7 +58,7 @@ import { {{ subtitleText() }} - + @@ -75,6 +75,12 @@ export class SignUpAuthScreenComponent { titleText = injectTranslation("labels", "signUp"); subtitleText = injectTranslation("prompts", "enterDetailsToCreate"); - @Output() signUp = new EventEmitter(); + constructor() { + injectUserAuthenticated((user) => { + this.signUp.emit(user); + }); + } + + @Output() signUp = new EventEmitter(); @Output() signIn = new EventEmitter(); } diff --git a/packages/angular/src/lib/provider.ts b/packages/angular/src/lib/provider.ts index 815dc6c3e..0c1d1b480 100644 --- a/packages/angular/src/lib/provider.ts +++ b/packages/angular/src/lib/provider.ts @@ -30,7 +30,7 @@ import { } from "@angular/core"; import { isPlatformBrowser } from "@angular/common"; import { FirebaseApps } from "@angular/fire/app"; -import { Auth } from "@angular/fire/auth"; +import { Auth, authState, User } from "@angular/fire/auth"; import { createEmailLinkAuthFormSchema, createForgotPasswordAuthFormSchema, @@ -95,6 +95,23 @@ export function injectUI() { return ui.asReadonly(); } +export function injectUserAuthenticated(onAuthenticated: (user: User) => void) { + const auth = inject(Auth); + const state = authState(auth); + + effect((onCleanup) => { + const subscription = state.subscribe((user) => { + if (user && !user.isAnonymous) { + onAuthenticated(user); + } + }); + + onCleanup(() => { + subscription.unsubscribe(); + }); + }); +} + export function injectRecaptchaVerifier(element: () => ElementRef) { const ui = injectUI(); const platformId = inject(PLATFORM_ID); diff --git a/packages/angular/src/lib/tests/test-helpers.ts b/packages/angular/src/lib/tests/test-helpers.ts index 4d6a977c0..f606320d8 100644 --- a/packages/angular/src/lib/tests/test-helpers.ts +++ b/packages/angular/src/lib/tests/test-helpers.ts @@ -287,6 +287,8 @@ export const injectRecaptchaVerifier = jest.fn().mockImplementation(() => { }); }); +export const injectUserAuthenticated = jest.fn(); + export const RecaptchaVerifier = jest.fn().mockImplementation(() => ({ clear: jest.fn(), render: jest.fn(), diff --git a/packages/react/src/auth/screens/email-link-auth-screen.test.tsx b/packages/react/src/auth/screens/email-link-auth-screen.test.tsx index b20df6d42..52734863d 100644 --- a/packages/react/src/auth/screens/email-link-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/email-link-auth-screen.test.tsx @@ -15,11 +15,11 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"; import { EmailLinkAuthScreen } from "~/auth/screens/email-link-auth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; -import type { MultiFactorResolver } from "firebase/auth"; +import type { MultiFactorResolver, User } from "firebase/auth"; vi.mock("~/auth/forms/email-link-auth-form", () => ({ EmailLinkAuthForm: () =>
Email Link Form
, @@ -201,7 +201,18 @@ describe("", () => { session: null, hints: [], }; - const ui = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignIn = vi.fn(); @@ -212,11 +223,85 @@ describe("", () => { ); - fireEvent.click(screen.getByTestId("mfa-on-success")); + const mockUser = { + uid: "mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignIn when user authenticates via useOnUserAuthenticated hook", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignIn).toHaveBeenCalledTimes(1); - expect(onSignIn).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) }) + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignIn for anonymous users", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignIn).not.toHaveBeenCalled(); }); }); diff --git a/packages/react/src/auth/screens/email-link-auth-screen.tsx b/packages/react/src/auth/screens/email-link-auth-screen.tsx index 103e1e85e..7072b9c90 100644 --- a/packages/react/src/auth/screens/email-link-auth-screen.tsx +++ b/packages/react/src/auth/screens/email-link-auth-screen.tsx @@ -15,15 +15,20 @@ */ import type { PropsWithChildren } from "react"; +import type { User } from "firebase/auth"; import { getTranslation } from "@invertase/firebaseui-core"; import { Divider } from "~/components/divider"; -import { useUI } from "~/hooks"; +import { useOnUserAuthenticated, useUI } from "~/hooks"; import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card"; import { EmailLinkAuthForm, type EmailLinkAuthFormProps } from "../forms/email-link-auth-form"; import { RedirectError } from "~/components/redirect-error"; import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; -export type EmailLinkAuthScreenProps = PropsWithChildren; +export type EmailLinkAuthScreenProps = PropsWithChildren< + Pick & { + onSignIn?: (user: User) => void; + } +>; export function EmailLinkAuthScreen({ children, onEmailSent, onSignIn }: EmailLinkAuthScreenProps) { const ui = useUI(); @@ -32,8 +37,10 @@ export function EmailLinkAuthScreen({ children, onEmailSent, onSignIn }: EmailLi const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); const mfaResolver = ui.multiFactorResolver; + useOnUserAuthenticated(onSignIn); + if (mfaResolver) { - return ; + return ; } return ( @@ -44,7 +51,7 @@ export function EmailLinkAuthScreen({ children, onEmailSent, onSignIn }: EmailLi {subtitleText} - + {children ? ( <> {getTranslation(ui, "messages", "dividerOr")} diff --git a/packages/react/src/auth/screens/oauth-screen.test.tsx b/packages/react/src/auth/screens/oauth-screen.test.tsx index 3d9e3aee3..4afcedab1 100644 --- a/packages/react/src/auth/screens/oauth-screen.test.tsx +++ b/packages/react/src/auth/screens/oauth-screen.test.tsx @@ -14,11 +14,11 @@ */ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"; import { OAuthScreen } from "~/auth/screens/oauth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; -import { MultiFactorResolver } from "firebase/auth"; +import { MultiFactorResolver, type User } from "firebase/auth"; vi.mock("~/components/policies", async (originalModule) => { const module = await originalModule(); @@ -230,7 +230,18 @@ describe("", () => { session: null, hints: [], }; - const ui = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignIn = vi.fn(); @@ -241,11 +252,85 @@ describe("", () => { ); - fireEvent.click(screen.getByTestId("mfa-on-success")); + const mockUser = { + uid: "oauth-mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignIn when user authenticates via useOnUserAuthenticated hook", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + OAuth Provider + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignIn).toHaveBeenCalledTimes(1); - expect(onSignIn).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "oauth-mfa-user" }) }) + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignIn for anonymous users", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + OAuth Provider + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignIn).not.toHaveBeenCalled(); }); }); diff --git a/packages/react/src/auth/screens/oauth-screen.tsx b/packages/react/src/auth/screens/oauth-screen.tsx index 4fb263d5e..c3591e370 100644 --- a/packages/react/src/auth/screens/oauth-screen.tsx +++ b/packages/react/src/auth/screens/oauth-screen.tsx @@ -15,16 +15,16 @@ */ import { getTranslation } from "@invertase/firebaseui-core"; -import { type UserCredential } from "firebase/auth"; +import { type User } from "firebase/auth"; import { type PropsWithChildren } from "react"; -import { useUI } from "~/hooks"; +import { useOnUserAuthenticated, useUI } from "~/hooks"; import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card"; import { Policies } from "~/components/policies"; import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; import { RedirectError } from "~/components/redirect-error"; export type OAuthScreenProps = PropsWithChildren<{ - onSignIn?: (credential: UserCredential) => void; + onSignIn?: (user: User) => void; }>; export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { @@ -34,8 +34,10 @@ export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); const mfaResolver = ui.multiFactorResolver; + useOnUserAuthenticated(onSignIn); + if (mfaResolver) { - return ; + return ; } return ( diff --git a/packages/react/src/auth/screens/phone-auth-screen.test.tsx b/packages/react/src/auth/screens/phone-auth-screen.test.tsx index 07ecb4686..3159aec39 100644 --- a/packages/react/src/auth/screens/phone-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/phone-auth-screen.test.tsx @@ -14,11 +14,11 @@ */ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; +import { render, screen, cleanup, act } from "@testing-library/react"; import { PhoneAuthScreen } from "~/auth/screens/phone-auth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; -import { MultiFactorResolver } from "firebase/auth"; +import { MultiFactorResolver, type User } from "firebase/auth"; vi.mock("~/auth/forms/phone-auth-form", () => ({ PhoneAuthForm: ({ resendDelay }: { resendDelay?: number }) => ( @@ -99,20 +99,6 @@ describe("", () => { expect(screen.getByTestId("phone-auth-form")).toBeDefined(); }); - // it("passes resendDelay prop to PhoneAuthForm", () => { - // const ui = createMockUI(); - - // render( - // - // - // - // ); - - // const phoneForm = screen.getByTestId("phone-auth-form"); - // expect(phoneForm).toBeDefined(); - // expect(phoneForm.getAttribute("data-resend-delay")).toBe("60"); - // }); - it("renders a divider with children when present", () => { const ui = createMockUI({ locale: registerLocale("test", { @@ -256,7 +242,18 @@ describe("", () => { session: null, hints: [], }; - const ui = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignIn = vi.fn(); @@ -267,13 +264,86 @@ describe("", () => { ); - // Simulate nested MFA form success - const trigger = screen.getByTestId("mfa-on-success"); - trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + // Simulate the MFA flow success - this would trigger auth state change + const mockUser = { + uid: "phone-mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignIn).toHaveBeenCalledTimes(1); - expect(onSignIn).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "phone-mfa-user" }) }) + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignIn when user authenticates via useOnUserAuthenticated hook", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignIn for anonymous users", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignIn).not.toHaveBeenCalled(); }); }); diff --git a/packages/react/src/auth/screens/phone-auth-screen.tsx b/packages/react/src/auth/screens/phone-auth-screen.tsx index 8e234d0be..53436370b 100644 --- a/packages/react/src/auth/screens/phone-auth-screen.tsx +++ b/packages/react/src/auth/screens/phone-auth-screen.tsx @@ -17,13 +17,16 @@ import type { PropsWithChildren } from "react"; import { getTranslation } from "@invertase/firebaseui-core"; import { Divider } from "~/components/divider"; -import { useUI } from "~/hooks"; +import { useOnUserAuthenticated, useUI } from "~/hooks"; import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card"; -import { PhoneAuthForm, type PhoneAuthFormProps } from "../forms/phone-auth-form"; +import { PhoneAuthForm } from "../forms/phone-auth-form"; import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; import { RedirectError } from "~/components/redirect-error"; +import type { User } from "firebase/auth"; -export type PhoneAuthScreenProps = PropsWithChildren; +export type PhoneAuthScreenProps = PropsWithChildren<{ + onSignIn?: (user: User) => void; +}>; export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { const ui = useUI(); @@ -32,8 +35,10 @@ export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); const mfaResolver = ui.multiFactorResolver; + useOnUserAuthenticated(props.onSignIn); + if (mfaResolver) { - return ; + return ; } return ( @@ -44,7 +49,7 @@ export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { {subtitleText} - + {children ? ( <> {getTranslation(ui, "messages", "dividerOr")} diff --git a/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx b/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx index 2e7154f19..4a9b176ed 100644 --- a/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx @@ -14,11 +14,11 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; import { SignInAuthScreen } from "~/auth/screens/sign-in-auth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; -import { MultiFactorResolver } from "firebase/auth"; +import { MultiFactorResolver, type User } from "firebase/auth"; vi.mock("~/auth/forms/sign-in-auth-form", () => ({ SignInAuthForm: ({ @@ -285,7 +285,18 @@ describe("", () => { session: null, hints: [], }; - const ui = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignIn = vi.fn(); @@ -296,12 +307,86 @@ describe("", () => { ); - // Simulate the MFA child reporting success with a credential - fireEvent.click(screen.getByTestId("mfa-on-success")); + // Simulate the MFA child reporting success - this would trigger auth state change + const mockUser = { + uid: "mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignIn when user authenticates via useOnUserAuthenticated hook", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignIn).toHaveBeenCalledTimes(1); - expect(onSignIn).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) }) + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignIn for anonymous users", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignIn).not.toHaveBeenCalled(); }); }); diff --git a/packages/react/src/auth/screens/sign-in-auth-screen.tsx b/packages/react/src/auth/screens/sign-in-auth-screen.tsx index 857bf4738..0da74d2ff 100644 --- a/packages/react/src/auth/screens/sign-in-auth-screen.tsx +++ b/packages/react/src/auth/screens/sign-in-auth-screen.tsx @@ -15,26 +15,29 @@ */ import type { PropsWithChildren } from "react"; +import type { User } from "firebase/auth"; import { getTranslation } from "@invertase/firebaseui-core"; import { Divider } from "~/components/divider"; -import { useUI } from "~/hooks"; +import { useOnUserAuthenticated, useUI } from "~/hooks"; import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; import { SignInAuthForm, type SignInAuthFormProps } from "../forms/sign-in-auth-form"; import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; import { RedirectError } from "~/components/redirect-error"; -export type SignInAuthScreenProps = PropsWithChildren; +export type SignInAuthScreenProps = PropsWithChildren> & { + onSignIn?: (user: User) => void; +}; -export function SignInAuthScreen({ children, ...props }: SignInAuthScreenProps) { +export function SignInAuthScreen({ children, onSignIn, ...props }: SignInAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - const mfaResolver = ui.multiFactorResolver; + useOnUserAuthenticated(onSignIn); - if (mfaResolver) { - return ; + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx b/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx index c77148c60..2c7d21b56 100644 --- a/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx @@ -14,11 +14,11 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; import { SignUpAuthScreen } from "~/auth/screens/sign-up-auth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; -import { MultiFactorResolver } from "firebase/auth"; +import { MultiFactorResolver, type User } from "firebase/auth"; vi.mock("~/auth/forms/sign-up-auth-form", () => ({ SignUpAuthForm: ({ onSignInClick }: { onSignInClick?: () => void }) => ( @@ -259,7 +259,18 @@ describe("", () => { session: null, hints: [], }; - const ui = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignUp = vi.fn(); @@ -270,13 +281,85 @@ describe("", () => { ); - // Simulate nested MFA form success - const trigger = screen.getByTestId("mfa-on-success"); - trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const mockUser = { + uid: "signup-mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignUp).toHaveBeenCalledTimes(1); + expect(onSignUp).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignUp when user authenticates via useOnUserAuthenticated hook", () => { + const onSignUp = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignUp).toHaveBeenCalledTimes(1); - expect(onSignUp).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "signup-mfa-user" }) }) + expect(onSignUp).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignUp for anonymous users", () => { + const onSignUp = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignUp).not.toHaveBeenCalled(); }); }); diff --git a/packages/react/src/auth/screens/sign-up-auth-screen.tsx b/packages/react/src/auth/screens/sign-up-auth-screen.tsx index 1a9de7329..5dac1981c 100644 --- a/packages/react/src/auth/screens/sign-up-auth-screen.tsx +++ b/packages/react/src/auth/screens/sign-up-auth-screen.tsx @@ -14,27 +14,30 @@ * limitations under the License. */ -import { type PropsWithChildren } from "react"; +import type { PropsWithChildren } from "react"; +import type { User } from "firebase/auth"; import { Divider } from "~/components/divider"; -import { useUI } from "~/hooks"; +import { useOnUserAuthenticated, useUI } from "~/hooks"; import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; import { SignUpAuthForm, type SignUpAuthFormProps } from "../forms/sign-up-auth-form"; import { getTranslation } from "@invertase/firebaseui-core"; import { RedirectError } from "~/components/redirect-error"; import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; -export type SignUpAuthScreenProps = PropsWithChildren; +export type SignUpAuthScreenProps = PropsWithChildren> & { + onSignUp?: (user: User) => void; +}; -export function SignUpAuthScreen({ children, ...props }: SignUpAuthScreenProps) { +export function SignUpAuthScreen({ children, onSignUp, ...props }: SignUpAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signUp"); const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate"); - const mfaResolver = ui.multiFactorResolver; + useOnUserAuthenticated(onSignUp); - if (mfaResolver) { - return ; + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/packages/react/src/hooks.test.tsx b/packages/react/src/hooks.test.tsx index ce2fed504..622be7ab6 100644 --- a/packages/react/src/hooks.test.tsx +++ b/packages/react/src/hooks.test.tsx @@ -26,10 +26,11 @@ import { usePhoneAuthNumberFormSchema, usePhoneAuthVerifyFormSchema, useRecaptchaVerifier, + useOnUserAuthenticated, } from "./hooks"; import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; import { registerLocale, enUs } from "@invertase/firebaseui-translations"; -import type { RecaptchaVerifier } from "firebase/auth"; +import type { RecaptchaVerifier, User } from "firebase/auth"; // Mock RecaptchaVerifier from firebase/auth const mockRender = vi.fn(); @@ -929,3 +930,254 @@ describe("useRecaptchaVerifier", () => { expect(result.current).toBe(mockVerifier); }); }); + +describe("useOnUserAuthenticated", () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + }); + + it("calls callback when a non-anonymous user is authenticated", () => { + const mockCallback = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + let unsubscribe: (() => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + unsubscribe = vi.fn(); + return unsubscribe; + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + const { unmount } = renderHook(() => useOnUserAuthenticated(mockCallback), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + expect(authStateChangeCallback).toBeDefined(); + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith(mockUser); + + unmount(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it("does not call callback when user is anonymous", () => { + const mockCallback = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + renderHook(() => useOnUserAuthenticated(mockCallback), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(mockCallback).not.toHaveBeenCalled(); + }); + + it("does not call callback when user is null", () => { + const mockCallback = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + renderHook(() => useOnUserAuthenticated(mockCallback), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + authStateChangeCallback!(null); + }); + + expect(mockCallback).not.toHaveBeenCalled(); + }); + + it("works without a callback", () => { + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + renderHook(() => useOnUserAuthenticated(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + }); + + it("unsubscribes from auth state changes on unmount", () => { + const mockCallback = vi.fn(); + let unsubscribe: (() => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn(() => { + unsubscribe = vi.fn(); + return unsubscribe; + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + const { unmount } = renderHook(() => useOnUserAuthenticated(mockCallback), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + expect(unsubscribe).toBeDefined(); + + unmount(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it("resubscribes when auth instance changes", () => { + const mockCallback = vi.fn(); + const mockAuth1 = { + onAuthStateChanged: vi.fn(() => vi.fn()), + }; + const mockAuth2 = { + onAuthStateChanged: vi.fn(() => vi.fn()), + }; + + const mockUI1 = createMockUI({ + auth: mockAuth1 as any, + }); + const mockUI2 = createMockUI({ + auth: mockAuth2 as any, + }); + + const { rerender } = renderHook(() => useOnUserAuthenticated(mockCallback), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI1 }), + }); + + expect(mockAuth1.onAuthStateChanged).toHaveBeenCalledTimes(1); + expect(mockAuth2.onAuthStateChanged).not.toHaveBeenCalled(); + + rerender(); + // Note: The hook depends on auth, but since we're using the same mockUI instance, + // we need to create a new wrapper with a different UI + const { rerender: rerender2 } = renderHook(() => useOnUserAuthenticated(mockCallback), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI2 }), + }); + + rerender2(); + + // The effect should re-run when auth changes, but since we're using a new wrapper, + // we need to check that the new auth instance's onAuthStateChanged is called + expect(mockAuth2.onAuthStateChanged).toHaveBeenCalledTimes(1); + }); + + it("resubscribes when callback changes", () => { + const mockCallback1 = vi.fn(); + const mockCallback2 = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + const unsubscribeFunctions: (() => void)[] = []; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + const unsubscribe = vi.fn(); + unsubscribeFunctions.push(unsubscribe); + return unsubscribe; + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + const { rerender } = renderHook(({ callback }) => useOnUserAuthenticated(callback), { + initialProps: { callback: mockCallback1 }, + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + expect(unsubscribeFunctions).toHaveLength(1); + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(mockCallback1).toHaveBeenCalledTimes(1); + expect(mockCallback2).not.toHaveBeenCalled(); + + rerender({ callback: mockCallback2 }); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(2); + expect(unsubscribeFunctions).toHaveLength(2); + expect(unsubscribeFunctions[0]).toHaveBeenCalledTimes(1); + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(mockCallback1).toHaveBeenCalledTimes(1); + expect(mockCallback2).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index 390c7639d..b86791d49 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -15,7 +15,7 @@ */ import { useContext, useMemo, useEffect, useRef } from "react"; -import type { RecaptchaVerifier } from "firebase/auth"; +import type { RecaptchaVerifier, User } from "firebase/auth"; import { createEmailLinkAuthFormSchema, createForgotPasswordAuthFormSchema, @@ -53,6 +53,19 @@ const ui = initializeUI(...); return ui; } +export function useOnUserAuthenticated(callback?: (user: User) => void) { + const ui = useUI(); + const auth = ui.auth; + + useEffect(() => { + return auth.onAuthStateChanged((user) => { + if (user && !user.isAnonymous) { + callback?.(user); + } + }); + }, [auth, callback]); +} + export function useRedirectError() { const ui = useUI(); return useMemo(() => { diff --git a/packages/react/tests/utils.tsx b/packages/react/tests/utils.tsx index 2b62c8138..a9956ed2d 100644 --- a/packages/react/tests/utils.tsx +++ b/packages/react/tests/utils.tsx @@ -3,14 +3,21 @@ import type { Auth } from "firebase/auth"; import { enUs } from "@invertase/firebaseui-translations"; import { Behavior, FirebaseUI, FirebaseUIOptions, FirebaseUIStore, initializeUI } from "@invertase/firebaseui-core"; import { FirebaseUIProvider } from "../src/context"; +import { vi } from "vitest"; export function createMockUI(overrides?: Partial): FirebaseUIStore { + const defaultAuth = { + onAuthStateChanged: vi.fn(() => vi.fn()), + } as unknown as Auth; + + const { auth, ...restOverrides } = overrides || {}; + return initializeUI({ app: {} as FirebaseApp, - auth: {} as Auth, + auth: auth ?? defaultAuth, locale: enUs, behaviors: [] as Behavior[], - ...overrides, + ...restOverrides, }); } diff --git a/packages/shadcn/src/components/email-link-auth-screen.test.tsx b/packages/shadcn/src/components/email-link-auth-screen.test.tsx index 29b98b0c0..3bc4493b5 100644 --- a/packages/shadcn/src/components/email-link-auth-screen.test.tsx +++ b/packages/shadcn/src/components/email-link-auth-screen.test.tsx @@ -15,12 +15,12 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, act } from "@testing-library/react"; import { EmailLinkAuthScreen } from "./email-link-auth-screen"; import { createMockUI } from "../../tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; import { FirebaseUIProvider } from "@invertase/firebaseui-react"; -import { MultiFactorResolver } from "firebase/auth"; +import { MultiFactorResolver, type User } from "firebase/auth"; vi.mock("./email-link-auth-form", () => ({ EmailLinkAuthForm: ({ onEmailSent, onSignIn }: any) => ( @@ -116,16 +116,14 @@ describe("", () => { }); const onEmailSentMock = vi.fn(); - const onSignInMock = vi.fn(); render( - + ); expect(screen.getByTestId("onEmailSent-prop")).toBeInTheDocument(); - expect(screen.getByTestId("onSignIn-prop")).toBeInTheDocument(); }); it("should not render separator when no children", () => { @@ -246,7 +244,18 @@ describe("", () => { session: null, hints: [], }; - const mockUI = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); mockUI.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignIn = vi.fn(); @@ -257,11 +266,85 @@ describe("", () => { ); - fireEvent.click(screen.getByTestId("mfa-on-success")); + const mockUser = { + uid: "email-link-mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignIn).toHaveBeenCalledTimes(1); - expect(onSignIn).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "email-link-mfa-user" }) }) + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignIn when user authenticates via useOnUserAuthenticated hook", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignIn for anonymous users", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignIn).not.toHaveBeenCalled(); }); }); diff --git a/packages/shadcn/src/components/email-link-auth-screen.tsx b/packages/shadcn/src/components/email-link-auth-screen.tsx index 88828712c..171a55de7 100644 --- a/packages/shadcn/src/components/email-link-auth-screen.tsx +++ b/packages/shadcn/src/components/email-link-auth-screen.tsx @@ -1,7 +1,7 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { useUI, type EmailLinkAuthScreenProps } from "@invertase/firebaseui-react"; +import { useUI, type EmailLinkAuthScreenProps, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; @@ -11,15 +11,16 @@ import { RedirectError } from "@/components/redirect-error"; export type { EmailLinkAuthScreenProps }; -export function EmailLinkAuthScreen({ children, ...props }: EmailLinkAuthScreenProps) { +export function EmailLinkAuthScreen({ children, onSignIn, ...props }: EmailLinkAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - const mfaResolver = ui.multiFactorResolver; - if (mfaResolver) { - return ; + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/packages/shadcn/src/components/oauth-screen.test.tsx b/packages/shadcn/src/components/oauth-screen.test.tsx index 855ad6280..bb49c5799 100644 --- a/packages/shadcn/src/components/oauth-screen.test.tsx +++ b/packages/shadcn/src/components/oauth-screen.test.tsx @@ -14,11 +14,11 @@ */ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, act } from "@testing-library/react"; import { OAuthScreen } from "@/components/oauth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "../../tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; -import { MultiFactorResolver } from "firebase/auth"; +import { MultiFactorResolver, type User } from "firebase/auth"; vi.mock("@/components/policies", () => ({ Policies: () =>
Policies
, @@ -228,7 +228,18 @@ describe("", () => { session: null, hints: [], }; - const ui = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignIn = vi.fn(); @@ -239,11 +250,85 @@ describe("", () => { ); - fireEvent.click(screen.getByTestId("mfa-on-success")); + const mockUser = { + uid: "oauth-mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignIn when user authenticates via useOnUserAuthenticated hook", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + OAuth Provider + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignIn).toHaveBeenCalledTimes(1); - expect(onSignIn).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "oauth-mfa-user" }) }) + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignIn for anonymous users", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + OAuth Provider + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignIn).not.toHaveBeenCalled(); }); }); diff --git a/packages/shadcn/src/components/oauth-screen.tsx b/packages/shadcn/src/components/oauth-screen.tsx index 1281d74e1..a586527d2 100644 --- a/packages/shadcn/src/components/oauth-screen.tsx +++ b/packages/shadcn/src/components/oauth-screen.tsx @@ -1,16 +1,16 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { type UserCredential } from "firebase/auth"; +import { type User } from "firebase/auth"; import { type PropsWithChildren } from "react"; -import { useUI } from "@invertase/firebaseui-react"; +import { useUI, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardHeader, CardDescription, CardTitle } from "@/components/ui/card"; import { Policies } from "@/components/policies"; import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen"; import { RedirectError } from "@/components/redirect-error"; export type OAuthScreenProps = PropsWithChildren<{ - onSignIn?: (credential: UserCredential) => void; + onSignIn?: (user: User) => void; }>; export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { @@ -18,10 +18,11 @@ export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - const mfaResolver = ui.multiFactorResolver; - if (mfaResolver) { - return ; + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/packages/shadcn/src/components/phone-auth-screen.test.tsx b/packages/shadcn/src/components/phone-auth-screen.test.tsx index edac411f8..d789517f3 100644 --- a/packages/shadcn/src/components/phone-auth-screen.test.tsx +++ b/packages/shadcn/src/components/phone-auth-screen.test.tsx @@ -14,11 +14,11 @@ */ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, act } from "@testing-library/react"; import { PhoneAuthScreen } from "@/components/phone-auth-screen"; import { CreateFirebaseUIProvider, createMockUI } from "../../tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; -import { MultiFactorResolver } from "firebase/auth"; +import { MultiFactorResolver, type User } from "firebase/auth"; vi.mock("@/components/phone-auth-form", () => ({ PhoneAuthForm: ({ resendDelay }: { resendDelay?: number }) => ( @@ -239,7 +239,18 @@ describe("", () => { session: null, hints: [], }; - const ui = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignIn = vi.fn(); @@ -250,11 +261,85 @@ describe("", () => { ); - fireEvent.click(screen.getByTestId("mfa-on-success")); + const mockUser = { + uid: "phone-mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignIn when user authenticates via useOnUserAuthenticated hook", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignIn).toHaveBeenCalledTimes(1); - expect(onSignIn).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "phone-mfa-user" }) }) + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignIn for anonymous users", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignIn).not.toHaveBeenCalled(); }); }); diff --git a/packages/shadcn/src/components/phone-auth-screen.tsx b/packages/shadcn/src/components/phone-auth-screen.tsx index ad31fae73..7908c58c8 100644 --- a/packages/shadcn/src/components/phone-auth-screen.tsx +++ b/packages/shadcn/src/components/phone-auth-screen.tsx @@ -2,24 +2,28 @@ import type { PropsWithChildren } from "react"; import { getTranslation } from "@invertase/firebaseui-core"; -import { useUI } from "@invertase/firebaseui-react"; +import { useUI, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardHeader, CardDescription, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; -import { PhoneAuthForm, type PhoneAuthFormProps } from "@/components/phone-auth-form"; +import { PhoneAuthForm } from "@/components/phone-auth-form"; import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen"; import { RedirectError } from "@/components/redirect-error"; +import type { User } from "firebase/auth"; -export type PhoneAuthScreenProps = PropsWithChildren; +export type PhoneAuthScreenProps = PropsWithChildren<{ + onSignIn?: (user: User) => void; +}>; -export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { +export function PhoneAuthScreen({ children, onSignIn }: PhoneAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - const mfaResolver = ui.multiFactorResolver; - if (mfaResolver) { - return ; + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; } return ( @@ -30,7 +34,7 @@ export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { {subtitleText} - + {children ? ( <> diff --git a/packages/shadcn/src/components/sign-in-auth-screen.test.tsx b/packages/shadcn/src/components/sign-in-auth-screen.test.tsx index cf5abfdd7..dca816c20 100644 --- a/packages/shadcn/src/components/sign-in-auth-screen.test.tsx +++ b/packages/shadcn/src/components/sign-in-auth-screen.test.tsx @@ -15,12 +15,12 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, act } from "@testing-library/react"; import { SignInAuthScreen } from "./sign-in-auth-screen"; import { createMockUI } from "../../tests/utils"; import { FirebaseUIProvider } from "@invertase/firebaseui-react"; import { registerLocale } from "@invertase/firebaseui-translations"; -import { MultiFactorResolver } from "firebase/auth"; +import { MultiFactorResolver, type User } from "firebase/auth"; vi.mock("./sign-in-auth-form", () => ({ SignInAuthForm: ({ onSignIn, onForgotPasswordClick, onRegisterClick }: any) => ( @@ -144,11 +144,11 @@ describe("", () => { it("should forward props to SignInAuthForm", () => { const mockUI = createMockUI(); const onForgotPasswordClickMock = vi.fn(); - const onRegisterClickMock = vi.fn(); + const onSignUpClickMock = vi.fn(); render( - + ); @@ -248,7 +248,18 @@ describe("", () => { session: null, hints: [], }; - const ui = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignIn = vi.fn(); @@ -259,11 +270,85 @@ describe("", () => { ); - fireEvent.click(screen.getByTestId("mfa-on-success")); + const mockUser = { + uid: "mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignIn).toHaveBeenCalledTimes(1); - expect(onSignIn).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) }) + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignIn when user authenticates via useOnUserAuthenticated hook", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignIn for anonymous users", () => { + const onSignIn = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const ui = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignIn).not.toHaveBeenCalled(); }); }); diff --git a/packages/shadcn/src/components/sign-in-auth-screen.tsx b/packages/shadcn/src/components/sign-in-auth-screen.tsx index 322c34848..3397fac0d 100644 --- a/packages/shadcn/src/components/sign-in-auth-screen.tsx +++ b/packages/shadcn/src/components/sign-in-auth-screen.tsx @@ -1,7 +1,7 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { useUI, type SignInAuthScreenProps } from "@invertase/firebaseui-react"; +import { useUI, type SignInAuthScreenProps, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; @@ -10,16 +10,16 @@ import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-a export type { SignInAuthScreenProps }; -export function SignInAuthScreen({ children, ...props }: SignInAuthScreenProps) { +export function SignInAuthScreen({ children, onSignIn, ...props }: SignInAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - const mfaResolver = ui.multiFactorResolver; + useOnUserAuthenticated(onSignIn); - if (mfaResolver) { - return ; + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/packages/shadcn/src/components/sign-up-auth-screen.test.tsx b/packages/shadcn/src/components/sign-up-auth-screen.test.tsx index 5e7437845..afbaf6567 100644 --- a/packages/shadcn/src/components/sign-up-auth-screen.test.tsx +++ b/packages/shadcn/src/components/sign-up-auth-screen.test.tsx @@ -15,12 +15,12 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, act } from "@testing-library/react"; import { SignUpAuthScreen } from "./sign-up-auth-screen"; import { createMockUI } from "../../tests/utils"; import { registerLocale } from "@invertase/firebaseui-translations"; import { FirebaseUIProvider } from "@invertase/firebaseui-react"; -import { MultiFactorResolver } from "firebase/auth"; +import { MultiFactorResolver, type User } from "firebase/auth"; vi.mock("./sign-up-auth-form", () => ({ SignUpAuthForm: ({ onSignUp, onSignInClick }: any) => ( @@ -115,16 +115,14 @@ describe("", () => { }), }); - const onSignUpMock = vi.fn(); const onSignInClickMock = vi.fn(); render( - + ); - expect(screen.getByTestId("onSignUp-prop")).toBeInTheDocument(); expect(screen.getByTestId("onSignInClick-prop")).toBeInTheDocument(); }); @@ -246,7 +244,18 @@ describe("", () => { session: null, hints: [], }; - const mockUI = createMockUI(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); mockUI.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); const onSignUp = vi.fn(); @@ -257,11 +266,85 @@ describe("", () => { ); - fireEvent.click(screen.getByTestId("mfa-on-success")); + const mockUser = { + uid: "signup-mfa-user", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); expect(onSignUp).toHaveBeenCalledTimes(1); - expect(onSignUp).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.objectContaining({ uid: "signup-mfa-user" }) }) + expect(onSignUp).toHaveBeenCalledWith(mockUser); + }); + + it("calls onSignUp when user authenticates via useOnUserAuthenticated hook", () => { + const onSignUp = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + ); + + expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1); + + const mockUser = { + uid: "test-user-id", + isAnonymous: false, + } as User; + + act(() => { + authStateChangeCallback!(mockUser); + }); + + expect(onSignUp).toHaveBeenCalledTimes(1); + expect(onSignUp).toHaveBeenCalledWith(mockUser); + }); + + it("does not call onSignUp for anonymous users", () => { + const onSignUp = vi.fn(); + let authStateChangeCallback: ((user: User | null) => void) | null = null; + + const mockAuth = { + onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => { + authStateChangeCallback = callback; + return vi.fn(); + }), + }; + + const mockUI = createMockUI({ + auth: mockAuth as any, + }); + + render( + + + + ); + + const mockAnonymousUser = { + uid: "anonymous-user-id", + isAnonymous: true, + } as User; + + act(() => { + authStateChangeCallback!(mockAnonymousUser); + }); + + expect(onSignUp).not.toHaveBeenCalled(); }); }); diff --git a/packages/shadcn/src/components/sign-up-auth-screen.tsx b/packages/shadcn/src/components/sign-up-auth-screen.tsx index 23838f3f7..f358a163e 100644 --- a/packages/shadcn/src/components/sign-up-auth-screen.tsx +++ b/packages/shadcn/src/components/sign-up-auth-screen.tsx @@ -1,7 +1,7 @@ "use client"; import { getTranslation } from "@invertase/firebaseui-core"; -import { useUI, type SignUpAuthScreenProps } from "@invertase/firebaseui-react"; +import { useUI, type SignUpAuthScreenProps, useOnUserAuthenticated } from "@invertase/firebaseui-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; @@ -10,15 +10,16 @@ import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-a export type { SignUpAuthScreenProps }; -export function SignUpAuthScreen({ children, ...props }: SignUpAuthScreenProps) { +export function SignUpAuthScreen({ children, onSignUp, ...props }: SignUpAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signUp"); const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate"); - const mfaResolver = ui.multiFactorResolver; - if (mfaResolver) { - return ; + useOnUserAuthenticated(onSignUp); + + if (ui.multiFactorResolver) { + return ; } return ( diff --git a/packages/shadcn/tests/utils.tsx b/packages/shadcn/tests/utils.tsx index 31e8cedc0..0d232fbce 100644 --- a/packages/shadcn/tests/utils.tsx +++ b/packages/shadcn/tests/utils.tsx @@ -7,36 +7,46 @@ import { FirebaseUIStore } from "@invertase/firebaseui-core"; import { vi } from "vitest"; export function createMockUI(overrides?: Partial) { + const defaultAuth = { + currentUser: null, + onAuthStateChanged: vi.fn(() => vi.fn()), + } as unknown as Auth; + + const { auth, ...restOverrides } = overrides || {}; + return initializeUI({ app: {} as FirebaseApp, - auth: { - currentUser: null, - } as unknown as Auth, + auth: auth ?? defaultAuth, locale: enUs, behaviors: [] as Behavior[], - ...overrides, + ...restOverrides, }); } export function createMockUIWithUser(overrides?: Partial) { + const defaultAuth = { + currentUser: { + uid: "test-user-id", + email: "test@example.com", + _onReload: vi.fn(), + _multiFactor: { + enrolledFactors: [], + enroll: vi.fn(), + unenroll: vi.fn(), + getSession: vi.fn(), + }, + }, + onAuthStateChanged: vi.fn(() => vi.fn()), + } as unknown as Auth; + + const { auth, ...restOverrides } = overrides || {}; + return initializeUI({ app: {} as FirebaseApp, - auth: { - currentUser: { - uid: "test-user-id", - email: "test@example.com", - _onReload: vi.fn(), - _multiFactor: { - enrolledFactors: [], - enroll: vi.fn(), - unenroll: vi.fn(), - getSession: vi.fn(), - }, - }, - } as unknown as Auth, + auth: auth ?? defaultAuth, locale: enUs, behaviors: [] as Behavior[], - ...overrides, + ...restOverrides, }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9df7f686..2a623a709 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,13 +215,13 @@ importers: devDependencies: '@angular-devkit/architect': specifier: latest - version: 0.2003.9(chokidar@4.0.3) + version: 0.2003.10(chokidar@4.0.3) '@angular-devkit/build-angular': specifier: latest - version: 20.3.9(2529a6727af0abeddcf8d134a84a80aa) + version: 20.3.10(2529a6727af0abeddcf8d134a84a80aa) '@angular-devkit/core': specifier: latest - version: 20.3.9(chokidar@4.0.3) + version: 20.3.10(chokidar@4.0.3) '@angular/cli': specifier: ^20.2.2 version: 20.3.7(@types/node@20.19.24)(chokidar@4.0.3) @@ -1108,16 +1108,16 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@angular-devkit/architect@0.2003.7': - resolution: {integrity: sha512-NGHLfrNQNjwWwvyQomMM1AqRaqH3UU0TwySJh9XlSc9dC/roB5zD2NjLf98K4LfAIfHvDBwkQ+dMo3F556/Xuw==} + '@angular-devkit/architect@0.2003.10': + resolution: {integrity: sha512-2SWetxJzS8gRX6OKQstkWx37VRvZVgcEBDLsDSaeTjpnwh81A+niZQjAVRdwL0NEt1Wixk/RxfeUuCmdyyHvhQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} - '@angular-devkit/architect@0.2003.9': - resolution: {integrity: sha512-p0GO2H8hiZjRHI9sm4tXTF3OpWaEnkqvB0GBGJfGp8RvpPfDA2t3j2NAUNtd75H+B0xdfyWLmNq9YJGpy6gznA==} + '@angular-devkit/architect@0.2003.7': + resolution: {integrity: sha512-NGHLfrNQNjwWwvyQomMM1AqRaqH3UU0TwySJh9XlSc9dC/roB5zD2NjLf98K4LfAIfHvDBwkQ+dMo3F556/Xuw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} - '@angular-devkit/build-angular@20.3.7': - resolution: {integrity: sha512-KVA6ztqrZz/DKSCk/iV9fz9Af+54YyZs25KwClBi+7/RJIBNml8CZQLW51VxIkbjD9aZdVZdUMkkbQJp5MgY5w==} + '@angular-devkit/build-angular@20.3.10': + resolution: {integrity: sha512-SWGh1ASXEXtzFv/OSlmYGsYlIWHNeZRWkwkBe6mPfxZMX4JZ4HKbxmMtKV9hifvFdITU393IxPH5JXlFZJpZhQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: '@angular/compiler-cli': ^20.0.0 @@ -1126,11 +1126,11 @@ packages: '@angular/platform-browser': ^20.0.0 '@angular/platform-server': ^20.0.0 '@angular/service-worker': ^20.0.0 - '@angular/ssr': ^20.3.7 + '@angular/ssr': ^20.3.10 '@web/test-runner': ^0.20.0 browser-sync: ^3.0.2 - jest: ^29.5.0 - jest-environment-jsdom: ^29.5.0 + jest: ^29.5.0 || ^30.2.0 + jest-environment-jsdom: ^29.5.0 || ^30.2.0 karma: ^6.3.0 ng-packagr: ^20.0.0 protractor: ^7.0.0 @@ -1166,8 +1166,8 @@ packages: tailwindcss: optional: true - '@angular-devkit/build-angular@20.3.9': - resolution: {integrity: sha512-DCzHY+EQ98u0h1n8s9add1KVSNWco1RW/Rl8TRkEuGmRQ43MpOfTIZQvlnnqaeMcNH0fZ4zkybVBDj7korJbZg==} + '@angular-devkit/build-angular@20.3.7': + resolution: {integrity: sha512-KVA6ztqrZz/DKSCk/iV9fz9Af+54YyZs25KwClBi+7/RJIBNml8CZQLW51VxIkbjD9aZdVZdUMkkbQJp5MgY5w==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: '@angular/compiler-cli': ^20.0.0 @@ -1176,11 +1176,11 @@ packages: '@angular/platform-browser': ^20.0.0 '@angular/platform-server': ^20.0.0 '@angular/service-worker': ^20.0.0 - '@angular/ssr': ^20.3.9 + '@angular/ssr': ^20.3.7 '@web/test-runner': ^0.20.0 browser-sync: ^3.0.2 - jest: ^29.5.0 || ^30.2.0 - jest-environment-jsdom: ^29.5.0 || ^30.2.0 + jest: ^29.5.0 + jest-environment-jsdom: ^29.5.0 karma: ^6.3.0 ng-packagr: ^20.0.0 protractor: ^7.0.0 @@ -1216,22 +1216,22 @@ packages: tailwindcss: optional: true - '@angular-devkit/build-webpack@0.2003.7': - resolution: {integrity: sha512-9CVEUWOzf7sk6eudFEG3pfDT1AScUJ4+ekRFKyD5Q5sZivMjjVFqwQp7YUpHpZusZX6sgpT+Crj5Ydva+I/dvw==} + '@angular-devkit/build-webpack@0.2003.10': + resolution: {integrity: sha512-/e76O5MnoAplV+LW6XAWyd8e1KR1HqRTCSTngLMO+VMADbcQkD4i01ouridlxVLKkGDg83hvASUz2M6x0duZ9w==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: webpack: ^5.30.0 webpack-dev-server: ^5.0.2 - '@angular-devkit/build-webpack@0.2003.9': - resolution: {integrity: sha512-2VSKR4BR/M3g5VvAJpKdytAErPt8Oj+HzTKp+ujVeJEBs3U48bpb6mZJOMTxU1YFf2hvawDQo5aiwkondS1qLg==} + '@angular-devkit/build-webpack@0.2003.7': + resolution: {integrity: sha512-9CVEUWOzf7sk6eudFEG3pfDT1AScUJ4+ekRFKyD5Q5sZivMjjVFqwQp7YUpHpZusZX6sgpT+Crj5Ydva+I/dvw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: webpack: ^5.30.0 webpack-dev-server: ^5.0.2 - '@angular-devkit/core@20.3.7': - resolution: {integrity: sha512-psmcjwYcXve4sLrcdnARc15/Wfd3RpydbtLo9+mViNzk5HQ6L2eEztKl/2QVYMgzZVIa1GfhjwUllVCyLAv3sg==} + '@angular-devkit/core@20.3.10': + resolution: {integrity: sha512-COOT2eVebDwHhwENk12VR6m0wjL8D7p0dncEHF15zaBt1IXEnVhGESjSrs5klnPnt5T55qCBKyCTaeK7i/cS8Q==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: chokidar: ^4.0.0 @@ -1239,8 +1239,8 @@ packages: chokidar: optional: true - '@angular-devkit/core@20.3.9': - resolution: {integrity: sha512-bXsAGIUb4p60x548YmvnMvjwd3FwWz6re1uTM7dV0XH8nQn3XMhOQ3Q3sAckzJHxkDuaRhB3K/a4kupoOmVfTQ==} + '@angular-devkit/core@20.3.7': + resolution: {integrity: sha512-psmcjwYcXve4sLrcdnARc15/Wfd3RpydbtLo9+mViNzk5HQ6L2eEztKl/2QVYMgzZVIa1GfhjwUllVCyLAv3sg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: chokidar: ^4.0.0 @@ -1299,8 +1299,8 @@ packages: peerDependencies: '@angular/core': 20.3.7 - '@angular/build@20.3.7': - resolution: {integrity: sha512-NHN5JNDqUc0Ux4IZPCe/fpFAnuRHujkxVfRHSqDFW5+jtj2JuW1XO6qlX+kDheFRlj/NvFgTpidKsE9IjpfMWQ==} + '@angular/build@20.3.10': + resolution: {integrity: sha512-nQrj1nMNZygYDilThc7hPrD6/NIWF/BOSgMfE4VkXQp8d0QronP3HFJ/h77MeoughMRFRhix0pqQSlXJQ2SGTQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: '@angular/compiler': ^20.0.0 @@ -1310,7 +1310,7 @@ packages: '@angular/platform-browser': ^20.0.0 '@angular/platform-server': ^20.0.0 '@angular/service-worker': ^20.0.0 - '@angular/ssr': ^20.3.7 + '@angular/ssr': ^20.3.10 karma: ^6.4.0 less: ^4.2.0 ng-packagr: ^20.0.0 @@ -1345,8 +1345,8 @@ packages: vitest: optional: true - '@angular/build@20.3.9': - resolution: {integrity: sha512-Ulimvg6twPSCraaZECEmENfKBlD4M1yqeHlg6dCzFNM4xcwaGUnuG6O3cIQD59DaEvaG73ceM2y8ftYdxAwFow==} + '@angular/build@20.3.7': + resolution: {integrity: sha512-NHN5JNDqUc0Ux4IZPCe/fpFAnuRHujkxVfRHSqDFW5+jtj2JuW1XO6qlX+kDheFRlj/NvFgTpidKsE9IjpfMWQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: '@angular/compiler': ^20.0.0 @@ -1356,7 +1356,7 @@ packages: '@angular/platform-browser': ^20.0.0 '@angular/platform-server': ^20.0.0 '@angular/service-worker': ^20.0.0 - '@angular/ssr': ^20.3.9 + '@angular/ssr': ^20.3.7 karma: ^6.4.0 less: ^4.2.0 ng-packagr: ^20.0.0 @@ -3506,16 +3506,16 @@ packages: cpu: [x64] os: [win32] - '@ngtools/webpack@20.3.7': - resolution: {integrity: sha512-AlFf28hylqopJYz4P5MOGEmasOGtXntN/xExOuurP4P9xuUrO99FvaVm0+RPgw8iKeojNW5Bi6qFS77gLof56w==} + '@ngtools/webpack@20.3.10': + resolution: {integrity: sha512-W/+CGQFhmYEMJ/YgkC5p9khkxu2ocrvM0Pe0GxcUldrpBpdm1GCphEH1kTo7MeCupUK4/6rXGUt+GoA6PYchOg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: '@angular/compiler-cli': ^20.0.0 typescript: '>=5.8 <6.0' webpack: ^5.54.0 - '@ngtools/webpack@20.3.9': - resolution: {integrity: sha512-3h5laY9+kP7Tzociy3Lg5sMfpTTKMU+XbLQAHxnIvywHLD6r/fgVkwRli8GZf5JFMTwAkul0AQPKom9SCSWJLg==} + '@ngtools/webpack@20.3.7': + resolution: {integrity: sha512-AlFf28hylqopJYz4P5MOGEmasOGtXntN/xExOuurP4P9xuUrO99FvaVm0+RPgw8iKeojNW5Bi6qFS77gLof56w==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: '@angular/compiler-cli': ^20.0.0 @@ -10201,27 +10201,27 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@angular-devkit/architect@0.2003.7(chokidar@4.0.3)': + '@angular-devkit/architect@0.2003.10(chokidar@4.0.3)': dependencies: - '@angular-devkit/core': 20.3.7(chokidar@4.0.3) + '@angular-devkit/core': 20.3.10(chokidar@4.0.3) rxjs: 7.8.2 transitivePeerDependencies: - chokidar - '@angular-devkit/architect@0.2003.9(chokidar@4.0.3)': + '@angular-devkit/architect@0.2003.7(chokidar@4.0.3)': dependencies: - '@angular-devkit/core': 20.3.9(chokidar@4.0.3) + '@angular-devkit/core': 20.3.7(chokidar@4.0.3) rxjs: 7.8.2 transitivePeerDependencies: - chokidar - '@angular-devkit/build-angular@20.3.7(352e872bc592fcb4f0bf67d221329859)': + '@angular-devkit/build-angular@20.3.10(2529a6727af0abeddcf8d134a84a80aa)': dependencies: '@ampproject/remapping': 2.3.0 - '@angular-devkit/architect': 0.2003.7(chokidar@4.0.3) - '@angular-devkit/build-webpack': 0.2003.7(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2) - '@angular-devkit/core': 20.3.7(chokidar@4.0.3) - '@angular/build': 20.3.7(1849c53c536bd3612d435156c655d6b9) + '@angular-devkit/architect': 0.2003.10(chokidar@4.0.3) + '@angular-devkit/build-webpack': 0.2003.10(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2) + '@angular-devkit/core': 20.3.10(chokidar@4.0.3) + '@angular/build': 20.3.10(1be407f5110624cbb4aee41c10128f32) '@angular/compiler-cli': 20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3) '@babel/core': 7.28.3 '@babel/generator': 7.28.3 @@ -10233,7 +10233,7 @@ snapshots: '@babel/preset-env': 7.28.3(@babel/core@7.28.3) '@babel/runtime': 7.28.3 '@discoveryjs/json-ext': 0.6.3 - '@ngtools/webpack': 20.3.7(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(typescript@5.9.3)(webpack@5.101.2) + '@ngtools/webpack': 20.3.10(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(typescript@5.9.3)(webpack@5.101.2) ansi-colors: 4.1.3 autoprefixer: 10.4.21(postcss@8.5.6) babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2) @@ -10279,7 +10279,7 @@ snapshots: '@angular/platform-server': 20.3.7(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/compiler@20.3.7)(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.7(@angular/animations@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@angular/ssr': 20.3.7(64ca8375dbaf48ae24b53908d91cad2b) esbuild: 0.25.9 - jest: 30.2.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)) + jest: 30.2.0(@types/node@20.19.24) jest-environment-jsdom: 30.2.0 ng-packagr: 20.3.0(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(tailwindcss@4.1.16)(tslib@2.8.1)(typescript@5.9.3) tailwindcss: 4.1.16 @@ -10306,13 +10306,13 @@ snapshots: - webpack-cli - yaml - '@angular-devkit/build-angular@20.3.9(2529a6727af0abeddcf8d134a84a80aa)': + '@angular-devkit/build-angular@20.3.7(352e872bc592fcb4f0bf67d221329859)': dependencies: '@ampproject/remapping': 2.3.0 - '@angular-devkit/architect': 0.2003.9(chokidar@4.0.3) - '@angular-devkit/build-webpack': 0.2003.9(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2(esbuild@0.25.9)) - '@angular-devkit/core': 20.3.9(chokidar@4.0.3) - '@angular/build': 20.3.9(1be407f5110624cbb4aee41c10128f32) + '@angular-devkit/architect': 0.2003.7(chokidar@4.0.3) + '@angular-devkit/build-webpack': 0.2003.7(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2(esbuild@0.25.9)) + '@angular-devkit/core': 20.3.7(chokidar@4.0.3) + '@angular/build': 20.3.7(1849c53c536bd3612d435156c655d6b9) '@angular/compiler-cli': 20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3) '@babel/core': 7.28.3 '@babel/generator': 7.28.3 @@ -10324,7 +10324,7 @@ snapshots: '@babel/preset-env': 7.28.3(@babel/core@7.28.3) '@babel/runtime': 7.28.3 '@discoveryjs/json-ext': 0.6.3 - '@ngtools/webpack': 20.3.9(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(typescript@5.9.3)(webpack@5.101.2(esbuild@0.25.9)) + '@ngtools/webpack': 20.3.7(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(typescript@5.9.3)(webpack@5.101.2(esbuild@0.25.9)) ansi-colors: 4.1.3 autoprefixer: 10.4.21(postcss@8.5.6) babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2) @@ -10370,7 +10370,7 @@ snapshots: '@angular/platform-server': 20.3.7(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/compiler@20.3.7)(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.7(@angular/animations@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.7(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@angular/ssr': 20.3.7(64ca8375dbaf48ae24b53908d91cad2b) esbuild: 0.25.9 - jest: 30.2.0(@types/node@20.19.24) + jest: 30.2.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)) jest-environment-jsdom: 30.2.0 ng-packagr: 20.3.0(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(tailwindcss@4.1.16)(tslib@2.8.1)(typescript@5.9.3) tailwindcss: 4.1.16 @@ -10397,25 +10397,25 @@ snapshots: - webpack-cli - yaml - '@angular-devkit/build-webpack@0.2003.7(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2)': + '@angular-devkit/build-webpack@0.2003.10(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2)': dependencies: - '@angular-devkit/architect': 0.2003.7(chokidar@4.0.3) + '@angular-devkit/architect': 0.2003.10(chokidar@4.0.3) rxjs: 7.8.2 webpack: 5.101.2(esbuild@0.25.9) webpack-dev-server: 5.2.2(webpack@5.101.2) transitivePeerDependencies: - chokidar - '@angular-devkit/build-webpack@0.2003.9(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2(esbuild@0.25.9))': + '@angular-devkit/build-webpack@0.2003.7(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2(esbuild@0.25.9))': dependencies: - '@angular-devkit/architect': 0.2003.9(chokidar@4.0.3) + '@angular-devkit/architect': 0.2003.7(chokidar@4.0.3) rxjs: 7.8.2 webpack: 5.101.2(esbuild@0.25.9) webpack-dev-server: 5.2.2(webpack@5.101.2) transitivePeerDependencies: - chokidar - '@angular-devkit/core@20.3.7(chokidar@4.0.3)': + '@angular-devkit/core@20.3.10(chokidar@4.0.3)': dependencies: ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) @@ -10426,7 +10426,7 @@ snapshots: optionalDependencies: chokidar: 4.0.3 - '@angular-devkit/core@20.3.9(chokidar@4.0.3)': + '@angular-devkit/core@20.3.7(chokidar@4.0.3)': dependencies: ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) @@ -10449,8 +10449,8 @@ snapshots: '@angular-eslint/builder@20.5.0(chokidar@4.0.3)(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@angular-devkit/architect': 0.2003.9(chokidar@4.0.3) - '@angular-devkit/core': 20.3.9(chokidar@4.0.3) + '@angular-devkit/architect': 0.2003.10(chokidar@4.0.3) + '@angular-devkit/core': 20.3.10(chokidar@4.0.3) eslint: 9.38.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -10481,7 +10481,7 @@ snapshots: '@angular-eslint/schematics@20.5.0(@angular-eslint/template-parser@20.5.0(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.46.2)(@typescript-eslint/utils@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(chokidar@4.0.3)(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@angular-devkit/core': 20.3.9(chokidar@4.0.3) + '@angular-devkit/core': 20.3.10(chokidar@4.0.3) '@angular-devkit/schematics': 20.3.7(chokidar@4.0.3) '@angular-eslint/eslint-plugin': 20.5.0(@typescript-eslint/utils@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) '@angular-eslint/eslint-plugin-template': 20.5.0(@angular-eslint/template-parser@20.5.0(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.46.2)(@typescript-eslint/utils@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) @@ -10515,17 +10515,17 @@ snapshots: '@angular/core': 20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1) tslib: 2.8.1 - '@angular/build@20.3.7(1849c53c536bd3612d435156c655d6b9)': + '@angular/build@20.3.10(1be407f5110624cbb4aee41c10128f32)': dependencies: '@ampproject/remapping': 2.3.0 - '@angular-devkit/architect': 0.2003.7(chokidar@4.0.3) + '@angular-devkit/architect': 0.2003.10(chokidar@4.0.3) '@angular/compiler': 20.3.7 '@angular/compiler-cli': 20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3) '@babel/core': 7.28.3 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-split-export-declaration': 7.24.7 - '@inquirer/confirm': 5.1.14(@types/node@24.9.2) - '@vitejs/plugin-basic-ssl': 2.1.0(vite@7.1.11(@types/node@24.9.2)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.6)) + '@inquirer/confirm': 5.1.14(@types/node@20.19.24) + '@vitejs/plugin-basic-ssl': 2.1.0(vite@7.1.11(@types/node@20.19.24)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.6)) beasties: 0.3.5 browserslist: 4.27.0 esbuild: 0.25.9 @@ -10545,7 +10545,7 @@ snapshots: tinyglobby: 0.2.14 tslib: 2.8.1 typescript: 5.9.3 - vite: 7.1.11(@types/node@24.9.2)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.6) + vite: 7.1.11(@types/node@20.19.24)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.6) watchpack: 2.4.4 optionalDependencies: '@angular/core': 20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1) @@ -10557,7 +10557,7 @@ snapshots: ng-packagr: 20.3.0(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(tailwindcss@4.1.16)(tslib@2.8.1)(typescript@5.9.3) postcss: 8.5.6 tailwindcss: 4.1.16 - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@25.0.1)(less@4.4.0)(lightningcss@1.30.2)(msw@2.11.6(@types/node@24.9.2)(typescript@5.9.3))(sass@1.90.0)(terser@5.43.1)(tsx@4.20.6) + vitest: 3.2.4(@types/node@20.19.24)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@25.0.1)(less@4.4.0)(lightningcss@1.30.2)(msw@2.11.6(@types/node@20.19.24)(typescript@5.9.3))(sass@1.90.0)(terser@5.43.1)(tsx@4.20.6) transitivePeerDependencies: - '@types/node' - chokidar @@ -10571,17 +10571,17 @@ snapshots: - tsx - yaml - '@angular/build@20.3.9(1be407f5110624cbb4aee41c10128f32)': + '@angular/build@20.3.7(1849c53c536bd3612d435156c655d6b9)': dependencies: '@ampproject/remapping': 2.3.0 - '@angular-devkit/architect': 0.2003.9(chokidar@4.0.3) + '@angular-devkit/architect': 0.2003.7(chokidar@4.0.3) '@angular/compiler': 20.3.7 '@angular/compiler-cli': 20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3) '@babel/core': 7.28.3 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-split-export-declaration': 7.24.7 - '@inquirer/confirm': 5.1.14(@types/node@20.19.24) - '@vitejs/plugin-basic-ssl': 2.1.0(vite@7.1.11(@types/node@20.19.24)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.6)) + '@inquirer/confirm': 5.1.14(@types/node@24.9.2) + '@vitejs/plugin-basic-ssl': 2.1.0(vite@7.1.11(@types/node@24.9.2)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.6)) beasties: 0.3.5 browserslist: 4.27.0 esbuild: 0.25.9 @@ -10601,7 +10601,7 @@ snapshots: tinyglobby: 0.2.14 tslib: 2.8.1 typescript: 5.9.3 - vite: 7.1.11(@types/node@20.19.24)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.6) + vite: 7.1.11(@types/node@24.9.2)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.6) watchpack: 2.4.4 optionalDependencies: '@angular/core': 20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)(zone.js@0.15.1) @@ -10613,7 +10613,7 @@ snapshots: ng-packagr: 20.3.0(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(tailwindcss@4.1.16)(tslib@2.8.1)(typescript@5.9.3) postcss: 8.5.6 tailwindcss: 4.1.16 - vitest: 3.2.4(@types/node@20.19.24)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@25.0.1)(less@4.4.0)(lightningcss@1.30.2)(msw@2.11.6(@types/node@20.19.24)(typescript@5.9.3))(sass@1.90.0)(terser@5.43.1)(tsx@4.20.6) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@25.0.1)(less@4.4.0)(lightningcss@1.30.2)(msw@2.11.6(@types/node@24.9.2)(typescript@5.9.3))(sass@1.90.0)(terser@5.43.1)(tsx@4.20.6) transitivePeerDependencies: - '@types/node' - chokidar @@ -13138,13 +13138,13 @@ snapshots: '@next/swc-win32-x64-msvc@15.1.7': optional: true - '@ngtools/webpack@20.3.7(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(typescript@5.9.3)(webpack@5.101.2)': + '@ngtools/webpack@20.3.10(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(typescript@5.9.3)(webpack@5.101.2)': dependencies: '@angular/compiler-cli': 20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3) typescript: 5.9.3 webpack: 5.101.2(esbuild@0.25.9) - '@ngtools/webpack@20.3.9(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(typescript@5.9.3)(webpack@5.101.2(esbuild@0.25.9))': + '@ngtools/webpack@20.3.7(@angular/compiler-cli@20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3))(typescript@5.9.3)(webpack@5.101.2(esbuild@0.25.9))': dependencies: '@angular/compiler-cli': 20.3.7(@angular/compiler@20.3.7)(typescript@5.9.3) typescript: 5.9.3 @@ -13174,7 +13174,7 @@ snapshots: '@npmcli/fs@4.0.0': dependencies: - semver: 7.7.2 + semver: 7.7.3 '@npmcli/git@6.0.3': dependencies: @@ -14919,7 +14919,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@20.19.24)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@25.0.1)(less@4.4.0)(lightningcss@1.30.2)(msw@2.11.6(@types/node@20.19.24)(typescript@5.9.3))(sass@1.90.0)(terser@5.43.1)(tsx@4.20.6) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(less@4.4.2)(lightningcss@1.30.2)(msw@2.11.6(@types/node@24.9.2)(typescript@5.9.3))(sass@1.93.2)(terser@5.43.1)(tsx@4.20.6) '@vitest/utils@3.2.4': dependencies: @@ -18336,7 +18336,7 @@ snapshots: make-fetch-happen: 14.0.3 nopt: 8.1.0 proc-log: 5.0.0 - semver: 7.7.2 + semver: 7.7.3 tar: 7.5.1 tinyglobby: 0.2.15 which: 5.0.0 @@ -18361,7 +18361,7 @@ snapshots: npm-install-checks@7.1.2: dependencies: - semver: 7.7.2 + semver: 7.7.3 npm-normalize-package-bin@4.0.0: {}