diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..bcc5b1f1e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,53 @@ +--- +name: Bug report +about: Create a report to help us improve +--- + + + + + + +### [REQUIRED] Describe your environment + +- Operating System version: **\_** +- Browser version: **\_** +- Firebase UI version: **\_** +- Firebase SDK version: **\_** +- Package name: **\_** + + + +### [REQUIRED] Describe the problem + +#### Steps to reproduce + + + +#### Relevant Code + + + + + +```javascript +// TODO(you): code here to reproduce the problem +``` diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..a09db44fb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,16 @@ +--- +name: Feature request +about: Suggest an idea for this project +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/readme-banner.png b/.github/readme-banner.png new file mode 100644 index 000000000..35f1ccb6e Binary files /dev/null and b/.github/readme-banner.png differ diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 000000000..1984ae8ca --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,35 @@ +name: Lint and Format Check + +on: + push: + branches: + - "@invertase/v7-development" + pull_request: + +jobs: + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run ESLint check + run: pnpm run lint:check + + - name: Run Prettier check + run: pnpm run format:check diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 000000000..92372eec0 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,51 @@ +name: Test + +on: + push: + branches: + - "@invertase/v7-development" + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: "20" + check-latest: true + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: latest + + - name: Install dependencies + run: pnpm install + + - name: Build packages + run: pnpm run build + + - name: Install Firebase CLI + run: npm i -g firebase-tools@14.15.2 + + - name: Enable webframeworks experiment + run: firebase experiments:enable webframeworks + + - name: Start Firebase emulator + run: | + firebase emulators:start --only auth --project demo-test & + sleep 15 + # Wait for emulator to be ready + until wget -q --spider http://localhost:9099 2>/dev/null; do + echo "Waiting for emulator to start..." + sleep 2 + done + echo "Emulator is ready" + + - name: Run tests + run: pnpm test diff --git a/.gitignore b/.gitignore index a547bf36d..b2cd68310 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,19 @@ dist dist-ssr *.local +# Angular +.angular +.firebase + +# Next.js +.next + +# Coverage +coverage + +# Firebase +.firebase + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/.opensource/project.json b/.opensource/project.json index 78b425e07..dd02985de 100644 --- a/.opensource/project.json +++ b/.opensource/project.json @@ -1,19 +1,13 @@ { - "name": "FirebaseUI for Web", - - "platforms": [ - "Web" - ], - - "content": "README.md", + "name": "FirebaseUI for Web", - "pages" : { - "LANGUAGES.md": "Supported Languages" - }, - - "related": [ - "firebase/firebaseui-android", - "firebase/firebaseui-ios", - "firebase/firebaseui-web-react" - ] + "platforms": ["Web"], + + "content": "README.md", + + "pages": { + "LANGUAGES.md": "Supported Languages" + }, + + "related": ["firebase/firebaseui-android", "firebase/firebaseui-ios", "firebase/firebaseui-web-react"] } diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..4790318cb --- /dev/null +++ b/.prettierignore @@ -0,0 +1,23 @@ +# Dependencies +node_modules/ +pnpm-lock.yaml +package-lock.json +yarn.lock + +# Build outputs +dist/ +build/ +.angular/ +releases/ + +# Generated files +*.min.js +*.min.css +packages/styles/dist.css + +# Logs +*.log + +# OS generated files +.DS_Store +Thumbs.db diff --git a/packages/firebaseui-core/.prettierrc b/.prettierrc similarity index 56% rename from packages/firebaseui-core/.prettierrc rename to .prettierrc index fa52a3eeb..37702140f 100644 --- a/packages/firebaseui-core/.prettierrc +++ b/.prettierrc @@ -1,8 +1,9 @@ { "semi": true, "trailingComma": "es5", - "singleQuote": true, + "singleQuote": false, "printWidth": 120, "tabWidth": 2, - "useTabs": false + "useTabs": false, + "endOfLine": "auto" } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 407252b40..122002681 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,118 @@ Want to contribute? Great! First, read this page (including the small print at the end). +## Setup + +### Prerequisites + +- [Node.js](https://nodejs.org/) (version 18 or higher recommended) +- [pnpm](https://pnpm.io/) - This workspace uses pnpm for package management + +### Installation + +1. Clone the repository: + ```bash + git clone https://github.com/firebase/firebaseui-web.git + cd firebaseui-web + ``` + +2. Install dependencies: + ```bash + pnpm install + ``` + +### Development Workflow + +This is a monorepo managed with pnpm, containing both `packages` and `examples` sub-directories. + +#### Building + +Build all packages: +```bash +pnpm build +``` + +Build only the packages (excluding examples): +```bash +pnpm build:packages +``` + +#### Testing + +Run all tests: +```bash +pnpm test +``` + +Run tests in watch mode: +```bash +pnpm test:watch +``` + +Run tests for a specific package: +```bash +pnpm --filter= run test +``` + +#### Linting and Formatting + +Check for linting errors: +```bash +pnpm lint:check +``` + +Fix linting errors automatically: +```bash +pnpm lint:fix +``` + +Check for formatting issues: +```bash +pnpm format:check +``` + +Format code automatically: +```bash +pnpm format:write +``` + +### Project Structure + +The project is organized as follows: + +- **`packages/`**: Framework-agnostic and framework-specific packages + - `core`: Framework-agnostic core package providing `initializeUI` and authentication functions + - `translations`: Translation utilities and locale mappings + - `styles`: CSS utility classes and compiled styles + - `react`: React components and hooks + - `angular`: Angular components and DI functionality + - `shadcn`: Shadcn UI components + +- **`examples/`**: Example applications demonstrating usage + - `react`: React example + - `nextjs`: Next.js example + - `nextjs-ssr`: Next.js SSR example + - `angular`: Angular example + - `shadcn`: Shadcn example + +The dependency graph: +``` +core → translations +react → core +angular → core +react → styles +angular → styles +shadcn → react +``` + +### Additional Notes + +- All packages extend the same base `tsconfig.json` file +- Vitest is the preferred testing framework +- The workspace uses pnpm catalogs to ensure dependency version alignment +- Linting is controlled by ESLint via a root flatconfig `eslint.config.ts` file +- Formatting is controlled by Prettier integrated with ESLint via the `.prettierrc` file + ### Before you contribute Before we can use your code, you must sign the [Google Individual Contributor diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 000000000..11ee7b3f1 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,49 @@ +# Firebase UI for Web + +A library for building UIs with Firebase, with first class support for Angular and shad (with Shadcn). + +## General rules + +- The workspace is managed with pnpm. Always use pnpm commands for installation and execution. +- This is a monorepo, with `packages` and `examples` sub-directories. +- Linting is controlled by ESLint, via a root flatconfig `eslint.config.ts` file. Run `pnpm lint:check` for linting errors. +- Formatting is controlled vi Prettier integrated with ESLint via the `.prettierrc` file. Run `pnpm format:check` for formatting errors. +- The workspace uses pnpm cataloges to ensure dependency version alignment. If a dependency exists twice, it should be cataloged. +- Tests can be run for the entire workspace via `pnpm test` or scoped to a package via `test:`. + +## Structure + +The project structure is setup in a way which provides a framework agnostic set of packages; `core`, `translations` and `styles`. + +- `core`: The main entry-point to the package via `initalizeUI`. Firebase UI provides it's own functional exports, which when called wraps the Firebase JS SDK functionality, however manages state, translated error handling and behaviors (configurable by the user). +- `translations`: A package exporting utilities and translation mappings for various languages, which `core` depends on. +- `styles`: A package providing CSS utility classes which frameworks can use to provide consistent styling. The `styles` package works for existing Tailwind users, but also exports a distributable file with compiled "tailwindless" CSS. The CSS styles heavily depend on CSS variables for customization. + +Additionally, framework specific packages depend on these agnostic packages to offer full integration with the frameworks: + +- `react`: Exposes React UI components (in the form of screens, full page components, or forms, the bare-bones UI forms) & hooks, enabling users to easily build their own UIs or consume the built in ones. +- `angular`: Exposes Angular UI components (in the form of screens, full page components, or forms, the bare-bones UI forms) & DI functionality, enabling users to easily build their own UIs or consume the built in ones. This package depends directly on AngularFire. + +The dependency graph is: + +``` +graph TD + core --> translations; + react --> core; + angular --> core; + angular --> styles; + react --> styles; + shadcn --> react; +``` + +## Misc + +- All packages extend the same base `tsconfig.json` file. +- Where possible, prefer Vitest testing framework. + +## Additional Context + +- `core`: @./packages/core/GEMINI.md +- `react`: @./packages/react/GEMINI.md +- `styles`: @./packages/styles/GEMINI.md +- `translations`: @./packages/translations/GEMINI.md diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 000000000..4ffceeb89 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,377 @@ +# Migration Guide + +## Overview + +FirebaseUI for Web has been completely rewritten from the ground up. The previous version (v6) was a single JavaScript package that provided a monolithic authentication UI solution. The new version (v7) represents a modern, modular architecture that separates concerns and provides better flexibility for developers. + +### Architecture Changes + +**Previous Version (v6):** +- Single JavaScript package (`firebaseui`) that handled both authentication logic and UI rendering +- Tightly coupled to DOM manipulation and jQuery-like patterns +- Limited customization options +- Framework-agnostic but with a rigid structure + +**New Version (v7):** +- **Framework-agnostic core package** (`@invertase/firebaseui-core`): Contains all authentication logic, state management, behaviors, and utilities without any UI dependencies +- **Framework-specific packages**: Separate packages for React (`@invertase/firebaseui-react`), Angular (`@invertase/firebaseui-angular`), and Shadcn components +- **Supporting packages**: Separate packages for styles (`@invertase/firebaseui-styles`) and translations (`@invertase/firebaseui-translations`) +- **Composable architecture**: Components are designed to be composed together, allowing for greater flexibility +- **Modern patterns**: Uses reactive stores (nanostores), TypeScript throughout, and modern framework patterns + +### Migration Path + +**Important:** There is no direct migration path from v6 to v7. This is a complete rewrite with a fundamentally different architecture and API. You cannot simply update the package version and expect your existing code to work. + +Instead, you will need to: +1. Remove the old `firebaseui` package +2. Install the appropriate new package(s) for your framework +3. Rewrite your authentication UI components using the new API +4. Update your configuration and styling approach + +### What This Guide Covers + +This migration guide maps features and concepts from the old [v6 version](https://github.com/firebase/firebaseui-web/tree/v6) to the new v7 rewrite, helping you understand: +- How authentication methods translate between versions +- How configuration options map to the new behaviors system +- How UI customization works in the new architecture +- How to achieve similar functionality with the new component-based approach + +## Migrating + +### 1. Installing Packages + +First, remove the old `firebaseui` package and install the appropriate new package(s) for your framework: + +
+ React + + Remove the old package: + ```bash + npm uninstall firebaseui + ``` + + Install the new React package: + ```bash + npm install @invertase/firebaseui-react + ``` + + The package automatically includes the core package as a dependency, so you don't need to install `@invertase/firebaseui-core` separately. +
+ +
+ Angular + + Remove the old package: + ```bash + npm uninstall firebaseui + ``` + + Install the new Angular package: + ```bash + npm install @invertase/firebaseui-angular + ``` + + **Note:** The Angular package requires [AngularFire](https://github.com/angular/angularfire) to be installed and configured first. +
+ +
+ Shadcn + + Remove the old package: + ```bash + npm uninstall firebaseui + ``` + + Ensure you have [installed and setup](https://ui.shadcn.com/docs/installation) Shadcn in your project first. + + Add the Firebase UI registry to your `components.json`: + ```json + { + ... + "registries": { + "@firebase": "https://fir-ui-shadcn-registry.web.app/r/{name}.json" + } + } + ``` + + Then add components as needed: + ```bash + npx shadcn@latest add @firebase/sign-in-auth-screen + ``` + + This will automatically install all required dependencies. +
+ +### 2. Initialization + +The initialization process is fundamentally different between v6 and v7. + +**Old Way (v6):** +```javascript +// Initialize the FirebaseUI Widget using Firebase. +var ui = new firebaseui.auth.AuthUI(firebase.auth()); + +// The start method will wait until the DOM is loaded. +ui.start('#firebaseui-auth-container', uiConfig); +``` + +**New Way (v7):** + +
+ React (+Shadcn) + + ```tsx + import { initializeApp } from 'firebase/app'; + import { initializeUI } from '@invertase/firebaseui-core'; + import { FirebaseUIProvider } from '@invertase/firebaseui-react'; + + const app = initializeApp({ ... }); + + const ui = initializeUI({ + app, + // behaviors and other configuration go here + }); + + function App() { + return ( + + {/* Your app components */} + + ); + } + ``` +
+ +
+ Angular + + ```ts + import { provideFirebaseApp, initializeApp } from '@angular/fire/app'; + import { initializeUI } from '@invertase/firebaseui-core'; + + export const appConfig: ApplicationConfig = { + providers: [ + provideFirebaseApp(() => initializeApp({ ... })), + provideFirebaseUI((apps) => initializeUI({ + app: apps[0], + // behaviors and other configuration go here + })), + ] + }; + ``` +
+ +### 3. Configuration Options Migration + +The following table maps v6 configuration options to their v7 equivalents: + +| v6 Option | Migration Guide | +|----------|------------------| +| `autoUpgradeAnonymousUsers` | **Use the `autoUpgradeAnonymousUsers` behavior.**

Import `autoUpgradeAnonymousUsers` from `@invertase/firebaseui-core` and add it to your behaviors array:
`behaviors: [autoUpgradeAnonymousUsers({ async onUpgrade(ui, oldUserId, credential) { /* handle merge */ } })]`

The `onUpgrade` callback replaces the `signInFailure` callback for handling merge conflicts. | +| `callbacks` | **Use component props/events instead.**

v6 callbacks like `signInSuccessWithAuthResult`, `signInFailure`, etc. are replaced by component event handlers:

**React:** `onSignIn={(user) => { ... }}`, `onSignUp={(user) => { ... }}`, `onForgotPasswordClick={() => { ... }}`

**Angular:** `(signIn)="onSignIn($event)"`, `(signUp)="onSignUp($event)"`, `(forgotPassword)="onForgotPassword()"`

These are passed directly to the components you use, giving you more control over the flow. | +| `credentialHelper` | **Use the `oneTapSignIn` behavior.**

The credential helper (Account Chooser) from v6 is replaced by Google One Tap in v7. Import `oneTapSignIn` from `@invertase/firebaseui-core` and add it to your behaviors array:
`behaviors: [oneTapSignIn({ clientId: '...', autoSelect: false, cancelOnTapOutside: false })]`

**Note:** This requires Google Sign In to be enabled in Firebase Console. Get the `clientId` from "Web SDK configuration" settings. See [Google One Tap documentation](https://developers.google.com/identity/gsi/web/reference/js-reference) for all configuration options. | +| `queryParameterForSignInSuccessUrl` | **Handle in your routing logic.**

v7 doesn't have built-in URL parameter handling. Instead, handle redirects in your `onSignIn` callback by reading URL params:

**React/Angular:** `const urlParams = new URLSearchParams(window.location.search);`
`const redirectUrl = urlParams.get('signInSuccessUrl') || '/dashboard';`
`window.location.href = redirectUrl;`

**Angular (with Router):** Use `ActivatedRoute` to read query params and `Router` to navigate. | +| `queryParameterForWidgetMode` | **Not applicable.**

v7 doesn't use widget modes. Instead, you explicitly render the components you need:

**React:** ``, ``

**Angular:** ``, `` | +| `signInFlow` | **Use provider strategy behaviors.**

Replace `signInFlow: 'redirect'` with:
`import { providerRedirectStrategy } from '@invertase/firebaseui-core'`
`behaviors: [providerRedirectStrategy()]`

Replace `signInFlow: 'popup'` with:
`import { providerPopupStrategy } from '@invertase/firebaseui-core'`
`behaviors: [providerPopupStrategy()]`

**Note:** `popup` is the default strategy in v7. | +| `immediateFederatedRedirect` | **Control via component rendering.**

v7 doesn't have this option. Instead, you control whether to show OAuth buttons or redirect immediately by conditionally rendering components:

**React:** `{singleProvider ? : }`

**Angular:** Use `*ngIf` or `@if` to conditionally render `` or use `Router` to navigate directly. | +| `signInOptions` | **Use OAuth button components directly.**

v6's `signInOptions` array is replaced by explicitly rendering the OAuth provider buttons you want:

**React:** Import `GoogleSignInButton`, `FacebookSignInButton`, `AppleSignInButton` from `@invertase/firebaseui-react` and render them inside ``.

**Angular:** Import `GoogleSignInButtonComponent`, `FacebookSignInButtonComponent`, `AppleSignInButtonComponent` from `@invertase/firebaseui-angular` and use selectors ``, ``, `` inside ``.

The order you place the buttons determines their display order. | +| `signInSuccessUrl` | **Handle in `onSignIn` callback.**

Instead of a configuration option, handle redirects in your component's `onSignIn` callback:

**React:** ` { window.location.href = '/dashboard'; }} />`

**Angular:** `` with `onSignIn(user: User) { this.router.navigate(['/dashboard']); }`

*Required in v6 when `signInSuccessWithAuthResult` callback is not used or returns `true`. | +| `tosUrl` | **Pass via `policies` prop.**

**React:** Pass `policies={{ termsOfServiceUrl: 'https://example.com/tos', privacyPolicyUrl: 'https://example.com/privacy' }}` to ``.

**Angular:** Use `provideFirebaseUIPolicies(() => ({ termsOfServiceUrl: '...', privacyPolicyUrl: '...' }))`.

The policies are automatically rendered in auth forms and screens. | +| `privacyPolicyUrl` | **Pass via `policies` prop.**

See `tosUrl` above - both URLs are passed together in the `policies` object. | +| `adminRestrictedOperation` | **Handle in your UI logic.**

v7 doesn't have built-in support for this GCIP-specific feature. You'll need to:
(1) Check if sign-up is disabled in your Firebase project settings
(2) Handle the `auth/admin-restricted-operation` error in your error handling
(3) Display appropriate messaging to users when sign-up attempts are blocked

You can check for this error in your `onSignUp` or form error handlers and display custom UI accordingly. | + +### Additional Configuration + +#### Configure Phone Provider + +In v6, phone authentication country code configuration was handled via the `signInOptions` configuration. In v7, this is controlled by the `countryCodes` behavior. + +**v6:** +```javascript +signInOptions: [ + { + provider: firebase.auth.PhoneAuthProvider.PROVIDER_ID, + defaultCountry: 'GB', + whitelistedCountries: ['GB', 'US', 'FR'] + } +] +``` + +**v7:** +Use the `countryCodes` behavior to configure allowed countries and default country: + +```ts +import { countryCodes } from '@invertase/firebaseui-core'; + +const ui = initializeUI({ + app, + behaviors: [ + countryCodes({ + allowedCountries: ['GB', 'US', 'FR'], // only allow Great Britain, USA and France + defaultCountry: 'GB', // GB is default + }), + ], +}); +``` + +The `countryCodes` behavior affects all phone authentication flows, including regular phone sign-in and multi-factor authentication (MFA) enrollment. The `CountrySelector` component automatically uses these settings to filter and display available countries. + +#### Sign In Flows + +In v6, you configured the sign-in flow (popup vs redirect) via the `signInFlow` configuration option. In v7, this is controlled by provider strategy behaviors. + +**v6:** +```javascript +var uiConfig = { + signInFlow: 'popup', // or 'redirect' + // ... +}; +``` + +**v7:** +Use the `providerPopupStrategy` (default) or `providerRedirectStrategy` behaviors: + +```ts +import { providerPopupStrategy, providerRedirectStrategy } from '@invertase/firebaseui-core'; + +// For popup flow (default) +const ui = initializeUI({ + app, + behaviors: [providerPopupStrategy()], +}); + +// For redirect flow +const ui = initializeUI({ + app, + behaviors: [providerRedirectStrategy()], +}); +``` + +**Note:** The popup strategy is the default in v7. If you don't specify a strategy, popup will be used. The strategy applies to all OAuth providers (Google, Facebook, Apple, etc.). + +#### Multi-tenancy Support + +v7 supports multi-tenancy by allowing you to pass a custom `Auth` instance with a `tenantId` configured to `initializeUI`. + +**v6:** +```javascript +var tenantAuth = firebase.auth(app).tenantId = 'tenant-id'; +var ui = new firebaseui.auth.AuthUI(tenantAuth); +``` + +**v7:** + +**React:** +```tsx +import { getAuth } from 'firebase/auth'; +import { initializeUI } from '@invertase/firebaseui-core'; + +const auth = getAuth(app); +auth.tenantId = 'tenant-id'; + +const ui = initializeUI({ + app, + auth, // Pass the auth instance with tenantId +}); +``` + +**Angular:** +```ts +import { getAuth } from 'firebase/auth'; +import { initializeUI } from '@invertase/firebaseui-core'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideFirebaseApp(() => initializeApp({ ... })), + provideAuth(() => { + const auth = getAuth(); + auth.tenantId = 'tenant-id'; + return auth; + }), + provideFirebaseUI((apps) => { + const auth = getAuth(apps[0]); + auth.tenantId = 'tenant-id'; + return initializeUI({ + app: apps[0], + auth, + }); + }), + ], +}; +``` + +#### Enabling Anonymous User Upgrade + +In v6, anonymous user upgrade was configured via the `autoUpgradeAnonymousUsers` option. In v7, this is handled by the `autoUpgradeAnonymousUsers` behavior. + +**v6:** +```javascript +var uiConfig = { + autoUpgradeAnonymousUsers: true, + callbacks: { + signInFailure: function(error) { + // Handle merge conflicts + } + } +}; +``` + +**v7:** +Use the `autoUpgradeAnonymousUsers` behavior: + +```ts +import { autoUpgradeAnonymousUsers } from '@invertase/firebaseui-core'; + +const ui = initializeUI({ + app, + behaviors: [ + autoUpgradeAnonymousUsers({ + async onUpgrade(ui, oldUserId, credential) { + // Handle account merge logic + // e.g., migrate data from oldUserId to new user + console.log(`Upgrading anonymous user ${oldUserId} to ${credential.user.uid}`); + }, + }), + ], +}); +``` + +The behavior automatically upgrades anonymous users when they sign in with any credential (email/password, OAuth, phone, etc.). The `onUpgrade` callback is optional and allows you to perform custom logic during the upgrade, such as migrating user data. + +#### Handling Anonymous User Upgrade Merge Conflicts + +In v6, merge conflicts (when an account already exists with the same credential) were handled via the `signInFailure` callback. In v7, the upgrade process handles this differently. + +**How it works in v7:** + +When an anonymous user attempts to sign in with a credential that's already associated with an existing account, Firebase Auth will automatically link the anonymous account to the existing account. The upgrade process: + +1. **Stores the anonymous user ID** in `localStorage` before redirect flows (for OAuth redirects) +2. **Automatically links** the anonymous account to the existing account +3. **Calls the `onUpgrade` callback** (if provided) with both the old anonymous user ID and the new credential +4. **Cleans up** the stored anonymous user ID from `localStorage` + +**Example:** +```ts +const ui = initializeUI({ + app, + behaviors: [ + autoUpgradeAnonymousUsers({ + async onUpgrade(ui, oldUserId, credential) { + // oldUserId is the anonymous user's ID + // credential.user.uid is the existing account's ID after linking + + // Migrate any data from the anonymous account + await migrateUserData(oldUserId, credential.user.uid); + + // The anonymous account is now linked to the existing account + // The user is signed in with their existing account + }, + }), + ], +}); +``` + +**Note:** If a merge conflict occurs and the linking fails (e.g., due to account linking restrictions), Firebase Auth will throw an error that you can handle in your error handling logic. The `onUpgrade` callback will only be called if the upgrade is successful. + diff --git a/README.md b/README.md index 33afe5bed..3c50dbf33 100644 --- a/README.md +++ b/README.md @@ -1,571 +1,2189 @@ +Banner + # FirebaseUI for Web -This repository contains the source code for the FirebaseUI for Web project rewrite, focused on providing Authentication components for popular JavaScript frameworks. +Firebase UI for Web brings out-of-the-box components for Firebase for your favourite frameworks: -## Installation +- Support for [React](https://react.dev/), [Shadcn](https://ui.shadcn.com/) and [Angular](https://angular.dev/). +- Composable authentication components; Email/Password Sign Up/In, Forgot Password, Email Link, Phone Auth, OAuth, Multi-Factor and more. +- Configure the behavior of internal logic and UI via behaviors. +- Framework agnostic core package; bring your own UI. +- Built-in localization via translations. -FirebaseUI requires the `firebase` package to be installed: +## Migration -```bash -npm install firebase -``` +Firebase UI is a complete rewrite to support modern languages and frameworks. You can view the previous [v6 version](https://github.com/firebase/firebaseui-web/tree/v6) on GitHub. -**Note**: Since the packages are not yet published to npm, you must manually install them from GitHub releases. Once published, these steps will be simplified. +If you are looking to migrate, please check the [MIGRATION.md](MIGRATION.md) guide. -###  Framework-specific Installation +## Table of contents -Packages have been created for both `React` and `Angular`. For now, they're only available as direct downloads from this repository. Add the following to your `package.json` file: +- [Getting Started](#getting-started) +- [Styling](#styling) +- [Behaviors](#behaviors) +- [Translations](#translations) +- [Reference API](#reference) +- [Bring your own UI](#bring-your-own-ui) -
- React +## Getting Started -```json -{ - "dependencies": { - "@firebase-ui/react": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-react-0.0.1.tgz", - "@firebase-ui/core": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-core-0.0.1.tgz", - "@firebase-ui/styles": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-styles-0.0.1.tgz", - "@firebase-ui/translations": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-translations-0.0.1.tgz" - } -} -``` +To get started, make sure that the [`firebase`](https://www.npmjs.com/package/firebase) package is installed in your project: -
+```bash +npm install firebase +``` -
- Angular +Once installed, setup Firebase in your project ensuring you have configured your Firebase instance via `initializeApp`: -FirebaseUI for Angular depends on the [AngularFire](https://github.com/angular/angularfire) package: +```ts +import { initializeApp } from 'firebase/app'; -```json -{ - "dependencies": { - "@angular/fire": "^19.1.0", - "@firebase-ui/angular": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-angular-0.0.1.tgz", - "@firebase-ui/core": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-core-0.0.1.tgz", - "@firebase-ui/styles": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-styles-0.0.1.tgz", - "@firebase-ui/translations": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-translations-0.0.1.tgz" - } -} +const app = initializeApp({ ... }); ``` -
+Next, follow the framework specific installation steps, for either React, Shadcn or Angular: -## Getting Started +
+ React -FirebaseUI requires that your Firebase app is setup following the [Getting Started with Firebase](https://firebase.google.com/docs/web/setup) flow for Web: + Install the `@invertase/firebaseui-react` package: -### Initialization + ```bash + npm install @invertase/firebaseui-react + ``` -```ts -import { initializeApp } from 'firebase/app'; + Alongside your Firebase configuration, import the `initializeUI` function and pass your configured Firebase App instance: -const app = initializeApp({ ... }); -``` + ```ts + import { initializeApp } from 'firebase/app'; + import { initializeUI } from '@invertase/firebaseui-core'; -Next, setup and configure FirebaseUI, import the `initializeUI` function from `@firebase-ui/core`: + const app = initializeApp({ ... }); -```ts -import { initializeUI } from "@firebase-ui/core"; + const ui = initializeUI({ + app, + }); + ``` -const ui = initializeUI(); -``` + Once configured, provide the `ui` instance to your application by wrapping it within the `FirebaseUIProvider` component: + + ```tsx + import { FirebaseUIProvider } from '@invertase/firebaseui-react'; -> To learn more about configuring FirebaseUI, view the [configuration](#configuration) section. + function App() { + return ( + + ... + + ); + } + ``` -### Framework Setup + Ensure your application includes the bundled styles for Firebase UI (see [styling](#styling) for additional info). -
- React + ```css + @import "@invertase/firebaseui-styles/dist.min.css"; + /* Or for tailwind users */ + @import "@invertase/firebaseui-styles/tailwind"; + ``` -FirebaseUI for React requires that your application be wrapped in the `ConfigProvider`, providing the initialized UI configuration. React expects the `FirebaseApp` instance be provided to the `initializeUI` configuration: + That's it 🎉 You can now import components and start building: -```tsx -import { initializeApp } from 'firebase/app'; -import { initializeUI } from "@firebase-ui/core"; -import { ConfigProvider } from '@firebase-ui/react'; - -const app = initializeApp({ .. }); -const ui = initializeUI({ app }); - -createRoot(document.getElementById("root")!).render( - - - - - -); -``` + ```tsx + import { SignInAuthScreen } from '@invertase/firebaseui-react'; + + export function MySignInPage() { + return ( + <> +
Welcome
+ { ... }} /> + + ) + } + ``` + View the [reference API](#reference) for a full list of components.
- Angular + Shadcn -FirebaseUI depends on [AngularFire](https://github.com/angular/angularfire) being configured to inject Firebase Auth into your Angular application. Additionally, the `provideFirebaseUI` function is required to inject FirebaseUI into your application: + Firstly, ensure you have [installed and setup](https://ui.shadcn.com/docs/installation) Shadcn in your project. -```tsx -import { provideFirebaseApp, initializeApp } from '@angular/fire/app'; -import { provideAuth, getAuth } from '@angular/fire/auth'; -import { provideFirebaseUI } from '@firebase-ui/angular'; -import { initializeUI } from '@firebase-ui/core'; + Once configured, add the `@firebase` registry to your `components.json` file: -export const appConfig: ApplicationConfig = { - providers: [ - provideFirebaseApp(() => initializeApp({ .. })), - provideAuth(() => getAuth()), - provideFirebaseUI(() => initializeUI({})) - .. - ], - .. -} -``` + ```json + { + ... + "registries": { + "@firebase": "https://fir-ui-shadcn-registry.web.app/r/{name}.json" + } + } + ``` -
+ Next, add a Firebase UI component from the registry, e.g. -### Styling + ```bash + npx shadcn@latest add @firebase/sign-in-auth-screen + ``` -Next, import the CSS styles for the FirebaseUI project. + This will automatically install any required dependencies. -If you are using [TailwindCSS](https://tailwindcss.com/), import the base CSS from the `@firebase-ui/styles` package after your Tailwind import: + Alongside your Firebase configuration, import the `initializeUI` function and pass your configured Firebase App instance: -```css -@import "tailwindcss"; -@import "@firebase-ui/styles/src/base.css"; -``` + ```ts + import { initializeApp } from 'firebase/app'; + import { initializeUI } from '@invertase/firebaseui-core'; -If you are not using Tailwind, import the distributable CSS in your project: + const app = initializeApp({ ... }); -```css -@import "@firebase-ui/styles/dist.css"; -``` + const ui = initializeUI({ + app, + }); + ``` + + Once configured, provide the `ui` instance to your application by wrapping it within the `FirebaseUIProvider` component: + + ```tsx + import { FirebaseUIProvider } from '@invertase/firebaseui-react'; -To learn more about theming, view the [theming](#theming) section. + function App() { + return ( + + ... + + ); + } + ``` -### Authentication Components + That's it 🎉 You can now import components and start building: -FirebaseUI provides a number of opinionated components designed to drop into your application which handle common user flows, such as signing in or registration. + ```tsx + import { SignInAuthScreen } from '@/components/sign-in-auth-screen'; -### Sign-in + export function MySignInPage() { + return ( + <> +
Welcome
+ { ... }} /> + + ) + } + ``` -Allows users to sign in with an email and password: + View the [reference API](#reference) for a full list of components. +
- React + Angular -```tsx -import { SignInAuthScreen } from "@firebase-ui/react"; + The Angular project requires that [AngularFire](https://github.com/angular/angularfire) is setup and configured before using Firebase UI. -function App() { - return ; -} -``` + Once you have provided the Firebase App instance to your application using `provideFirebaseApp`, install the Firebase UI for Angular package: -Props: `onForgotPasswordClick` / `onRegisterClick` + ```bash + npm install @invertase/firebaseui-angular + ``` -Additionally, allow the user to sign in with an OAuth provider by providing children: + Alongside your existing providers, add the `provideFirebaseUI` provider, returning a new Firebase UI instance via `initializeUI`: -```tsx -import { SignInAuthScreen, GoogleSignInButton } from "@firebase-ui/react"; + ```ts + import { provideFirebaseApp, initializeApp } from '@angular/fire/app'; + import { initializeUI } from '@invertase/firebaseui-core'; -function App() { - return ( - - - - ); -} -``` + export const appConfig: ApplicationConfig = { + providers: [ + provideFirebaseApp(() => initializeApp({ ... })), + provideFirebaseUI((apps) => initializeUI({ app: apps[0] })), + ] + }; + ``` + + Ensure your application includes the bundled styles for Firebase UI (see [styling](#styling) for additional info). + + ```css + @import "@invertase/firebaseui-styles/dist.min.css"; + /* Or for tailwind users */ + @import "@invertase/firebaseui-styles/tailwind"; + ``` + + That's it 🎉 You can now import components and start building: + + ```tsx + import { Component } from "@angular/core"; + import { SignInAuthScreenComponent } from "@invertase/firebaseui-angular"; + + @Component({ + selector: "sign-in-route", + standalone: true, + imports: [CommonModule, SignInAuthScreenComponent], + template: ` +
Sign In
+ + `, + }) + export class SignInRoute { + onSignIn(user: User) { + // ... + } + } + ``` + View the [reference API](#reference) for a full list of components.
-
- Angular +## Styling -```tsx -import { SignUpAuthScreenComponent } from "@firebase-ui/angular"; +Firebase UI provides out-of-the-box styling via CSS, and provides means to customize the UI to align with your existing application or guidelines. -@Component({ - selector: "app-root", - imports: [SignUpAuthScreenComponent], - template: ``, -}) -export class AppComponent {} -``` +> Note: if you are using Shadcn this section does not apply. All styles are inherited from your Shadcn configuration. -
+Ensure your application imports the Firebase UI CSS file. This can be handled a number of ways depending on your setup: + +### CSS Bundling -## Configuration +If your bundler supports importing CSS files from node_modules: -The initializeUI function accepts an options object that allows you to customize FirebaseUI’s behavior. +Via JS: -### Type Definition +```ts +import '@invertase/firebaseui-styles/dist.min.css'; +``` -```js -type FirebaseUIConfigurationOptions = { - app: FirebaseApp; - locale?: Locale | undefined; - translations?: RegisteredTranslations[] | undefined; - behaviors?: Partial>[] | undefined; - recaptchaMode?: 'normal' | 'invisible' | undefined; -}; +Via CSS: + +```css +@import "@invertase/firebaseui-styles/dist.min.css"; ``` -**App**: The initialized Firebase app instance. This is required. +### Tailwind -**Locale**: Optional locale string to override the default language (e.g., 'en', 'fr', 'es'). +If you are using [Tailwind CSS](https://tailwindcss.com/), add the Tailwind specific CSS file: -**Translations**: Add or override translation strings for labels, prompts, and errors. +```css +@import "tailwindcss"; +@import "@invertase/firebaseui-styles/tailwind"; +``` -**Behaviors**: Customize UI behavior such as automatic sign-in or error handling. +### Via CDN -**RecaptchaMode**:Set the reCAPTCHA mode for phone auth (default is 'normal'). +If none of these options apply, include the CSS file via a CDN: -## Theming +```html + + + +``` -FirebaseUI provides a basic default theme out of the box, however the theme can be customized to match your application's design. +### Theming -The package uses CSS Variables, which can be overridden in your application's CSS. Below is a list of all available variables: +Out of the box, Firebase UI provides a neutral light and dark theme with some opinionated styling (colors, border radii etc). These are all controlled via CSS variables, allowing you to update these at will to match any existing UI design guidelines. To modify the variables, override the following CSS variables: ```css :root { /* The primary color is used for the button and link colors */ - --fui-primary: var(--color-black); + --fui-primary: ...; /* The primary hover color is used for the button and link colors when hovered */ - --fui-primary-hover: --alpha(var(--fui-primary) / 85%); + --fui-primary-hover: ...; /* The primary surface color is used for the button text color */ - --fui-primary-surface: var(--color-white); + --fui-primary-surface: ...; /* The text color used for body text */ - --fui-text: var(--color-black); + --fui-text: ...; /* The muted text color used for body text, such as subtitles */ - --fui-text-muted: var(--color-gray-800); + --fui-text-muted: ...; /* The background color of the cards */ - --fui-background: var(--color-white); + --fui-background: ...; /* The border color used for none input fields */ - --fui-border: var(--color-gray-200); + --fui-border: ...; /* The input color used for input fields */ - --fui-input: var(--color-gray-300); + --fui-input: ...; /* The error color used for error messages */ - --fui-error: var(--color-red-500); + --fui-error: ...; /* The radius used for the input fields */ - --fui-radius: var(--radius-sm); + --fui-radius: ...; /* The radius used for the cards */ - --fui-radius-card: var(--radius-xl); + --fui-radius-card: ...; } ``` -The default values are based on the [TailwindCSS](https://tailwindcss.com/docs/theme) theme variables. You can override these values with other TailwindCSS theme variables, or custom CSS values. +## Behaviors + +Out of the box, Firebase UI applies sensible default behaviors for how the UI should handle specific scenarios which may occur during user flows. You can however customize this behavior by modifying your `initializeUI` to provide an array of "behaviors", for example: + +```ts +import { requireDisplayName } from '@invertase/firebaseui-core'; + +const ui = initializeUI({ + app, + behaviors: [ + requireDisplayName(), + ], +}); +``` -## FirebaseUI Core Integration +#### `autoAnonymousLogin` -`@firebase-ui/core` is a framework-agnostic layer that manages the complete lifecycle of Firebase Authentication flows. It exposes a reactive store via nanostores that can be wrapped and adapted into any JavaScript framework such as React, Angular, Vue, Svelte, or SolidJS to name a few. +The `autoAnonymousLogin` behavior will automatically sign users in via [anonymous authentication](https://firebase.google.com/docs/auth/web/anonymous-auth) when initialized. Whilst authenticating, the Firebase UI state will be set to "loading", allowing you to block the loading of the application if you wish. -### What FirebaseUI Core Provides +```ts +import { autoAnonymousLogin } from '@invertase/firebaseui-core'; -- Manages Firebase Authentication flows (sign-in, sign-out, linking, etc.) +const ui = initializeUI({ + app, + behaviors: [autoAnonymousLogin()], +}); +``` -- Reactive UI state via [nanostores](https://github.com/nanostores/nanostores) +#### `autoUpgradeAnonymousUsers` -- Form schemas using [Zod](https://zod.dev/) +The `autoUpgradeAnonymousUsers` behavior will automatically upgrade a user who is anonymously authenticated with your application upon a successful sign in (including OAuth). You can optionally provide a callback to handle an upgrade (such as merging account data). During the async callback, the UI will stay in a pending state. -- Pluggable behaviors (e.g. autoAnonymousLogin) +```ts +import { autoUpgradeAnonymousUsers } from '@invertase/firebaseui-core'; -- i18n and translations +const ui = initializeUI({ + app, + behaviors: [autoUpgradeAnonymousUsers({ + async onUpgrade(ui, oldUserId, credential) { + // Some account upgrade logic. + } + })], +}); +``` -- Error parsing and localization +#### `recaptchaVerification` -#### Initialize the Core +The `recaptchaVerification` behavior allows you to customize how the [reCAPTCHA provider](https://firebase.google.com/docs/app-check/web/recaptcha-provider) is rendered during some UI flows (such as Phone Authentication). -Call initializeUI() with your Firebase app and configuration options: +By default, the reCAPTCHA UI will be rendered in "invisible" mode. To override this: -```js -import { initializeUI } from '@firebase-ui/core'; +```ts +import { recaptchaVerification } from '@invertase/firebaseui-core'; const ui = initializeUI({ - app: firebaseApp, - .. + app, + behaviors: [recaptchaVerification({ + size: "compact", // "normal" | "invisible" | "compact" + theme: "dark", // "light" | "dark" + })], }); ``` -Configuration Type: +#### `providerRedirectStrategy` -```js -type FirebaseUIConfigurationOptions = { - app: FirebaseApp; - locale?: Locale | undefined; - translations?: RegisteredTranslations[] | undefined; - behaviors?: Partial>[] | undefined; - recaptchaMode?: 'normal' | 'invisible' | undefined; -}; -``` +The `providerRedirectStrategy` behavior redirects any external provider authentication (e.g. OAuth) via a redirect flow. -#### Firebase Authentication Flows +```ts +import { providerRedirectStrategy } from '@invertase/firebaseui-core'; -**signInWithEmailAndPassword**: Signs in the user based on an email/password credential. +const ui = initializeUI({ + app, + behaviors: [providerRedirectStrategy()], +}); +``` -- _ui_: FirebaseUIConfiguration -- _email_: string -- _password_: string +#### `providerPopupStrategy` -**createUserWithEmailAndPassword**: Creates a user account based on an email/password credential. +The `providerPopupStrategy` behavior causes any external provider authentication (e.g. OAuth) to be handled via a popup window. This is the default strategy. -- _ui_: FirebaseUIConfiguration -- _email_: string -- _password_: string +```ts +import { providerPopupStrategy } from '@invertase/firebaseui-core'; -**signInWithPhoneNumber**: Signs in the user based on a provided phone number, using ReCaptcha to verify the sign-in. +const ui = initializeUI({ + app, + behaviors: [providerPopupStrategy()], +}); +``` -- _ui_: FirebaseUIConfiguration -- _phoneNumber_: string -- _recaptchaVerifier_: string +#### `oneTapSignIn` -**confirmPhoneNumber**: Verifies the phonenumber credential and signs in the user. +The `oneTapSignIn` behavior triggers the [Google One Tap](https://developers.google.com/identity/gsi/web/guides/features) experience to render. -- _ui_: FirebaseUIConfiguration -- _confirmationResult_: [ConfirmationResult](https://firebase.google.com/docs/reference/node/firebase.auth.ConfirmationResult) -- _verificationCode_: string +Note: This behavior requires that Google Sign In is enabled as an authentication method on the Firebase Console. Once enabled, you can obtain the required `clientId` via the "Web SDK configuration" settings on the Console. -**sendPasswordResetEmail**: Sends password reset instructions to an email account. +The One Tap popup can be additionally configured via this behavior: -- _ui_: FirebaseUIConfiguration -- _email_: string +```ts +import { oneTapSignIn } from '@invertase/firebaseui-core'; -**sendSignInLinkToEmail**: Send an sign-in links to an email account. +const ui = initializeUI({ + app, + behaviors: [oneTapSignIn({ + clientId: "...", // required - from Firebase Console under Google provider + autoSelect: false, // optional + cancelOnTapOutside: false, // optional + })], +}); +``` -- _ui_: FirebaseUIConfiguration -- _email_: string +See https://developers.google.com/identity/gsi/web/reference/js-reference for a full list of configuration options. -**signInWithEmailLink**: Signs in with the user with the email link. If `autoUpgradeAnonymousCredential` then a pending credential will be handled. +#### `requireDisplayName` -- _ui_: FirebaseUIConfiguration -- _email_: string -- _link_: string +The `requireDisplayName` behavior configures Firebase UI to display a required "Display Name" input box in the UI, which is applied to the users account during sign up flows. -**signInAnonymously**: Signs in as an anonymous user. +If you are not using pre-built components, the `createUserWithEmailAndPassword` function from Firebase UI will throw if a display name is not provided. -- _ui_: FirebaseUIConfiguration +```ts +import { requireDisplayName } from '@invertase/firebaseui-core'; -**signInWithOAuth**: Signs in with a provider such as Google via a redirect link. If `autoUpgradeAnonymousCredential` then the account will upgraded. +const ui = initializeUI({ + app, + behaviors: [requireDisplayName()], +}); +``` -- _ui_: FirebaseUIConfiguration -- _provider_: [AuthProvider](https://firebase.google.com/docs/reference/node/firebase.auth.AuthProvider) +#### `countryCodes` -**completeEmailLinkSignIn**: Completes the signing process based on a user signing in with an email link. +The `countryCodes` behavior controls how country codes are consumed throughout your application, for example during Phone Authentication flows when selecting a phone numbers country code. -- _ui_: FirebaseUIConfiguration -- _currentUrl_: string +```ts +import { countryCodes } from '@invertase/firebaseui-core'; -#### Provide a Store via Context +const ui = initializeUI({ + app, + behaviors: [countryCodes({ + allowedCountries: ['GB', 'US', 'FR'], // only allow Great Britain, USA and France + defaultCountry: 'GB', // GB is default + })], +}); +``` -Using the returned `FirebaseUIConfiguration`, it is reccomended to use local context/providers/dependency-injection to expose the FirebaseUIConfiguration to the application. Here is an example context wrapper which accepts the configuration as a `ui` parameter: +## Translations -```js -/** Creates a framework-agnostic context for Firebase UI configuration **/ -export function createFirebaseUIContext(initialConfig) { - let config = initialConfig; - const subscribers = new Set(); +> Note: Firebase UI currently only provides English (en-US) translations out of the box. - return { - /** Retrieve current config **/ - getConfig() { - return config; - }, +Firebase UI provides a mechanism for overriding any localized strings in the UI components. To define your own custom locale, use the `registerLocale` function from the `@invertase/firebaseui-translations` package: - /** Update config and notify subscribers **/ - setConfig(newConfig) { - config = newConfig; - subscribers.forEach((callback) => callback(config)); - }, +```ts +import { registerLocale } from '@invertase/firebaseui-translations'; - /** Subscribe to config changes (for use in any framework) **/ - subscribe(callback) { - subscribers.add(callback); - /** Optionally call immediately with current config**/ - callback(config); - return () => subscribers.delete(callback); - }, - }; -} +const frFr = registerLocale('fr-FR', { + labels: { + signIn: "Sign In, Matey", + }, +}); ``` -FirebaseUI Configuration Type: - -```js -export type FirebaseUIConfiguration = { - app: FirebaseApp, - getAuth: () => Auth, - setLocale: (locale: Locale) => void, - state: FirebaseUIState, - setState: (state: FirebaseUIState) => void, - locale: Locale, - translations: TranslationsConfig, - behaviors: Partial>, - recaptchaMode: "normal" | "invisible", -}; +To use this locale, provide it to the `initializeUI` configuration: + +```ts +const ui = initializeUI({ + app, + locale: frFr, +}); ``` -Through this approach, you can now achieve global access to the FirebaseUI methods and state. +### Dynamic translations -#### State Management +To dynamically change your locale during the applications lifecycle (e.g. a language drop down), you can call the `setLocale` method on the UI instance: -FirebaseUI Core provides built-in state management to track the current step in the authentication flow. This can be used to drive UI transitions, control rendering, or show progress indicators. +```ts +const ui = initializeUI({ + app, + locale: frFr, +}); -##### Available States +... -```js -type FirebaseUIState = - | "loading" - | "idle" - | "signing-in" - | "signing-out" - | "linking" - | "creating-user" - | "sending-password-reset-email" - | "sending-sign-in-link-to-email"; + ``` -These represent the current phase of the user experience — such as waiting for input, submitting credentials, or linking accounts. +### Fallback -##### Updating State Manually +By default, any missing translations will fallback to English if not specified. You can pass a 3rd "fallback" argument locale to the `registerLocale` function. -The core module automatically updates state based on auth activity, but you can also override it manually if needed: +## Reference -```js -/** Set the UI state to "idle" **/ -ui.setState("idle"); -``` +
+ @invertase/firebaseui-core -##### Reading State in Your App + **`initializeUI`** -In a component, you can access the current state through the FirebaseUI configuration: + Initalizes a new `FirebaseUIStore` instance. -```js -/** Sample: Framework-agnostic UI state management **/ + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | config | FirebaseUIOptions | The configuration for Firebase UI | + | name | string? | An optional name for the instance. | -/** Create a simple UI state store with an initial state **/ -const uiStore = createUIStateStore({ state: "idle" }); + Returns `FirebaseUIStore`. -uiStore.subscribe((ui) => { - /** Replace `showSpinner` and `showMainApp` with your actual rendering logic **/ - if (ui.state === "signing-in") { - showSpinner(); - } else { - showMainApp(); - } -}); -``` + **`signInWithEmailAndPassword`** -### Translations (i18n) + Signs the user in with an email and password. -You can pass one or more translations to support localized strings. + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + | email | string | The users email address. | + | password | string | The users password. | -```js -import { english } from "@firebase-ui/translations"; + Returns `Promise`. -initializeUI({ - app, - locale: "en", - translations: [english], -}); -``` + **`createUserWithEmailAndPassword`** -To override or add your own strings: + Creates a new user account with an email and password. -```js -const customFr = { - locale: "fr", - translations: { - errors: { - invalidEmail: "Adresse e-mail invalide", - }, - }, -}; -``` + | Argument | Type | Description | + |------------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + | email | string | The users email address. | + | password | string | The users password. | + | displayName| string? | Optional display name for the user. | -To use a string at runtime (e.g., in an error message): + Returns `Promise`. -```js -import { getTranslation } from "@firebase-ui/core"; + **`verifyPhoneNumber`** -const message = getTranslation(config, "errors", "unknownError"); -``` + Verifies a phone number and sends a verification code. -**When multiple translation sets are passed, FirebaseUI merges them in order — allowing you to layer overrides on top of built-in language packs.** + | Argument | Type | Description | + |---------------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + | phoneNumber | string | The phone number to verify. | + | appVerifier | ApplicationVerifier | The reCAPTCHA verifier. | + | mfaUser | MultiFactorUser? | Optional MFA user for enrollment flow. | + | mfaHint | MultiFactorInfo? | Optional MFA hint for assertion flow. | -### Form Schemas + Returns `Promise` (verification ID). -FirebaseUI uses Zod to validate authentication forms. This ensures consistent, strongly typed, and localized error handling across form components. + **`confirmPhoneNumber`** -Each schema can be used standalone or integrated into your custom forms. You can pass in a TranslationsConfig object to localize error messages. + Confirms a phone number verification with the verification code. -#### Available Schemas + | Argument | Type | Description | + |----------------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + | verificationId | string | The verification ID from verifyPhoneNumber. | + | verificationCode | string | The verification code sent to the phone. | -**createEmailFormSchema(translations?)** -Validates a sign-in or sign-up form using email and password. + Returns `Promise`. -- _email_: Must be a valid email address. + **`sendPasswordResetEmail`** -- _password_: Must be at least 8 characters. + Sends a password reset email to the user. -```js -import { createEmailFormSchema } from "@firebase-ui/core"; + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + | email | string | The users email address. | -const schema = createEmailFormSchema(translations); -``` + Returns `Promise`. -**createForgotPasswordFormSchema(translations?)** -Validates the forgot password form. + **`sendSignInLinkToEmail`** -- _email_: Must be a valid email address. + Sends a sign-in link to the user's email address. -```js -const schema = createForgotPasswordFormSchema(translations); -``` + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + | email | string | The users email address. | -**createEmailLinkFormSchema(translations?)** -Validates the email link authentication form. + Returns `Promise`. -- _email_: Must be a valid email address. + **`signInWithEmailLink`** -```js -const schema = createEmailLinkFormSchema(translations); -``` + Signs in a user with an email link. -**createPhoneFormSchema(translations?)** -Validates the phone number authentication form using reCAPTCHA. + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + | email | string | The users email address. | + | link | string | The email link from the sign-in email. | -- _phoneNumber_: Must be a valid phone number with at least 10 digits. + Returns `Promise`. -- _verificationCode_: Optional, must be at least 6 digits if provided. + **`signInWithCredential`** -- _recaptchaVerifier_: Must be an instance of RecaptchaVerifier. + Signs in a user with an authentication credential. -```js -const schema = createPhoneFormSchema(translations); -``` + | Argument | Type | Description | + |-----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + | credential| AuthCredential | The authentication credential. | -#### Handling Form Errors + Returns `Promise`. -Handling errors can be managed using [Zods parsing functions](http://zod.dev/basics?ref=ossgallery&id=handling-errors) such as `safeParse` + **`signInWithCustomToken`** -### Error Handling + Signs in a user with a custom token. -The core library provides a function for handling errors. + | Argument | Type | Description | + |------------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + | customToken| string | The custom token. | -#### handleFirebaseError() + Returns `Promise`. -```js -export function handleFirebaseError( - ui: FirebaseUIConfiguration, - error: any, - opts?: { - enableHandleExistingCredential?: boolean; - } -) -``` + **`signInAnonymously`** + + Signs in a user anonymously. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + + Returns `Promise`. + + **`signInWithProvider`** + + Signs in a user with an OAuth provider (e.g., Google, Facebook, etc.). + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + | provider | AuthProvider | The OAuth provider. | + + Returns `Promise`. + + **`completeEmailLinkSignIn`** + + Completes the email link sign-in flow by checking if the current URL is a valid email link. + + | Argument | Type | Description | + |-----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + | currentUrl| string | The current URL to check. | + + Returns `Promise`. + + **`generateTotpQrCode`** + + Generates a QR code data URL for TOTP (Time-based One-Time Password) enrollment. + + | Argument | Type | Description | + |------------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + | secret | TotpSecret | The TOTP secret. | + | accountName| string? | Optional account name for the QR code. | + | issuer | string? | Optional issuer name for the QR code. | + + Returns `string` (data URL of the QR code). + + **`signInWithMultiFactorAssertion`** + + Signs in a user with a multi-factor authentication assertion. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + | assertion| MultiFactorAssertion | The MFA assertion. | + + Returns `Promise`. + + **`enrollWithMultiFactorAssertion`** + + Enrolls a multi-factor authentication method for the current user. + + | Argument | Type | Description | + |------------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + | assertion | MultiFactorAssertion | The MFA assertion. | + | displayName| string? | Optional display name for the MFA method. Throws if not provided and the `requireDisplayName` behavior is enabled. | + + Returns `Promise`. + + **`generateTotpSecret`** + + Generates a TOTP secret for multi-factor authentication enrollment. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + + Returns `Promise`. + + **`autoAnonymousLogin`** + + Automatically signs in users anonymously when the UI initializes. + + Returns `Behavior<"autoAnonymousLogin">`. + + **`autoUpgradeAnonymousUsers`** + + Automatically upgrades anonymous users to permanent accounts when they sign in with a credential or provider. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | options | AutoUpgradeAnonymousUsersOptions? | Optional configuration. | + | options.onUpgrade | function? | Optional callback when upgrade occurs. | + + Returns `Behavior<"autoUpgradeAnonymousCredential" | "autoUpgradeAnonymousProvider" | "autoUpgradeAnonymousUserRedirectHandler">`. + + **`recaptchaVerification`** + + Configures reCAPTCHA verification for phone authentication. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | options | RecaptchaVerificationOptions? | Optional reCAPTCHA configuration. | + + Returns `Behavior<"recaptchaVerification">`. + + **`providerRedirectStrategy`** + + Configures OAuth providers to use redirect flow (full page redirect). + + Returns `Behavior<"providerSignInStrategy" | "providerLinkStrategy">`. + + **`providerPopupStrategy`** + + Configures OAuth providers to use popup flow (popup window). + + Returns `Behavior<"providerSignInStrategy" | "providerLinkStrategy">`. + + **`oneTapSignIn`** + + Enables Google One Tap sign-in. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | options | OneTapSignInOptions | Configuration for One Tap sign-in. | + + Returns `Behavior<"oneTapSignIn">`. + + **`requireDisplayName`** + + Requires users to provide a display name during registration. + + Returns `Behavior<"requireDisplayName">`. + + **`countryCodes`** + + Configures country code handling for phone number input. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | options | CountryCodesOptions? | Optional country codes configuration. | + + Returns `Behavior<"countryCodes">`. + + **`hasBehavior`** + + Checks if a behavior is enabled on a Firebase UI instance. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + | key | string | The behavior key to check. | + + Returns `boolean`. + + **`getBehavior`** + + Gets a behavior handler from a Firebase UI instance. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + | key | string | The behavior key to retrieve. | + + Returns the behavior handler function. + + **`defaultBehaviors`** + + The default behaviors that are automatically included in a Firebase UI instance. Includes `recaptchaVerification`, `providerRedirectStrategy`, and `countryCodes`. + + Type: `Behavior<"recaptchaVerification">`. + + **`countryData`** + + An array of country data objects containing name, dial code, country code, and emoji for all supported countries. + + Type: `readonly CountryData[]`. + + **`formatPhoneNumber`** + + Formats a phone number according to the specified country data. + + | Argument | Type | Description | + |------------|:-----------------:|------------------------------------| + | phoneNumber| string | The phone number to format. | + | countryData| CountryData | The country data to use for formatting. | + + Returns `string` (formatted phone number in E164 format). + + **`FirebaseUIError`** + + A custom error class that extends FirebaseError with localized error messages. + + **`handleFirebaseError`** + + Handles Firebase errors and converts them to FirebaseUIError with localized messages. Also handles special cases like account linking and multi-factor authentication. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + | error | unknown | The error to handle. | + + Throws `FirebaseUIError`. + + **`getTranslation`** + + Gets a translated string for a given category and key. + + | Argument | Type | Description | + |--------------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + | category | TranslationCategory | The translation category. | + | key | TranslationKey | The translation key. | + | replacements | Record? | Optional replacements for placeholders. | + + Returns `string`. + + **`createSignInAuthFormSchema`** + + Creates a Zod schema for email/password sign-in form validation. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + + Returns `ZodObject` with `email` and `password` fields. + + **`createSignUpAuthFormSchema`** + + Creates a Zod schema for email/password sign-up form validation. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + + Returns `ZodObject` with `email`, `password`, and optionally `displayName` fields. + + **`createForgotPasswordAuthFormSchema`** + + Creates a Zod schema for forgot password form validation. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + + Returns `ZodObject` with `email` field. + + **`createEmailLinkAuthFormSchema`** + + Creates a Zod schema for email link authentication form validation. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + + Returns `ZodObject` with `email` field. + + **`createPhoneAuthNumberFormSchema`** + + Creates a Zod schema for phone number input form validation. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + + Returns `ZodObject` with `phoneNumber` field. + + **`createPhoneAuthVerifyFormSchema`** + + Creates a Zod schema for phone verification code form validation. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + + Returns `ZodObject` with `verificationId` and `verificationCode` fields. + + **`createMultiFactorPhoneAuthNumberFormSchema`** + + Creates a Zod schema for multi-factor phone authentication number form validation. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + + Returns `ZodObject` with `phoneNumber` and `displayName` fields. + + **`createMultiFactorPhoneAuthAssertionFormSchema`** + + Creates a Zod schema for multi-factor phone authentication assertion form validation. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + + Returns `ZodObject` with `phoneNumber` field. + + **`createMultiFactorPhoneAuthVerifyFormSchema`** + + Creates a Zod schema for multi-factor phone authentication verification form validation. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + + Returns `ZodObject` with `verificationId` and `verificationCode` fields. + + **`createMultiFactorTotpAuthNumberFormSchema`** + + Creates a Zod schema for multi-factor TOTP authentication form validation. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + + Returns `ZodObject` with `displayName` field. + + **`createMultiFactorTotpAuthVerifyFormSchema`** + + Creates a Zod schema for multi-factor TOTP verification code form validation. + + | Argument | Type | Description | + |----------|:-----------------:|------------------------------------| + | ui | FirebaseUI | The Firebase UI instance. | + + Returns `ZodObject` with `verificationCode` field. + +
+ +
+ @invertase/firebaseui-react + + **`FirebaseUIProvider`** + + Provider component that wraps your application and provides Firebase UI context. + + | Prop | Type | Description | + |----------|:----:|-------------| + | ui | `FirebaseUIStore` | The UI store (from `initializeUI`) | + | policies | `{ termsOfServiceUrl: PolicyURL; privacyPolicyUrl: PolicyURL; onNavigate?: (url: PolicyURL) => void; }?` | Optional policies configuration. If provided, UI components will automatically render the policies. | + | children | `React.ReactNode` | Child components | + + **`SignInAuthForm`** + + Form component for email/password sign-in. + + | Prop | Type | Description | + |------|:----:|-------------| + | onSignIn | `(credential: UserCredential) => void?` | Callback when sign-in succeeds | + | onForgotPasswordClick | `() => void?` | Callback when forgot password link is clicked | + | onSignUpClick | `() => void?` | Callback when sign-up link is clicked | + + **`SignUpAuthForm`** + + Form component for email/password sign-up. + + | Prop | Type | Description | + |------|:----:|-------------| + | onSignUp | `(credential: UserCredential) => void?` | Callback when sign-up succeeds | + | onSignInClick | `() => void?` | Callback when sign-in link is clicked | + + **`ForgotPasswordAuthForm`** + + Form component for password reset. + + | Prop | Type | Description | + |------|:----:|-------------| + | onSendPasswordResetEmail | `() => void?` | Callback when password reset email is sent | + | onBackClick | `() => void?` | Callback when back button is clicked | + + **`EmailLinkAuthForm`** + + Form component for email link authentication. + + | Prop | Type | Description | + |------|:----:|-------------| + | onSendSignInLinkToEmail | `() => void?` | Callback when sign-in link email is sent | + + **`PhoneAuthForm`** + + Form component for phone number authentication. + + | Prop | Type | Description | + |------|:----:|-------------| + | onVerifyPhoneNumber | `() => void?` | Callback when phone number verification is initiated | + | onVerifyCode | `(credential: UserCredential) => void?` | Callback when verification code is verified | + + **`MultiFactorAuthAssertionForm`** + + Form component for multi-factor authentication assertion during sign-in. + + | Prop | Type | Description | + |------|:----:|-------------| + | onAssert | `(credential: UserCredential) => void?` | Callback when MFA assertion succeeds | + + **`MultiFactorAuthEnrollmentForm`** + + Form component for multi-factor authentication enrollment. + + | Prop | Type | Description | + |------|:----:|-------------| + | onEnroll | `() => void?` | Callback when MFA enrollment succeeds | + + **`SmsMultiFactorAssertionForm`** + + Form component for SMS-based multi-factor authentication assertion. + + | Prop | Type | Description | + |------|:----:|-------------| + | onAssert | `(credential: UserCredential) => void?` | Callback when SMS MFA assertion succeeds | + + **`SmsMultiFactorEnrollmentForm`** + + Form component for SMS-based multi-factor authentication enrollment. + + | Prop | Type | Description | + |------|:----:|-------------| + | onEnroll | `() => void?` | Callback when SMS MFA enrollment succeeds | + + **`TotpMultiFactorAssertionForm`** + + Form component for TOTP-based multi-factor authentication assertion. + + | Prop | Type | Description | + |------|:----:|-------------| + | onAssert | `(credential: UserCredential) => void?` | Callback when TOTP MFA assertion succeeds | + + **`TotpMultiFactorEnrollmentForm`** + + Form component for TOTP-based multi-factor authentication enrollment. + + | Prop | Type | Description | + |------|:----:|-------------| + | onEnroll | `() => void?` | Callback when TOTP MFA enrollment succeeds | + + **`SignInAuthScreen`** + + Screen component for email/password sign-in. Extends `SignInAuthFormProps` and accepts `children`. + + | Prop | Type | Description | + |------|:----:|-------------| + | onSignIn | `(user: User) => void?` | Callback when sign-in succeeds | + | onForgotPasswordClick | `() => void?` | Callback when forgot password link is clicked | + | onSignUpClick | `() => void?` | Callback when sign-up link is clicked | + + **`SignUpAuthScreen`** + + Screen component for email/password sign-up. Extends `SignUpAuthFormProps` and accepts `children`. + + | Prop | Type | Description | + |------|:----:|-------------| + | onSignUp | `(user: User) => void?` | Callback when sign-up succeeds | + | onSignInClick | `() => void?` | Callback when sign-in link is clicked | + + **`ForgotPasswordAuthScreen`** + + Screen component for password reset. Extends `ForgotPasswordAuthFormProps`. + + **`EmailLinkAuthScreen`** + + Screen component for email link authentication. Extends `EmailLinkAuthFormProps` and accepts `children`. + + | Prop | Type | Description | + |------|:----:|-------------| + | onSendSignInLinkToEmail | `() => void?` | Callback when sign-in link email is sent | + | onSignIn | `(user: User) => void?` | Callback when sign-in succeeds | + + **`PhoneAuthScreen`** + + Screen component for phone number authentication. Extends `PhoneAuthFormProps` and accepts `children`. + + | Prop | Type | Description | + |------|:----:|-------------| + | onVerifyPhoneNumber | `() => void?` | Callback when phone number verification is initiated | + | onVerifyCode | `(user: User) => void?` | Callback when verification code is verified | + + **`MultiFactorAuthAssertionScreen`** + + Screen component for multi-factor authentication assertion. Extends `MultiFactorAuthAssertionFormProps`. + + | Prop | Type | Description | + |------|:----:|-------------| + | onAssert | `(user: User) => void?` | Callback when MFA assertion succeeds | + + **`MultiFactorAuthEnrollmentScreen`** + + Screen component for multi-factor authentication enrollment. Extends `MultiFactorAuthEnrollmentFormProps`. + + **`OAuthScreen`** + + Screen component for OAuth provider sign-in. + + | Prop | Type | Description | + |------|:----:|-------------| + | onSignIn | `(user: User) => void?` | Callback when sign-in succeeds | + | children | `React.ReactNode?` | Child components | + + **`OAuthButton`** + + Generic OAuth button component. + + | Prop | Type | Description | + |------|:----:|-------------| + | provider | `AuthProvider` | Firebase Auth provider instance | + | themed | `boolean \| string?` | Whether to apply themed styling | + | onSignIn | `(credential: UserCredential) => void?` | Callback when sign-in succeeds | + | children | `React.ReactNode?` | Button content | + + **`GoogleSignInButton`** + + Google OAuth sign-in button component. + + | Prop | Type | Description | + |------|:----:|-------------| + | themed | `boolean \| string?` | Whether to apply themed styling | + | onSignIn | `(credential: UserCredential) => void?` | Callback when sign-in succeeds | + + **`AppleSignInButton`** + + Apple OAuth sign-in button component. + + | Prop | Type | Description | + |------|:----:|-------------| + | themed | `boolean \| string?` | Whether to apply themed styling | + | onSignIn | `(credential: UserCredential) => void?` | Callback when sign-in succeeds | + + **`FacebookSignInButton`** + + Facebook OAuth sign-in button component. + + | Prop | Type | Description | + |------|:----:|-------------| + | themed | `boolean \| string?` | Whether to apply themed styling | + | onSignIn | `(credential: UserCredential) => void?` | Callback when sign-in succeeds | + + **`GitHubSignInButton`** + + GitHub OAuth sign-in button component. + + | Prop | Type | Description | + |------|:----:|-------------| + | themed | `boolean \| string?` | Whether to apply themed styling | + | onSignIn | `(credential: UserCredential) => void?` | Callback when sign-in succeeds | + + **`MicrosoftSignInButton`** + + Microsoft OAuth sign-in button component. + + | Prop | Type | Description | + |------|:----:|-------------| + | themed | `boolean \| string?` | Whether to apply themed styling | + | onSignIn | `(credential: UserCredential) => void?` | Callback when sign-in succeeds | + + **`TwitterSignInButton`** + + Twitter OAuth sign-in button component. + + | Prop | Type | Description | + |------|:----:|-------------| + | themed | `boolean \| string?` | Whether to apply themed styling | + | onSignIn | `(credential: UserCredential) => void?` | Callback when sign-in succeeds | + + **`Button`** + + Button component with variant support. + + | Prop | Type | Description | + |------|:----:|-------------| + | variant | `"primary" \| "secondary" \| "outline"?` | Button style variant | + | asChild | `boolean?` | Render as child component using Slot | + | ...props | `ComponentProps<"button">` | Standard button HTML attributes | + + **`Card`** + + Card container component. + + | Prop | Type | Description | + |------|:----:|-------------| + | children | `React.ReactNode?` | Card content | + | ...props | `ComponentProps<"div">` | Standard div HTML attributes | + + **`CardHeader`** + + Card header component. Accepts `children` and standard div props. + + **`CardTitle`** + + Card title component. Accepts `children` and standard h2 props. + + **`CardSubtitle`** + + Card subtitle component. Accepts `children` and standard p props. + + **`CardContent`** + + Card content component. Accepts `children` and standard div props. + + **`CountrySelector`** + + Country selector component for phone number input. + + | Prop | Type | Description | + |------|:----:|-------------| + | ...props | `ComponentProps<"div">` | Standard div HTML attributes | + + **`Divider`** + + Divider component. + + | Prop | Type | Description | + |------|:----:|-------------| + | children | `React.ReactNode?` | Divider content | + | ...props | `ComponentProps<"div">` | Standard div HTML attributes | + + **`Policies`** + + Component that renders terms of service and privacy policy links. Automatically rendered when policies are provided to `FirebaseUIProvider`. + + **`RedirectError`** + + Component that displays redirect errors from Firebase UI authentication flow. + + **`useUI`** + + Gets the Firebase UI configuration from context. + + Returns `FirebaseUI`. + + **`useRedirectError`** + + Gets the redirect error from the UI store. + + Returns `string | undefined`. + + **`useSignInAuthFormSchema`** + + Creates a Zod schema for sign-in form validation. + + Returns `ZodObject` with `email` and `password` fields. + + **`useSignUpAuthFormSchema`** + + Creates a Zod schema for sign-up form validation. + + Returns `ZodObject` with `email`, `password`, and optionally `displayName` fields. + + **`useForgotPasswordAuthFormSchema`** + + Creates a Zod schema for forgot password form validation. + + Returns `ZodObject` with `email` field. + + **`useEmailLinkAuthFormSchema`** + + Creates a Zod schema for email link authentication form validation. + + Returns `ZodObject` with `email` field. + + **`usePhoneAuthNumberFormSchema`** + + Creates a Zod schema for phone number input form validation. + + Returns `ZodObject` with `phoneNumber` field. + + **`usePhoneAuthVerifyFormSchema`** + + Creates a Zod schema for phone verification code form validation. + + Returns `ZodObject` with `verificationId` and `verificationCode` fields. + + **`useMultiFactorPhoneAuthNumberFormSchema`** + + Creates a Zod schema for multi-factor phone authentication number form validation. + + Returns `ZodObject` with `phoneNumber` and `displayName` fields. + + **`useMultiFactorPhoneAuthVerifyFormSchema`** + + Creates a Zod schema for multi-factor phone authentication verification form validation. + + Returns `ZodObject` with `verificationId` and `verificationCode` fields. + + **`useMultiFactorTotpAuthNumberFormSchema`** + + Creates a Zod schema for multi-factor TOTP authentication form validation. + + Returns `ZodObject` with `displayName` field. + + **`useMultiFactorTotpAuthVerifyFormSchema`** + + Creates a Zod schema for multi-factor TOTP verification code form validation. + + Returns `ZodObject` with `verificationCode` field. + + **`useRecaptchaVerifier`** + + Creates and manages a reCAPTCHA verifier instance. + + | Argument | Type | Description | + |----------|:----:|-------------| + | ref | `React.RefObject` | Reference to the DOM element where reCAPTCHA should be rendered | + + Returns `RecaptchaVerifier \| null`. + + **`useSignInAuthForm`** + + Hook for managing sign-in form state and validation. + + Returns form state and handlers. + + **`useSignInAuthFormAction`** + + Hook for sign-in form submission action. + + Returns async action handler. + + **`useSignUpAuthForm`** + + Hook for managing sign-up form state and validation. + + Returns form state and handlers. + + **`useSignUpAuthFormAction`** + + Hook for sign-up form submission action. + + Returns async action handler. + + **`useRequireDisplayName`** + + Hook to check if display name is required for sign-up. + + Returns `boolean`. + + **`useForgotPasswordAuthForm`** + + Hook for managing forgot password form state and validation. + + Returns form state and handlers. + + **`useForgotPasswordAuthFormAction`** + + Hook for forgot password form submission action. + + Returns async action handler. + + **`useEmailLinkAuthForm`** + + Hook for managing email link auth form state and validation. + + Returns form state and handlers. + + **`useEmailLinkAuthFormAction`** + + Hook for email link auth form submission action. + + Returns async action handler. + + **`useEmailLinkAuthFormCompleteSignIn`** + + Hook to complete email link authentication. + + Returns async action handler. + + **`usePhoneNumberForm`** + + Hook for managing phone number form state and validation. + + Returns form state and handlers. + + **`usePhoneNumberFormAction`** + + Hook for phone number form submission action. + + Returns async action handler. + + **`useVerifyPhoneNumberForm`** + + Hook for managing phone verification form state and validation. + + Returns form state and handlers. + + **`useVerifyPhoneNumberFormAction`** + + Hook for phone verification form submission action. + + Returns async action handler. + + **`useMultiFactorAssertionCleanup`** + + Hook for cleaning up multi-factor assertion state. + + **`useSmsMultiFactorAssertionPhoneFormAction`** + + Hook for SMS MFA assertion phone form submission action. + + Returns async action handler. + + **`useSmsMultiFactorAssertionVerifyFormAction`** + + Hook for SMS MFA assertion verification form submission action. + + Returns async action handler. + + **`useSmsMultiFactorEnrollmentPhoneNumberForm`** + + Hook for managing SMS MFA enrollment phone number form state. + + Returns form state and handlers. + + **`useSmsMultiFactorEnrollmentPhoneAuthFormAction`** + + Hook for SMS MFA enrollment phone auth form submission action. + + Returns async action handler. + + **`useMultiFactorEnrollmentVerifyPhoneNumberForm`** + + Hook for managing MFA enrollment phone verification form state. + + Returns form state and handlers. + + **`useMultiFactorEnrollmentVerifyPhoneNumberFormAction`** + + Hook for MFA enrollment phone verification form submission action. + + Returns async action handler. + + **`useTotpMultiFactorAssertionForm`** + + Hook for managing TOTP MFA assertion form state. + + Returns form state and handlers. + + **`useTotpMultiFactorAssertionFormAction`** + + Hook for TOTP MFA assertion form submission action. + + Returns async action handler. + + **`useTotpMultiFactorSecretGenerationForm`** + + Hook for managing TOTP secret generation form state. + + Returns form state and handlers. + + **`useTotpMultiFactorSecretGenerationFormAction`** + + Hook for TOTP secret generation form submission action. + + Returns async action handler. + + **`useMultiFactorEnrollmentVerifyTotpForm`** + + Hook for managing MFA enrollment TOTP verification form state. + + Returns form state and handlers. + + **`useMultiFactorEnrollmentVerifyTotpFormAction`** + + Hook for MFA enrollment TOTP verification form submission action. + + Returns async action handler. + + **`useSignInWithProvider`** + + Hook for OAuth provider sign-in. + + | Argument | Type | Description | + |----------|:----:|-------------| + | provider | `AuthProvider` | Firebase Auth provider instance | + + Returns async action handler. + + **`useCountries`** + + Hook to get list of countries for country selector. + + Returns array of country data. + + **`useDefaultCountry`** + + Hook to get default country for country selector. + + Returns country data or `undefined`. + + **`PolicyContext`** + + React context for policy configuration. + + **`PolicyProps`** + + Type for policy configuration. + + | Property | Type | Description | + |----------|:----:|-------------| + | termsOfServiceUrl | `PolicyURL` | URL to terms of service | + | privacyPolicyUrl | `PolicyURL` | URL to privacy policy | + | onNavigate | `(url: PolicyURL) => void?` | Optional navigation handler | + + **`PolicyURL`** + + Type alias: `string \| URL` + + **`FirebaseUIProviderProps`** + + Type for `FirebaseUIProvider` component props. + +
+ +
+ Shadcn + + The shadcn registry is available at: https://fir-ui-shadcn-registry.web.app/r/{name}.json + + | Name | Path | Description | + |----------|:----------------:|-------------| + | apple-sign-in-button | /r/apple-sign-in-button.json | A button component for Apple OAuth authentication. | + | country-selector | /r/country-selector.json | A country selector component for phone number input with country codes and flags. | + | email-link-auth-form | /r/email-link-auth-form.json | A form allowing users to sign in via email link. | + | email-link-auth-screen | /r/email-link-auth-screen.json | A screen allowing users to sign in via email link. | + | facebook-sign-in-button | /r/facebook-sign-in-button.json | A button component for Facebook OAuth authentication. | + | forgot-password-auth-form | /r/forgot-password-auth-form.json | A form allowing users to reset their password via email. | + | forgot-password-auth-screen | /r/forgot-password-auth-screen.json | A screen allowing users to reset their password via email. | + | github-sign-in-button | /r/github-sign-in-button.json | A button component for GitHub OAuth authentication. | + | google-sign-in-button | /r/google-sign-in-button.json | A button component for Google OAuth authentication. | + | microsoft-sign-in-button | /r/microsoft-sign-in-button.json | A button component for Microsoft OAuth authentication. | + | multi-factor-auth-assertion-form | /r/multi-factor-auth-assertion-form.json | A form allowing users to complete multi-factor authentication during sign-in with TOTP or SMS options. | + | multi-factor-auth-assertion-screen | /r/multi-factor-auth-assertion-screen.json | A screen allowing users to complete multi-factor authentication during sign-in with TOTP or SMS options. | + | multi-factor-auth-enrollment-form | /r/multi-factor-auth-enrollment-form.json | A form allowing users to select and configure multi-factor authentication methods. | + | multi-factor-auth-enrollment-screen | /r/multi-factor-auth-enrollment-screen.json | A screen allowing users to set up multi-factor authentication with TOTP or SMS options. | + | oauth-button | /r/oauth-button.json | A button component for OAuth authentication providers. | + | oauth-screen | /r/oauth-screen.json | A screen allowing users to sign in with OAuth providers. | + | phone-auth-form | /r/phone-auth-form.json | A form allowing users to authenticate using their phone number with SMS verification. | + | phone-auth-screen | /r/phone-auth-screen.json | A screen allowing users to authenticate using their phone number with SMS verification. | + | policies | /r/policies.json | A component allowing users to navigate to the terms of service and privacy policy. | + | redirect-error | /r/redirect-error.json | A component that displays redirect errors from Firebase UI authentication flow. | + | sign-in-auth-form | /r/sign-in-auth-form.json | A form allowing users to sign in with email and password. | + | sign-in-auth-screen | /r/sign-in-auth-screen.json | A screen allowing users to sign in with email and password. | + | sign-up-auth-form | /r/sign-up-auth-form.json | A form allowing users to sign up with email and password. | + | sign-up-auth-screen | /r/sign-up-auth-screen.json | A screen allowing users to sign up with email and password. | + | sms-multi-factor-assertion-form | /r/sms-multi-factor-assertion-form.json | A form allowing users to complete SMS-based multi-factor authentication during sign-in. | + | sms-multi-factor-enrollment-form | /r/sms-multi-factor-enrollment-form.json | A form allowing users to enroll SMS-based multi-factor authentication. | + | totp-multi-factor-assertion-form | /r/totp-multi-factor-assertion-form.json | A form allowing users to complete TOTP-based multi-factor authentication during sign-in. | + | totp-multi-factor-enrollment-form | /r/totp-multi-factor-enrollment-form.json | A form allowing users to enroll TOTP-based multi-factor authentication with QR code generation. | + | twitter-sign-in-button | /r/twitter-sign-in-button.json | A button component for Twitter OAuth authentication. | + +
+ +
+ @invertase/firebaseui-angular + + **`provideFirebaseUI`** + + Provider function that configures Firebase UI for your Angular application. + + | Argument | Type | Description | + |----------|:----:|-------------| + | uiFactory | `(apps: FirebaseApps) => FirebaseUIStore` | Factory function that creates the UI store from Firebase apps | + + Returns `EnvironmentProviders`. + + **`provideFirebaseUIPolicies`** + + Provider function that configures policies (terms of service and privacy policy) for Firebase UI. + + | Argument | Type | Description | + |----------|:----:|-------------| + | factory | `() => PolicyConfig` | Factory function that returns policy configuration | + + Returns `EnvironmentProviders`. + + **`SignInAuthFormComponent`** + + Selector: `fui-sign-in-auth-form` + + Form component for email/password sign-in. + + | Output | Type | Description | + |--------|:----:|-------------| + | signIn | `EventEmitter` | Emitted when sign-in succeeds | + | forgotPassword | `EventEmitter` | Emitted when forgot password link is clicked | + | signUp | `EventEmitter` | Emitted when sign-up link is clicked | + + **`SignUpAuthFormComponent`** + + Selector: `fui-sign-up-auth-form` + + Form component for email/password sign-up. + + | Output | Type | Description | + |--------|:----:|-------------| + | signUp | `EventEmitter` | Emitted when sign-up succeeds | + | signIn | `EventEmitter` | Emitted when sign-in link is clicked | + + **`ForgotPasswordAuthFormComponent`** + + Selector: `fui-forgot-password-auth-form` + + Form component for password reset. + + | Output | Type | Description | + |--------|:----:|-------------| + | passwordSent | `EventEmitter` | Emitted when password reset email is sent | + | backToSignIn | `EventEmitter` | Emitted when back button is clicked | + + **`EmailLinkAuthFormComponent`** + + Selector: `fui-email-link-auth-form` + + Form component for email link authentication. + + | Output | Type | Description | + |--------|:----:|-------------| + | emailSent | `EventEmitter` | Emitted when sign-in link email is sent | + | signIn | `EventEmitter` | Emitted when sign-in succeeds (via link or MFA) | + + **`PhoneAuthFormComponent`** + + Selector: `fui-phone-auth-form` + + Form component for phone number authentication. + + | Output | Type | Description | + |--------|:----:|-------------| + | signIn | `EventEmitter` | Emitted when phone verification succeeds | + + **`MultiFactorAuthAssertionFormComponent`** + + Selector: `fui-multi-factor-auth-assertion-form` + + Form component for multi-factor authentication assertion during sign-in. + + | Output | Type | Description | + |--------|:----:|-------------| + | onSuccess | `EventEmitter` | Emitted when MFA assertion succeeds | + + **`SmsMultiFactorAssertionFormComponent`** + + Selector: `fui-sms-multi-factor-assertion-form` + + Form component for SMS-based multi-factor authentication assertion. + + | Input | Type | Description | + |-------|:----:|-------------| + | hint | `MultiFactorInfo` | The MFA hint for SMS verification | + + | Output | Type | Description | + |--------|:----:|-------------| + | onSuccess | `EventEmitter` | Emitted when SMS MFA assertion succeeds | + + **`SmsMultiFactorAssertionPhoneFormComponent`** + + Selector: `fui-sms-multi-factor-assertion-phone-form` + + Phone number form component for SMS MFA assertion. + + **`SmsMultiFactorAssertionVerifyFormComponent`** + + Selector: `fui-sms-multi-factor-assertion-verify-form` + + Verification code form component for SMS MFA assertion. + + **`TotpMultiFactorAssertionFormComponent`** + + Selector: `fui-totp-multi-factor-assertion-form` + + Form component for TOTP-based multi-factor authentication assertion. + + | Input | Type | Description | + |-------|:----:|-------------| + | hint | `MultiFactorInfo` | The MFA hint for TOTP verification | + + | Output | Type | Description | + |--------|:----:|-------------| + | onSuccess | `EventEmitter` | Emitted when TOTP MFA assertion succeeds | + + **`SignInAuthScreenComponent`** + + Selector: `fui-sign-in-auth-screen` + + Screen component for email/password sign-in. + + | Output | Type | Description | + |--------|:----:|-------------| + | signIn | `EventEmitter` | Emitted when sign-in succeeds | + | signUp | `EventEmitter` | Emitted when sign-up link is clicked | + + **`SignUpAuthScreenComponent`** + + Selector: `fui-sign-up-auth-screen` + + Screen component for email/password sign-up. + + | Output | Type | Description | + |--------|:----:|-------------| + | signUp | `EventEmitter` | Emitted when sign-up succeeds | + | signIn | `EventEmitter` | Emitted when sign-in link is clicked | + + **`ForgotPasswordAuthScreenComponent`** + + Selector: `fui-forgot-password-auth-screen` + + Screen component for password reset. + + | Output | Type | Description | + |--------|:----:|-------------| + | passwordSent | `EventEmitter` | Emitted when password reset email is sent | + | backToSignIn | `EventEmitter` | Emitted when back button is clicked | + + **`EmailLinkAuthScreenComponent`** + + Selector: `fui-email-link-auth-screen` + + Screen component for email link authentication. + + | Output | Type | Description | + |--------|:----:|-------------| + | emailSent | `EventEmitter` | Emitted when sign-in link email is sent | + | signIn | `EventEmitter` | Emitted when sign-in succeeds | + + **`PhoneAuthScreenComponent`** + + Selector: `fui-phone-auth-screen` + + Screen component for phone number authentication. + + | Output | Type | Description | + |--------|:----:|-------------| + | signIn | `EventEmitter` | Emitted when phone verification succeeds | + + **`OAuthScreenComponent`** + + Selector: `fui-oauth-screen` + + Screen component for OAuth provider sign-in. + + | Output | Type | Description | + |--------|:----:|-------------| + | onSignIn | `EventEmitter` | Emitted when OAuth sign-in succeeds | + + **`OAuthButtonComponent`** + + Selector: `fui-oauth-button` + + Generic OAuth button component. + + | Input | Type | Description | + |-------|:----:|-------------| + | provider | `AuthProvider` | Firebase Auth provider instance | + + | Output | Type | Description | + |--------|:----:|-------------| + | signIn | `EventEmitter` | Emitted when sign-in succeeds | + + **`GoogleSignInButtonComponent`** + + Selector: `fui-google-sign-in-button` + + Google OAuth sign-in button component. + + | Output | Type | Description | + |--------|:----:|-------------| + | signIn | `EventEmitter` | Emitted when sign-in succeeds | + + **`AppleSignInButtonComponent`** + + Selector: `fui-apple-sign-in-button` + + Apple OAuth sign-in button component. + + | Output | Type | Description | + |--------|:----:|-------------| + | signIn | `EventEmitter` | Emitted when sign-in succeeds | + + **`FacebookSignInButtonComponent`** + + Selector: `fui-facebook-sign-in-button` + + Facebook OAuth sign-in button component. + + | Output | Type | Description | + |--------|:----:|-------------| + | signIn | `EventEmitter` | Emitted when sign-in succeeds | + + **`GithubSignInButtonComponent`** + + Selector: `fui-github-sign-in-button` + + GitHub OAuth sign-in button component. + + | Output | Type | Description | + |--------|:----:|-------------| + | signIn | `EventEmitter` | Emitted when sign-in succeeds | + + **`MicrosoftSignInButtonComponent`** + + Selector: `fui-microsoft-sign-in-button` + + Microsoft OAuth sign-in button component. + + | Output | Type | Description | + |--------|:----:|-------------| + | signIn | `EventEmitter` | Emitted when sign-in succeeds | + + **`TwitterSignInButtonComponent`** + + Selector: `fui-twitter-sign-in-button` + + Twitter OAuth sign-in button component. + + | Output | Type | Description | + |--------|:----:|-------------| + | signIn | `EventEmitter` | Emitted when sign-in succeeds | + + **`ButtonComponent`** + + Selector: `button[fui-button]` + + Button component with variant support. + + | Input | Type | Description | + |-------|:----:|-------------| + | variant | `"primary" \| "secondary" \| "outline"?` | Button style variant | + + **`CardComponent`** + + Selector: `fui-card` + + Card container component. + + **`CardHeaderComponent`** + + Selector: `fui-card-header` + + Card header component. + + **`CardTitleComponent`** + + Selector: `fui-card-title` + + Card title component. + + **`CardSubtitleComponent`** + + Selector: `fui-card-subtitle` + + Card subtitle component. + + **`CardContentComponent`** + + Selector: `fui-card-content` + + Card content component. + + **`CountrySelectorComponent`** + + Selector: `fui-country-selector` + + Country selector component for phone number input. + + | Input | Type | Description | + |-------|:----:|-------------| + | value | `CountryCode` | Selected country code | + + | Output | Type | Description | + |--------|:----:|-------------| + | valueChange | `EventEmitter` | Emitted when country selection changes | + + **`DividerComponent`** + + Selector: `fui-divider` + + Divider component. + + **`PoliciesComponent`** + + Selector: `fui-policies` + + Component that renders terms of service and privacy policy links. Automatically rendered when policies are provided via `provideFirebaseUIPolicies`. + + **`RedirectErrorComponent`** + + Selector: `fui-redirect-error` + + Component that displays redirect errors from Firebase UI authentication flow. + + **`ContentComponent`** + + Selector: `fui-content` + + Content wrapper component. + + **`injectUI`** + + Injects the Firebase UI configuration as a read-only signal. + + Returns `ReadonlySignal`. + + **`injectRedirectError`** + + Injects the redirect error from the UI store as a signal. + + Returns `Signal`. + + **`injectTranslation`** + + Injects a translated string for a given category and key. + + | Argument | Type | Description | + |----------|:----:|-------------| + | category | `string` | The translation category | + | key | `string` | The translation key | + + Returns `Signal`. + + **`injectSignInAuthFormSchema`** + + Injects a Zod schema for sign-in form validation. + + Returns `Signal` with `email` and `password` fields. + + **`injectSignUpAuthFormSchema`** + + Injects a Zod schema for sign-up form validation. + + Returns `Signal` with `email`, `password`, and optionally `displayName` fields. + + **`injectForgotPasswordAuthFormSchema`** + + Injects a Zod schema for forgot password form validation. + + Returns `Signal` with `email` field. + + **`injectEmailLinkAuthFormSchema`** + + Injects a Zod schema for email link authentication form validation. + + Returns `Signal` with `email` field. + + **`injectPhoneAuthFormSchema`** + + Injects a Zod schema for phone number input form validation. + + Returns `Signal` with `phoneNumber` field. + + **`injectPhoneAuthVerifyFormSchema`** + + Injects a Zod schema for phone verification code form validation. + + Returns `Signal` with `verificationId` and `verificationCode` fields. + + **`injectMultiFactorPhoneAuthNumberFormSchema`** + + Injects a Zod schema for multi-factor phone authentication number form validation. + + Returns `Signal` with `phoneNumber` and `displayName` fields. + + **`injectMultiFactorPhoneAuthAssertionFormSchema`** + + Injects a Zod schema for multi-factor phone authentication assertion form validation. + + Returns `Signal` with `phoneNumber` field. + + **`injectMultiFactorPhoneAuthVerifyFormSchema`** + + Injects a Zod schema for multi-factor phone authentication verification form validation. + + Returns `Signal` with `verificationId` and `verificationCode` fields. + + **`injectMultiFactorTotpAuthNumberFormSchema`** + + Injects a Zod schema for multi-factor TOTP authentication form validation. + + Returns `Signal` with `displayName` field. + + **`injectMultiFactorTotpAuthVerifyFormSchema`** + + Injects a Zod schema for multi-factor TOTP verification code form validation. + + Returns `Signal` with `verificationCode` field. + + **`injectRecaptchaVerifier`** + + Injects a reCAPTCHA verifier instance. + + | Argument | Type | Description | + |----------|:----:|-------------| + | element | `() => ElementRef` | Function that returns the element reference where reCAPTCHA should be rendered | + + Returns `Signal`. + + **`injectPolicies`** + + Injects the policy configuration. + + Returns `PolicyConfig \| null`. + + **`injectCountries`** + + Injects the list of countries for country selector. + + Returns `Signal`. + + **`injectDefaultCountry`** + + Injects the default country for country selector. + + Returns `Signal`. + +
+ +## Bring your own UI + +The Firebase UI library is designed in a way which enables you to easily bring your own UI, or even framework, and still gain the benefits of what Firebase UI offers. + +### Screens vs Forms + +In Firebase UI, a "Screen" is an opinionated UI view which provides specific styling and layout, for example Screens provide a maximum width, are centered, within a card containing padding, a title and description. + +If you are building an application quickly or want an opinionated view, Screens work great. However, if you have constrained requirements (perhaps an existing login page), you can instead use Forms. + +Forms are less opinionated, and only contain the relevant logic required to function. For example, for a sign-in page, the "Sign in form" only includes the email, password and submit button form fields. A Form will fill its parent's width, allowing you to add a Form to any existing UI screens. Typically, Firebase UI screens are simply composed of surrounding UI logic and the form itself. + +Every supported platform follows this principle, thus you can easily swap out a Screen for a Form if required. For example with React: + +```diff +- import { SignInAuthScreen } from '@invertase/firebaseui-react'; ++ import { SignInAuthForm } from '@invertase/firebaseui-react'; +``` + +## Building your own UI + +Whether you're using a (currently) unsupported framework such as Svelte, SolidJS or Vue, you can still use Firebase UI to build your own UI. + +### `FirebaseUIStore` + +The `initializeUI` function returns a `FirebaseUIStore` - a framework agnostic [reactive store](https://github.com/nanostores/nanostores) which allows you to subscribe to changes to the UI instance, such as state or locale updates: + +```ts +const ui = initializeUI({ + app, +}); + +// Subscribe to UI changes +ui.listen((ui) => { + console.log('State changed', ui.state); // loading | pending | idle + console.log('Current locale', ui.locale); + console.log('MFA Assertion', ui.multiFactorResolver); +}); + +// Update the store +store.setKey('state', 'loading'); +``` + +The reactive store allows you to easily add states to your application, such as disabling buttons, checking for MFA assertions and more. + +### Core package + +The `@invertase/firebaseui-core` exports functionality which is directly tied to Firebase UI. Some of these functions mimic the Firebase JS SDK (with added benefits), whereas others are specifically for Firebase UI. + +For example, let's use the `signInWithEmailAndPassword` function: + +```ts +import { signInWithEmailAndPassword } from '@invertase/firebaseui-core'; + +await signInWithEmailAndPassword(ui.get(), 'test@test.com', '123456'); +``` + +This API is almost the same as the [Firebase JS SDK](https://firebase.google.com/docs/reference/js/auth?_gl=1*rb4770*_up*MQ..*_ga*MTE2NzQ1NDU4MC4xNzYyNzgzNTA0*_ga_CW55HF8NVT*czE3NjI3ODM1MDMkbzEkZzAkdDE3NjI3ODM1MDMkajYwJGwwJGgw#signinwithemailandpassword_21ad33b) functionality, however instead provides a stable `FirebaseUI` instance from our `FirebaseUIStore`. + +However internally, Firebase UI will additionally handle the following: + +1. Setting the UI state to `pending` (allowing you to modify any UI (e.g. disabled states)). +2. Automatically triggering any [behaviors](#behaviors), for example automatically upgrading an anonymous user to account. +3. Automatically link any pending credentials (in the event an [account exists with a different credential](https://firebase.google.com/docs/auth/web/google-signin#handling-account-exists-with-different-credential-errors)). +4. Automatically catch any errors thrown from Firebase, handling `account-exists-with-different-credential` errors and any multi-factor assertions which are triggered (see below). +5. Automatically provide a `FirebaseUIError`, which returns a translated error message based on the configured locale. +6. Sets the UI state back to `idle` once the flow has completed. + +All of the functionality within Firebase UI flows a similar logic flow. See the [Reference API](#reference) for more details on all of the available functionality. + +### Multi-factor assertions + +As mentioned above, Firebase UI will automatically capture MFA errors, and provide you with the [`MultiFactorResolver`](https://firebase.google.com/docs/reference/js/auth.multifactorresolver?_gl=1*163rwe5*_up*MQ..*_ga*NDEwODIyMDY5LjE3NjI3ODM2OTQ.*_ga_CW55HF8NVT*czE3NjI3ODM2OTQkbzEkZzAkdDE3NjI3ODM2OTQkajYwJGwwJGgw) to handle the assertion: + +```ts +ui.listen((ui) => { + if (ui.multiFactorResolver) { + // Show a MFA assertion flow + } +}); +``` + +The core package additionally exposes a `signInWithMultiFactorAssertion` function for signing the user in with one of their enrolled factors. + +### Custom providers + +Out of the box, Firebase UI provides styled, themeable buttons for all of the Firebase supported providers. If you wish to add a custom provider, either +supporting SAML or OIDC, you can achive this by extending the OAuth component: + +
+ React + + ```tsx + import { OAuthProvider } from 'firebase/auth'; + import { OAuthButton } from '@invertase/firebaseui-react'; + + function MyProviderButton() { + // Get the provider ID from the Firebase Console + const provider = new OAuthProvider('oidc.my-provider'); + + return ( + + Sign in with my provider + + ) + } + ``` +
+ +
+ Angular + + ```ts + import { Component } from '@angular/core'; + import { CommonModule } from '@angular/common'; + import { OAuthProvider, UserCredential } from '@angular/fire/auth'; + import { OAuthButtonComponent } from '@invertase/firebaseui-angular'; + + @Component({ + selector: 'app-my-provider-button', + standalone: true, + imports: [CommonModule, OAuthButtonComponent], + template: ` + + Sign in with my provider + + `, + }) + export class MyProviderButtonComponent { + // Get the provider ID from the Firebase Console + provider = new OAuthProvider('oidc.my-provider'); + } + ``` +
+ +If the `themed` prop is provided, you can trigger the styling via providing some custom CSS which targets the button: + +```css +.fui-provider__button[data-provider="oidc.my-provider"][data-themed="true"] { + --my-provider: blue; + --color-primary: var(--my-provider); + --color-primary-hover: --alpha(var(--my-provider) / 85%); + --color-primary-surface: #FFFFFF; + --color-border: var(--my-provider); +} + +/* If using Shadcn */ +button[data-provider="oidc.my-provider"][data-themed="true"] { + ... +``` + +## Contributing -This function will run through a series of checks to catch known Firebase errors: +Please see the [CONTRIBUTING](CONTRIBUTING.md) guide. -1. `auth/account-exists-with-different-credential`: Checking the error code to see if an account already exists for the user. If `enableHandleExistingCredential` is enabled the library will update the local storage automtaically before throwing the error. +## License -2. `FirebaseUIError`: Alternatively, a FirebaseUIError will be thrown with the appropriate code. +See [LICENSE](LICENSE). \ No newline at end of file diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 000000000..844817204 --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,107 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import js from "@eslint/js"; +import { globalIgnores } from "eslint/config"; +import globals from "globals"; +import tseslint from "typescript-eslint"; +import pluginPrettier from "eslint-plugin-prettier"; +import pluginReact from "eslint-plugin-react"; +import pluginReactHooks from "eslint-plugin-react-hooks"; +import pluginAngular from "angular-eslint"; + +const config: any[] = [ + globalIgnores([ + "**/dist/**", + "**/node_modules/**", + "**/build/**", + "**/.next/**", + "**/out/**", + "**/.firebase/**", + "**/.angular/**", + "**/releases/**", + "**/shadcn/public-dev/**", + "packages/styles/dist.css", + "packages/angular/**", + "packages/shadcn/public", + ]), + ...tseslint.configs.recommended, + { + // All TypeScript files + files: ["**/*.ts", "**/*.tsx"], + plugins: { js, prettier: pluginPrettier }, + languageOptions: { globals: { ...globals.browser, ...globals.node } }, + rules: { + "prettier/prettier": "error", + "arrow-body-style": "off", + "prefer-arrow-callback": "off", + "@typescript-eslint/consistent-type-imports": [ + "error", + { + disallowTypeAnnotations: false, + prefer: "type-imports", + fixStyle: "inline-type-imports", + }, + ], + }, + }, + { + // Angular package specific rules + files: ["packages/angular/src/**/*.{ts,tsx}"], + processor: pluginAngular.processInlineTemplates, + }, + { + // React package specific rules + files: ["packages/react/src/**/*.{ts,tsx}", "packages/shadcn/src/**/*.{ts,tsx}"], + plugins: { react: pluginReact, "react-hooks": pluginReactHooks }, + languageOptions: { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + settings: { + react: { + version: "detect", + }, + }, + rules: { + ...pluginReact.configs.recommended.rules, + ...pluginReactHooks.configs.recommended.rules, + "react/react-in-jsx-scope": "off", // Not needed with React 17+ + }, + }, + { + // Test files - more lenient rules + files: [ + "**/*.test.{ts,tsx}", + "**/*.spec.{ts,tsx}", + "**/tests/**/*.{ts,tsx}", + // These are generated from shadcn, so we don't need to lint them + "examples/shadcn/src/components/**/*.tsx", + ], + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/consistent-type-imports": "off", + }, + }, +]; + +export default config; diff --git a/examples/angular/.firebaserc b/examples/angular/.firebaserc new file mode 100644 index 000000000..043e32416 --- /dev/null +++ b/examples/angular/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "fir-ui-rework" + } +} diff --git a/examples/angular/.gitignore b/examples/angular/.gitignore index cc7b14135..3f558e873 100644 --- a/examples/angular/.gitignore +++ b/examples/angular/.gitignore @@ -36,6 +36,7 @@ yarn-error.log /libpeerconnection.log testem.log /typings +.firebase # System files .DS_Store diff --git a/examples/angular/.postcssrc.json b/examples/angular/.postcssrc.json index 72f908df1..e092dc7c1 100644 --- a/examples/angular/.postcssrc.json +++ b/examples/angular/.postcssrc.json @@ -2,4 +2,4 @@ "plugins": { "@tailwindcss/postcss": {} } -} \ No newline at end of file +} diff --git a/examples/angular/.prettierrc b/examples/angular/.prettierrc new file mode 100644 index 000000000..37702140f --- /dev/null +++ b/examples/angular/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "endOfLine": "auto" +} diff --git a/examples/angular/README.md b/examples/angular/README.md index c6b0f264b..05a96cb33 100644 --- a/examples/angular/README.md +++ b/examples/angular/README.md @@ -38,10 +38,10 @@ This will compile your project and store the build artifacts in the `dist/` dire ## Running unit tests -To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command: +To execute unit tests with [Vitest](https://vitest.dev), use the following command: ```bash -ng test +pnpm test ``` ## Running end-to-end tests diff --git a/examples/angular/angular.json b/examples/angular/angular.json index 3fdb95ddc..a297e333d 100644 --- a/examples/angular/angular.json +++ b/examples/angular/angular.json @@ -10,14 +10,12 @@ "prefix": "app", "architect": { "build": { - "builder": "@angular-devkit/build-angular:application", + "builder": "@angular/build:application", "options": { "outputPath": "dist/angular-ssr", "index": "src/index.html", "browser": "src/main.ts", - "polyfills": [ - "zone.js" - ], + "polyfills": ["zone.js"], "tsConfig": "tsconfig.app.json", "assets": [ { @@ -25,12 +23,10 @@ "input": "public" } ], - "styles": [ - "src/styles.css" - ], + "styles": ["src/styles.css"], "scripts": [], "server": "src/main.server.ts", - "prerender": true, + "prerender": false, "ssr": { "entry": "src/server.ts" } @@ -40,8 +36,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "500kB", - "maximumError": "1MB" + "maximumWarning": "1MB", + "maximumError": "2MB" }, { "type": "anyComponentStyle", @@ -60,7 +56,7 @@ "defaultConfiguration": "production" }, "serve": { - "builder": "@angular-devkit/build-angular:dev-server", + "builder": "@angular/build:dev-server", "configurations": { "production": { "buildTarget": "angular-ssr:build:production" @@ -72,65 +68,61 @@ "defaultConfiguration": "development" }, "extract-i18n": { - "builder": "@angular-devkit/build-angular:extract-i18n" - }, - "test": { - "builder": "@angular-devkit/build-angular:karma", - "options": { - "polyfills": [ - "zone.js", - "zone.js/testing" - ], - "tsConfig": "tsconfig.spec.json", - "assets": [ - { - "glob": "**/*", - "input": "public" - } - ], - "styles": [ - "src/styles.css" - ], - "scripts": [] - } + "builder": "@angular/build:extract-i18n" } } }, - "firebaseui-angular": { + "angular": { "projectType": "library", - "root": "projects/firebaseui-angular", - "sourceRoot": "projects/firebaseui-angular/src", + "root": "projects/angular", + "sourceRoot": "projects/angular/src", "prefix": "lib", "architect": { "build": { - "builder": "@angular-devkit/build-angular:ng-packagr", + "builder": "@angular/build:ng-packagr", "options": { - "project": "projects/firebaseui-angular/ng-package.json" + "project": "projects/angular/ng-package.json" }, "configurations": { "production": { - "tsConfig": "projects/firebaseui-angular/tsconfig.lib.prod.json" + "tsConfig": "projects/angular/tsconfig.lib.prod.json" }, "development": { - "tsConfig": "projects/firebaseui-angular/tsconfig.lib.json" + "tsConfig": "projects/angular/tsconfig.lib.json" } }, "defaultConfiguration": "production" - }, - "test": { - "builder": "@angular-devkit/build-angular:karma", - "options": { - "tsConfig": "projects/firebaseui-angular/tsconfig.spec.json", - "polyfills": [ - "zone.js", - "zone.js/testing" - ] - } } } } }, "cli": { "analytics": false + }, + "schematics": { + "@schematics/angular:component": { + "type": "component" + }, + "@schematics/angular:directive": { + "type": "directive" + }, + "@schematics/angular:service": { + "type": "service" + }, + "@schematics/angular:guard": { + "typeSeparator": "." + }, + "@schematics/angular:interceptor": { + "typeSeparator": "." + }, + "@schematics/angular:module": { + "typeSeparator": "." + }, + "@schematics/angular:pipe": { + "typeSeparator": "." + }, + "@schematics/angular:resolver": { + "typeSeparator": "." + } } } diff --git a/examples/angular/eslint.config.js b/examples/angular/eslint.config.js new file mode 100644 index 000000000..164788c0f --- /dev/null +++ b/examples/angular/eslint.config.js @@ -0,0 +1,49 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import js from "@eslint/js"; +import prettier from "eslint-config-prettier"; +import typescript from "@typescript-eslint/eslint-plugin"; +import typescriptParser from "@typescript-eslint/parser"; + +export default [ + { ignores: ["dist/**", "node_modules/**", ".angular/**"] }, + js.configs.recommended, + prettier, + { + files: ["**/*.ts"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + parser: typescriptParser, + parserOptions: { + project: "./tsconfig.json", + }, + }, + plugins: { + "@typescript-eslint": typescript, + }, + rules: { + "no-unused-vars": "off", // Use TypeScript version instead + "no-console": "off", // Allow console in examples + "no-undef": "off", // TypeScript handles this + "prefer-const": "error", + "no-var": "error", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": ["error", { varsIgnorePattern: "^_", argsIgnorePattern: "^_" }], + }, + }, +]; diff --git a/examples/angular/package-lock.json b/examples/angular/package-lock.json deleted file mode 100644 index 4f1a7002e..000000000 --- a/examples/angular/package-lock.json +++ /dev/null @@ -1,25569 +0,0 @@ -{ - "name": "angular-example", - "version": "0.0.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "angular-example", - "version": "0.0.0", - "dependencies": { - "@angular/animations": "^19.1.0", - "@angular/common": "^19.1.0", - "@angular/compiler": "^19.1.0", - "@angular/core": "^19.1.0", - "@angular/fire": "^19.0.0", - "@angular/forms": "^19.1.0", - "@angular/platform-browser": "^19.1.0", - "@angular/platform-browser-dynamic": "^19.1.0", - "@angular/platform-server": "^19.1.0", - "@angular/router": "^19.1.0", - "@angular/ssr": "^19.1.7", - "@firebase-ui/angular": "https://github.com/invertase/firebaseui-web/releases/download/@firebase-ui/angular@0.0.1/firebase-ui-angular-0.0.1.tgz", - "@firebase-ui/core": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-core-0.0.1.tgz", - "@firebase-ui/styles": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-styles-0.0.1.tgz", - "@firebase-ui/translations": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-translations-0.0.1.tgz", - "@tailwindcss/postcss": "^4.0.6", - "express": "^4.18.2", - "postcss": "^8.5.2", - "rxjs": "~7.8.0", - "tailwindcss": "^4.0.6", - "tslib": "^2.3.0", - "zone.js": "~0.15.0" - }, - "devDependencies": { - "@angular-devkit/build-angular": "^19.1.7", - "@angular/cli": "^19.1.7", - "@angular/compiler-cli": "^19.1.0", - "@tanstack/angular-form": "^0.42.0", - "@types/express": "^4.17.17", - "@types/jasmine": "~5.1.0", - "@types/node": "^18.18.0", - "firebase": "^11", - "jasmine-core": "~5.5.0", - "karma": "~6.4.0", - "karma-chrome-launcher": "~3.2.0", - "karma-coverage": "~2.2.0", - "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "~2.1.0", - "nanostores": "^0.11.3", - "ng-packagr": "^19.1.0", - "typescript": "~5.7.2" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@angular-devkit/architect": { - "version": "0.1902.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.12.tgz", - "integrity": "sha512-LfUc7k84YL290hAxsG+FvjQpXugQXyw5aDzrQQB4iTYhBgaABu2aaNOU4eu3JH+F8NeXd2EBF/YMr2LDSkYlMw==", - "dev": true, - "dependencies": { - "@angular-devkit/core": "19.2.12", - "rxjs": "7.8.1" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular-devkit/architect/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@angular-devkit/build-angular": { - "version": "19.2.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-19.2.12.tgz", - "integrity": "sha512-gPx3Vi7QFzHkSV388en6VqSqasojitJKuKmgTMPOV5keLtpOylPv3rjnr8oO9rYbYmLsT/WTUsP7bYiZhrr19Q==", - "dev": true, - "dependencies": { - "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1902.12", - "@angular-devkit/build-webpack": "0.1902.12", - "@angular-devkit/core": "19.2.12", - "@angular/build": "19.2.12", - "@babel/core": "7.26.10", - "@babel/generator": "7.26.10", - "@babel/helper-annotate-as-pure": "7.25.9", - "@babel/helper-split-export-declaration": "7.24.7", - "@babel/plugin-transform-async-generator-functions": "7.26.8", - "@babel/plugin-transform-async-to-generator": "7.25.9", - "@babel/plugin-transform-runtime": "7.26.10", - "@babel/preset-env": "7.26.9", - "@babel/runtime": "7.26.10", - "@discoveryjs/json-ext": "0.6.3", - "@ngtools/webpack": "19.2.12", - "@vitejs/plugin-basic-ssl": "1.2.0", - "ansi-colors": "4.1.3", - "autoprefixer": "10.4.20", - "babel-loader": "9.2.1", - "browserslist": "^4.21.5", - "copy-webpack-plugin": "12.0.2", - "css-loader": "7.1.2", - "esbuild-wasm": "0.25.4", - "fast-glob": "3.3.3", - "http-proxy-middleware": "3.0.5", - "istanbul-lib-instrument": "6.0.3", - "jsonc-parser": "3.3.1", - "karma-source-map-support": "1.4.0", - "less": "4.2.2", - "less-loader": "12.2.0", - "license-webpack-plugin": "4.0.2", - "loader-utils": "3.3.1", - "mini-css-extract-plugin": "2.9.2", - "open": "10.1.0", - "ora": "5.4.1", - "picomatch": "4.0.2", - "piscina": "4.8.0", - "postcss": "8.5.2", - "postcss-loader": "8.1.1", - "resolve-url-loader": "5.0.0", - "rxjs": "7.8.1", - "sass": "1.85.0", - "sass-loader": "16.0.5", - "semver": "7.7.1", - "source-map-loader": "5.0.0", - "source-map-support": "0.5.21", - "terser": "5.39.0", - "tree-kill": "1.2.2", - "tslib": "2.8.1", - "webpack": "5.98.0", - "webpack-dev-middleware": "7.4.2", - "webpack-dev-server": "5.2.0", - "webpack-merge": "6.0.1", - "webpack-subresource-integrity": "5.1.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "optionalDependencies": { - "esbuild": "0.25.4" - }, - "peerDependencies": { - "@angular/compiler-cli": "^19.0.0 || ^19.2.0-next.0", - "@angular/localize": "^19.0.0 || ^19.2.0-next.0", - "@angular/platform-server": "^19.0.0 || ^19.2.0-next.0", - "@angular/service-worker": "^19.0.0 || ^19.2.0-next.0", - "@angular/ssr": "^19.2.12", - "@web/test-runner": "^0.20.0", - "browser-sync": "^3.0.2", - "jest": "^29.5.0", - "jest-environment-jsdom": "^29.5.0", - "karma": "^6.3.0", - "ng-packagr": "^19.0.0 || ^19.2.0-next.0", - "protractor": "^7.0.0", - "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "typescript": ">=5.5 <5.9" - }, - "peerDependenciesMeta": { - "@angular/localize": { - "optional": true - }, - "@angular/platform-server": { - "optional": true - }, - "@angular/service-worker": { - "optional": true - }, - "@angular/ssr": { - "optional": true - }, - "@web/test-runner": { - "optional": true - }, - "browser-sync": { - "optional": true - }, - "jest": { - "optional": true - }, - "jest-environment-jsdom": { - "optional": true - }, - "karma": { - "optional": true - }, - "ng-packagr": { - "optional": true - }, - "protractor": { - "optional": true - }, - "tailwindcss": { - "optional": true - } - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/postcss": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz", - "integrity": "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.8", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@angular-devkit/build-webpack": { - "version": "0.1902.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1902.12.tgz", - "integrity": "sha512-JNwvzaN2RVbG1IClFPXhNpysVwf55nWmVsNN5iQHRXkD3kpqnaOfhUBtlhBBjLf/i6cwKEne2TI8zciaEYr+iw==", - "dev": true, - "dependencies": { - "@angular-devkit/architect": "0.1902.12", - "rxjs": "7.8.1" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.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" - } - }, - "node_modules/@angular-devkit/build-webpack/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@angular-devkit/core": { - "version": "19.2.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.12.tgz", - "integrity": "sha512-v5pdfZHZ8MTZozfpkhKoPFBpXQW+2GFbTfdyis8FBtevJWCbIsCR3xhodgI4jwzkSEAraN4oVtWvSytdNyBC6A==", - "dependencies": { - "ajv": "8.17.1", - "ajv-formats": "3.0.1", - "jsonc-parser": "3.3.1", - "picomatch": "4.0.2", - "rxjs": "7.8.1", - "source-map": "0.7.4" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^4.0.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, - "node_modules/@angular-devkit/core/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@angular-devkit/schematics": { - "version": "19.2.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.12.tgz", - "integrity": "sha512-vK5NI/asi1snWFkw02DpmC8tLq6u5ZbUwwXxgALKuVwGl3g1VLzrHrkoSCrcsOO9Nu6GQOPbxax2lR/DICmytg==", - "dependencies": { - "@angular-devkit/core": "19.2.12", - "jsonc-parser": "3.3.1", - "magic-string": "0.30.17", - "ora": "5.4.1", - "rxjs": "7.8.1" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular-devkit/schematics/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@angular/animations": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-19.2.11.tgz", - "integrity": "sha512-NR33bZVho7EgTc1fmCnmkwc2/U266n311Wfvk7VVtz+0Q9WliNdDLBon654V8IWSKvlqKXyU3W+fp0VjH/FvSw==", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/common": "19.2.11", - "@angular/core": "19.2.11" - } - }, - "node_modules/@angular/build": { - "version": "19.2.12", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.2.12.tgz", - "integrity": "sha512-G28ux1T5QDlWporwupWbcodBN3rcyHfK2Dh5M3UC5hj0GstpfEHcpBHxawZzIxhqPKy//tdVLlzORUgvAwnqbA==", - "dev": true, - "dependencies": { - "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1902.12", - "@babel/core": "7.26.10", - "@babel/helper-annotate-as-pure": "7.25.9", - "@babel/helper-split-export-declaration": "7.24.7", - "@babel/plugin-syntax-import-attributes": "7.26.0", - "@inquirer/confirm": "5.1.6", - "@vitejs/plugin-basic-ssl": "1.2.0", - "beasties": "0.3.2", - "browserslist": "^4.23.0", - "esbuild": "0.25.4", - "fast-glob": "3.3.3", - "https-proxy-agent": "7.0.6", - "istanbul-lib-instrument": "6.0.3", - "listr2": "8.2.5", - "magic-string": "0.30.17", - "mrmime": "2.0.1", - "parse5-html-rewriting-stream": "7.0.0", - "picomatch": "4.0.2", - "piscina": "4.8.0", - "rollup": "4.34.8", - "sass": "1.85.0", - "semver": "7.7.1", - "source-map-support": "0.5.21", - "vite": "6.2.7", - "watchpack": "2.4.2" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "optionalDependencies": { - "lmdb": "3.2.6" - }, - "peerDependencies": { - "@angular/compiler": "^19.0.0 || ^19.2.0-next.0", - "@angular/compiler-cli": "^19.0.0 || ^19.2.0-next.0", - "@angular/localize": "^19.0.0 || ^19.2.0-next.0", - "@angular/platform-server": "^19.0.0 || ^19.2.0-next.0", - "@angular/service-worker": "^19.0.0 || ^19.2.0-next.0", - "@angular/ssr": "^19.2.12", - "karma": "^6.4.0", - "less": "^4.2.0", - "ng-packagr": "^19.0.0 || ^19.2.0-next.0", - "postcss": "^8.4.0", - "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "typescript": ">=5.5 <5.9" - }, - "peerDependenciesMeta": { - "@angular/localize": { - "optional": true - }, - "@angular/platform-server": { - "optional": true - }, - "@angular/service-worker": { - "optional": true - }, - "@angular/ssr": { - "optional": true - }, - "karma": { - "optional": true - }, - "less": { - "optional": true - }, - "ng-packagr": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tailwindcss": { - "optional": true - } - } - }, - "node_modules/@angular/build/node_modules/vite": { - "version": "6.2.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.7.tgz", - "integrity": "sha512-qg3LkeuinTrZoJHHF94coSaTfIPyBYoywp+ys4qu20oSJFbKMYoIJo0FWJT9q6Vp49l6z9IsJRbHdcGtiKbGoQ==", - "dev": true, - "dependencies": { - "esbuild": "^0.25.0", - "postcss": "^8.5.3", - "rollup": "^4.30.1" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/@angular/cli": { - "version": "19.2.12", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.12.tgz", - "integrity": "sha512-cZkHpM16uh3VouHG1XdWSk0ZWisQRxMVADk5IJlM9jMcPqnFyJwD/UXCS+XTaW3POpNDwsmbh2UB9Xabdgo7rw==", - "dev": true, - "dependencies": { - "@angular-devkit/architect": "0.1902.12", - "@angular-devkit/core": "19.2.12", - "@angular-devkit/schematics": "19.2.12", - "@inquirer/prompts": "7.3.2", - "@listr2/prompt-adapter-inquirer": "2.0.18", - "@schematics/angular": "19.2.12", - "@yarnpkg/lockfile": "1.1.0", - "ini": "5.0.0", - "jsonc-parser": "3.3.1", - "listr2": "8.2.5", - "npm-package-arg": "12.0.2", - "npm-pick-manifest": "10.0.0", - "pacote": "20.0.0", - "resolve": "1.22.10", - "semver": "7.7.1", - "symbol-observable": "4.0.0", - "yargs": "17.7.2" - }, - "bin": { - "ng": "bin/ng.js" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular/common": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.11.tgz", - "integrity": "sha512-/ZnF2Nfp6S6TAu3VlvUAIp4NVd81WE1Q95wuwSSuoEx2aSyXzI+1myyKWSYe/jYCyGuppmocjTciEh8mAInmOw==", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/core": "19.2.11", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@angular/compiler": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.11.tgz", - "integrity": "sha512-/ZGFAEO2TyqkaE4neR8lGL9I2QeO2sRVFqulQv7Bu8zKTPStjcsFCwNkp+TNX8Oq/1rLcY9XWAOsUk1//AZd8Q==", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - } - }, - "node_modules/@angular/compiler-cli": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.11.tgz", - "integrity": "sha512-15aoOg+qj7Z3Uap1JKHMy51y12M09AOnseDBa0SYKidSx15XwZi8d01hv7sRaQJX/6Ie5cug9GiAbLKts6R33w==", - "dev": true, - "dependencies": { - "@babel/core": "7.26.9", - "@jridgewell/sourcemap-codec": "^1.4.14", - "chokidar": "^4.0.0", - "convert-source-map": "^1.5.1", - "reflect-metadata": "^0.2.0", - "semver": "^7.0.0", - "tslib": "^2.3.0", - "yargs": "^17.2.1" - }, - "bin": { - "ng-xi18n": "bundles/src/bin/ng_xi18n.js", - "ngc": "bundles/src/bin/ngc.js", - "ngcc": "bundles/ngcc/index.js" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/compiler": "19.2.11", - "typescript": ">=5.5 <5.9" - } - }, - "node_modules/@angular/compiler-cli/node_modules/@babel/core": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", - "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", - "dev": true, - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.9", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.9", - "@babel/parser": "^7.26.9", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.9", - "@babel/types": "^7.26.9", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@angular/core": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.11.tgz", - "integrity": "sha512-kmtJQB7B5F2V1JIzy1oBPS6WrRyedSYkuge+XoX1mCSFJDef8HRNd7GopnQ0Zaz0vOTGvCCkWvvaH/+7s2lmAQ==", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "rxjs": "^6.5.3 || ^7.4.0", - "zone.js": "~0.15.0" - } - }, - "node_modules/@angular/fire": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/@angular/fire/-/fire-19.1.0.tgz", - "integrity": "sha512-yyELJQLxF56EoGW8HUxfATBUeX5rzNpt/PjNAhSlmWdQ12jXVkgGeWyWsl5gvUlxhpFKIt+EVp3nYvwIlzey6Q==", - "dependencies": { - "@angular-devkit/schematics": "^19.0.0", - "@schematics/angular": "^19.0.0", - "firebase": "^11.2.0", - "rxfire": "^6.1.0", - "tslib": "^2.3.0" - }, - "peerDependencies": { - "@angular/common": "^19.0.0", - "@angular/core": "^19.0.0", - "@angular/platform-browser": "^19.0.0", - "@angular/platform-browser-dynamic": "^19.0.0", - "@angular/platform-server": "^19.0.0", - "firebase-tools": "^13.0.0", - "rxjs": "~7.8.0" - }, - "peerDependenciesMeta": { - "@angular/platform-server": { - "optional": true - }, - "firebase-tools": { - "optional": true - } - } - }, - "node_modules/@angular/forms": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.11.tgz", - "integrity": "sha512-ZH9ccuT6rTirNSbiMRtGRkRrj69a2/+BVaa/kEpUHjh41wDQXxhOlOfPZd/sfj04QiAzIpsYmVJrmoV7/LxPSw==", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/common": "19.2.11", - "@angular/core": "19.2.11", - "@angular/platform-browser": "19.2.11", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@angular/platform-browser": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.11.tgz", - "integrity": "sha512-wAPJtgzmxBEpW31sa2eg9QssCHBZ52Zc9nm6azTflDlOAyfm9bzqec7y3wqy5sgVue/qID2gzHqmpS3Nx3o0xg==", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/animations": "19.2.11", - "@angular/common": "19.2.11", - "@angular/core": "19.2.11" - }, - "peerDependenciesMeta": { - "@angular/animations": { - "optional": true - } - } - }, - "node_modules/@angular/platform-browser-dynamic": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-19.2.11.tgz", - "integrity": "sha512-1/0FmjSAvsK+A6gWLgEc60YMnWQchP9fP6y4sE1uQOThIgK+qLnLjZqZn7uOw8zMDBMtxB7SlepajnXftVXddw==", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/common": "19.2.11", - "@angular/compiler": "19.2.11", - "@angular/core": "19.2.11", - "@angular/platform-browser": "19.2.11" - } - }, - "node_modules/@angular/platform-server": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/platform-server/-/platform-server-19.2.11.tgz", - "integrity": "sha512-RbIE99k6QRw1EDDFFpjwM1aVVZlZ6B6zXWJTcjLUTCkF2tcZd2zZH3/3qiENETlFFI4A4VE1zTTtZD3/29sJnA==", - "dependencies": { - "tslib": "^2.3.0", - "xhr2": "^0.2.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/common": "19.2.11", - "@angular/compiler": "19.2.11", - "@angular/core": "19.2.11", - "@angular/platform-browser": "19.2.11", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@angular/router": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.11.tgz", - "integrity": "sha512-nBwMwRgQ3s1c1CPItPnTJTf81NDOQHvK41r2MIJGHa3H9LONlcbY07q/9p49fqt/xn/dgoOmQTtJ22b/nbIJAQ==", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/common": "19.2.11", - "@angular/core": "19.2.11", - "@angular/platform-browser": "19.2.11", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@angular/ssr": { - "version": "19.2.12", - "resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-19.2.12.tgz", - "integrity": "sha512-RNi/u6Hbg8bJ1FYOUbjT5dmyfM+H5kok1MuRWvpSaVUpH2s/CMNQ/F9fw6vzay2Nr/qVHeq+eeYYY8QXn2ZbhA==", - "dependencies": { - "tslib": "^2.3.0" - }, - "peerDependencies": { - "@angular/common": "^19.0.0 || ^19.2.0-next.0", - "@angular/core": "^19.0.0 || ^19.2.0-next.0", - "@angular/platform-server": "^19.0.0 || ^19.2.0-next.0", - "@angular/router": "^19.0.0 || ^19.2.0-next.0" - }, - "peerDependenciesMeta": { - "@angular/platform-server": { - "optional": true - } - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.2.tgz", - "integrity": "sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", - "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", - "dev": true, - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.10", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.10", - "@babel/types": "^7.26.10", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.10.tgz", - "integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.26.10", - "@babel/types": "^7.26.10", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", - "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", - "dev": true, - "dependencies": { - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", - "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-member-expression-to-functions": "^7.27.1", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.27.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", - "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", - "dev": true, - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz", - "integrity": "sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "regexpu-core": "^6.2.0", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", - "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", - "dev": true, - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz", - "integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==", - "dev": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", - "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", - "dev": true, - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz", - "integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-remap-async-to-generator/node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", - "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", - "dev": true, - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", - "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", - "dev": true, - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.27.1", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "dev": true, - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", - "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", - "dev": true, - "dependencies": { - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-wrap-function": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.27.1.tgz", - "integrity": "sha512-NFJK2sHUvrjo8wAU/nQTWU890/zB2jj0qBcCbZbbf+005cAsv6tMjXz31fBign6M5ov1o0Bllu+9nbqkfsjjJQ==", - "dev": true, - "dependencies": { - "@babel/template": "^7.27.1", - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz", - "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==", - "dev": true, - "dependencies": { - "@babel/template": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz", - "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.27.1" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz", - "integrity": "sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", - "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", - "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", - "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.13.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.27.1.tgz", - "integrity": "sha512-6BpaYGDavZqkI6yT+KSPdpZFfpnd68UKXbcjI9pJ13pvHhPrCKWOOLp+ysvMeA+DxnhuPpgIaRpxRxo5A9t5jw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", - "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", - "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-unicode-sets-regex": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", - "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.26.8.tgz", - "integrity": "sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.26.5", - "@babel/helper-remap-async-to-generator": "^7.25.9", - "@babel/traverse": "^7.26.8" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", - "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-remap-async-to-generator": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.1.tgz", - "integrity": "sha512-QEcFlMl9nGTgh1rn2nIeU5bkfb9BAjaQcWbiP4LvKxUot52ABcTkpcyJ7f2Q2U2RuQ84BNLgts3jRme2dTx6Fw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", - "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", - "dev": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.27.1.tgz", - "integrity": "sha512-s734HmYU78MVzZ++joYM+NkJusItbdRcbm+AGRgJCt3iA+yux0QpD9cBVdz3tKyrjVYWRl7j0mHSmv4lhV0aoA==", - "dev": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" - } - }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.27.1.tgz", - "integrity": "sha512-7iLhfFAubmpeJe/Wo2TVuDrykh/zlWXLzPNdL0Jqn/Xu8R3QQ8h9ff8FQoISZOsw74/HFqFI7NX63HN7QFIHKA==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/traverse": "^7.27.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-classes/node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", - "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", - "dev": true, - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", - "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/template": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.27.1.tgz", - "integrity": "sha512-ttDCqhfvpE9emVkXbPD8vyxxh4TWYACVybGkDj+oReOGwnp066ITEivDlLwe0b1R0+evJ13IXQuLNB5w1fhC5Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", - "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", - "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", - "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", - "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz", - "integrity": "sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", - "dev": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", - "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz", - "integrity": "sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", - "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", - "dev": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", - "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", - "dev": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz", - "integrity": "sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==", - "dev": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", - "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", - "dev": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", - "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-new-target": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", - "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", - "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", - "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.27.2.tgz", - "integrity": "sha512-AIUHD7xJ1mCrj3uPozvtngY3s0xpv7Nu7DoUSnzNY6Xam1Cy4rUznR//pvMHOhQ4AvbCexhbqXCtpxGHOGOO6g==", - "dev": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.27.1", - "@babel/plugin-transform-parameters": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-super": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", - "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", - "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.1.tgz", - "integrity": "sha512-018KRk76HWKeZ5l4oTj2zPpSh+NbGdt0st5S6x0pga6HgrjBOJb24mMDHorFopOOd6YHkLgOZ+zaCjZGPO4aKg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", - "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", - "dev": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", - "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-property-in-object/node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", - "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", - "dev": true, - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.1.tgz", - "integrity": "sha512-B19lbbL7PMrKr52BNPjCqg1IyNUIjTcxKj8uX9zHO+PmWN93s19NDr/f69mIkEp2x9nmDJ08a7lgHaTTzvW7mw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", - "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", - "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-runtime": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.26.10.tgz", - "integrity": "sha512-NWaL2qG6HRpONTnj4JvDU6th4jYeZOJgu3QhmFTCihib0ermtOJqktA5BduGm3suhhVe9EMP9c9+mfJ/I9slqw==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-plugin-utils": "^7.26.5", - "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.11.0", - "babel-plugin-polyfill-regenerator": "^0.6.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-spread": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", - "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", - "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", - "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", - "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", - "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/preset-env": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.9.tgz", - "integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.26.8", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-plugin-utils": "^7.26.5", - "@babel/helper-validator-option": "^7.25.9", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.26.0", - "@babel/plugin-syntax-import-attributes": "^7.26.0", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.25.9", - "@babel/plugin-transform-async-generator-functions": "^7.26.8", - "@babel/plugin-transform-async-to-generator": "^7.25.9", - "@babel/plugin-transform-block-scoped-functions": "^7.26.5", - "@babel/plugin-transform-block-scoping": "^7.25.9", - "@babel/plugin-transform-class-properties": "^7.25.9", - "@babel/plugin-transform-class-static-block": "^7.26.0", - "@babel/plugin-transform-classes": "^7.25.9", - "@babel/plugin-transform-computed-properties": "^7.25.9", - "@babel/plugin-transform-destructuring": "^7.25.9", - "@babel/plugin-transform-dotall-regex": "^7.25.9", - "@babel/plugin-transform-duplicate-keys": "^7.25.9", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", - "@babel/plugin-transform-dynamic-import": "^7.25.9", - "@babel/plugin-transform-exponentiation-operator": "^7.26.3", - "@babel/plugin-transform-export-namespace-from": "^7.25.9", - "@babel/plugin-transform-for-of": "^7.26.9", - "@babel/plugin-transform-function-name": "^7.25.9", - "@babel/plugin-transform-json-strings": "^7.25.9", - "@babel/plugin-transform-literals": "^7.25.9", - "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", - "@babel/plugin-transform-member-expression-literals": "^7.25.9", - "@babel/plugin-transform-modules-amd": "^7.25.9", - "@babel/plugin-transform-modules-commonjs": "^7.26.3", - "@babel/plugin-transform-modules-systemjs": "^7.25.9", - "@babel/plugin-transform-modules-umd": "^7.25.9", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", - "@babel/plugin-transform-new-target": "^7.25.9", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.26.6", - "@babel/plugin-transform-numeric-separator": "^7.25.9", - "@babel/plugin-transform-object-rest-spread": "^7.25.9", - "@babel/plugin-transform-object-super": "^7.25.9", - "@babel/plugin-transform-optional-catch-binding": "^7.25.9", - "@babel/plugin-transform-optional-chaining": "^7.25.9", - "@babel/plugin-transform-parameters": "^7.25.9", - "@babel/plugin-transform-private-methods": "^7.25.9", - "@babel/plugin-transform-private-property-in-object": "^7.25.9", - "@babel/plugin-transform-property-literals": "^7.25.9", - "@babel/plugin-transform-regenerator": "^7.25.9", - "@babel/plugin-transform-regexp-modifiers": "^7.26.0", - "@babel/plugin-transform-reserved-words": "^7.25.9", - "@babel/plugin-transform-shorthand-properties": "^7.25.9", - "@babel/plugin-transform-spread": "^7.25.9", - "@babel/plugin-transform-sticky-regex": "^7.25.9", - "@babel/plugin-transform-template-literals": "^7.26.8", - "@babel/plugin-transform-typeof-symbol": "^7.26.7", - "@babel/plugin-transform-unicode-escapes": "^7.25.9", - "@babel/plugin-transform-unicode-property-regex": "^7.25.9", - "@babel/plugin-transform-unicode-regex": "^7.25.9", - "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.11.0", - "babel-plugin-polyfill-regenerator": "^0.6.1", - "core-js-compat": "^3.40.0", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-env/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/preset-modules": { - "version": "0.1.6-no-external-plugins", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", - "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/types": "^7.4.4", - "esutils": "^2.0.2" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", - "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", - "dev": true, - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz", - "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.1", - "@babel/parser": "^7.27.1", - "@babel/template": "^7.27.1", - "@babel/types": "^7.27.1", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", - "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.27.1", - "@babel/types": "^7.27.1", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", - "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/@discoveryjs/json-ext": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", - "integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==", - "dev": true, - "engines": { - "node": ">=14.17.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", - "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", - "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", - "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", - "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", - "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", - "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", - "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", - "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", - "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", - "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", - "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", - "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", - "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", - "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", - "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", - "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", - "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", - "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", - "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", - "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", - "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", - "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", - "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", - "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", - "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@firebase-ui/angular": { - "version": "0.0.1", - "resolved": "https://github.com/invertase/firebaseui-web/releases/download/@firebase-ui/angular@0.0.1/firebase-ui-angular-0.0.1.tgz", - "integrity": "sha512-usltgMAzwGFN2ghawAbMKy1Tgdf/VhbUFoiYsCWdiyS1oQ9hyjQvxhz0uDDrhg/bJW965VGUQVU81q2FmWqIDA==", - "dependencies": { - "@tanstack/angular-form": "^1.1.0", - "nanostores": "^0.11.3", - "tslib": "^2.3.0", - "zod": "^3.24.1" - }, - "peerDependencies": { - "@angular/common": "^19.1.0", - "@angular/core": "^19.1.0", - "@firebase-ui/core": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-core-0.0.1.tgz", - "@firebase-ui/translations": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-translations-0.0.1.tgz" - } - }, - "node_modules/@firebase-ui/angular/node_modules/@tanstack/angular-form": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@tanstack/angular-form/-/angular-form-1.11.2.tgz", - "integrity": "sha512-ll9ZHqjfqPIA4fRQsyrA22PZJtinQeNJYJBHAROrr+h3IbN7NOA/4yRVxjQWCwhFpwh9PU8Cl563a52x9c0iIQ==", - "dependencies": { - "@tanstack/angular-store": "^0.7.0", - "@tanstack/form-core": "1.11.2", - "tslib": "^2.8.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "@angular/core": ">=19.0.0" - } - }, - "node_modules/@firebase-ui/angular/node_modules/@tanstack/form-core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.11.2.tgz", - "integrity": "sha512-HAocV5E6y4EHisH6qPvredkr2X5ARULDLWx8Z7Jz9pNz0bUBzUjPF/QtVBHQKrYMrwl9cE+TxddcghjiQYDsmQ==", - "dependencies": { - "@tanstack/store": "^0.7.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@firebase-ui/core": { - "version": "0.0.1", - "resolved": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-core-0.0.1.tgz", - "integrity": "sha512-qwZPZvhZ99ODLmI/2aHNLjS61rS8BQnyMJYCama+567UPp3jU2GgLzS9XD5CB1Iy4IvmPfgFYHRh1evpmx7evA==", - "license": "MIT", - "dependencies": { - "@firebase-ui/translations": "0.0.1", - "nanostores": "^0.11.3", - "zod": "^3.24.1" - }, - "peerDependencies": { - "firebase": "^11" - } - }, - "node_modules/@firebase-ui/styles": { - "version": "0.0.1", - "resolved": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-styles-0.0.1.tgz", - "integrity": "sha512-aRsD27AjgsXTPOylYT7Qu3IeI0cOT1eZ6MiCddH5n8cHpG9lpXDwYD1+Bqo7ZBs6Wqi3LuX+6iI5Aq374E025w==" - }, - "node_modules/@firebase-ui/translations": { - "version": "0.0.1", - "resolved": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-translations-0.0.1.tgz", - "integrity": "sha512-k8mzvjPvRHlrB1zPXNVuq6vIOkzY5t7Ta97Lqrml+rmfpP/eISy9991eH0Rwy/Xoc10qCj6DMw9bQWBRVsnbCg==" - }, - "node_modules/@firebase/ai": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-1.3.0.tgz", - "integrity": "sha512-qBxJTtl9hpgZr050kVFTRADX6I0Ss6mEQyp/JEkBgKwwxixKnaRNqEDGFba4OKNL7K8E4Y7LlA/ZW6L8aCKH4A==", - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x", - "@firebase/app-types": "0.x" - } - }, - "node_modules/@firebase/analytics": { - "version": "0.10.16", - "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.16.tgz", - "integrity": "sha512-cMtp19He7Fd6uaj/nDEul+8JwvJsN8aRSJyuA1QN3QrKvfDDp+efjVurJO61sJpkVftw9O9nNMdhFbRcTmTfRQ==", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/installations": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/analytics-compat": { - "version": "0.2.22", - "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.22.tgz", - "integrity": "sha512-VogWHgwkdYhjWKh8O1XU04uPrRaiDihkWvE/EMMmtWtaUtVALnpLnUurc3QtSKdPnvTz5uaIGKlW84DGtSPFbw==", - "dependencies": { - "@firebase/analytics": "0.10.16", - "@firebase/analytics-types": "0.8.3", - "@firebase/component": "0.6.17", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/analytics-types": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", - "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==" - }, - "node_modules/@firebase/app": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.13.0.tgz", - "integrity": "sha512-Vj3MST245nq+V5UmmfEkB3isIgPouyUr8yGJlFeL9Trg/umG5ogAvrjAYvQ8gV7daKDoQSRnJKWI2JFpQqRsuQ==", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "idb": "7.1.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/app-check": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.10.0.tgz", - "integrity": "sha512-AZlRlVWKcu8BH4Yf8B5EI8sOi2UNGTS8oMuthV45tbt6OVUTSQwFPIEboZzhNJNKY+fPsg7hH8vixUWFZ3lrhw==", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/app-check-compat": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.3.25.tgz", - "integrity": "sha512-3zrsPZWAKfV7DVC20T2dgfjzjtQnSJS65OfMOiddMUtJL1S5i0nAZKsdX0bOEvvrd0SBIL8jYnfpfDeQRnhV3w==", - "dependencies": { - "@firebase/app-check": "0.10.0", - "@firebase/app-check-types": "0.5.3", - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/app-check-interop-types": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", - "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==" - }, - "node_modules/@firebase/app-check-types": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", - "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==" - }, - "node_modules/@firebase/app-compat": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.4.0.tgz", - "integrity": "sha512-LjLUrzbUgTa/sCtPoLKT2C7KShvLVHS3crnU1Du02YxnGVLE0CUBGY/NxgfR/Zg84mEbj1q08/dgesojxjn0dA==", - "dependencies": { - "@firebase/app": "0.13.0", - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/app-types": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", - "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==" - }, - "node_modules/@firebase/auth": { - "version": "1.10.5", - "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.10.5.tgz", - "integrity": "sha512-6wF/NdMTwObL4RNQePunuzMr9O3gyftisvFZFFKf57D2HONXo87YymogRV8d+Z7SLA0rcNBN1gLJVk2D0y97gA==", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x", - "@react-native-async-storage/async-storage": "^1.18.1" - }, - "peerDependenciesMeta": { - "@react-native-async-storage/async-storage": { - "optional": true - } - } - }, - "node_modules/@firebase/auth-compat": { - "version": "0.5.25", - "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.5.25.tgz", - "integrity": "sha512-YKUYnvrxXBRhH/iYEwSOv85VPvc6P36GW1OCDRebTw/cvgoj7pwac2nZKYFs5FHlNYe7Bc9I4BoY2X0vlkJo+g==", - "dependencies": { - "@firebase/auth": "1.10.5", - "@firebase/auth-types": "0.13.0", - "@firebase/component": "0.6.17", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/auth-interop-types": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", - "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==" - }, - "node_modules/@firebase/auth-types": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz", - "integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==", - "peerDependencies": { - "@firebase/app-types": "0.x", - "@firebase/util": "1.x" - } - }, - "node_modules/@firebase/component": { - "version": "0.6.17", - "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.17.tgz", - "integrity": "sha512-M6DOg7OySrKEFS8kxA3MU5/xc37fiOpKPMz6cTsMUcsuKB6CiZxxNAvgFta8HGRgEpZbi8WjGIj6Uf+TpOhyzg==", - "dependencies": { - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/data-connect": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.3.8.tgz", - "integrity": "sha512-xC50SxurrP0j9ksltZ8O2SuPuWTu9KymNxtSE4bmcc/HMOnOHaURgLyrQpcC5Pc7HmtCBxh9Q/lNKyc37rj5/g==", - "dependencies": { - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/database": { - "version": "1.0.18", - "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.18.tgz", - "integrity": "sha512-uXtYQmK6JCmqSx7dTOQD/qZtSnbMqnwvklF9n7wOJbdti4wKHmeUzgGXhPwDhN/R/BDTq78zKAbXya7hrCQjHw==", - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "faye-websocket": "0.11.4", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/database-compat": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.0.9.tgz", - "integrity": "sha512-9S6zK5+Tzslkt+lrYHDqbCbKBSQn3YYrNLIw8hTa/ALoqRLNTXF6acQIlxAxSeZj1hTttE6RRbuxxpMQJYt83w==", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/database": "1.0.18", - "@firebase/database-types": "1.0.14", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/database-types": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.14.tgz", - "integrity": "sha512-8a0Q1GrxM0akgF0RiQHliinhmZd+UQPrxEmUv7MnQBYfVFiLtKOgs3g6ghRt/WEGJHyQNslZ+0PocIwNfoDwKw==", - "dependencies": { - "@firebase/app-types": "0.9.3", - "@firebase/util": "1.12.0" - } - }, - "node_modules/@firebase/firestore": { - "version": "4.7.15", - "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.7.15.tgz", - "integrity": "sha512-FgWTmkNBEXdKCoN2ngBNjrMaXuBx6QwjiZZVnOGg+VjUmiBq5gAqlDIW5bZY6i/NYvLUrWugdqIs7y9GHEqwww==", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "@firebase/webchannel-wrapper": "1.0.3", - "@grpc/grpc-js": "~1.9.0", - "@grpc/proto-loader": "^0.7.8", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/firestore-compat": { - "version": "0.3.50", - "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.3.50.tgz", - "integrity": "sha512-1hAM+iaIqy2HHvSHQ56ccOOIigTeWAwjIpeQ+/O92uBoiajEITHdJofnGHglhhB5VV5qFl59Yz/AVDc+DssdYg==", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/firestore": "4.7.15", - "@firebase/firestore-types": "3.0.3", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/firestore-types": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", - "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", - "peerDependencies": { - "@firebase/app-types": "0.x", - "@firebase/util": "1.x" - } - }, - "node_modules/@firebase/functions": { - "version": "0.12.7", - "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.12.7.tgz", - "integrity": "sha512-gi8cw7yvaz19Erut+S0rHzNOWp4zPxAU/Kplb+XQoaE5gMV7MjHQoOGnYhSY8uOVj5f80S553s+2OBszG+14Ag==", - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.6.17", - "@firebase/messaging-interop-types": "0.2.3", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/functions-compat": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.3.24.tgz", - "integrity": "sha512-UjJabci+Bqci+A9WqfJ6sjZp+wGvi47llnQMjQRrF4coKfUyu9zBNTXhbx5W3rdVFQYwnWJm8VuluuNh2PCuyQ==", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/functions": "0.12.7", - "@firebase/functions-types": "0.6.3", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/functions-types": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", - "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==" - }, - "node_modules/@firebase/installations": { - "version": "0.6.17", - "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.17.tgz", - "integrity": "sha512-zfhqCNJZRe12KyADtRrtOj+SeSbD1H/K8J24oQAJVv/u02eQajEGlhZtcx9Qk7vhGWF5z9dvIygVDYqLL4o1XQ==", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/util": "1.12.0", - "idb": "7.1.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/installations-compat": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.17.tgz", - "integrity": "sha512-J7afeCXB7yq25FrrJAgbx8mn1nG1lZEubOLvYgG7ZHvyoOCK00sis5rj7TgDrLYJgdj/SJiGaO1BD3BAp55TeA==", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/installations": "0.6.17", - "@firebase/installations-types": "0.5.3", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/installations-types": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", - "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", - "peerDependencies": { - "@firebase/app-types": "0.x" - } - }, - "node_modules/@firebase/logger": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.4.tgz", - "integrity": "sha512-mH0PEh1zoXGnaR8gD1DeGeNZtWFKbnz9hDO91dIml3iou1gpOnLqXQ2dJfB71dj6dpmUjcQ6phY3ZZJbjErr9g==", - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/messaging": { - "version": "0.12.21", - "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.21.tgz", - "integrity": "sha512-bYJ2Evj167Z+lJ1ach6UglXz5dUKY1zrJZd15GagBUJSR7d9KfiM1W8dsyL0lDxcmhmA/sLaBYAAhF1uilwN0g==", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/installations": "0.6.17", - "@firebase/messaging-interop-types": "0.2.3", - "@firebase/util": "1.12.0", - "idb": "7.1.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/messaging-compat": { - "version": "0.2.21", - "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.21.tgz", - "integrity": "sha512-1yMne+4BGLbHbtyu/VyXWcLiefUE1+K3ZGfVTyKM4BH4ZwDFRGoWUGhhx+tKRX4Tu9z7+8JN67SjnwacyNWK5g==", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/messaging": "0.12.21", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/messaging-interop-types": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", - "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==" - }, - "node_modules/@firebase/performance": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.6.tgz", - "integrity": "sha512-AsOz74dSTlyQGlnnbLWXiHFAsrxhpssPOsFFi4HgOJ5DjzkK7ZdZ/E9uMPrwFoXJyMVoybGRuqsL/wkIbFITsA==", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/installations": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0", - "web-vitals": "^4.2.4" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/performance-compat": { - "version": "0.2.19", - "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.19.tgz", - "integrity": "sha512-4cU0T0BJ+LZK/E/UwFcvpBCVdkStgBMQwBztM9fJPT6udrEUk3ugF5/HT+E2Z22FCXtIaXDukJbYkE/c3c6IHw==", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/performance": "0.7.6", - "@firebase/performance-types": "0.2.3", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/performance-types": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", - "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==" - }, - "node_modules/@firebase/remote-config": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.6.4.tgz", - "integrity": "sha512-ZyLJRT46wtycyz2+opEkGaoFUOqRQjt/0NX1WfUISOMCI/PuVoyDjqGpq24uK+e8D5NknyTpiXCVq5dowhScmg==", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/installations": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/remote-config-compat": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.17.tgz", - "integrity": "sha512-KelsBD0sXSC0u3esr/r6sJYGRN6pzn3bYuI/6pTvvmZbjBlxQkRabHAVH6d+YhLcjUXKIAYIjZszczd1QJtOyA==", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/remote-config": "0.6.4", - "@firebase/remote-config-types": "0.4.0", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/remote-config-types": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.4.0.tgz", - "integrity": "sha512-7p3mRE/ldCNYt8fmWMQ/MSGRmXYlJ15Rvs9Rk17t8p0WwZDbeK7eRmoI1tvCPaDzn9Oqh+yD6Lw+sGLsLg4kKg==" - }, - "node_modules/@firebase/storage": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.13.11.tgz", - "integrity": "sha512-nBtCGGpr39vuAeTQhG73nvMq3BjQBTgIg6fWufB6qglWYQCgky/XE4duSrOhTp2/QC+H3/SnaE/nKOQmjnPqjg==", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/storage-compat": { - "version": "0.3.21", - "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.3.21.tgz", - "integrity": "sha512-LG3978H2Vy1XGa0Jz9VNFwgMrhjy/G8CTV8GkWpArzu+AhI/SE9c0e06SiXcFsVaQW2rObcqFa0zp51LDaVzRA==", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/storage": "0.13.11", - "@firebase/storage-types": "0.8.3", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/storage-types": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", - "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", - "peerDependencies": { - "@firebase/app-types": "0.x", - "@firebase/util": "1.x" - } - }, - "node_modules/@firebase/util": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.12.0.tgz", - "integrity": "sha512-Z4rK23xBCwgKDqmzGVMef+Vb4xso2j5Q8OG0vVL4m4fA5ZjPMYQazu8OJJC3vtQRC3SQ/Pgx/6TPNVsCd70QRw==", - "hasInstallScript": true, - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/webchannel-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.3.tgz", - "integrity": "sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==" - }, - "node_modules/@grpc/grpc-js": { - "version": "1.9.15", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", - "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", - "dependencies": { - "@grpc/proto-loader": "^0.7.8", - "@types/node": ">=12.12.47" - }, - "engines": { - "node": "^8.13.0 || >=10.10.0" - } - }, - "node_modules/@grpc/proto-loader": { - "version": "0.7.15", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", - "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", - "dependencies": { - "lodash.camelcase": "^4.3.0", - "long": "^5.0.0", - "protobufjs": "^7.2.5", - "yargs": "^17.7.2" - }, - "bin": { - "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@inquirer/checkbox": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.6.tgz", - "integrity": "sha512-62u896rWCtKKE43soodq5e/QcRsA22I+7/4Ov7LESWnKRO6BVo2A1DFLDmXL9e28TB0CfHc3YtkbPm7iwajqkg==", - "dev": true, - "dependencies": { - "@inquirer/core": "^10.1.11", - "@inquirer/figures": "^1.0.11", - "@inquirer/type": "^3.0.6", - "ansi-escapes": "^4.3.2", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/confirm": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.6.tgz", - "integrity": "sha512-6ZXYK3M1XmaVBZX6FCfChgtponnL0R6I7k8Nu+kaoNkT828FVZTcca1MqmWQipaW2oNREQl5AaPCUOOCVNdRMw==", - "dev": true, - "dependencies": { - "@inquirer/core": "^10.1.7", - "@inquirer/type": "^3.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "10.1.11", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.11.tgz", - "integrity": "sha512-BXwI/MCqdtAhzNQlBEFE7CEflhPkl/BqvAuV/aK6lW3DClIfYVDWPP/kXuXHtBWC7/EEbNqd/1BGq2BGBBnuxw==", - "dev": true, - "dependencies": { - "@inquirer/figures": "^1.0.11", - "@inquirer/type": "^3.0.6", - "ansi-escapes": "^4.3.2", - "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/editor": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.11.tgz", - "integrity": "sha512-YoZr0lBnnLFPpfPSNsQ8IZyKxU47zPyVi9NLjCWtna52//M/xuL0PGPAxHxxYhdOhnvY2oBafoM+BI5w/JK7jw==", - "dev": true, - "dependencies": { - "@inquirer/core": "^10.1.11", - "@inquirer/type": "^3.0.6", - "external-editor": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.13.tgz", - "integrity": "sha512-HgYNWuZLHX6q5y4hqKhwyytqAghmx35xikOGY3TcgNiElqXGPas24+UzNPOwGUZa5Dn32y25xJqVeUcGlTv+QQ==", - "dev": true, - "dependencies": { - "@inquirer/core": "^10.1.11", - "@inquirer/type": "^3.0.6", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.11.tgz", - "integrity": "sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==", - "dev": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/input": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.1.10.tgz", - "integrity": "sha512-kV3BVne3wJ+j6reYQUZi/UN9NZGZLxgc/tfyjeK3mrx1QI7RXPxGp21IUTv+iVHcbP4ytZALF8vCHoxyNSC6qg==", - "dev": true, - "dependencies": { - "@inquirer/core": "^10.1.11", - "@inquirer/type": "^3.0.6" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/number": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.13.tgz", - "integrity": "sha512-IrLezcg/GWKS8zpKDvnJ/YTflNJdG0qSFlUM/zNFsdi4UKW/CO+gaJpbMgQ20Q58vNKDJbEzC6IebdkprwL6ew==", - "dev": true, - "dependencies": { - "@inquirer/core": "^10.1.11", - "@inquirer/type": "^3.0.6" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/password": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.13.tgz", - "integrity": "sha512-NN0S/SmdhakqOTJhDwOpeBEEr8VdcYsjmZHDb0rblSh2FcbXQOr+2IApP7JG4WE3sxIdKytDn4ed3XYwtHxmJQ==", - "dev": true, - "dependencies": { - "@inquirer/core": "^10.1.11", - "@inquirer/type": "^3.0.6", - "ansi-escapes": "^4.3.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/prompts": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", - "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", - "dev": true, - "dependencies": { - "@inquirer/checkbox": "^4.1.2", - "@inquirer/confirm": "^5.1.6", - "@inquirer/editor": "^4.2.7", - "@inquirer/expand": "^4.0.9", - "@inquirer/input": "^4.1.6", - "@inquirer/number": "^3.0.9", - "@inquirer/password": "^4.0.9", - "@inquirer/rawlist": "^4.0.9", - "@inquirer/search": "^3.0.9", - "@inquirer/select": "^4.0.9" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/rawlist": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.1.tgz", - "integrity": "sha512-VBUC0jPN2oaOq8+krwpo/mf3n/UryDUkKog3zi+oIi8/e5hykvdntgHUB9nhDM78RubiyR1ldIOfm5ue+2DeaQ==", - "dev": true, - "dependencies": { - "@inquirer/core": "^10.1.11", - "@inquirer/type": "^3.0.6", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/search": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.0.13.tgz", - "integrity": "sha512-9g89d2c5Izok/Gw/U7KPC3f9kfe5rA1AJ24xxNZG0st+vWekSk7tB9oE+dJv5JXd0ZSijomvW0KPMoBd8qbN4g==", - "dev": true, - "dependencies": { - "@inquirer/core": "^10.1.11", - "@inquirer/figures": "^1.0.11", - "@inquirer/type": "^3.0.6", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/select": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.2.1.tgz", - "integrity": "sha512-gt1Kd5XZm+/ddemcT3m23IP8aD8rC9drRckWoP/1f7OL46Yy2FGi8DSmNjEjQKtPl6SV96Kmjbl6p713KXJ/Jg==", - "dev": true, - "dependencies": { - "@inquirer/core": "^10.1.11", - "@inquirer/figures": "^1.0.11", - "@inquirer/type": "^3.0.6", - "ansi-escapes": "^4.3.2", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/type": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.6.tgz", - "integrity": "sha512-/mKVCtVpyBu3IDarv0G+59KC4stsD5mDsGpYh+GKs1NZT88Jh52+cuoA1AtLk2Q0r/quNl+1cSUyLRHBFeD0XA==", - "dev": true, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@jsonjoy.com/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", - "dev": true, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/json-pack": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.2.0.tgz", - "integrity": "sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA==", - "dev": true, - "dependencies": { - "@jsonjoy.com/base64": "^1.1.1", - "@jsonjoy.com/util": "^1.1.2", - "hyperdyperid": "^1.2.0", - "thingies": "^1.20.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/util": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.6.0.tgz", - "integrity": "sha512-sw/RMbehRhN68WRtcKCpQOPfnH6lLP4GJfqzi3iYej8tnzpZUDr6UkZYJjcjjC0FWEJOJbyM3PTIwxucUmDG2A==", - "dev": true, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", - "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", - "dev": true - }, - "node_modules/@listr2/prompt-adapter-inquirer": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.18.tgz", - "integrity": "sha512-0hz44rAcrphyXcA8IS7EJ2SCoaBZD2u5goE8S/e+q/DL+dOGpqpcLidVOFeLG3VgML62SXmfRLAhWt0zL1oW4Q==", - "dev": true, - "dependencies": { - "@inquirer/type": "^1.5.5" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@inquirer/prompts": ">= 3 < 8" - } - }, - "node_modules/@listr2/prompt-adapter-inquirer/node_modules/@inquirer/type": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.5.tgz", - "integrity": "sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==", - "dev": true, - "dependencies": { - "mute-stream": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@listr2/prompt-adapter-inquirer/node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@lmdb/lmdb-darwin-arm64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.2.6.tgz", - "integrity": "sha512-yF/ih9EJJZc72psFQbwnn8mExIWfTnzWJg+N02hnpXtDPETYLmQswIMBn7+V88lfCaFrMozJsUvcEQIkEPU0Gg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lmdb/lmdb-darwin-x64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.2.6.tgz", - "integrity": "sha512-5BbCumsFLbCi586Bb1lTWQFkekdQUw8/t8cy++Uq251cl3hbDIGEwD9HAwh8H6IS2F6QA9KdKmO136LmipRNkg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lmdb/lmdb-linux-arm": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.2.6.tgz", - "integrity": "sha512-+6XgLpMb7HBoWxXj+bLbiiB4s0mRRcDPElnRS3LpWRzdYSe+gFk5MT/4RrVNqd2MESUDmb53NUXw1+BP69bjiQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lmdb/lmdb-linux-arm64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.2.6.tgz", - "integrity": "sha512-l5VmJamJ3nyMmeD1ANBQCQqy7do1ESaJQfKPSm2IG9/ADZryptTyCj8N6QaYgIWewqNUrcbdMkJajRQAt5Qjfg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lmdb/lmdb-linux-x64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.2.6.tgz", - "integrity": "sha512-nDYT8qN9si5+onHYYaI4DiauDMx24OAiuZAUsEqrDy+ja/3EbpXPX/VAkMV8AEaQhy3xc4dRC+KcYIvOFefJ4Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lmdb/lmdb-win32-x64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.2.6.tgz", - "integrity": "sha512-XlqVtILonQnG+9fH2N3Aytria7P/1fwDgDhl29rde96uH2sLB8CHORIf2PfuLVzFQJ7Uqp8py9AYwr3ZUCFfWg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@napi-rs/nice": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.0.1.tgz", - "integrity": "sha512-zM0mVWSXE0a0h9aKACLwKmD6nHcRiKrPpCfvaKqG1CqDEyjEawId0ocXxVzPMCAm6kkWr2P025msfxXEnt8UGQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "optionalDependencies": { - "@napi-rs/nice-android-arm-eabi": "1.0.1", - "@napi-rs/nice-android-arm64": "1.0.1", - "@napi-rs/nice-darwin-arm64": "1.0.1", - "@napi-rs/nice-darwin-x64": "1.0.1", - "@napi-rs/nice-freebsd-x64": "1.0.1", - "@napi-rs/nice-linux-arm-gnueabihf": "1.0.1", - "@napi-rs/nice-linux-arm64-gnu": "1.0.1", - "@napi-rs/nice-linux-arm64-musl": "1.0.1", - "@napi-rs/nice-linux-ppc64-gnu": "1.0.1", - "@napi-rs/nice-linux-riscv64-gnu": "1.0.1", - "@napi-rs/nice-linux-s390x-gnu": "1.0.1", - "@napi-rs/nice-linux-x64-gnu": "1.0.1", - "@napi-rs/nice-linux-x64-musl": "1.0.1", - "@napi-rs/nice-win32-arm64-msvc": "1.0.1", - "@napi-rs/nice-win32-ia32-msvc": "1.0.1", - "@napi-rs/nice-win32-x64-msvc": "1.0.1" - } - }, - "node_modules/@napi-rs/nice-android-arm-eabi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.1.tgz", - "integrity": "sha512-5qpvOu5IGwDo7MEKVqqyAxF90I6aLj4n07OzpARdgDRfz8UbBztTByBp0RC59r3J1Ij8uzYi6jI7r5Lws7nn6w==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.0.1.tgz", - "integrity": "sha512-GqvXL0P8fZ+mQqG1g0o4AO9hJjQaeYG84FRfZaYjyJtZZZcMjXW5TwkL8Y8UApheJgyE13TQ4YNUssQaTgTyvA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-91k3HEqUl2fsrz/sKkuEkscj6EAj3/eZNCLqzD2AA0TtVbkQi8nqxZCZDMkfklULmxLkMxuUdKe7RvG/T6s2AA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.0.1.tgz", - "integrity": "sha512-jXnMleYSIR/+TAN/p5u+NkCA7yidgswx5ftqzXdD5wgy/hNR92oerTXHc0jrlBisbd7DpzoaGY4cFD7Sm5GlgQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-j+iJ/ezONXRQsVIB/FJfwjeQXX7A2tf3gEXs4WUGFrJjpe/z2KB7sOv6zpkm08PofF36C9S7wTNuzHZ/Iiccfw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.0.1.tgz", - "integrity": "sha512-G8RgJ8FYXYkkSGQwywAUh84m946UTn6l03/vmEXBYNJxQJcD+I3B3k5jmjFG/OPiU8DfvxutOP8bi+F89MCV7Q==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-IMDak59/W5JSab1oZvmNbrms3mHqcreaCeClUjwlwDr0m3BoR09ZiN8cKFBzuSlXgRdZ4PNqCYNeGQv7YMTjuA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-wG8fa2VKuWM4CfjOjjRX9YLIbysSVV1S3Kgm2Fnc67ap/soHBeYZa6AGMeR5BJAylYRjnoVOzV19Cmkco3QEPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-ppc64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.0.1.tgz", - "integrity": "sha512-lxQ9WrBf0IlNTCA9oS2jg/iAjQyTI6JHzABV664LLrLA/SIdD+I1i3Mjf7TsnoUbgopBcCuDztVLfJ0q9ubf6Q==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-riscv64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.0.1.tgz", - "integrity": "sha512-3xs69dO8WSWBb13KBVex+yvxmUeEsdWexxibqskzoKaWx9AIqkMbWmE2npkazJoopPKX2ULKd8Fm9veEn0g4Ig==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-s390x-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.0.1.tgz", - "integrity": "sha512-lMFI3i9rlW7hgToyAzTaEybQYGbQHDrpRkg+1gJWEpH0PLAQoZ8jiY0IzakLfNWnVda1eTYYlxxFYzW8Rqczkg==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-x64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.0.1.tgz", - "integrity": "sha512-XQAJs7DRN2GpLN6Fb+ZdGFeYZDdGl2Fn3TmFlqEL5JorgWKrQGRUrpGKbgZ25UeZPILuTKJ+OowG2avN8mThBA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-x64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-/rodHpRSgiI9o1faq9SZOp/o2QkKQg7T+DK0R5AkbnI/YxvAIEHf2cngjYzLMQSQgUhxym+LFr+UGZx4vK4QdQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-win32-arm64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-rEcz9vZymaCB3OqEXoHnp9YViLct8ugF+6uO5McifTedjq4QMQs3DHz35xBEGhH3gJWEsXMUbzazkz5KNM5YUg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-win32-ia32-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.0.1.tgz", - "integrity": "sha512-t7eBAyPUrWL8su3gDxw9xxxqNwZzAqKo0Szv3IjVQd1GpXXVkb6vBBQUuxfIYaXMzZLwlxRQ7uzM2vdUE9ULGw==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-win32-x64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-JlF+uDcatt3St2ntBG8H02F1mM45i5SF9W+bIKiReVE6wiy3o16oBP/yxt+RZ+N6LbCImJXJ6bXNO2kn9AXicg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@ngtools/webpack": { - "version": "19.2.12", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.2.12.tgz", - "integrity": "sha512-MTxkM+jZPQP55q0BWx/1w2kaN9mSFC14V9+p4sfNm/OXk7fibtxz5lXH/2sDGFWJi36s4gppKqfHBhp9OTdHCQ==", - "dev": true, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "@angular/compiler-cli": "^19.0.0 || ^19.2.0-next.0", - "typescript": ">=5.5 <5.9", - "webpack": "^5.54.0" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@npmcli/agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", - "dev": true, - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, - "node_modules/@npmcli/fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", - "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", - "dev": true, - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/git": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-6.0.3.tgz", - "integrity": "sha512-GUYESQlxZRAdhs3UhbB6pVRNUELQOHXwK9ruDkwmCv2aZ5y0SApQzUJCg02p3A7Ue2J5hxvlk1YI53c00NmRyQ==", - "dev": true, - "dependencies": { - "@npmcli/promise-spawn": "^8.0.0", - "ini": "^5.0.0", - "lru-cache": "^10.0.1", - "npm-pick-manifest": "^10.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "semver": "^7.3.5", - "which": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/git/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "dev": true, - "engines": { - "node": ">=16" - } - }, - "node_modules/@npmcli/git/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, - "node_modules/@npmcli/git/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "dev": true, - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/installed-package-contents": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-3.0.0.tgz", - "integrity": "sha512-fkxoPuFGvxyrH+OQzyTkX2LUEamrF4jZSmxjAtPPHHGO0dqsQ8tTKjnIS8SAnPHdk2I03BDtSMR5K/4loKg79Q==", - "dev": true, - "dependencies": { - "npm-bundled": "^4.0.0", - "npm-normalize-package-bin": "^4.0.0" - }, - "bin": { - "installed-package-contents": "bin/index.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/node-gyp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-4.0.0.tgz", - "integrity": "sha512-+t5DZ6mO/QFh78PByMq1fGSAub/agLJZDRfJRMeOSNCt8s9YVlTjmGpIPwPhvXTGUIJk+WszlT0rQa1W33yzNA==", - "dev": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/package-json": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-6.1.1.tgz", - "integrity": "sha512-d5qimadRAUCO4A/Txw71VM7UrRZzV+NPclxz/dc+M6B2oYwjWTjqh8HA/sGQgs9VZuJ6I/P7XIAlJvgrl27ZOw==", - "dev": true, - "dependencies": { - "@npmcli/git": "^6.0.0", - "glob": "^10.2.2", - "hosted-git-info": "^8.0.0", - "json-parse-even-better-errors": "^4.0.0", - "proc-log": "^5.0.0", - "semver": "^7.5.3", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/package-json/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@npmcli/package-json/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@npmcli/package-json/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@npmcli/promise-spawn": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-8.0.2.tgz", - "integrity": "sha512-/bNJhjc+o6qL+Dwz/bqfTQClkEO5nTQ1ZEcdCkAQjhkZMHIh22LPG7fNh1enJP1NKWDqYiiABnjFCY7E0zHYtQ==", - "dev": true, - "dependencies": { - "which": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/promise-spawn/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "dev": true, - "engines": { - "node": ">=16" - } - }, - "node_modules/@npmcli/promise-spawn/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "dev": true, - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/redact": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-3.2.2.tgz", - "integrity": "sha512-7VmYAmk4csGv08QzrDKScdzn11jHPFGyqJW39FyPgPuAp3zIaUmuCo1yxw9aGs+NEJuTGQ9Gwqpt93vtJubucg==", - "dev": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/run-script": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-9.1.0.tgz", - "integrity": "sha512-aoNSbxtkePXUlbZB+anS1LqsJdctG5n3UVhfU47+CDdwMi6uNTBMF9gPcQRnqghQd2FGzcwwIFBruFMxjhBewg==", - "dev": true, - "dependencies": { - "@npmcli/node-gyp": "^4.0.0", - "@npmcli/package-json": "^6.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "node-gyp": "^11.0.0", - "proc-log": "^5.0.0", - "which": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/run-script/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "dev": true, - "engines": { - "node": ">=16" - } - }, - "node_modules/@npmcli/run-script/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "dev": true, - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@parcel/watcher": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", - "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "dependencies": { - "detect-libc": "^1.0.3", - "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", - "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", - "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", - "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", - "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", - "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", - "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", - "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", - "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", - "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", - "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", - "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", - "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", - "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher/node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "dev": true, - "optional": true, - "bin": { - "detect-libc": "bin/detect-libc.js" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/@parcel/watcher/node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, - "optional": true - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" - }, - "node_modules/@rollup/plugin-json": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", - "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", - "dev": true, - "dependencies": { - "@rollup/pluginutils": "^5.1.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", - "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", - "dev": true, - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz", - "integrity": "sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.8.tgz", - "integrity": "sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.8.tgz", - "integrity": "sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.8.tgz", - "integrity": "sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.8.tgz", - "integrity": "sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.8.tgz", - "integrity": "sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.8.tgz", - "integrity": "sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.8.tgz", - "integrity": "sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.8.tgz", - "integrity": "sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.8.tgz", - "integrity": "sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz", - "integrity": "sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.8.tgz", - "integrity": "sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.8.tgz", - "integrity": "sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.0.tgz", - "integrity": "sha512-ieQljaZKuJpmWvd8gW87ZmSFwid6AxMDk5bhONJ57U8zT77zpZ/TPKkU9HpnnFrM4zsgr4kiGuzbIbZTGi7u9A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.8.tgz", - "integrity": "sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.8.tgz", - "integrity": "sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.8.tgz", - "integrity": "sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz", - "integrity": "sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.8.tgz", - "integrity": "sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz", - "integrity": "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/wasm-node": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/wasm-node/-/wasm-node-4.41.0.tgz", - "integrity": "sha512-G+y2Uj8XvsPWMA+kVfKPcrhOWtcwKaCCr8KNZPiADfJV4+g4HUeJKuT8Fz71F7PNVD3t+xqX8rlpIULAlAJ+sQ==", - "dev": true, - "dependencies": { - "@types/estree": "1.0.7" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/@schematics/angular": { - "version": "19.2.12", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.2.12.tgz", - "integrity": "sha512-6S6tclFctLrjMvhpi8eVvswIpXqlybRpZLCTWyVeWIC6PHYLEyFmFoOhuhcSmOdtnwudvzOt6xWnWEVb3qXZbQ==", - "dependencies": { - "@angular-devkit/core": "19.2.12", - "@angular-devkit/schematics": "19.2.12", - "jsonc-parser": "3.3.1" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@sigstore/bundle": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-3.1.0.tgz", - "integrity": "sha512-Mm1E3/CmDDCz3nDhFKTuYdB47EdRFRQMOE/EAbiG1MJW77/w1b3P7Qx7JSrVJs8PfwOLOVcKQCHErIwCTyPbag==", - "dev": true, - "dependencies": { - "@sigstore/protobuf-specs": "^0.4.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@sigstore/core": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-2.0.0.tgz", - "integrity": "sha512-nYxaSb/MtlSI+JWcwTHQxyNmWeWrUXJJ/G4liLrGG7+tS4vAz6LF3xRXqLH6wPIVUoZQel2Fs4ddLx4NCpiIYg==", - "dev": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@sigstore/protobuf-specs": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.4.2.tgz", - "integrity": "sha512-F2ye+n1INNhqT0MW+LfUEvTUPc/nS70vICJcxorKl7/gV9CO39+EDCw+qHNKEqvsDWk++yGVKCbzK1qLPvmC8g==", - "dev": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@sigstore/sign": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-3.1.0.tgz", - "integrity": "sha512-knzjmaOHOov1Ur7N/z4B1oPqZ0QX5geUfhrVaqVlu+hl0EAoL4o+l0MSULINcD5GCWe3Z0+YJO8ues6vFlW0Yw==", - "dev": true, - "dependencies": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.0", - "make-fetch-happen": "^14.0.2", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@sigstore/tuf": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-3.1.1.tgz", - "integrity": "sha512-eFFvlcBIoGwVkkwmTi/vEQFSva3xs5Ot3WmBcjgjVdiaoelBLQaQ/ZBfhlG0MnG0cmTYScPpk7eDdGDWUcFUmg==", - "dev": true, - "dependencies": { - "@sigstore/protobuf-specs": "^0.4.1", - "tuf-js": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@sigstore/verify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-2.1.1.tgz", - "integrity": "sha512-hVJD77oT67aowHxwT4+M6PGOp+E2LtLdTK3+FC0lBO9T7sYwItDMXZ7Z07IDCvR1M717a4axbIWckrW67KMP/w==", - "dev": true, - "dependencies": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@sindresorhus/merge-streams": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", - "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@socket.io/component-emitter": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", - "dev": true - }, - "node_modules/@tailwindcss/node": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.7.tgz", - "integrity": "sha512-9rsOpdY9idRI2NH6CL4wORFY0+Q6fnx9XP9Ju+iq/0wJwGD5IByIgFmwVbyy4ymuyprj8Qh4ErxMKTUL4uNh3g==", - "dependencies": { - "@ampproject/remapping": "^2.3.0", - "enhanced-resolve": "^5.18.1", - "jiti": "^2.4.2", - "lightningcss": "1.30.1", - "magic-string": "^0.30.17", - "source-map-js": "^1.2.1", - "tailwindcss": "4.1.7" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.7.tgz", - "integrity": "sha512-5SF95Ctm9DFiUyjUPnDGkoKItPX/k+xifcQhcqX5RA85m50jw1pT/KzjdvlqxRja45Y52nR4MR9fD1JYd7f8NQ==", - "hasInstallScript": true, - "dependencies": { - "detect-libc": "^2.0.4", - "tar": "^7.4.3" - }, - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.7", - "@tailwindcss/oxide-darwin-arm64": "4.1.7", - "@tailwindcss/oxide-darwin-x64": "4.1.7", - "@tailwindcss/oxide-freebsd-x64": "4.1.7", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.7", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.7", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.7", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.7", - "@tailwindcss/oxide-linux-x64-musl": "4.1.7", - "@tailwindcss/oxide-wasm32-wasi": "4.1.7", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.7", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.7" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.7.tgz", - "integrity": "sha512-IWA410JZ8fF7kACus6BrUwY2Z1t1hm0+ZWNEzykKmMNM09wQooOcN/VXr0p/WJdtHZ90PvJf2AIBS/Ceqx1emg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.7.tgz", - "integrity": "sha512-81jUw9To7fimGGkuJ2W5h3/oGonTOZKZ8C2ghm/TTxbwvfSiFSDPd6/A/KE2N7Jp4mv3Ps9OFqg2fEKgZFfsvg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.7.tgz", - "integrity": "sha512-q77rWjEyGHV4PdDBtrzO0tgBBPlQWKY7wZK0cUok/HaGgbNKecegNxCGikuPJn5wFAlIywC3v+WMBt0PEBtwGw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.7.tgz", - "integrity": "sha512-RfmdbbK6G6ptgF4qqbzoxmH+PKfP4KSVs7SRlTwcbRgBwezJkAO3Qta/7gDy10Q2DcUVkKxFLXUQO6J3CRvBGw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.7.tgz", - "integrity": "sha512-OZqsGvpwOa13lVd1z6JVwQXadEobmesxQ4AxhrwRiPuE04quvZHWn/LnihMg7/XkN+dTioXp/VMu/p6A5eZP3g==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.7.tgz", - "integrity": "sha512-voMvBTnJSfKecJxGkoeAyW/2XRToLZ227LxswLAwKY7YslG/Xkw9/tJNH+3IVh5bdYzYE7DfiaPbRkSHFxY1xA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.7.tgz", - "integrity": "sha512-PjGuNNmJeKHnP58M7XyjJyla8LPo+RmwHQpBI+W/OxqrwojyuCQ+GUtygu7jUqTEexejZHr/z3nBc/gTiXBj4A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.7.tgz", - "integrity": "sha512-HMs+Va+ZR3gC3mLZE00gXxtBo3JoSQxtu9lobbZd+DmfkIxR54NO7Z+UQNPsa0P/ITn1TevtFxXTpsRU7qEvWg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.7.tgz", - "integrity": "sha512-MHZ6jyNlutdHH8rd+YTdr3QbXrHXqwIhHw9e7yXEBcQdluGwhpQY2Eku8UZK6ReLaWtQ4gijIv5QoM5eE+qlsA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.7.tgz", - "integrity": "sha512-ANaSKt74ZRzE2TvJmUcbFQ8zS201cIPxUDm5qez5rLEwWkie2SkGtA4P+GPTj+u8N6JbPrC8MtY8RmJA35Oo+A==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@emnapi/wasi-threads": "^1.0.2", - "@napi-rs/wasm-runtime": "^0.2.9", - "@tybys/wasm-util": "^0.9.0", - "tslib": "^2.8.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.7.tgz", - "integrity": "sha512-HUiSiXQ9gLJBAPCMVRk2RT1ZrBjto7WvqsPBwUrNK2BcdSxMnk19h4pjZjI7zgPhDxlAbJSumTC4ljeA9y0tEw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.7.tgz", - "integrity": "sha512-rYHGmvoHiLJ8hWucSfSOEmdCBIGZIq7SpkPRSqLsH2Ab2YUNgKeAPT1Fi2cx3+hnYOrAb0jp9cRyode3bBW4mQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/postcss": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.7.tgz", - "integrity": "sha512-88g3qmNZn7jDgrrcp3ZXEQfp9CVox7xjP1HN2TFKI03CltPVd/c61ydn5qJJL8FYunn0OqBaW5HNUga0kmPVvw==", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.7", - "@tailwindcss/oxide": "4.1.7", - "postcss": "^8.4.41", - "tailwindcss": "4.1.7" - } - }, - "node_modules/@tanstack/angular-form": { - "version": "0.42.1", - "resolved": "https://registry.npmjs.org/@tanstack/angular-form/-/angular-form-0.42.1.tgz", - "integrity": "sha512-7uMewhfDrCo8X+CZSMGBu6xifeIhvGsDpwZeXrUYDrS7ZzVzUysFLuZPbGLylmWTVBRhdK85A6xXjoiBiAYP2A==", - "dev": true, - "dependencies": { - "@tanstack/angular-store": "^0.7.0", - "@tanstack/form-core": "0.42.1", - "tslib": "^2.8.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "@angular/core": ">=19.0.0" - } - }, - "node_modules/@tanstack/angular-store": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@tanstack/angular-store/-/angular-store-0.7.0.tgz", - "integrity": "sha512-Ybl3fCZpfubPDQPbhhvpLGHFx2FRwQHv5bi5tluOtlkTZw3gVxuF+rMxVHfvm3CTI418W7VwiRfPz8//8Gxvkw==", - "dependencies": { - "@tanstack/store": "0.7.0", - "tslib": "^2.8.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "@angular/common": ">=19.0.0", - "@angular/core": ">=19.0.0" - } - }, - "node_modules/@tanstack/form-core": { - "version": "0.42.1", - "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-0.42.1.tgz", - "integrity": "sha512-jTU0jyHqFceujdtPNv3jPVej1dTqBwa8TYdIyWB5BCwRVUBZEp1PiYEBkC9r92xu5fMpBiKc+JKud3eeVjuMiA==", - "dev": true, - "dependencies": { - "@tanstack/store": "^0.7.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/store": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.0.tgz", - "integrity": "sha512-CNIhdoUsmD2NolYuaIs8VfWM467RK6oIBAW4nPEKZhg1smZ+/CwtCdpURgp7nxSqOaV9oKkzdWD80+bC66F/Jg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tufjs/canonical-json": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", - "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", - "dev": true, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@tufjs/models": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-3.0.1.tgz", - "integrity": "sha512-UUYHISyhCU3ZgN8yaear3cGATHb3SMuKHsQ/nVbHXcmnBf+LzQ/cQfhNG+rfaSHgqGKNEm2cOCLVLELStUQ1JA==", - "dev": true, - "dependencies": { - "@tufjs/canonical-json": "2.0.0", - "minimatch": "^9.0.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@tufjs/models/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@tufjs/models/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/bonjour": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", - "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect-history-api-fallback": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", - "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", - "dev": true, - "dependencies": { - "@types/express-serve-static-core": "*", - "@types/node": "*" - } - }, - "node_modules/@types/cors": { - "version": "2.8.18", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.18.tgz", - "integrity": "sha512-nX3d0sxJW41CqQvfOzVG1NCTXfFDrDWIghCZncpHeWlVFd81zxB/DLhg7avFg6eHLCRX7ckBmoIIcqa++upvJA==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true - }, - "node_modules/@types/express": { - "version": "4.17.22", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.22.tgz", - "integrity": "sha512-eZUmSnhRX9YRSkplpz0N+k6NljUUn5l3EWZIKZvYzhvMphEuNiyyy1viH/ejgt66JWgALwC/gtSUAeQKtSwW/w==", - "dev": true, - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", - "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", - "dev": true, - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true - }, - "node_modules/@types/http-proxy": { - "version": "1.17.16", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz", - "integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/jasmine": { - "version": "5.1.8", - "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.8.tgz", - "integrity": "sha512-u7/CnvRdh6AaaIzYjCgUuVbREFgulhX05Qtf6ZtW+aOcjCKKVvKgpkPYJBFTZSHtFBYimzU4zP0V2vrEsq9Wcg==", - "dev": true - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true - }, - "node_modules/@types/node": { - "version": "18.19.101", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.101.tgz", - "integrity": "sha512-Ykg7fcE3+cOQlLUv2Ds3zil6DVjriGQaSN/kEpl5HQ3DIGM6W0F2n9+GkWV4bRt7KjLymgzNdTnSKCbFUUJ7Kw==", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/node-forge": { - "version": "1.3.11", - "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.11.tgz", - "integrity": "sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true - }, - "node_modules/@types/retry": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", - "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", - "dev": true - }, - "node_modules/@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-index": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", - "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", - "dev": true, - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", - "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dev": true, - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" - } - }, - "node_modules/@types/sockjs": { - "version": "0.3.36", - "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", - "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@vitejs/plugin-basic-ssl": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.2.0.tgz", - "integrity": "sha512-mkQnxTkcldAzIsomk1UuLfAu9n+kpQ3JbHcpCp7d2Oo6ITtji8pHS3QToOWjhPFvNQSnhlkAjmGbhv2QvwO/7Q==", - "dev": true, - "engines": { - "node": ">=14.21.3" - }, - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" - } - }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "dev": true, - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "dev": true - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "dev": true - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "dev": true - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "dev": true, - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "dev": true - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "dev": true, - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "dev": true, - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "dev": true - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true - }, - "node_modules/@yarnpkg/lockfile": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", - "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", - "dev": true - }, - "node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", - "dev": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/adjust-sourcemap-loader": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", - "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", - "dev": true, - "dependencies": { - "loader-utils": "^2.0.0", - "regex-parser": "^2.2.11" - }, - "engines": { - "node": ">=8.9" - } - }, - "node_modules/adjust-sourcemap-loader/node_modules/loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", - "dev": true, - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, - "engines": { - "node": ">=8.9.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", - "dev": true, - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-html-community": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", - "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", - "dev": true, - "engines": [ - "node >= 0.8.0" - ], - "bin": { - "ansi-html": "bin/ansi-html" - } - }, - "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, - "node_modules/autoprefixer": { - "version": "10.4.20", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", - "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "browserslist": "^4.23.3", - "caniuse-lite": "^1.0.30001646", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/babel-loader": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz", - "integrity": "sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==", - "dev": true, - "dependencies": { - "find-cache-dir": "^4.0.0", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 14.15.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0", - "webpack": ">=5" - } - }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.13", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz", - "integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.6.4", - "semver": "^6.3.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", - "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", - "dev": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.3", - "core-js-compat": "^3.40.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz", - "integrity": "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==", - "dev": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.4" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/base64id": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", - "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", - "dev": true, - "engines": { - "node": "^4.5.0 || >= 5.9" - } - }, - "node_modules/batch": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", - "dev": true - }, - "node_modules/beasties": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.3.2.tgz", - "integrity": "sha512-p4AF8uYzm9Fwu8m/hSVTCPXrRBPmB34hQpHsec2KOaR9CZmgoU8IOv4Cvwq4hgz2p4hLMNbsdNl5XeA6XbAQwA==", - "dev": true, - "dependencies": { - "css-select": "^5.1.0", - "css-what": "^6.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "htmlparser2": "^10.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.49", - "postcss-media-query-parser": "^0.2.3" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/big.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", - "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/bonjour-service": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", - "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "multicast-dns": "^7.2.5" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.24.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", - "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001716", - "electron-to-chromium": "^1.5.149", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dev": true, - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/cacache": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", - "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", - "dev": true, - "dependencies": { - "@npmcli/fs": "^4.0.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/cacache/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/cacache/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, - "node_modules/cacache/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001718", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", - "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", - "dev": true - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "devOptional": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "engines": { - "node": ">=18" - } - }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "dev": true, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "dev": true, - "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "engines": { - "node": ">= 12" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/clone-deep": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", - "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", - "dev": true, - "dependencies": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/clone-deep/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true - }, - "node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", - "dev": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/common-path-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", - "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", - "dev": true - }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "dev": true - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "dev": true, - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", - "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", - "dev": true, - "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.0.2", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/compression/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/compression/node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/connect": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", - "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", - "dev": true, - "dependencies": { - "debug": "2.6.9", - "finalhandler": "1.1.2", - "parseurl": "~1.3.3", - "utils-merge": "1.0.1" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/connect-history-api-fallback": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", - "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", - "dev": true, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/connect/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/connect/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/connect/node_modules/finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", - "dev": true, - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "statuses": "~1.5.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/connect/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/connect/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "dev": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/connect/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true - }, - "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" - }, - "node_modules/copy-anything": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", - "integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==", - "dev": true, - "dependencies": { - "is-what": "^3.14.1" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/copy-webpack-plugin": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz", - "integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==", - "dev": true, - "dependencies": { - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.1", - "globby": "^14.0.0", - "normalize-path": "^3.0.0", - "schema-utils": "^4.2.0", - "serialize-javascript": "^6.0.2" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - } - }, - "node_modules/core-js-compat": { - "version": "3.42.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.42.0.tgz", - "integrity": "sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ==", - "dev": true, - "dependencies": { - "browserslist": "^4.24.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dev": true, - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "dev": true, - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-loader": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", - "integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==", - "dev": true, - "dependencies": { - "icss-utils": "^5.1.0", - "postcss": "^8.4.33", - "postcss-modules-extract-imports": "^3.1.0", - "postcss-modules-local-by-default": "^4.0.5", - "postcss-modules-scope": "^3.2.0", - "postcss-modules-values": "^4.0.0", - "postcss-value-parser": "^4.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "webpack": "^5.27.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/css-select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", - "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", - "dev": true, - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", - "dev": true, - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/custom-event": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", - "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", - "dev": true - }, - "node_modules/date-format": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", - "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/default-browser": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", - "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", - "dev": true, - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", - "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dependency-graph": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz", - "integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "engines": { - "node": ">=8" - } - }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true - }, - "node_modules/di": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", - "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", - "dev": true - }, - "node_modules/dns-packet": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", - "dev": true, - "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/dom-serialize": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", - "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", - "dev": true, - "dependencies": { - "custom-event": "~1.0.0", - "ent": "~2.2.0", - "extend": "^3.0.0", - "void-elements": "^2.0.0" - } - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ] - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dev": true, - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.155", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz", - "integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==", - "dev": true - }, - "node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true - }, - "node_modules/emojis-list": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/engine.io": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", - "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", - "dev": true, - "dependencies": { - "@types/cors": "^2.8.12", - "@types/node": ">=10.0.0", - "accepts": "~1.3.4", - "base64id": "2.0.0", - "cookie": "~0.7.2", - "cors": "~2.8.5", - "debug": "~4.3.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.17.1" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", - "dev": true, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/engine.io/node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/engine.io/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/ent": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", - "integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "punycode": "^1.4.1", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true - }, - "node_modules/errno": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", - "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", - "dev": true, - "optional": true, - "dependencies": { - "prr": "~1.0.1" - }, - "bin": { - "errno": "cli.js" - } - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", - "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.4", - "@esbuild/android-arm": "0.25.4", - "@esbuild/android-arm64": "0.25.4", - "@esbuild/android-x64": "0.25.4", - "@esbuild/darwin-arm64": "0.25.4", - "@esbuild/darwin-x64": "0.25.4", - "@esbuild/freebsd-arm64": "0.25.4", - "@esbuild/freebsd-x64": "0.25.4", - "@esbuild/linux-arm": "0.25.4", - "@esbuild/linux-arm64": "0.25.4", - "@esbuild/linux-ia32": "0.25.4", - "@esbuild/linux-loong64": "0.25.4", - "@esbuild/linux-mips64el": "0.25.4", - "@esbuild/linux-ppc64": "0.25.4", - "@esbuild/linux-riscv64": "0.25.4", - "@esbuild/linux-s390x": "0.25.4", - "@esbuild/linux-x64": "0.25.4", - "@esbuild/netbsd-arm64": "0.25.4", - "@esbuild/netbsd-x64": "0.25.4", - "@esbuild/openbsd-arm64": "0.25.4", - "@esbuild/openbsd-x64": "0.25.4", - "@esbuild/sunos-x64": "0.25.4", - "@esbuild/win32-arm64": "0.25.4", - "@esbuild/win32-ia32": "0.25.4", - "@esbuild/win32-x64": "0.25.4" - } - }, - "node_modules/esbuild-wasm": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.25.4.tgz", - "integrity": "sha512-2HlCS6rNvKWaSKhWaG/YIyRsTsL3gUrMP2ToZMBIjw9LM7vVcIs+rz8kE2vExvTJgvM8OKPqNpcHawY/BQc/qQ==", - "dev": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esrecurse/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/exponential-backoff": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", - "integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==", - "dev": true - }, - "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true - }, - "node_modules/external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "dev": true, - "dependencies": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ] - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", - "dev": true, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/find-cache-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", - "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", - "dev": true, - "dependencies": { - "common-path-prefix": "^3.0.0", - "pkg-dir": "^7.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/find-up": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", - "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", - "dev": true, - "dependencies": { - "locate-path": "^7.1.0", - "path-exists": "^5.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/firebase": { - "version": "11.8.0", - "resolved": "https://registry.npmjs.org/firebase/-/firebase-11.8.0.tgz", - "integrity": "sha512-zIv11czOqFayPllaJySKIKB2pS+xoWOnfI7j85SOiBKY1IW3NuZIaL+UgsZA+4PQZkPhFP8vmU2/oOun04ALbg==", - "dependencies": { - "@firebase/ai": "1.3.0", - "@firebase/analytics": "0.10.16", - "@firebase/analytics-compat": "0.2.22", - "@firebase/app": "0.13.0", - "@firebase/app-check": "0.10.0", - "@firebase/app-check-compat": "0.3.25", - "@firebase/app-compat": "0.4.0", - "@firebase/app-types": "0.9.3", - "@firebase/auth": "1.10.5", - "@firebase/auth-compat": "0.5.25", - "@firebase/data-connect": "0.3.8", - "@firebase/database": "1.0.18", - "@firebase/database-compat": "2.0.9", - "@firebase/firestore": "4.7.15", - "@firebase/firestore-compat": "0.3.50", - "@firebase/functions": "0.12.7", - "@firebase/functions-compat": "0.3.24", - "@firebase/installations": "0.6.17", - "@firebase/installations-compat": "0.2.17", - "@firebase/messaging": "0.12.21", - "@firebase/messaging-compat": "0.2.21", - "@firebase/performance": "0.7.6", - "@firebase/performance-compat": "0.2.19", - "@firebase/remote-config": "0.6.4", - "@firebase/remote-config-compat": "0.2.17", - "@firebase/storage": "0.13.11", - "@firebase/storage-compat": "0.3.21", - "@firebase/util": "1.12.0" - } - }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "bin": { - "flat": "cli.js" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true - }, - "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "dev": true, - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", - "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true - }, - "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/globby": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", - "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", - "dev": true, - "dependencies": { - "@sindresorhus/merge-streams": "^2.1.0", - "fast-glob": "^3.3.3", - "ignore": "^7.0.3", - "path-type": "^6.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" - }, - "node_modules/handle-thing": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", - "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", - "dev": true - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hosted-git-info": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", - "integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==", - "dev": true, - "dependencies": { - "lru-cache": "^10.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, - "node_modules/hpack.js": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", - "dev": true, - "dependencies": { - "inherits": "^2.0.1", - "obuf": "^1.0.0", - "readable-stream": "^2.0.1", - "wbuf": "^1.1.0" - } - }, - "node_modules/hpack.js/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/hpack.js/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, - "node_modules/hpack.js/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true - }, - "node_modules/htmlparser2": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", - "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", - "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.1", - "entities": "^6.0.0" - } - }, - "node_modules/htmlparser2/node_modules/entities": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", - "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", - "dev": true, - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true - }, - "node_modules/http-deceiver": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", - "dev": true - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/http-parser-js": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==" - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/http-proxy-middleware": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", - "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", - "dev": true, - "dependencies": { - "@types/http-proxy": "^1.17.15", - "debug": "^4.3.6", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.3", - "is-plain-object": "^5.0.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/hyperdyperid": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", - "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", - "dev": true, - "engines": { - "node": ">=10.18" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/icss-utils": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", - "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", - "dev": true, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/idb": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", - "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/ignore": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", - "integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/ignore-walk": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-7.0.0.tgz", - "integrity": "sha512-T4gbf83A4NH95zvhVYZc+qWocBBGlpzUXLPGurJggw/WIOwicfXJChLDP/iBZnN5WqROSu5Bm3hhle4z8a8YGQ==", - "dev": true, - "dependencies": { - "minimatch": "^9.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/ignore-walk/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/ignore-walk/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/image-size": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", - "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", - "dev": true, - "optional": true, - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/immutable": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz", - "integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==", - "dev": true - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ini": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", - "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", - "dev": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/injection-js": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/injection-js/-/injection-js-2.5.0.tgz", - "integrity": "sha512-UpY2ONt4xbht4GhSqQ2zMJ1rBIQq4uOY+DlR6aOeYyqK7xadXt7UQbJIyxmgk288bPMkIZKjViieHm0O0i72Jw==", - "dev": true, - "dependencies": { - "tslib": "^2.0.0" - } - }, - "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", - "dev": true, - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-network-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.1.0.tgz", - "integrity": "sha512-tUdRRAnhT+OtCZR/LxZelH/C7QtjtFrTu5tXCA8pl55eTUElUHT+GPYV8MBMBvea/j+NxQqVt3LbWMRir7Gx9g==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-plain-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", - "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-what": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz", - "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==", - "dev": true - }, - "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", - "dev": true, - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true - }, - "node_modules/isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", - "dev": true, - "engines": { - "node": ">= 8.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", - "dev": true, - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jasmine-core": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.5.0.tgz", - "integrity": "sha512-NHOvoPO6o9gVR6pwqEACTEpbgcH+JJ6QDypyymGbSUIFIFsMMbBJ/xsFNud8MSClfnWclXd7RQlAZBz7yVo5TQ==", - "dev": true - }, - "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "dev": true - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", - "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", - "dev": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonc-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==" - }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", - "dev": true, - "engines": [ - "node >= 0.2.0" - ] - }, - "node_modules/karma": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", - "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", - "dev": true, - "dependencies": { - "@colors/colors": "1.5.0", - "body-parser": "^1.19.0", - "braces": "^3.0.2", - "chokidar": "^3.5.1", - "connect": "^3.7.0", - "di": "^0.0.1", - "dom-serialize": "^2.2.1", - "glob": "^7.1.7", - "graceful-fs": "^4.2.6", - "http-proxy": "^1.18.1", - "isbinaryfile": "^4.0.8", - "lodash": "^4.17.21", - "log4js": "^6.4.1", - "mime": "^2.5.2", - "minimatch": "^3.0.4", - "mkdirp": "^0.5.5", - "qjobs": "^1.2.0", - "range-parser": "^1.2.1", - "rimraf": "^3.0.2", - "socket.io": "^4.7.2", - "source-map": "^0.6.1", - "tmp": "^0.2.1", - "ua-parser-js": "^0.7.30", - "yargs": "^16.1.1" - }, - "bin": { - "karma": "bin/karma" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/karma-chrome-launcher": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz", - "integrity": "sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==", - "dev": true, - "dependencies": { - "which": "^1.2.1" - } - }, - "node_modules/karma-coverage": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz", - "integrity": "sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==", - "dev": true, - "dependencies": { - "istanbul-lib-coverage": "^3.2.0", - "istanbul-lib-instrument": "^5.1.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.1", - "istanbul-reports": "^3.0.5", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/karma-coverage/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/karma-coverage/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/karma-jasmine": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz", - "integrity": "sha512-i/zQLFrfEpRyQoJF9fsCdTMOF5c2dK7C7OmsuKg2D0YSsuZSfQDiLuaiktbuio6F2wiCsZSnSnieIQ0ant/uzQ==", - "dev": true, - "dependencies": { - "jasmine-core": "^4.1.0" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "karma": "^6.0.0" - } - }, - "node_modules/karma-jasmine-html-reporter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-2.1.0.tgz", - "integrity": "sha512-sPQE1+nlsn6Hwb5t+HHwyy0A1FNCVKuL1192b+XNauMYWThz2kweiBVW1DqloRpVvZIJkIoHVB7XRpK78n1xbQ==", - "dev": true, - "peerDependencies": { - "jasmine-core": "^4.0.0 || ^5.0.0", - "karma": "^6.0.0", - "karma-jasmine": "^5.0.0" - } - }, - "node_modules/karma-jasmine/node_modules/jasmine-core": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.6.1.tgz", - "integrity": "sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==", - "dev": true - }, - "node_modules/karma-source-map-support": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", - "integrity": "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A==", - "dev": true, - "dependencies": { - "source-map-support": "^0.5.5" - } - }, - "node_modules/karma/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/karma/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/karma/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/karma/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/karma/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/karma/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/karma/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/karma/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/karma/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/karma/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/karma/node_modules/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", - "dev": true, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/karma/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/karma/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/karma/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/launch-editor": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.10.0.tgz", - "integrity": "sha512-D7dBRJo/qcGX9xlvt/6wUYzQxjh5G1RvZPgPv8vi4KRU99DVQL/oW7tnVOCCTm2HGeo3C5HvGE5Yrh6UBoZ0vA==", - "dev": true, - "dependencies": { - "picocolors": "^1.0.0", - "shell-quote": "^1.8.1" - } - }, - "node_modules/less": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/less/-/less-4.2.2.tgz", - "integrity": "sha512-tkuLHQlvWUTeQ3doAqnHbNn8T6WX1KA8yvbKG9x4VtKtIjHsVKQZCH11zRgAfbDAXC2UNIg/K9BYAAcEzUIrNg==", - "dev": true, - "dependencies": { - "copy-anything": "^2.0.1", - "parse-node-version": "^1.0.1", - "tslib": "^2.3.0" - }, - "bin": { - "lessc": "bin/lessc" - }, - "engines": { - "node": ">=6" - }, - "optionalDependencies": { - "errno": "^0.1.1", - "graceful-fs": "^4.1.2", - "image-size": "~0.5.0", - "make-dir": "^2.1.0", - "mime": "^1.4.1", - "needle": "^3.1.0", - "source-map": "~0.6.0" - } - }, - "node_modules/less-loader": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-12.2.0.tgz", - "integrity": "sha512-MYUxjSQSBUQmowc0l5nPieOYwMzGPUaTzB6inNW/bdPEG9zOL3eAAD1Qw5ZxSPk7we5dMojHwNODYMV1hq4EVg==", - "dev": true, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "less": "^3.5.0 || ^4.0.0", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/less/node_modules/make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "optional": true, - "dependencies": { - "pify": "^4.0.1", - "semver": "^5.6.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/less/node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "optional": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/less/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "optional": true, - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/less/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/license-webpack-plugin": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", - "integrity": "sha512-771TFWFD70G1wLTC4oU2Cw4qvtmNrIw+wRvBtn+okgHl7slJVi7zfNcdmqDL72BojM30VNJ2UHylr1o77U37Jw==", - "dev": true, - "dependencies": { - "webpack-sources": "^3.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - }, - "webpack-sources": { - "optional": true - } - } - }, - "node_modules/lightningcss": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", - "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-darwin-arm64": "1.30.1", - "lightningcss-darwin-x64": "1.30.1", - "lightningcss-freebsd-x64": "1.30.1", - "lightningcss-linux-arm-gnueabihf": "1.30.1", - "lightningcss-linux-arm64-gnu": "1.30.1", - "lightningcss-linux-arm64-musl": "1.30.1", - "lightningcss-linux-x64-gnu": "1.30.1", - "lightningcss-linux-x64-musl": "1.30.1", - "lightningcss-win32-arm64-msvc": "1.30.1", - "lightningcss-win32-x64-msvc": "1.30.1" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", - "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", - "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", - "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", - "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", - "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", - "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", - "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", - "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", - "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", - "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, - "node_modules/listr2": { - "version": "8.2.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.5.tgz", - "integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==", - "dev": true, - "dependencies": { - "cli-truncate": "^4.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/listr2/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/listr2/node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true - }, - "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/lmdb": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.2.6.tgz", - "integrity": "sha512-SuHqzPl7mYStna8WRotY8XX/EUZBjjv3QyKIByeCLFfC9uXT/OIHByEcA07PzbMfQAM0KYJtLgtpMRlIe5dErQ==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "dependencies": { - "msgpackr": "^1.11.2", - "node-addon-api": "^6.1.0", - "node-gyp-build-optional-packages": "5.2.2", - "ordered-binary": "^1.5.3", - "weak-lru-cache": "^1.2.2" - }, - "bin": { - "download-lmdb-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@lmdb/lmdb-darwin-arm64": "3.2.6", - "@lmdb/lmdb-darwin-x64": "3.2.6", - "@lmdb/lmdb-linux-arm": "3.2.6", - "@lmdb/lmdb-linux-arm64": "3.2.6", - "@lmdb/lmdb-linux-x64": "3.2.6", - "@lmdb/lmdb-win32-x64": "3.2.6" - } - }, - "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", - "dev": true, - "engines": { - "node": ">=6.11.5" - } - }, - "node_modules/loader-utils": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", - "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", - "dev": true, - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/locate-path": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "dev": true, - "dependencies": { - "p-locate": "^6.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" - }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-escapes": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", - "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", - "dev": true, - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", - "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", - "dev": true, - "dependencies": { - "get-east-asian-width": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", - "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/log4js": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", - "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", - "dev": true, - "dependencies": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "flatted": "^3.2.7", - "rfdc": "^1.3.0", - "streamroller": "^3.1.5" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-fetch-happen": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", - "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", - "dev": true, - "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "ssri": "^12.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/make-fetch-happen/node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/memfs": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.2.tgz", - "integrity": "sha512-NgYhCOWgovOXSzvYgUW0LQ7Qy72rWQMGGFJDoWg4G30RHd3z77VbYdtJ4fembJXBy8pMIUA31XNAupobOQlwdg==", - "dev": true, - "dependencies": { - "@jsonjoy.com/json-pack": "^1.0.3", - "@jsonjoy.com/util": "^1.3.0", - "tree-dump": "^1.0.1", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">= 4.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mini-css-extract-plugin": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz", - "integrity": "sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w==", - "dev": true, - "dependencies": { - "schema-utils": "^4.0.0", - "tapable": "^2.2.1" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", - "dev": true, - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-fetch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", - "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", - "dev": true, - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/msgpackr": { - "version": "1.11.4", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.4.tgz", - "integrity": "sha512-uaff7RG9VIC4jacFW9xzL3jc0iM32DNHe4jYVycBcjUePT/Klnfj7pqtWJt9khvDFizmjN2TlYniYmSS2LIaZg==", - "dev": true, - "optional": true, - "optionalDependencies": { - "msgpackr-extract": "^3.0.2" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" - } - }, - "node_modules/multicast-dns": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", - "dev": true, - "dependencies": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" - }, - "bin": { - "multicast-dns": "cli.js" - } - }, - "node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", - "dev": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/nanostores": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-0.11.4.tgz", - "integrity": "sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/needle": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/needle/-/needle-3.3.1.tgz", - "integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==", - "dev": true, - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.3", - "sax": "^1.2.4" - }, - "bin": { - "needle": "bin/needle" - }, - "engines": { - "node": ">= 4.4.x" - } - }, - "node_modules/needle/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true - }, - "node_modules/ng-packagr": { - "version": "19.2.2", - "resolved": "https://registry.npmjs.org/ng-packagr/-/ng-packagr-19.2.2.tgz", - "integrity": "sha512-dFuwFsDJMBSd1YtmLLcX5bNNUCQUlRqgf34aXA+79PmkOP+0eF8GP2949wq3+jMjmFTNm80Oo8IUYiSLwklKCQ==", - "dev": true, - "dependencies": { - "@rollup/plugin-json": "^6.1.0", - "@rollup/wasm-node": "^4.24.0", - "ajv": "^8.17.1", - "ansi-colors": "^4.1.3", - "browserslist": "^4.22.1", - "chokidar": "^4.0.1", - "commander": "^13.0.0", - "convert-source-map": "^2.0.0", - "dependency-graph": "^1.0.0", - "esbuild": "^0.25.0", - "fast-glob": "^3.3.2", - "find-cache-dir": "^3.3.2", - "injection-js": "^2.4.0", - "jsonc-parser": "^3.3.1", - "less": "^4.2.0", - "ora": "^5.1.0", - "piscina": "^4.7.0", - "postcss": "^8.4.47", - "rxjs": "^7.8.1", - "sass": "^1.81.0" - }, - "bin": { - "ng-packagr": "cli/main.js" - }, - "engines": { - "node": "^18.19.1 || >=20.11.1" - }, - "optionalDependencies": { - "rollup": "^4.24.0" - }, - "peerDependencies": { - "@angular/compiler-cli": "^19.0.0 || ^19.1.0-next.0 || ^19.2.0-next.0", - "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "tslib": "^2.3.0", - "typescript": ">=5.5 <5.9" - }, - "peerDependenciesMeta": { - "tailwindcss": { - "optional": true - } - } - }, - "node_modules/ng-packagr/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "node_modules/ng-packagr/node_modules/find-cache-dir": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", - "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", - "dev": true, - "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/avajs/find-cache-dir?sponsor=1" - } - }, - "node_modules/ng-packagr/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ng-packagr/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ng-packagr/node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ng-packagr/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ng-packagr/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ng-packagr/node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ng-packagr/node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ng-packagr/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", - "dev": true, - "optional": true - }, - "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", - "dev": true, - "engines": { - "node": ">= 6.13.0" - } - }, - "node_modules/node-gyp": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.2.0.tgz", - "integrity": "sha512-T0S1zqskVUSxcsSTkAsLc7xCycrRYmtDHadDinzocrThjyQCn5kMlEBSj6H4qDbgsIOSLmmlRIeb0lZXj+UArA==", - "dev": true, - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "tar": "^7.4.3", - "tinyglobby": "^0.2.12", - "which": "^5.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "dev": true, - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, - "node_modules/node-gyp/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "dev": true, - "engines": { - "node": ">=16" - } - }, - "node_modules/node-gyp/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "dev": true, - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true - }, - "node_modules/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", - "dev": true, - "dependencies": { - "abbrev": "^3.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm-bundled": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-4.0.0.tgz", - "integrity": "sha512-IxaQZDMsqfQ2Lz37VvyyEtKLe8FsRZuysmedy/N06TU1RyVppYKXrO4xIhR0F+7ubIBox6Q7nir6fQI3ej39iA==", - "dev": true, - "dependencies": { - "npm-normalize-package-bin": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-install-checks": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-7.1.1.tgz", - "integrity": "sha512-u6DCwbow5ynAX5BdiHQ9qvexme4U3qHW3MWe5NqH+NeBm0LbiH6zvGjNNew1fY+AZZUtVHbOPF3j7mJxbUzpXg==", - "dev": true, - "dependencies": { - "semver": "^7.1.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-normalize-package-bin": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", - "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", - "dev": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-package-arg": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-12.0.2.tgz", - "integrity": "sha512-f1NpFjNI9O4VbKMOlA5QoBq/vSQPORHcTZ2feJpFkTHJ9eQkdlmZEKSjcAhxTGInC7RlEyScT9ui67NaOsjFWA==", - "dev": true, - "dependencies": { - "hosted-git-info": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^6.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-packlist": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-9.0.0.tgz", - "integrity": "sha512-8qSayfmHJQTx3nJWYbbUmflpyarbLMBc6LCAjYsiGtXxDB68HaZpb8re6zeaLGxZzDuMdhsg70jryJe+RrItVQ==", - "dev": true, - "dependencies": { - "ignore-walk": "^7.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-pick-manifest": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-10.0.0.tgz", - "integrity": "sha512-r4fFa4FqYY8xaM7fHecQ9Z2nE9hgNfJR+EmoKv0+chvzWkBcORX3r0FpTByP+CbOVJDladMXnPQGVN8PBLGuTQ==", - "dev": true, - "dependencies": { - "npm-install-checks": "^7.1.0", - "npm-normalize-package-bin": "^4.0.0", - "npm-package-arg": "^12.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-registry-fetch": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-18.0.2.tgz", - "integrity": "sha512-LeVMZBBVy+oQb5R6FDV9OlJCcWDU+al10oKpe+nsvcHnG24Z3uM3SvJYKfGJlfGjVU8v9liejCrUR/M5HO5NEQ==", - "dev": true, - "dependencies": { - "@npmcli/redact": "^3.0.0", - "jsonparse": "^1.3.1", - "make-fetch-happen": "^14.0.0", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minizlib": "^3.0.1", - "npm-package-arg": "^12.0.0", - "proc-log": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "dev": true - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/open": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", - "integrity": "sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==", - "dev": true, - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/ora/node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ora/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ora/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" - }, - "node_modules/ora/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ordered-binary": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.5.3.tgz", - "integrity": "sha512-oGFr3T+pYdTGJ+YFEILMpS3es+GiIbs9h/XQrclBXUtd44ey7XwfsMzM31f64I1SQOawDoDr/D823kNCADI8TA==", - "dev": true, - "optional": true - }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", - "dev": true, - "dependencies": { - "p-limit": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-map": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", - "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-retry": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", - "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", - "dev": true, - "dependencies": { - "@types/retry": "0.12.2", - "is-network-error": "^1.0.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-retry/node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true - }, - "node_modules/pacote": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-20.0.0.tgz", - "integrity": "sha512-pRjC5UFwZCgx9kUFDVM9YEahv4guZ1nSLqwmWiLUnDbGsjs+U5w7z6Uc8HNR1a6x8qnu5y9xtGE6D1uAuYz+0A==", - "dev": true, - "dependencies": { - "@npmcli/git": "^6.0.0", - "@npmcli/installed-package-contents": "^3.0.0", - "@npmcli/package-json": "^6.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "@npmcli/run-script": "^9.0.0", - "cacache": "^19.0.0", - "fs-minipass": "^3.0.0", - "minipass": "^7.0.2", - "npm-package-arg": "^12.0.0", - "npm-packlist": "^9.0.0", - "npm-pick-manifest": "^10.0.0", - "npm-registry-fetch": "^18.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "sigstore": "^3.0.0", - "ssri": "^12.0.0", - "tar": "^6.1.11" - }, - "bin": { - "pacote": "bin/index.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/pacote/node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/pacote/node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/pacote/node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pacote/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pacote/node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dev": true, - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pacote/node_modules/tar/node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/pacote/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pacote/node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/pacote/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse-json/node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, - "node_modules/parse-node-version": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", - "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-html-rewriting-stream": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-7.0.0.tgz", - "integrity": "sha512-mazCyGWkmCRWDI15Zp+UiCqMp/0dgEmkZRvhlsqqKYr4SsVm/TvnSpD9fCvqCA2zoWJcfRym846ejWBBHRiYEg==", - "dev": true, - "dependencies": { - "entities": "^4.3.0", - "parse5": "^7.0.0", - "parse5-sax-parser": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-sax-parser": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-7.0.0.tgz", - "integrity": "sha512-5A+v2SNsq8T6/mG3ahcz8ZtQ0OUFTatxPbeidoMB7tkJSGDY3tdfl4MHovtLQHkEn5CGxijNWRQHhRQ6IRpXKg==", - "dev": true, - "dependencies": { - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/node_modules/entities": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", - "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", - "dev": true, - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, - "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" - }, - "node_modules/path-type": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", - "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" - }, - "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/piscina": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.8.0.tgz", - "integrity": "sha512-EZJb+ZxDrQf3dihsUL7p42pjNyrNIFJCrRHPMgxu/svsj+P3xS3fuEWp7k2+rfsavfl1N0G29b1HGs7J0m8rZA==", - "dev": true, - "optionalDependencies": { - "@napi-rs/nice": "^1.0.1" - } - }, - "node_modules/pkg-dir": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", - "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", - "dev": true, - "dependencies": { - "find-up": "^6.3.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.8", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-loader": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.1.tgz", - "integrity": "sha512-0IeqyAsG6tYiDRCYKQJLAmgQr47DX6N7sFSWvQxt6AcupX8DIdmykuk/o/tx0Lze3ErGHJEp5OSRxrelC6+NdQ==", - "dev": true, - "dependencies": { - "cosmiconfig": "^9.0.0", - "jiti": "^1.20.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "postcss": "^7.0.0 || ^8.0.1", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/postcss-loader/node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/postcss-media-query-parser": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", - "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", - "dev": true - }, - "node_modules/postcss-modules-extract-imports": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", - "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", - "dev": true, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-local-by-default": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", - "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", - "dev": true, - "dependencies": { - "icss-utils": "^5.0.0", - "postcss-selector-parser": "^7.0.0", - "postcss-value-parser": "^4.1.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-scope": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", - "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", - "dev": true, - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-values": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", - "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", - "dev": true, - "dependencies": { - "icss-utils": "^5.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true - }, - "node_modules/proc-log": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", - "dev": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/protobufjs": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.2.tgz", - "integrity": "sha512-f2ls6rpO6G153Cy+o2XQ+Y0sARLOZ17+OGVLHrc3VUKcLHYKEKWbkSujdBWQXM7gKn5NTfp0XnRPZn1MIu8n9w==", - "hasInstallScript": true, - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/prr": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", - "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", - "dev": true, - "optional": true - }, - "node_modules/punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "dev": true - }, - "node_modules/qjobs": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", - "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", - "dev": true, - "engines": { - "node": ">=0.9" - } - }, - "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "devOptional": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true - }, - "node_modules/regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "dev": true - }, - "node_modules/regenerate-unicode-properties": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", - "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", - "dev": true, - "dependencies": { - "regenerate": "^1.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true - }, - "node_modules/regex-parser": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz", - "integrity": "sha512-yXLRqatcCuKtVHsWrNg0JL3l1zGfdXeEvDa0bdu4tCDQw0RpMDZsqbkyRTUnKMR0tXF627V2oEWjBEaEdqTwtQ==", - "dev": true - }, - "node_modules/regexpu-core": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", - "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", - "dev": true, - "dependencies": { - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.0", - "regjsgen": "^0.8.0", - "regjsparser": "^0.12.0", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "dev": true - }, - "node_modules/regjsparser": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", - "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", - "dev": true, - "dependencies": { - "jsesc": "~3.0.2" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, - "node_modules/regjsparser/node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "dev": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true - }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve-url-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz", - "integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==", - "dev": true, - "dependencies": { - "adjust-sourcemap-loader": "^4.0.0", - "convert-source-map": "^1.7.0", - "loader-utils": "^2.0.0", - "postcss": "^8.2.14", - "source-map": "0.6.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/resolve-url-loader/node_modules/loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", - "dev": true, - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, - "engines": { - "node": ">=8.9.0" - } - }, - "node_modules/resolve-url-loader/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rollup": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.8.tgz", - "integrity": "sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ==", - "dev": true, - "dependencies": { - "@types/estree": "1.0.6" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.34.8", - "@rollup/rollup-android-arm64": "4.34.8", - "@rollup/rollup-darwin-arm64": "4.34.8", - "@rollup/rollup-darwin-x64": "4.34.8", - "@rollup/rollup-freebsd-arm64": "4.34.8", - "@rollup/rollup-freebsd-x64": "4.34.8", - "@rollup/rollup-linux-arm-gnueabihf": "4.34.8", - "@rollup/rollup-linux-arm-musleabihf": "4.34.8", - "@rollup/rollup-linux-arm64-gnu": "4.34.8", - "@rollup/rollup-linux-arm64-musl": "4.34.8", - "@rollup/rollup-linux-loongarch64-gnu": "4.34.8", - "@rollup/rollup-linux-powerpc64le-gnu": "4.34.8", - "@rollup/rollup-linux-riscv64-gnu": "4.34.8", - "@rollup/rollup-linux-s390x-gnu": "4.34.8", - "@rollup/rollup-linux-x64-gnu": "4.34.8", - "@rollup/rollup-linux-x64-musl": "4.34.8", - "@rollup/rollup-win32-arm64-msvc": "4.34.8", - "@rollup/rollup-win32-ia32-msvc": "4.34.8", - "@rollup/rollup-win32-x64-msvc": "4.34.8", - "fsevents": "~2.3.2" - } - }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true - }, - "node_modules/run-applescript": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", - "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/rxfire": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/rxfire/-/rxfire-6.1.0.tgz", - "integrity": "sha512-NezdjeY32VZcCuGO0bbb8H8seBsJSCaWdUwGsHNzUcAOHR0VGpzgPtzjuuLXr8R/iemkqSzbx/ioS7VwV43ynA==", - "peerDependencies": { - "firebase": "^9.0.0 || ^10.0.0 || ^11.0.0", - "rxjs": "^6.0.0 || ^7.0.0" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/sass": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz", - "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==", - "dev": true, - "dependencies": { - "chokidar": "^4.0.0", - "immutable": "^5.0.2", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" - } - }, - "node_modules/sass-loader": { - "version": "16.0.5", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.5.tgz", - "integrity": "sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw==", - "dev": true, - "dependencies": { - "neo-async": "^2.6.2" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", - "sass": "^1.3.0", - "sass-embedded": "*", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "node-sass": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/sax": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", - "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", - "dev": true, - "optional": true - }, - "node_modules/schema-utils": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", - "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/schema-utils/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/select-hose": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", - "dev": true - }, - "node_modules/selfsigned": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", - "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", - "dev": true, - "dependencies": { - "@types/node-forge": "^1.3.0", - "node-forge": "^1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/serve-index": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", - "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", - "dev": true, - "dependencies": { - "accepts": "~1.3.4", - "batch": "0.6.1", - "debug": "2.6.9", - "escape-html": "~1.0.3", - "http-errors": "~1.6.2", - "mime-types": "~2.1.17", - "parseurl": "~1.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-index/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/serve-index/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/http-errors": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", - "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", - "dev": true, - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.3", - "setprototypeof": "1.1.0", - "statuses": ">= 1.4.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", - "dev": true - }, - "node_modules/serve-index/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/serve-index/node_modules/setprototypeof": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", - "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", - "dev": true - }, - "node_modules/serve-index/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "node_modules/shallow-clone": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", - "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", - "dev": true, - "dependencies": { - "kind-of": "^6.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", - "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sigstore": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-3.1.0.tgz", - "integrity": "sha512-ZpzWAFHIFqyFE56dXqgX/DkDRZdz+rRcjoIk/RQU4IX0wiCv1l8S7ZrXDHcCc+uaf+6o7w3h2l3g6GYG5TKN9Q==", - "dev": true, - "dependencies": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.0", - "@sigstore/sign": "^3.1.0", - "@sigstore/tuf": "^3.1.0", - "@sigstore/verify": "^2.1.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/slash": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", - "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", - "dev": true, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socket.io": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", - "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", - "dev": true, - "dependencies": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.3.2", - "engine.io": "~6.6.0", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/socket.io-adapter": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", - "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", - "dev": true, - "dependencies": { - "debug": "~4.3.4", - "ws": "~8.17.1" - } - }, - "node_modules/socket.io-adapter/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/socket.io-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", - "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", - "dev": true, - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/socket.io-parser/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/socket.io/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/sockjs": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", - "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", - "dev": true, - "dependencies": { - "faye-websocket": "^0.11.3", - "uuid": "^8.3.2", - "websocket-driver": "^0.7.4" - } - }, - "node_modules/socks": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", - "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", - "dev": true, - "dependencies": { - "ip-address": "^9.0.5", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "engines": { - "node": ">= 8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", - "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", - "dev": true, - "dependencies": { - "iconv-lite": "^0.6.3", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.72.1" - } - }, - "node_modules/source-map-loader/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "dev": true, - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dev": true, - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.21", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", - "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", - "dev": true - }, - "node_modules/spdy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", - "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", - "dev": true, - "dependencies": { - "debug": "^4.1.0", - "handle-thing": "^2.0.0", - "http-deceiver": "^1.2.7", - "select-hose": "^2.0.0", - "spdy-transport": "^3.0.0" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/spdy-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", - "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", - "dev": true, - "dependencies": { - "debug": "^4.1.0", - "detect-node": "^2.0.4", - "hpack.js": "^2.1.6", - "obuf": "^1.1.2", - "readable-stream": "^3.0.6", - "wbuf": "^1.7.3" - } - }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true - }, - "node_modules/ssri": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", - "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", - "dev": true, - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/streamroller": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", - "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", - "dev": true, - "dependencies": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "fs-extra": "^8.1.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/symbol-observable": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", - "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", - "dev": true, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/tailwindcss": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz", - "integrity": "sha512-kr1o/ErIdNhTz8uzAYL7TpaUuzKIE6QPQ4qmSdxnoX/lo+5wmUHQA6h3L5yIqEImSRnAAURDirLu/BgiXGPAhg==" - }, - "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tar/node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "engines": { - "node": ">=18" - } - }, - "node_modules/terser": { - "version": "5.39.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", - "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", - "dev": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true - }, - "node_modules/thingies": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", - "integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==", - "dev": true, - "engines": { - "node": ">=10.18" - }, - "peerDependencies": { - "tslib": "^2" - } - }, - "node_modules/thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", - "dev": true - }, - "node_modules/tinyglobby": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", - "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", - "dev": true, - "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "dependencies": { - "os-tmpdir": "~1.0.2" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tree-dump": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.3.tgz", - "integrity": "sha512-il+Cv80yVHFBwokQSfd4bldvr1Md951DpgAGfmhydt04L+YzHgubm2tQ7zueWDcGENKHq0ZvGFR/hjvNXilHEg==", - "dev": true, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - }, - "node_modules/tuf-js": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.0.1.tgz", - "integrity": "sha512-+68OP1ZzSF84rTckf3FA95vJ1Zlx/uaXyiiKyPd1pA4rZNkpEvDAKmsu1xUSmbF/chCRYgZ6UZkDwC7PmzmAyA==", - "dev": true, - "dependencies": { - "@tufjs/models": "3.0.1", - "debug": "^4.3.6", - "make-fetch-happen": "^14.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typed-assert": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/typed-assert/-/typed-assert-1.0.9.tgz", - "integrity": "sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==", - "dev": true - }, - "node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ua-parser-js": { - "version": "0.7.40", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.40.tgz", - "integrity": "sha512-us1E3K+3jJppDBa3Tl0L3MOJiGhe1C6P0+nIvQAFYbxlMAx0h81eOwLmU57xgqToduDDPx3y5QsdjPfDu+FgOQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } - ], - "bin": { - "ua-parser-js": "script/cli.js" - }, - "engines": { - "node": "*" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" - }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "dev": true, - "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", - "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", - "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/unique-filename": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", - "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", - "dev": true, - "dependencies": { - "unique-slug": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/unique-slug": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", - "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", - "dev": true, - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dev": true, - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/validate-npm-package-name": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.0.tgz", - "integrity": "sha512-d7KLgL1LD3U3fgnvWEY1cQXoO/q6EQ1BSz48Sa149V/5zVTAbgmZIpyI8TRi6U9/JNyeYLlTKsEMPtLC27RFUg==", - "dev": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", - "dev": true, - "peer": true, - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.0.tgz", - "integrity": "sha512-KxN+zCjOYHGwCl4UCtSfZ6jrq/qi88JDUtiEFk8LELEHq2Egfc/FgW+jItZiOLRuQfb/3xJSgFuNPC9jzggX+A==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-android-arm64": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.0.tgz", - "integrity": "sha512-yDvqx3lWlcugozax3DItKJI5j05B0d4Kvnjx+5mwiUpWramVvmAByYigMplaoAQ3pvdprGCTCE03eduqE/8mPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.0.tgz", - "integrity": "sha512-2KOU574vD3gzcPSjxO0eyR5iWlnxxtmW1F5CkNOHmMlueKNCQkxR6+ekgWyVnz6zaZihpUNkGxjsYrkTJKhkaw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-darwin-x64": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.0.tgz", - "integrity": "sha512-gE5ACNSxHcEZyP2BA9TuTakfZvULEW4YAOtxl/A/YDbIir/wPKukde0BNPlnBiP88ecaN4BJI2TtAd+HKuZPQQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.0.tgz", - "integrity": "sha512-GSxU6r5HnWij7FoSo7cZg3l5GPg4HFLkzsFFh0N/b16q5buW1NAWuCJ+HMtIdUEi6XF0qH+hN0TEd78laRp7Dg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.0.tgz", - "integrity": "sha512-KGiGKGDg8qLRyOWmk6IeiHJzsN/OYxO6nSbT0Vj4MwjS2XQy/5emsmtoqLAabqrohbgLWJ5GV3s/ljdrIr8Qjg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.0.tgz", - "integrity": "sha512-46OzWeqEVQyX3N2/QdiU/CMXYDH/lSHpgfBkuhl3igpZiaB3ZIfSjKuOnybFVBQzjsLwkus2mjaESy8H41SzvA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.0.tgz", - "integrity": "sha512-lfgW3KtQP4YauqdPpcUZHPcqQXmTmH4nYU0cplNeW583CMkAGjtImw4PKli09NFi2iQgChk4e9erkwlfYem6Lg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.0.tgz", - "integrity": "sha512-nn8mEyzMbdEJzT7cwxgObuwviMx6kPRxzYiOl6o/o+ChQq23gfdlZcUNnt89lPhhz3BYsZ72rp0rxNqBSfqlqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.0.tgz", - "integrity": "sha512-l+QK99je2zUKGd31Gh+45c4pGDAqZSuWQiuRFCdHYC2CSiO47qUWsCcenrI6p22hvHZrDje9QjwSMAFL3iwXwQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.0.tgz", - "integrity": "sha512-WbnJaxPv1gPIm6S8O/Wg+wfE/OzGSXlBMbOe4ie+zMyykMOeqmgD1BhPxZQuDqwUN+0T/xOFtL2RUWBspnZj3w==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.0.tgz", - "integrity": "sha512-eRDWR5t67/b2g8Q/S8XPi0YdbKcCs4WQ8vklNnUYLaSWF+Cbv2axZsp4jni6/j7eKvMLYCYdcsv8dcU+a6QNFg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.0.tgz", - "integrity": "sha512-TWrZb6GF5jsEKG7T1IHwlLMDRy2f3DPqYldmIhnA2DVqvvhY2Ai184vZGgahRrg8k9UBWoSlHv+suRfTN7Ua4A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.0.tgz", - "integrity": "sha512-/L3pW48SxrWAlVsKCN0dGLB2bi8Nv8pr5S5ocSM+S0XCn5RCVCXqi8GVtHFsOBBCSeR+u9brV2zno5+mg3S4Aw==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.0.tgz", - "integrity": "sha512-XMLeKjyH8NsEDCRptf6LO8lJk23o9wvB+dJwcXMaH6ZQbbkHu2dbGIUindbMtRN6ux1xKi16iXWu6q9mu7gDhQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.0.tgz", - "integrity": "sha512-m/P7LycHZTvSQeXhFmgmdqEiTqSV80zn6xHaQ1JSqwCtD1YGtwEK515Qmy9DcB2HK4dOUVypQxvhVSy06cJPEg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.0.tgz", - "integrity": "sha512-4yodtcOrFHpbomJGVEqZ8fzD4kfBeCbpsUy5Pqk4RluXOdsWdjLnjhiKy2w3qzcASWd04fp52Xz7JKarVJ5BTg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.0.tgz", - "integrity": "sha512-tmazCrAsKzdkXssEc65zIE1oC6xPHwfy9d5Ta25SRCDOZS+I6RypVVShWALNuU9bxIfGA0aqrmzlzoM5wO5SPQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.0.tgz", - "integrity": "sha512-h1J+Yzjo/X+0EAvR2kIXJDuTuyT7drc+t2ALY0nIcGPbTatNOf0VWdhEA2Z4AAjv6X1NJV7SYo5oCTYRJhSlVA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/vite/node_modules/rollup": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.0.tgz", - "integrity": "sha512-HqMFpUbWlf/tvcxBFNKnJyzc7Lk+XO3FGc3pbNBLqEbOz0gPLRgcrlS3UF4MfUrVlstOaP/q0kM6GVvi+LrLRg==", - "dev": true, - "peer": true, - "dependencies": { - "@types/estree": "1.0.7" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.41.0", - "@rollup/rollup-android-arm64": "4.41.0", - "@rollup/rollup-darwin-arm64": "4.41.0", - "@rollup/rollup-darwin-x64": "4.41.0", - "@rollup/rollup-freebsd-arm64": "4.41.0", - "@rollup/rollup-freebsd-x64": "4.41.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.41.0", - "@rollup/rollup-linux-arm-musleabihf": "4.41.0", - "@rollup/rollup-linux-arm64-gnu": "4.41.0", - "@rollup/rollup-linux-arm64-musl": "4.41.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.41.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.41.0", - "@rollup/rollup-linux-riscv64-gnu": "4.41.0", - "@rollup/rollup-linux-riscv64-musl": "4.41.0", - "@rollup/rollup-linux-s390x-gnu": "4.41.0", - "@rollup/rollup-linux-x64-gnu": "4.41.0", - "@rollup/rollup-linux-x64-musl": "4.41.0", - "@rollup/rollup-win32-arm64-msvc": "4.41.0", - "@rollup/rollup-win32-ia32-msvc": "4.41.0", - "@rollup/rollup-win32-x64-msvc": "4.41.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/void-elements": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", - "integrity": "sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/watchpack": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", - "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", - "dev": true, - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/wbuf": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", - "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", - "dev": true, - "dependencies": { - "minimalistic-assert": "^1.0.0" - } - }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dependencies": { - "defaults": "^1.0.3" - } - }, - "node_modules/weak-lru-cache": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", - "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", - "dev": true, - "optional": true - }, - "node_modules/web-vitals": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", - "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==" - }, - "node_modules/webpack": { - "version": "5.98.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz", - "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", - "dev": true, - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.14.0", - "browserslist": "^4.24.0", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-dev-middleware": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.2.tgz", - "integrity": "sha512-xOO8n6eggxnwYpy1NlzUKpvrjfJTvae5/D6WOK0S2LSo7vjmo5gCM1DbLUmFqrMTJP+W/0YZNctm7jasWvLuBA==", - "dev": true, - "dependencies": { - "colorette": "^2.0.10", - "memfs": "^4.6.0", - "mime-types": "^2.1.31", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - } - } - }, - "node_modules/webpack-dev-server": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.0.tgz", - "integrity": "sha512-90SqqYXA2SK36KcT6o1bvwvZfJFcmoamqeJY7+boioffX9g9C0wjjJRGUrQIuh43pb0ttX7+ssavmj/WN2RHtA==", - "dev": true, - "dependencies": { - "@types/bonjour": "^3.5.13", - "@types/connect-history-api-fallback": "^1.5.4", - "@types/express": "^4.17.21", - "@types/serve-index": "^1.9.4", - "@types/serve-static": "^1.15.5", - "@types/sockjs": "^0.3.36", - "@types/ws": "^8.5.10", - "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.2.1", - "chokidar": "^3.6.0", - "colorette": "^2.0.10", - "compression": "^1.7.4", - "connect-history-api-fallback": "^2.0.0", - "express": "^4.21.2", - "graceful-fs": "^4.2.6", - "http-proxy-middleware": "^2.0.7", - "ipaddr.js": "^2.1.0", - "launch-editor": "^2.6.1", - "open": "^10.0.3", - "p-retry": "^6.2.0", - "schema-utils": "^4.2.0", - "selfsigned": "^2.4.1", - "serve-index": "^1.9.1", - "sockjs": "^0.3.24", - "spdy": "^4.0.2", - "webpack-dev-middleware": "^7.4.2", - "ws": "^8.18.0" - }, - "bin": { - "webpack-dev-server": "bin/webpack-dev-server.js" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - }, - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-dev-server/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/webpack-dev-server/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/webpack-dev-server/node_modules/http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", - "dev": true, - "dependencies": { - "@types/http-proxy": "^1.17.8", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.1", - "is-plain-obj": "^3.0.0", - "micromatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "@types/express": "^4.17.13" - }, - "peerDependenciesMeta": { - "@types/express": { - "optional": true - } - } - }, - "node_modules/webpack-dev-server/node_modules/ipaddr.js": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", - "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", - "dev": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/webpack-dev-server/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/webpack-dev-server/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/webpack-dev-server/node_modules/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", - "dev": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/webpack-merge": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", - "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", - "dev": true, - "dependencies": { - "clone-deep": "^4.0.1", - "flat": "^5.0.2", - "wildcard": "^2.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", - "dev": true, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack-subresource-integrity": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/webpack-subresource-integrity/-/webpack-subresource-integrity-5.1.0.tgz", - "integrity": "sha512-sacXoX+xd8r4WKsy9MvH/q/vBtEHr86cpImXwyg74pFIpERKt6FmB8cXpeuh0ZLgclOlHI4Wcll7+R5L02xk9Q==", - "dev": true, - "dependencies": { - "typed-assert": "^1.0.8" - }, - "engines": { - "node": ">= 12" - }, - "peerDependencies": { - "html-webpack-plugin": ">= 5.0.0-beta.1 < 6", - "webpack": "^5.12.0" - }, - "peerDependenciesMeta": { - "html-webpack-plugin": { - "optional": true - } - } - }, - "node_modules/webpack/node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, - "node_modules/wildcard": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", - "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", - "dev": true - }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "dev": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xhr2": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/xhr2/-/xhr2-0.2.1.tgz", - "integrity": "sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yocto-queue": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", - "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", - "dev": true, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", - "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "3.25.7", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.7.tgz", - "integrity": "sha512-YGdT1cVRmKkOg6Sq7vY7IkxdphySKnXhaUmFI4r4FcuFVNgpCb9tZfNwXbT6BPjD5oz0nubFsoo9pIqKrDcCvg==", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zone.js": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.0.tgz", - "integrity": "sha512-9oxn0IIjbCZkJ67L+LkhYWRyAy7axphb3VgE2MBDlOqnmHMPWGYMxJxBYFueFq/JGY2GMwS0rU+UCLunEmy5UA==" - } - }, - "dependencies": { - "@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==" - }, - "@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "requires": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "@angular-devkit/architect": { - "version": "0.1902.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.12.tgz", - "integrity": "sha512-LfUc7k84YL290hAxsG+FvjQpXugQXyw5aDzrQQB4iTYhBgaABu2aaNOU4eu3JH+F8NeXd2EBF/YMr2LDSkYlMw==", - "dev": true, - "requires": { - "@angular-devkit/core": "19.2.12", - "rxjs": "7.8.1" - }, - "dependencies": { - "rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "requires": { - "tslib": "^2.1.0" - } - } - } - }, - "@angular-devkit/build-angular": { - "version": "19.2.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-19.2.12.tgz", - "integrity": "sha512-gPx3Vi7QFzHkSV388en6VqSqasojitJKuKmgTMPOV5keLtpOylPv3rjnr8oO9rYbYmLsT/WTUsP7bYiZhrr19Q==", - "dev": true, - "requires": { - "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1902.12", - "@angular-devkit/build-webpack": "0.1902.12", - "@angular-devkit/core": "19.2.12", - "@angular/build": "19.2.12", - "@babel/core": "7.26.10", - "@babel/generator": "7.26.10", - "@babel/helper-annotate-as-pure": "7.25.9", - "@babel/helper-split-export-declaration": "7.24.7", - "@babel/plugin-transform-async-generator-functions": "7.26.8", - "@babel/plugin-transform-async-to-generator": "7.25.9", - "@babel/plugin-transform-runtime": "7.26.10", - "@babel/preset-env": "7.26.9", - "@babel/runtime": "7.26.10", - "@discoveryjs/json-ext": "0.6.3", - "@ngtools/webpack": "19.2.12", - "@vitejs/plugin-basic-ssl": "1.2.0", - "ansi-colors": "4.1.3", - "autoprefixer": "10.4.20", - "babel-loader": "9.2.1", - "browserslist": "^4.21.5", - "copy-webpack-plugin": "12.0.2", - "css-loader": "7.1.2", - "esbuild": "0.25.4", - "esbuild-wasm": "0.25.4", - "fast-glob": "3.3.3", - "http-proxy-middleware": "3.0.5", - "istanbul-lib-instrument": "6.0.3", - "jsonc-parser": "3.3.1", - "karma-source-map-support": "1.4.0", - "less": "4.2.2", - "less-loader": "12.2.0", - "license-webpack-plugin": "4.0.2", - "loader-utils": "3.3.1", - "mini-css-extract-plugin": "2.9.2", - "open": "10.1.0", - "ora": "5.4.1", - "picomatch": "4.0.2", - "piscina": "4.8.0", - "postcss": "8.5.2", - "postcss-loader": "8.1.1", - "resolve-url-loader": "5.0.0", - "rxjs": "7.8.1", - "sass": "1.85.0", - "sass-loader": "16.0.5", - "semver": "7.7.1", - "source-map-loader": "5.0.0", - "source-map-support": "0.5.21", - "terser": "5.39.0", - "tree-kill": "1.2.2", - "tslib": "2.8.1", - "webpack": "5.98.0", - "webpack-dev-middleware": "7.4.2", - "webpack-dev-server": "5.2.0", - "webpack-merge": "6.0.1", - "webpack-subresource-integrity": "5.1.0" - }, - "dependencies": { - "postcss": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz", - "integrity": "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==", - "dev": true, - "requires": { - "nanoid": "^3.3.8", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - } - }, - "rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "requires": { - "tslib": "^2.1.0" - } - } - } - }, - "@angular-devkit/build-webpack": { - "version": "0.1902.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1902.12.tgz", - "integrity": "sha512-JNwvzaN2RVbG1IClFPXhNpysVwf55nWmVsNN5iQHRXkD3kpqnaOfhUBtlhBBjLf/i6cwKEne2TI8zciaEYr+iw==", - "dev": true, - "requires": { - "@angular-devkit/architect": "0.1902.12", - "rxjs": "7.8.1" - }, - "dependencies": { - "rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "requires": { - "tslib": "^2.1.0" - } - } - } - }, - "@angular-devkit/core": { - "version": "19.2.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.12.tgz", - "integrity": "sha512-v5pdfZHZ8MTZozfpkhKoPFBpXQW+2GFbTfdyis8FBtevJWCbIsCR3xhodgI4jwzkSEAraN4oVtWvSytdNyBC6A==", - "requires": { - "ajv": "8.17.1", - "ajv-formats": "3.0.1", - "jsonc-parser": "3.3.1", - "picomatch": "4.0.2", - "rxjs": "7.8.1", - "source-map": "0.7.4" - }, - "dependencies": { - "rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "requires": { - "tslib": "^2.1.0" - } - } - } - }, - "@angular-devkit/schematics": { - "version": "19.2.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.12.tgz", - "integrity": "sha512-vK5NI/asi1snWFkw02DpmC8tLq6u5ZbUwwXxgALKuVwGl3g1VLzrHrkoSCrcsOO9Nu6GQOPbxax2lR/DICmytg==", - "requires": { - "@angular-devkit/core": "19.2.12", - "jsonc-parser": "3.3.1", - "magic-string": "0.30.17", - "ora": "5.4.1", - "rxjs": "7.8.1" - }, - "dependencies": { - "rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "requires": { - "tslib": "^2.1.0" - } - } - } - }, - "@angular/animations": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-19.2.11.tgz", - "integrity": "sha512-NR33bZVho7EgTc1fmCnmkwc2/U266n311Wfvk7VVtz+0Q9WliNdDLBon654V8IWSKvlqKXyU3W+fp0VjH/FvSw==", - "requires": { - "tslib": "^2.3.0" - } - }, - "@angular/build": { - "version": "19.2.12", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.2.12.tgz", - "integrity": "sha512-G28ux1T5QDlWporwupWbcodBN3rcyHfK2Dh5M3UC5hj0GstpfEHcpBHxawZzIxhqPKy//tdVLlzORUgvAwnqbA==", - "dev": true, - "requires": { - "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1902.12", - "@babel/core": "7.26.10", - "@babel/helper-annotate-as-pure": "7.25.9", - "@babel/helper-split-export-declaration": "7.24.7", - "@babel/plugin-syntax-import-attributes": "7.26.0", - "@inquirer/confirm": "5.1.6", - "@vitejs/plugin-basic-ssl": "1.2.0", - "beasties": "0.3.2", - "browserslist": "^4.23.0", - "esbuild": "0.25.4", - "fast-glob": "3.3.3", - "https-proxy-agent": "7.0.6", - "istanbul-lib-instrument": "6.0.3", - "listr2": "8.2.5", - "lmdb": "3.2.6", - "magic-string": "0.30.17", - "mrmime": "2.0.1", - "parse5-html-rewriting-stream": "7.0.0", - "picomatch": "4.0.2", - "piscina": "4.8.0", - "rollup": "4.34.8", - "sass": "1.85.0", - "semver": "7.7.1", - "source-map-support": "0.5.21", - "vite": "6.2.7", - "watchpack": "2.4.2" - }, - "dependencies": { - "vite": { - "version": "6.2.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.7.tgz", - "integrity": "sha512-qg3LkeuinTrZoJHHF94coSaTfIPyBYoywp+ys4qu20oSJFbKMYoIJo0FWJT9q6Vp49l6z9IsJRbHdcGtiKbGoQ==", - "dev": true, - "requires": { - "esbuild": "^0.25.0", - "fsevents": "~2.3.3", - "postcss": "^8.5.3", - "rollup": "^4.30.1" - } - } - } - }, - "@angular/cli": { - "version": "19.2.12", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.12.tgz", - "integrity": "sha512-cZkHpM16uh3VouHG1XdWSk0ZWisQRxMVADk5IJlM9jMcPqnFyJwD/UXCS+XTaW3POpNDwsmbh2UB9Xabdgo7rw==", - "dev": true, - "requires": { - "@angular-devkit/architect": "0.1902.12", - "@angular-devkit/core": "19.2.12", - "@angular-devkit/schematics": "19.2.12", - "@inquirer/prompts": "7.3.2", - "@listr2/prompt-adapter-inquirer": "2.0.18", - "@schematics/angular": "19.2.12", - "@yarnpkg/lockfile": "1.1.0", - "ini": "5.0.0", - "jsonc-parser": "3.3.1", - "listr2": "8.2.5", - "npm-package-arg": "12.0.2", - "npm-pick-manifest": "10.0.0", - "pacote": "20.0.0", - "resolve": "1.22.10", - "semver": "7.7.1", - "symbol-observable": "4.0.0", - "yargs": "17.7.2" - } - }, - "@angular/common": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.11.tgz", - "integrity": "sha512-/ZnF2Nfp6S6TAu3VlvUAIp4NVd81WE1Q95wuwSSuoEx2aSyXzI+1myyKWSYe/jYCyGuppmocjTciEh8mAInmOw==", - "requires": { - "tslib": "^2.3.0" - } - }, - "@angular/compiler": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.11.tgz", - "integrity": "sha512-/ZGFAEO2TyqkaE4neR8lGL9I2QeO2sRVFqulQv7Bu8zKTPStjcsFCwNkp+TNX8Oq/1rLcY9XWAOsUk1//AZd8Q==", - "requires": { - "tslib": "^2.3.0" - } - }, - "@angular/compiler-cli": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.11.tgz", - "integrity": "sha512-15aoOg+qj7Z3Uap1JKHMy51y12M09AOnseDBa0SYKidSx15XwZi8d01hv7sRaQJX/6Ie5cug9GiAbLKts6R33w==", - "dev": true, - "requires": { - "@babel/core": "7.26.9", - "@jridgewell/sourcemap-codec": "^1.4.14", - "chokidar": "^4.0.0", - "convert-source-map": "^1.5.1", - "reflect-metadata": "^0.2.0", - "semver": "^7.0.0", - "tslib": "^2.3.0", - "yargs": "^17.2.1" - }, - "dependencies": { - "@babel/core": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", - "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", - "dev": true, - "requires": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.9", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.9", - "@babel/parser": "^7.26.9", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.9", - "@babel/types": "^7.26.9", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "dependencies": { - "convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - } - } - }, - "@angular/core": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.11.tgz", - "integrity": "sha512-kmtJQB7B5F2V1JIzy1oBPS6WrRyedSYkuge+XoX1mCSFJDef8HRNd7GopnQ0Zaz0vOTGvCCkWvvaH/+7s2lmAQ==", - "requires": { - "tslib": "^2.3.0" - } - }, - "@angular/fire": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/@angular/fire/-/fire-19.1.0.tgz", - "integrity": "sha512-yyELJQLxF56EoGW8HUxfATBUeX5rzNpt/PjNAhSlmWdQ12jXVkgGeWyWsl5gvUlxhpFKIt+EVp3nYvwIlzey6Q==", - "requires": { - "@angular-devkit/schematics": "^19.0.0", - "@schematics/angular": "^19.0.0", - "firebase": "^11.2.0", - "rxfire": "^6.1.0", - "tslib": "^2.3.0" - } - }, - "@angular/forms": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.11.tgz", - "integrity": "sha512-ZH9ccuT6rTirNSbiMRtGRkRrj69a2/+BVaa/kEpUHjh41wDQXxhOlOfPZd/sfj04QiAzIpsYmVJrmoV7/LxPSw==", - "requires": { - "tslib": "^2.3.0" - } - }, - "@angular/platform-browser": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.11.tgz", - "integrity": "sha512-wAPJtgzmxBEpW31sa2eg9QssCHBZ52Zc9nm6azTflDlOAyfm9bzqec7y3wqy5sgVue/qID2gzHqmpS3Nx3o0xg==", - "requires": { - "tslib": "^2.3.0" - } - }, - "@angular/platform-browser-dynamic": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-19.2.11.tgz", - "integrity": "sha512-1/0FmjSAvsK+A6gWLgEc60YMnWQchP9fP6y4sE1uQOThIgK+qLnLjZqZn7uOw8zMDBMtxB7SlepajnXftVXddw==", - "requires": { - "tslib": "^2.3.0" - } - }, - "@angular/platform-server": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/platform-server/-/platform-server-19.2.11.tgz", - "integrity": "sha512-RbIE99k6QRw1EDDFFpjwM1aVVZlZ6B6zXWJTcjLUTCkF2tcZd2zZH3/3qiENETlFFI4A4VE1zTTtZD3/29sJnA==", - "requires": { - "tslib": "^2.3.0", - "xhr2": "^0.2.0" - } - }, - "@angular/router": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.11.tgz", - "integrity": "sha512-nBwMwRgQ3s1c1CPItPnTJTf81NDOQHvK41r2MIJGHa3H9LONlcbY07q/9p49fqt/xn/dgoOmQTtJ22b/nbIJAQ==", - "requires": { - "tslib": "^2.3.0" - } - }, - "@angular/ssr": { - "version": "19.2.12", - "resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-19.2.12.tgz", - "integrity": "sha512-RNi/u6Hbg8bJ1FYOUbjT5dmyfM+H5kok1MuRWvpSaVUpH2s/CMNQ/F9fw6vzay2Nr/qVHeq+eeYYY8QXn2ZbhA==", - "requires": { - "tslib": "^2.3.0" - } - }, - "@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "requires": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - } - }, - "@babel/compat-data": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.2.tgz", - "integrity": "sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==", - "dev": true - }, - "@babel/core": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", - "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", - "dev": true, - "requires": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.10", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.10", - "@babel/types": "^7.26.10", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "dependencies": { - "convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "@babel/generator": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.10.tgz", - "integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==", - "dev": true, - "requires": { - "@babel/parser": "^7.26.10", - "@babel/types": "^7.26.10", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^3.0.2" - } - }, - "@babel/helper-annotate-as-pure": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", - "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", - "dev": true, - "requires": { - "@babel/types": "^7.25.9" - } - }, - "@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "requires": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "dependencies": { - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "@babel/helper-create-class-features-plugin": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", - "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", - "dev": true, - "requires": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-member-expression-to-functions": "^7.27.1", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.27.1", - "semver": "^6.3.1" - }, - "dependencies": { - "@babel/helper-annotate-as-pure": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", - "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", - "dev": true, - "requires": { - "@babel/types": "^7.27.1" - } - }, - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "@babel/helper-create-regexp-features-plugin": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz", - "integrity": "sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==", - "dev": true, - "requires": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "regexpu-core": "^6.2.0", - "semver": "^6.3.1" - }, - "dependencies": { - "@babel/helper-annotate-as-pure": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", - "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", - "dev": true, - "requires": { - "@babel/types": "^7.27.1" - } - }, - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "@babel/helper-define-polyfill-provider": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz", - "integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==", - "dev": true, - "requires": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" - } - }, - "@babel/helper-member-expression-to-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", - "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", - "dev": true, - "requires": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - } - }, - "@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "requires": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - } - }, - "@babel/helper-module-transforms": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz", - "integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==", - "dev": true, - "requires": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.1" - } - }, - "@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "dev": true, - "requires": { - "@babel/types": "^7.27.1" - } - }, - "@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true - }, - "@babel/helper-remap-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", - "dev": true, - "requires": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "dependencies": { - "@babel/helper-annotate-as-pure": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", - "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", - "dev": true, - "requires": { - "@babel/types": "^7.27.1" - } - } - } - }, - "@babel/helper-replace-supers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", - "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", - "dev": true, - "requires": { - "@babel/helper-member-expression-to-functions": "^7.27.1", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.27.1" - } - }, - "@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "dev": true, - "requires": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - } - }, - "@babel/helper-split-export-declaration": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", - "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", - "dev": true, - "requires": { - "@babel/types": "^7.24.7" - } - }, - "@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true - }, - "@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true - }, - "@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true - }, - "@babel/helper-wrap-function": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.27.1.tgz", - "integrity": "sha512-NFJK2sHUvrjo8wAU/nQTWU890/zB2jj0qBcCbZbbf+005cAsv6tMjXz31fBign6M5ov1o0Bllu+9nbqkfsjjJQ==", - "dev": true, - "requires": { - "@babel/template": "^7.27.1", - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - } - }, - "@babel/helpers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz", - "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==", - "dev": true, - "requires": { - "@babel/template": "^7.27.1", - "@babel/types": "^7.27.1" - } - }, - "@babel/parser": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz", - "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==", - "dev": true, - "requires": { - "@babel/types": "^7.27.1" - } - }, - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz", - "integrity": "sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - } - }, - "@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", - "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", - "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", - "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1" - } - }, - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.27.1.tgz", - "integrity": "sha512-6BpaYGDavZqkI6yT+KSPdpZFfpnd68UKXbcjI9pJ13pvHhPrCKWOOLp+ysvMeA+DxnhuPpgIaRpxRxo5A9t5jw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - } - }, - "@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, - "requires": {} - }, - "@babel/plugin-syntax-import-assertions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", - "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-syntax-import-attributes": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", - "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.25.9" - } - }, - "@babel/plugin-syntax-unicode-sets-regex": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", - "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "dev": true, - "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - } - }, - "@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-async-generator-functions": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.26.8.tgz", - "integrity": "sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.26.5", - "@babel/helper-remap-async-to-generator": "^7.25.9", - "@babel/traverse": "^7.26.8" - } - }, - "@babel/plugin-transform-async-to-generator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", - "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", - "dev": true, - "requires": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-remap-async-to-generator": "^7.25.9" - } - }, - "@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-block-scoping": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.1.tgz", - "integrity": "sha512-QEcFlMl9nGTgh1rn2nIeU5bkfb9BAjaQcWbiP4LvKxUot52ABcTkpcyJ7f2Q2U2RuQ84BNLgts3jRme2dTx6Fw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-class-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", - "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", - "dev": true, - "requires": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-class-static-block": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.27.1.tgz", - "integrity": "sha512-s734HmYU78MVzZ++joYM+NkJusItbdRcbm+AGRgJCt3iA+yux0QpD9cBVdz3tKyrjVYWRl7j0mHSmv4lhV0aoA==", - "dev": true, - "requires": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-classes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.27.1.tgz", - "integrity": "sha512-7iLhfFAubmpeJe/Wo2TVuDrykh/zlWXLzPNdL0Jqn/Xu8R3QQ8h9ff8FQoISZOsw74/HFqFI7NX63HN7QFIHKA==", - "dev": true, - "requires": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/traverse": "^7.27.1", - "globals": "^11.1.0" - }, - "dependencies": { - "@babel/helper-annotate-as-pure": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", - "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", - "dev": true, - "requires": { - "@babel/types": "^7.27.1" - } - } - } - }, - "@babel/plugin-transform-computed-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", - "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/template": "^7.27.1" - } - }, - "@babel/plugin-transform-destructuring": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.27.1.tgz", - "integrity": "sha512-ttDCqhfvpE9emVkXbPD8vyxxh4TWYACVybGkDj+oReOGwnp066ITEivDlLwe0b1R0+evJ13IXQuLNB5w1fhC5Q==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-dotall-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", - "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", - "dev": true, - "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-duplicate-keys": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", - "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", - "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", - "dev": true, - "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-dynamic-import": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", - "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-exponentiation-operator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz", - "integrity": "sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - } - }, - "@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", - "dev": true, - "requires": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - } - }, - "@babel/plugin-transform-json-strings": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", - "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-logical-assignment-operators": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz", - "integrity": "sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-modules-amd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", - "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", - "dev": true, - "requires": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-modules-commonjs": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", - "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", - "dev": true, - "requires": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-modules-systemjs": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz", - "integrity": "sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==", - "dev": true, - "requires": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.1" - } - }, - "@babel/plugin-transform-modules-umd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", - "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", - "dev": true, - "requires": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", - "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", - "dev": true, - "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-new-target": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", - "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", - "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-numeric-separator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", - "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-object-rest-spread": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.27.2.tgz", - "integrity": "sha512-AIUHD7xJ1mCrj3uPozvtngY3s0xpv7Nu7DoUSnzNY6Xam1Cy4rUznR//pvMHOhQ4AvbCexhbqXCtpxGHOGOO6g==", - "dev": true, - "requires": { - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.27.1", - "@babel/plugin-transform-parameters": "^7.27.1" - } - }, - "@babel/plugin-transform-object-super": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" - } - }, - "@babel/plugin-transform-optional-catch-binding": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", - "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", - "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - } - }, - "@babel/plugin-transform-parameters": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.1.tgz", - "integrity": "sha512-018KRk76HWKeZ5l4oTj2zPpSh+NbGdt0st5S6x0pga6HgrjBOJb24mMDHorFopOOd6YHkLgOZ+zaCjZGPO4aKg==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-private-methods": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", - "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", - "dev": true, - "requires": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-private-property-in-object": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", - "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", - "dev": true, - "requires": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "dependencies": { - "@babel/helper-annotate-as-pure": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", - "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", - "dev": true, - "requires": { - "@babel/types": "^7.27.1" - } - } - } - }, - "@babel/plugin-transform-property-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-regenerator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.1.tgz", - "integrity": "sha512-B19lbbL7PMrKr52BNPjCqg1IyNUIjTcxKj8uX9zHO+PmWN93s19NDr/f69mIkEp2x9nmDJ08a7lgHaTTzvW7mw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-regexp-modifiers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", - "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", - "dev": true, - "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-reserved-words": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", - "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-runtime": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.26.10.tgz", - "integrity": "sha512-NWaL2qG6HRpONTnj4JvDU6th4jYeZOJgu3QhmFTCihib0ermtOJqktA5BduGm3suhhVe9EMP9c9+mfJ/I9slqw==", - "dev": true, - "requires": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-plugin-utils": "^7.26.5", - "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.11.0", - "babel-plugin-polyfill-regenerator": "^0.6.1", - "semver": "^6.3.1" - }, - "dependencies": { - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-spread": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", - "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - } - }, - "@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-template-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-typeof-symbol": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", - "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-unicode-escapes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", - "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-unicode-property-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", - "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", - "dev": true, - "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-unicode-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", - "dev": true, - "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-unicode-sets-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", - "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", - "dev": true, - "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/preset-env": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.9.tgz", - "integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==", - "dev": true, - "requires": { - "@babel/compat-data": "^7.26.8", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-plugin-utils": "^7.26.5", - "@babel/helper-validator-option": "^7.25.9", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.26.0", - "@babel/plugin-syntax-import-attributes": "^7.26.0", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.25.9", - "@babel/plugin-transform-async-generator-functions": "^7.26.8", - "@babel/plugin-transform-async-to-generator": "^7.25.9", - "@babel/plugin-transform-block-scoped-functions": "^7.26.5", - "@babel/plugin-transform-block-scoping": "^7.25.9", - "@babel/plugin-transform-class-properties": "^7.25.9", - "@babel/plugin-transform-class-static-block": "^7.26.0", - "@babel/plugin-transform-classes": "^7.25.9", - "@babel/plugin-transform-computed-properties": "^7.25.9", - "@babel/plugin-transform-destructuring": "^7.25.9", - "@babel/plugin-transform-dotall-regex": "^7.25.9", - "@babel/plugin-transform-duplicate-keys": "^7.25.9", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", - "@babel/plugin-transform-dynamic-import": "^7.25.9", - "@babel/plugin-transform-exponentiation-operator": "^7.26.3", - "@babel/plugin-transform-export-namespace-from": "^7.25.9", - "@babel/plugin-transform-for-of": "^7.26.9", - "@babel/plugin-transform-function-name": "^7.25.9", - "@babel/plugin-transform-json-strings": "^7.25.9", - "@babel/plugin-transform-literals": "^7.25.9", - "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", - "@babel/plugin-transform-member-expression-literals": "^7.25.9", - "@babel/plugin-transform-modules-amd": "^7.25.9", - "@babel/plugin-transform-modules-commonjs": "^7.26.3", - "@babel/plugin-transform-modules-systemjs": "^7.25.9", - "@babel/plugin-transform-modules-umd": "^7.25.9", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", - "@babel/plugin-transform-new-target": "^7.25.9", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.26.6", - "@babel/plugin-transform-numeric-separator": "^7.25.9", - "@babel/plugin-transform-object-rest-spread": "^7.25.9", - "@babel/plugin-transform-object-super": "^7.25.9", - "@babel/plugin-transform-optional-catch-binding": "^7.25.9", - "@babel/plugin-transform-optional-chaining": "^7.25.9", - "@babel/plugin-transform-parameters": "^7.25.9", - "@babel/plugin-transform-private-methods": "^7.25.9", - "@babel/plugin-transform-private-property-in-object": "^7.25.9", - "@babel/plugin-transform-property-literals": "^7.25.9", - "@babel/plugin-transform-regenerator": "^7.25.9", - "@babel/plugin-transform-regexp-modifiers": "^7.26.0", - "@babel/plugin-transform-reserved-words": "^7.25.9", - "@babel/plugin-transform-shorthand-properties": "^7.25.9", - "@babel/plugin-transform-spread": "^7.25.9", - "@babel/plugin-transform-sticky-regex": "^7.25.9", - "@babel/plugin-transform-template-literals": "^7.26.8", - "@babel/plugin-transform-typeof-symbol": "^7.26.7", - "@babel/plugin-transform-unicode-escapes": "^7.25.9", - "@babel/plugin-transform-unicode-property-regex": "^7.25.9", - "@babel/plugin-transform-unicode-regex": "^7.25.9", - "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.11.0", - "babel-plugin-polyfill-regenerator": "^0.6.1", - "core-js-compat": "^3.40.0", - "semver": "^6.3.1" - }, - "dependencies": { - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "@babel/preset-modules": { - "version": "0.1.6-no-external-plugins", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", - "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/types": "^7.4.4", - "esutils": "^2.0.2" - } - }, - "@babel/runtime": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", - "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", - "dev": true, - "requires": { - "regenerator-runtime": "^0.14.0" - } - }, - "@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - } - }, - "@babel/traverse": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz", - "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.1", - "@babel/parser": "^7.27.1", - "@babel/template": "^7.27.1", - "@babel/types": "^7.27.1", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "dependencies": { - "@babel/generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", - "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", - "dev": true, - "requires": { - "@babel/parser": "^7.27.1", - "@babel/types": "^7.27.1", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^3.0.2" - } - } - } - }, - "@babel/types": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", - "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", - "dev": true, - "requires": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - } - }, - "@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true - }, - "@discoveryjs/json-ext": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", - "integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==", - "dev": true - }, - "@esbuild/aix-ppc64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", - "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", - "dev": true, - "optional": true - }, - "@esbuild/android-arm": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", - "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", - "dev": true, - "optional": true - }, - "@esbuild/android-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", - "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", - "dev": true, - "optional": true - }, - "@esbuild/android-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", - "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", - "dev": true, - "optional": true - }, - "@esbuild/darwin-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", - "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", - "dev": true, - "optional": true - }, - "@esbuild/darwin-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", - "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", - "dev": true, - "optional": true - }, - "@esbuild/freebsd-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", - "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", - "dev": true, - "optional": true - }, - "@esbuild/freebsd-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", - "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", - "dev": true, - "optional": true - }, - "@esbuild/linux-arm": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", - "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", - "dev": true, - "optional": true - }, - "@esbuild/linux-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", - "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", - "dev": true, - "optional": true - }, - "@esbuild/linux-ia32": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", - "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", - "dev": true, - "optional": true - }, - "@esbuild/linux-loong64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", - "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", - "dev": true, - "optional": true - }, - "@esbuild/linux-mips64el": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", - "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-ppc64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", - "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", - "dev": true, - "optional": true - }, - "@esbuild/linux-riscv64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", - "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", - "dev": true, - "optional": true - }, - "@esbuild/linux-s390x": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", - "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", - "dev": true, - "optional": true - }, - "@esbuild/linux-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", - "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", - "dev": true, - "optional": true - }, - "@esbuild/netbsd-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", - "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", - "dev": true, - "optional": true - }, - "@esbuild/netbsd-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", - "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", - "dev": true, - "optional": true - }, - "@esbuild/openbsd-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", - "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", - "dev": true, - "optional": true - }, - "@esbuild/openbsd-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", - "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", - "dev": true, - "optional": true - }, - "@esbuild/sunos-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", - "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", - "dev": true, - "optional": true - }, - "@esbuild/win32-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", - "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", - "dev": true, - "optional": true - }, - "@esbuild/win32-ia32": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", - "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", - "dev": true, - "optional": true - }, - "@esbuild/win32-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", - "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", - "dev": true, - "optional": true - }, - "@firebase-ui/angular": { - "version": "https://github.com/invertase/firebaseui-web/releases/download/@firebase-ui/angular@0.0.1/firebase-ui-angular-0.0.1.tgz", - "integrity": "sha512-usltgMAzwGFN2ghawAbMKy1Tgdf/VhbUFoiYsCWdiyS1oQ9hyjQvxhz0uDDrhg/bJW965VGUQVU81q2FmWqIDA==", - "requires": { - "@tanstack/angular-form": "^1.1.0", - "nanostores": "^0.11.3", - "tslib": "^2.3.0", - "zod": "^3.24.1" - }, - "dependencies": { - "@tanstack/angular-form": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@tanstack/angular-form/-/angular-form-1.11.2.tgz", - "integrity": "sha512-ll9ZHqjfqPIA4fRQsyrA22PZJtinQeNJYJBHAROrr+h3IbN7NOA/4yRVxjQWCwhFpwh9PU8Cl563a52x9c0iIQ==", - "requires": { - "@tanstack/angular-store": "^0.7.0", - "@tanstack/form-core": "1.11.2", - "tslib": "^2.8.1" - } - }, - "@tanstack/form-core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.11.2.tgz", - "integrity": "sha512-HAocV5E6y4EHisH6qPvredkr2X5ARULDLWx8Z7Jz9pNz0bUBzUjPF/QtVBHQKrYMrwl9cE+TxddcghjiQYDsmQ==", - "requires": { - "@tanstack/store": "^0.7.0" - } - } - } - }, - "@firebase-ui/core": { - "version": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-core-0.0.1.tgz", - "integrity": "sha512-qwZPZvhZ99ODLmI/2aHNLjS61rS8BQnyMJYCama+567UPp3jU2GgLzS9XD5CB1Iy4IvmPfgFYHRh1evpmx7evA==", - "requires": { - "@firebase-ui/translations": "0.0.1", - "nanostores": "^0.11.3", - "zod": "^3.24.1" - } - }, - "@firebase-ui/styles": { - "version": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-styles-0.0.1.tgz", - "integrity": "sha512-aRsD27AjgsXTPOylYT7Qu3IeI0cOT1eZ6MiCddH5n8cHpG9lpXDwYD1+Bqo7ZBs6Wqi3LuX+6iI5Aq374E025w==" - }, - "@firebase-ui/translations": { - "version": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-translations-0.0.1.tgz", - "integrity": "sha512-k8mzvjPvRHlrB1zPXNVuq6vIOkzY5t7Ta97Lqrml+rmfpP/eISy9991eH0Rwy/Xoc10qCj6DMw9bQWBRVsnbCg==" - }, - "@firebase/ai": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-1.3.0.tgz", - "integrity": "sha512-qBxJTtl9hpgZr050kVFTRADX6I0Ss6mEQyp/JEkBgKwwxixKnaRNqEDGFba4OKNL7K8E4Y7LlA/ZW6L8aCKH4A==", - "requires": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/analytics": { - "version": "0.10.16", - "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.16.tgz", - "integrity": "sha512-cMtp19He7Fd6uaj/nDEul+8JwvJsN8aRSJyuA1QN3QrKvfDDp+efjVurJO61sJpkVftw9O9nNMdhFbRcTmTfRQ==", - "requires": { - "@firebase/component": "0.6.17", - "@firebase/installations": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/analytics-compat": { - "version": "0.2.22", - "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.22.tgz", - "integrity": "sha512-VogWHgwkdYhjWKh8O1XU04uPrRaiDihkWvE/EMMmtWtaUtVALnpLnUurc3QtSKdPnvTz5uaIGKlW84DGtSPFbw==", - "requires": { - "@firebase/analytics": "0.10.16", - "@firebase/analytics-types": "0.8.3", - "@firebase/component": "0.6.17", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/analytics-types": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", - "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==" - }, - "@firebase/app": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.13.0.tgz", - "integrity": "sha512-Vj3MST245nq+V5UmmfEkB3isIgPouyUr8yGJlFeL9Trg/umG5ogAvrjAYvQ8gV7daKDoQSRnJKWI2JFpQqRsuQ==", - "requires": { - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "idb": "7.1.1", - "tslib": "^2.1.0" - } - }, - "@firebase/app-check": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.10.0.tgz", - "integrity": "sha512-AZlRlVWKcu8BH4Yf8B5EI8sOi2UNGTS8oMuthV45tbt6OVUTSQwFPIEboZzhNJNKY+fPsg7hH8vixUWFZ3lrhw==", - "requires": { - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/app-check-compat": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.3.25.tgz", - "integrity": "sha512-3zrsPZWAKfV7DVC20T2dgfjzjtQnSJS65OfMOiddMUtJL1S5i0nAZKsdX0bOEvvrd0SBIL8jYnfpfDeQRnhV3w==", - "requires": { - "@firebase/app-check": "0.10.0", - "@firebase/app-check-types": "0.5.3", - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/app-check-interop-types": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", - "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==" - }, - "@firebase/app-check-types": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", - "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==" - }, - "@firebase/app-compat": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.4.0.tgz", - "integrity": "sha512-LjLUrzbUgTa/sCtPoLKT2C7KShvLVHS3crnU1Du02YxnGVLE0CUBGY/NxgfR/Zg84mEbj1q08/dgesojxjn0dA==", - "requires": { - "@firebase/app": "0.13.0", - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/app-types": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", - "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==" - }, - "@firebase/auth": { - "version": "1.10.5", - "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.10.5.tgz", - "integrity": "sha512-6wF/NdMTwObL4RNQePunuzMr9O3gyftisvFZFFKf57D2HONXo87YymogRV8d+Z7SLA0rcNBN1gLJVk2D0y97gA==", - "requires": { - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/auth-compat": { - "version": "0.5.25", - "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.5.25.tgz", - "integrity": "sha512-YKUYnvrxXBRhH/iYEwSOv85VPvc6P36GW1OCDRebTw/cvgoj7pwac2nZKYFs5FHlNYe7Bc9I4BoY2X0vlkJo+g==", - "requires": { - "@firebase/auth": "1.10.5", - "@firebase/auth-types": "0.13.0", - "@firebase/component": "0.6.17", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/auth-interop-types": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", - "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==" - }, - "@firebase/auth-types": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz", - "integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==", - "requires": {} - }, - "@firebase/component": { - "version": "0.6.17", - "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.17.tgz", - "integrity": "sha512-M6DOg7OySrKEFS8kxA3MU5/xc37fiOpKPMz6cTsMUcsuKB6CiZxxNAvgFta8HGRgEpZbi8WjGIj6Uf+TpOhyzg==", - "requires": { - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/data-connect": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.3.8.tgz", - "integrity": "sha512-xC50SxurrP0j9ksltZ8O2SuPuWTu9KymNxtSE4bmcc/HMOnOHaURgLyrQpcC5Pc7HmtCBxh9Q/lNKyc37rj5/g==", - "requires": { - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/database": { - "version": "1.0.18", - "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.18.tgz", - "integrity": "sha512-uXtYQmK6JCmqSx7dTOQD/qZtSnbMqnwvklF9n7wOJbdti4wKHmeUzgGXhPwDhN/R/BDTq78zKAbXya7hrCQjHw==", - "requires": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "faye-websocket": "0.11.4", - "tslib": "^2.1.0" - } - }, - "@firebase/database-compat": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.0.9.tgz", - "integrity": "sha512-9S6zK5+Tzslkt+lrYHDqbCbKBSQn3YYrNLIw8hTa/ALoqRLNTXF6acQIlxAxSeZj1hTttE6RRbuxxpMQJYt83w==", - "requires": { - "@firebase/component": "0.6.17", - "@firebase/database": "1.0.18", - "@firebase/database-types": "1.0.14", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/database-types": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.14.tgz", - "integrity": "sha512-8a0Q1GrxM0akgF0RiQHliinhmZd+UQPrxEmUv7MnQBYfVFiLtKOgs3g6ghRt/WEGJHyQNslZ+0PocIwNfoDwKw==", - "requires": { - "@firebase/app-types": "0.9.3", - "@firebase/util": "1.12.0" - } - }, - "@firebase/firestore": { - "version": "4.7.15", - "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.7.15.tgz", - "integrity": "sha512-FgWTmkNBEXdKCoN2ngBNjrMaXuBx6QwjiZZVnOGg+VjUmiBq5gAqlDIW5bZY6i/NYvLUrWugdqIs7y9GHEqwww==", - "requires": { - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "@firebase/webchannel-wrapper": "1.0.3", - "@grpc/grpc-js": "~1.9.0", - "@grpc/proto-loader": "^0.7.8", - "tslib": "^2.1.0" - } - }, - "@firebase/firestore-compat": { - "version": "0.3.50", - "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.3.50.tgz", - "integrity": "sha512-1hAM+iaIqy2HHvSHQ56ccOOIigTeWAwjIpeQ+/O92uBoiajEITHdJofnGHglhhB5VV5qFl59Yz/AVDc+DssdYg==", - "requires": { - "@firebase/component": "0.6.17", - "@firebase/firestore": "4.7.15", - "@firebase/firestore-types": "3.0.3", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/firestore-types": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", - "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", - "requires": {} - }, - "@firebase/functions": { - "version": "0.12.7", - "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.12.7.tgz", - "integrity": "sha512-gi8cw7yvaz19Erut+S0rHzNOWp4zPxAU/Kplb+XQoaE5gMV7MjHQoOGnYhSY8uOVj5f80S553s+2OBszG+14Ag==", - "requires": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.6.17", - "@firebase/messaging-interop-types": "0.2.3", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/functions-compat": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.3.24.tgz", - "integrity": "sha512-UjJabci+Bqci+A9WqfJ6sjZp+wGvi47llnQMjQRrF4coKfUyu9zBNTXhbx5W3rdVFQYwnWJm8VuluuNh2PCuyQ==", - "requires": { - "@firebase/component": "0.6.17", - "@firebase/functions": "0.12.7", - "@firebase/functions-types": "0.6.3", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/functions-types": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", - "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==" - }, - "@firebase/installations": { - "version": "0.6.17", - "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.17.tgz", - "integrity": "sha512-zfhqCNJZRe12KyADtRrtOj+SeSbD1H/K8J24oQAJVv/u02eQajEGlhZtcx9Qk7vhGWF5z9dvIygVDYqLL4o1XQ==", - "requires": { - "@firebase/component": "0.6.17", - "@firebase/util": "1.12.0", - "idb": "7.1.1", - "tslib": "^2.1.0" - } - }, - "@firebase/installations-compat": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.17.tgz", - "integrity": "sha512-J7afeCXB7yq25FrrJAgbx8mn1nG1lZEubOLvYgG7ZHvyoOCK00sis5rj7TgDrLYJgdj/SJiGaO1BD3BAp55TeA==", - "requires": { - "@firebase/component": "0.6.17", - "@firebase/installations": "0.6.17", - "@firebase/installations-types": "0.5.3", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/installations-types": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", - "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", - "requires": {} - }, - "@firebase/logger": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.4.tgz", - "integrity": "sha512-mH0PEh1zoXGnaR8gD1DeGeNZtWFKbnz9hDO91dIml3iou1gpOnLqXQ2dJfB71dj6dpmUjcQ6phY3ZZJbjErr9g==", - "requires": { - "tslib": "^2.1.0" - } - }, - "@firebase/messaging": { - "version": "0.12.21", - "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.21.tgz", - "integrity": "sha512-bYJ2Evj167Z+lJ1ach6UglXz5dUKY1zrJZd15GagBUJSR7d9KfiM1W8dsyL0lDxcmhmA/sLaBYAAhF1uilwN0g==", - "requires": { - "@firebase/component": "0.6.17", - "@firebase/installations": "0.6.17", - "@firebase/messaging-interop-types": "0.2.3", - "@firebase/util": "1.12.0", - "idb": "7.1.1", - "tslib": "^2.1.0" - } - }, - "@firebase/messaging-compat": { - "version": "0.2.21", - "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.21.tgz", - "integrity": "sha512-1yMne+4BGLbHbtyu/VyXWcLiefUE1+K3ZGfVTyKM4BH4ZwDFRGoWUGhhx+tKRX4Tu9z7+8JN67SjnwacyNWK5g==", - "requires": { - "@firebase/component": "0.6.17", - "@firebase/messaging": "0.12.21", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/messaging-interop-types": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", - "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==" - }, - "@firebase/performance": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.6.tgz", - "integrity": "sha512-AsOz74dSTlyQGlnnbLWXiHFAsrxhpssPOsFFi4HgOJ5DjzkK7ZdZ/E9uMPrwFoXJyMVoybGRuqsL/wkIbFITsA==", - "requires": { - "@firebase/component": "0.6.17", - "@firebase/installations": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0", - "web-vitals": "^4.2.4" - } - }, - "@firebase/performance-compat": { - "version": "0.2.19", - "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.19.tgz", - "integrity": "sha512-4cU0T0BJ+LZK/E/UwFcvpBCVdkStgBMQwBztM9fJPT6udrEUk3ugF5/HT+E2Z22FCXtIaXDukJbYkE/c3c6IHw==", - "requires": { - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/performance": "0.7.6", - "@firebase/performance-types": "0.2.3", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/performance-types": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", - "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==" - }, - "@firebase/remote-config": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.6.4.tgz", - "integrity": "sha512-ZyLJRT46wtycyz2+opEkGaoFUOqRQjt/0NX1WfUISOMCI/PuVoyDjqGpq24uK+e8D5NknyTpiXCVq5dowhScmg==", - "requires": { - "@firebase/component": "0.6.17", - "@firebase/installations": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/remote-config-compat": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.17.tgz", - "integrity": "sha512-KelsBD0sXSC0u3esr/r6sJYGRN6pzn3bYuI/6pTvvmZbjBlxQkRabHAVH6d+YhLcjUXKIAYIjZszczd1QJtOyA==", - "requires": { - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/remote-config": "0.6.4", - "@firebase/remote-config-types": "0.4.0", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/remote-config-types": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.4.0.tgz", - "integrity": "sha512-7p3mRE/ldCNYt8fmWMQ/MSGRmXYlJ15Rvs9Rk17t8p0WwZDbeK7eRmoI1tvCPaDzn9Oqh+yD6Lw+sGLsLg4kKg==" - }, - "@firebase/storage": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.13.11.tgz", - "integrity": "sha512-nBtCGGpr39vuAeTQhG73nvMq3BjQBTgIg6fWufB6qglWYQCgky/XE4duSrOhTp2/QC+H3/SnaE/nKOQmjnPqjg==", - "requires": { - "@firebase/component": "0.6.17", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/storage-compat": { - "version": "0.3.21", - "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.3.21.tgz", - "integrity": "sha512-LG3978H2Vy1XGa0Jz9VNFwgMrhjy/G8CTV8GkWpArzu+AhI/SE9c0e06SiXcFsVaQW2rObcqFa0zp51LDaVzRA==", - "requires": { - "@firebase/component": "0.6.17", - "@firebase/storage": "0.13.11", - "@firebase/storage-types": "0.8.3", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - } - }, - "@firebase/storage-types": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", - "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", - "requires": {} - }, - "@firebase/util": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.12.0.tgz", - "integrity": "sha512-Z4rK23xBCwgKDqmzGVMef+Vb4xso2j5Q8OG0vVL4m4fA5ZjPMYQazu8OJJC3vtQRC3SQ/Pgx/6TPNVsCd70QRw==", - "requires": { - "tslib": "^2.1.0" - } - }, - "@firebase/webchannel-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.3.tgz", - "integrity": "sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==" - }, - "@grpc/grpc-js": { - "version": "1.9.15", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", - "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", - "requires": { - "@grpc/proto-loader": "^0.7.8", - "@types/node": ">=12.12.47" - } - }, - "@grpc/proto-loader": { - "version": "0.7.15", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", - "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", - "requires": { - "lodash.camelcase": "^4.3.0", - "long": "^5.0.0", - "protobufjs": "^7.2.5", - "yargs": "^17.7.2" - } - }, - "@inquirer/checkbox": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.6.tgz", - "integrity": "sha512-62u896rWCtKKE43soodq5e/QcRsA22I+7/4Ov7LESWnKRO6BVo2A1DFLDmXL9e28TB0CfHc3YtkbPm7iwajqkg==", - "dev": true, - "requires": { - "@inquirer/core": "^10.1.11", - "@inquirer/figures": "^1.0.11", - "@inquirer/type": "^3.0.6", - "ansi-escapes": "^4.3.2", - "yoctocolors-cjs": "^2.1.2" - } - }, - "@inquirer/confirm": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.6.tgz", - "integrity": "sha512-6ZXYK3M1XmaVBZX6FCfChgtponnL0R6I7k8Nu+kaoNkT828FVZTcca1MqmWQipaW2oNREQl5AaPCUOOCVNdRMw==", - "dev": true, - "requires": { - "@inquirer/core": "^10.1.7", - "@inquirer/type": "^3.0.4" - } - }, - "@inquirer/core": { - "version": "10.1.11", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.11.tgz", - "integrity": "sha512-BXwI/MCqdtAhzNQlBEFE7CEflhPkl/BqvAuV/aK6lW3DClIfYVDWPP/kXuXHtBWC7/EEbNqd/1BGq2BGBBnuxw==", - "dev": true, - "requires": { - "@inquirer/figures": "^1.0.11", - "@inquirer/type": "^3.0.6", - "ansi-escapes": "^4.3.2", - "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.2" - } - }, - "@inquirer/editor": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.11.tgz", - "integrity": "sha512-YoZr0lBnnLFPpfPSNsQ8IZyKxU47zPyVi9NLjCWtna52//M/xuL0PGPAxHxxYhdOhnvY2oBafoM+BI5w/JK7jw==", - "dev": true, - "requires": { - "@inquirer/core": "^10.1.11", - "@inquirer/type": "^3.0.6", - "external-editor": "^3.1.0" - } - }, - "@inquirer/expand": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.13.tgz", - "integrity": "sha512-HgYNWuZLHX6q5y4hqKhwyytqAghmx35xikOGY3TcgNiElqXGPas24+UzNPOwGUZa5Dn32y25xJqVeUcGlTv+QQ==", - "dev": true, - "requires": { - "@inquirer/core": "^10.1.11", - "@inquirer/type": "^3.0.6", - "yoctocolors-cjs": "^2.1.2" - } - }, - "@inquirer/figures": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.11.tgz", - "integrity": "sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==", - "dev": true - }, - "@inquirer/input": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.1.10.tgz", - "integrity": "sha512-kV3BVne3wJ+j6reYQUZi/UN9NZGZLxgc/tfyjeK3mrx1QI7RXPxGp21IUTv+iVHcbP4ytZALF8vCHoxyNSC6qg==", - "dev": true, - "requires": { - "@inquirer/core": "^10.1.11", - "@inquirer/type": "^3.0.6" - } - }, - "@inquirer/number": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.13.tgz", - "integrity": "sha512-IrLezcg/GWKS8zpKDvnJ/YTflNJdG0qSFlUM/zNFsdi4UKW/CO+gaJpbMgQ20Q58vNKDJbEzC6IebdkprwL6ew==", - "dev": true, - "requires": { - "@inquirer/core": "^10.1.11", - "@inquirer/type": "^3.0.6" - } - }, - "@inquirer/password": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.13.tgz", - "integrity": "sha512-NN0S/SmdhakqOTJhDwOpeBEEr8VdcYsjmZHDb0rblSh2FcbXQOr+2IApP7JG4WE3sxIdKytDn4ed3XYwtHxmJQ==", - "dev": true, - "requires": { - "@inquirer/core": "^10.1.11", - "@inquirer/type": "^3.0.6", - "ansi-escapes": "^4.3.2" - } - }, - "@inquirer/prompts": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", - "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", - "dev": true, - "requires": { - "@inquirer/checkbox": "^4.1.2", - "@inquirer/confirm": "^5.1.6", - "@inquirer/editor": "^4.2.7", - "@inquirer/expand": "^4.0.9", - "@inquirer/input": "^4.1.6", - "@inquirer/number": "^3.0.9", - "@inquirer/password": "^4.0.9", - "@inquirer/rawlist": "^4.0.9", - "@inquirer/search": "^3.0.9", - "@inquirer/select": "^4.0.9" - } - }, - "@inquirer/rawlist": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.1.tgz", - "integrity": "sha512-VBUC0jPN2oaOq8+krwpo/mf3n/UryDUkKog3zi+oIi8/e5hykvdntgHUB9nhDM78RubiyR1ldIOfm5ue+2DeaQ==", - "dev": true, - "requires": { - "@inquirer/core": "^10.1.11", - "@inquirer/type": "^3.0.6", - "yoctocolors-cjs": "^2.1.2" - } - }, - "@inquirer/search": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.0.13.tgz", - "integrity": "sha512-9g89d2c5Izok/Gw/U7KPC3f9kfe5rA1AJ24xxNZG0st+vWekSk7tB9oE+dJv5JXd0ZSijomvW0KPMoBd8qbN4g==", - "dev": true, - "requires": { - "@inquirer/core": "^10.1.11", - "@inquirer/figures": "^1.0.11", - "@inquirer/type": "^3.0.6", - "yoctocolors-cjs": "^2.1.2" - } - }, - "@inquirer/select": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.2.1.tgz", - "integrity": "sha512-gt1Kd5XZm+/ddemcT3m23IP8aD8rC9drRckWoP/1f7OL46Yy2FGi8DSmNjEjQKtPl6SV96Kmjbl6p713KXJ/Jg==", - "dev": true, - "requires": { - "@inquirer/core": "^10.1.11", - "@inquirer/figures": "^1.0.11", - "@inquirer/type": "^3.0.6", - "ansi-escapes": "^4.3.2", - "yoctocolors-cjs": "^2.1.2" - } - }, - "@inquirer/type": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.6.tgz", - "integrity": "sha512-/mKVCtVpyBu3IDarv0G+59KC4stsD5mDsGpYh+GKs1NZT88Jh52+cuoA1AtLk2Q0r/quNl+1cSUyLRHBFeD0XA==", - "dev": true, - "requires": {} - }, - "@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "requires": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true - }, - "emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "requires": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - } - }, - "wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "requires": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - } - } - } - }, - "@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "requires": { - "minipass": "^7.0.4" - } - }, - "@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true - }, - "@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "requires": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" - }, - "@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==" - }, - "@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, - "requires": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, - "@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" - }, - "@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "requires": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "@jsonjoy.com/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", - "dev": true, - "requires": {} - }, - "@jsonjoy.com/json-pack": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.2.0.tgz", - "integrity": "sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA==", - "dev": true, - "requires": { - "@jsonjoy.com/base64": "^1.1.1", - "@jsonjoy.com/util": "^1.1.2", - "hyperdyperid": "^1.2.0", - "thingies": "^1.20.0" - } - }, - "@jsonjoy.com/util": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.6.0.tgz", - "integrity": "sha512-sw/RMbehRhN68WRtcKCpQOPfnH6lLP4GJfqzi3iYej8tnzpZUDr6UkZYJjcjjC0FWEJOJbyM3PTIwxucUmDG2A==", - "dev": true, - "requires": {} - }, - "@leichtgewicht/ip-codec": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", - "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", - "dev": true - }, - "@listr2/prompt-adapter-inquirer": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.18.tgz", - "integrity": "sha512-0hz44rAcrphyXcA8IS7EJ2SCoaBZD2u5goE8S/e+q/DL+dOGpqpcLidVOFeLG3VgML62SXmfRLAhWt0zL1oW4Q==", - "dev": true, - "requires": { - "@inquirer/type": "^1.5.5" - }, - "dependencies": { - "@inquirer/type": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.5.tgz", - "integrity": "sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==", - "dev": true, - "requires": { - "mute-stream": "^1.0.0" - } - }, - "mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", - "dev": true - } - } - }, - "@lmdb/lmdb-darwin-arm64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.2.6.tgz", - "integrity": "sha512-yF/ih9EJJZc72psFQbwnn8mExIWfTnzWJg+N02hnpXtDPETYLmQswIMBn7+V88lfCaFrMozJsUvcEQIkEPU0Gg==", - "dev": true, - "optional": true - }, - "@lmdb/lmdb-darwin-x64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.2.6.tgz", - "integrity": "sha512-5BbCumsFLbCi586Bb1lTWQFkekdQUw8/t8cy++Uq251cl3hbDIGEwD9HAwh8H6IS2F6QA9KdKmO136LmipRNkg==", - "dev": true, - "optional": true - }, - "@lmdb/lmdb-linux-arm": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.2.6.tgz", - "integrity": "sha512-+6XgLpMb7HBoWxXj+bLbiiB4s0mRRcDPElnRS3LpWRzdYSe+gFk5MT/4RrVNqd2MESUDmb53NUXw1+BP69bjiQ==", - "dev": true, - "optional": true - }, - "@lmdb/lmdb-linux-arm64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.2.6.tgz", - "integrity": "sha512-l5VmJamJ3nyMmeD1ANBQCQqy7do1ESaJQfKPSm2IG9/ADZryptTyCj8N6QaYgIWewqNUrcbdMkJajRQAt5Qjfg==", - "dev": true, - "optional": true - }, - "@lmdb/lmdb-linux-x64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.2.6.tgz", - "integrity": "sha512-nDYT8qN9si5+onHYYaI4DiauDMx24OAiuZAUsEqrDy+ja/3EbpXPX/VAkMV8AEaQhy3xc4dRC+KcYIvOFefJ4Q==", - "dev": true, - "optional": true - }, - "@lmdb/lmdb-win32-x64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.2.6.tgz", - "integrity": "sha512-XlqVtILonQnG+9fH2N3Aytria7P/1fwDgDhl29rde96uH2sLB8CHORIf2PfuLVzFQJ7Uqp8py9AYwr3ZUCFfWg==", - "dev": true, - "optional": true - }, - "@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", - "dev": true, - "optional": true - }, - "@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", - "dev": true, - "optional": true - }, - "@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", - "dev": true, - "optional": true - }, - "@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", - "dev": true, - "optional": true - }, - "@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", - "dev": true, - "optional": true - }, - "@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", - "dev": true, - "optional": true - }, - "@napi-rs/nice": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.0.1.tgz", - "integrity": "sha512-zM0mVWSXE0a0h9aKACLwKmD6nHcRiKrPpCfvaKqG1CqDEyjEawId0ocXxVzPMCAm6kkWr2P025msfxXEnt8UGQ==", - "dev": true, - "optional": true, - "requires": { - "@napi-rs/nice-android-arm-eabi": "1.0.1", - "@napi-rs/nice-android-arm64": "1.0.1", - "@napi-rs/nice-darwin-arm64": "1.0.1", - "@napi-rs/nice-darwin-x64": "1.0.1", - "@napi-rs/nice-freebsd-x64": "1.0.1", - "@napi-rs/nice-linux-arm-gnueabihf": "1.0.1", - "@napi-rs/nice-linux-arm64-gnu": "1.0.1", - "@napi-rs/nice-linux-arm64-musl": "1.0.1", - "@napi-rs/nice-linux-ppc64-gnu": "1.0.1", - "@napi-rs/nice-linux-riscv64-gnu": "1.0.1", - "@napi-rs/nice-linux-s390x-gnu": "1.0.1", - "@napi-rs/nice-linux-x64-gnu": "1.0.1", - "@napi-rs/nice-linux-x64-musl": "1.0.1", - "@napi-rs/nice-win32-arm64-msvc": "1.0.1", - "@napi-rs/nice-win32-ia32-msvc": "1.0.1", - "@napi-rs/nice-win32-x64-msvc": "1.0.1" - } - }, - "@napi-rs/nice-android-arm-eabi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.1.tgz", - "integrity": "sha512-5qpvOu5IGwDo7MEKVqqyAxF90I6aLj4n07OzpARdgDRfz8UbBztTByBp0RC59r3J1Ij8uzYi6jI7r5Lws7nn6w==", - "dev": true, - "optional": true - }, - "@napi-rs/nice-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.0.1.tgz", - "integrity": "sha512-GqvXL0P8fZ+mQqG1g0o4AO9hJjQaeYG84FRfZaYjyJtZZZcMjXW5TwkL8Y8UApheJgyE13TQ4YNUssQaTgTyvA==", - "dev": true, - "optional": true - }, - "@napi-rs/nice-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-91k3HEqUl2fsrz/sKkuEkscj6EAj3/eZNCLqzD2AA0TtVbkQi8nqxZCZDMkfklULmxLkMxuUdKe7RvG/T6s2AA==", - "dev": true, - "optional": true - }, - "@napi-rs/nice-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.0.1.tgz", - "integrity": "sha512-jXnMleYSIR/+TAN/p5u+NkCA7yidgswx5ftqzXdD5wgy/hNR92oerTXHc0jrlBisbd7DpzoaGY4cFD7Sm5GlgQ==", - "dev": true, - "optional": true - }, - "@napi-rs/nice-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-j+iJ/ezONXRQsVIB/FJfwjeQXX7A2tf3gEXs4WUGFrJjpe/z2KB7sOv6zpkm08PofF36C9S7wTNuzHZ/Iiccfw==", - "dev": true, - "optional": true - }, - "@napi-rs/nice-linux-arm-gnueabihf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.0.1.tgz", - "integrity": "sha512-G8RgJ8FYXYkkSGQwywAUh84m946UTn6l03/vmEXBYNJxQJcD+I3B3k5jmjFG/OPiU8DfvxutOP8bi+F89MCV7Q==", - "dev": true, - "optional": true - }, - "@napi-rs/nice-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-IMDak59/W5JSab1oZvmNbrms3mHqcreaCeClUjwlwDr0m3BoR09ZiN8cKFBzuSlXgRdZ4PNqCYNeGQv7YMTjuA==", - "dev": true, - "optional": true - }, - "@napi-rs/nice-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-wG8fa2VKuWM4CfjOjjRX9YLIbysSVV1S3Kgm2Fnc67ap/soHBeYZa6AGMeR5BJAylYRjnoVOzV19Cmkco3QEPw==", - "dev": true, - "optional": true - }, - "@napi-rs/nice-linux-ppc64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.0.1.tgz", - "integrity": "sha512-lxQ9WrBf0IlNTCA9oS2jg/iAjQyTI6JHzABV664LLrLA/SIdD+I1i3Mjf7TsnoUbgopBcCuDztVLfJ0q9ubf6Q==", - "dev": true, - "optional": true - }, - "@napi-rs/nice-linux-riscv64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.0.1.tgz", - "integrity": "sha512-3xs69dO8WSWBb13KBVex+yvxmUeEsdWexxibqskzoKaWx9AIqkMbWmE2npkazJoopPKX2ULKd8Fm9veEn0g4Ig==", - "dev": true, - "optional": true - }, - "@napi-rs/nice-linux-s390x-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.0.1.tgz", - "integrity": "sha512-lMFI3i9rlW7hgToyAzTaEybQYGbQHDrpRkg+1gJWEpH0PLAQoZ8jiY0IzakLfNWnVda1eTYYlxxFYzW8Rqczkg==", - "dev": true, - "optional": true - }, - "@napi-rs/nice-linux-x64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.0.1.tgz", - "integrity": "sha512-XQAJs7DRN2GpLN6Fb+ZdGFeYZDdGl2Fn3TmFlqEL5JorgWKrQGRUrpGKbgZ25UeZPILuTKJ+OowG2avN8mThBA==", - "dev": true, - "optional": true - }, - "@napi-rs/nice-linux-x64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-/rodHpRSgiI9o1faq9SZOp/o2QkKQg7T+DK0R5AkbnI/YxvAIEHf2cngjYzLMQSQgUhxym+LFr+UGZx4vK4QdQ==", - "dev": true, - "optional": true - }, - "@napi-rs/nice-win32-arm64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-rEcz9vZymaCB3OqEXoHnp9YViLct8ugF+6uO5McifTedjq4QMQs3DHz35xBEGhH3gJWEsXMUbzazkz5KNM5YUg==", - "dev": true, - "optional": true - }, - "@napi-rs/nice-win32-ia32-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.0.1.tgz", - "integrity": "sha512-t7eBAyPUrWL8su3gDxw9xxxqNwZzAqKo0Szv3IjVQd1GpXXVkb6vBBQUuxfIYaXMzZLwlxRQ7uzM2vdUE9ULGw==", - "dev": true, - "optional": true - }, - "@napi-rs/nice-win32-x64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-JlF+uDcatt3St2ntBG8H02F1mM45i5SF9W+bIKiReVE6wiy3o16oBP/yxt+RZ+N6LbCImJXJ6bXNO2kn9AXicg==", - "dev": true, - "optional": true - }, - "@ngtools/webpack": { - "version": "19.2.12", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.2.12.tgz", - "integrity": "sha512-MTxkM+jZPQP55q0BWx/1w2kaN9mSFC14V9+p4sfNm/OXk7fibtxz5lXH/2sDGFWJi36s4gppKqfHBhp9OTdHCQ==", - "dev": true, - "requires": {} - }, - "@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "requires": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - } - }, - "@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true - }, - "@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "requires": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - } - }, - "@npmcli/agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", - "dev": true, - "requires": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "dependencies": { - "lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - } - } - }, - "@npmcli/fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", - "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", - "dev": true, - "requires": { - "semver": "^7.3.5" - } - }, - "@npmcli/git": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-6.0.3.tgz", - "integrity": "sha512-GUYESQlxZRAdhs3UhbB6pVRNUELQOHXwK9ruDkwmCv2aZ5y0SApQzUJCg02p3A7Ue2J5hxvlk1YI53c00NmRyQ==", - "dev": true, - "requires": { - "@npmcli/promise-spawn": "^8.0.0", - "ini": "^5.0.0", - "lru-cache": "^10.0.1", - "npm-pick-manifest": "^10.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "semver": "^7.3.5", - "which": "^5.0.0" - }, - "dependencies": { - "isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "dev": true - }, - "lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, - "which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "dev": true, - "requires": { - "isexe": "^3.1.1" - } - } - } - }, - "@npmcli/installed-package-contents": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-3.0.0.tgz", - "integrity": "sha512-fkxoPuFGvxyrH+OQzyTkX2LUEamrF4jZSmxjAtPPHHGO0dqsQ8tTKjnIS8SAnPHdk2I03BDtSMR5K/4loKg79Q==", - "dev": true, - "requires": { - "npm-bundled": "^4.0.0", - "npm-normalize-package-bin": "^4.0.0" - } - }, - "@npmcli/node-gyp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-4.0.0.tgz", - "integrity": "sha512-+t5DZ6mO/QFh78PByMq1fGSAub/agLJZDRfJRMeOSNCt8s9YVlTjmGpIPwPhvXTGUIJk+WszlT0rQa1W33yzNA==", - "dev": true - }, - "@npmcli/package-json": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-6.1.1.tgz", - "integrity": "sha512-d5qimadRAUCO4A/Txw71VM7UrRZzV+NPclxz/dc+M6B2oYwjWTjqh8HA/sGQgs9VZuJ6I/P7XIAlJvgrl27ZOw==", - "dev": true, - "requires": { - "@npmcli/git": "^6.0.0", - "glob": "^10.2.2", - "hosted-git-info": "^8.0.0", - "json-parse-even-better-errors": "^4.0.0", - "proc-log": "^5.0.0", - "semver": "^7.5.3", - "validate-npm-package-license": "^3.0.4" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - }, - "glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "requires": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - } - }, - "minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - } - } - } - }, - "@npmcli/promise-spawn": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-8.0.2.tgz", - "integrity": "sha512-/bNJhjc+o6qL+Dwz/bqfTQClkEO5nTQ1ZEcdCkAQjhkZMHIh22LPG7fNh1enJP1NKWDqYiiABnjFCY7E0zHYtQ==", - "dev": true, - "requires": { - "which": "^5.0.0" - }, - "dependencies": { - "isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "dev": true - }, - "which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "dev": true, - "requires": { - "isexe": "^3.1.1" - } - } - } - }, - "@npmcli/redact": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-3.2.2.tgz", - "integrity": "sha512-7VmYAmk4csGv08QzrDKScdzn11jHPFGyqJW39FyPgPuAp3zIaUmuCo1yxw9aGs+NEJuTGQ9Gwqpt93vtJubucg==", - "dev": true - }, - "@npmcli/run-script": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-9.1.0.tgz", - "integrity": "sha512-aoNSbxtkePXUlbZB+anS1LqsJdctG5n3UVhfU47+CDdwMi6uNTBMF9gPcQRnqghQd2FGzcwwIFBruFMxjhBewg==", - "dev": true, - "requires": { - "@npmcli/node-gyp": "^4.0.0", - "@npmcli/package-json": "^6.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "node-gyp": "^11.0.0", - "proc-log": "^5.0.0", - "which": "^5.0.0" - }, - "dependencies": { - "isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "dev": true - }, - "which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "dev": true, - "requires": { - "isexe": "^3.1.1" - } - } - } - }, - "@parcel/watcher": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", - "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", - "dev": true, - "optional": true, - "requires": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1", - "detect-libc": "^1.0.3", - "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" - }, - "dependencies": { - "detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "dev": true, - "optional": true - }, - "node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, - "optional": true - } - } - }, - "@parcel/watcher-android-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", - "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", - "dev": true, - "optional": true - }, - "@parcel/watcher-darwin-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", - "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", - "dev": true, - "optional": true - }, - "@parcel/watcher-darwin-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", - "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", - "dev": true, - "optional": true - }, - "@parcel/watcher-freebsd-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", - "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", - "dev": true, - "optional": true - }, - "@parcel/watcher-linux-arm-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", - "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", - "dev": true, - "optional": true - }, - "@parcel/watcher-linux-arm-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", - "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", - "dev": true, - "optional": true - }, - "@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", - "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", - "dev": true, - "optional": true - }, - "@parcel/watcher-linux-arm64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", - "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", - "dev": true, - "optional": true - }, - "@parcel/watcher-linux-x64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", - "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", - "dev": true, - "optional": true - }, - "@parcel/watcher-linux-x64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", - "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", - "dev": true, - "optional": true - }, - "@parcel/watcher-win32-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", - "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", - "dev": true, - "optional": true - }, - "@parcel/watcher-win32-ia32": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", - "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", - "dev": true, - "optional": true - }, - "@parcel/watcher-win32-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", - "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", - "dev": true, - "optional": true - }, - "@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true - }, - "@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" - }, - "@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" - }, - "@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" - }, - "@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" - }, - "@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "requires": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" - }, - "@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" - }, - "@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" - }, - "@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" - }, - "@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" - }, - "@rollup/plugin-json": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", - "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", - "dev": true, - "requires": { - "@rollup/pluginutils": "^5.1.0" - } - }, - "@rollup/pluginutils": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", - "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", - "dev": true, - "requires": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - } - }, - "@rollup/rollup-android-arm-eabi": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz", - "integrity": "sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw==", - "dev": true, - "optional": true - }, - "@rollup/rollup-android-arm64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.8.tgz", - "integrity": "sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q==", - "dev": true, - "optional": true - }, - "@rollup/rollup-darwin-arm64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.8.tgz", - "integrity": "sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q==", - "dev": true, - "optional": true - }, - "@rollup/rollup-darwin-x64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.8.tgz", - "integrity": "sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw==", - "dev": true, - "optional": true - }, - "@rollup/rollup-freebsd-arm64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.8.tgz", - "integrity": "sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA==", - "dev": true, - "optional": true - }, - "@rollup/rollup-freebsd-x64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.8.tgz", - "integrity": "sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.8.tgz", - "integrity": "sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-arm-musleabihf": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.8.tgz", - "integrity": "sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-arm64-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.8.tgz", - "integrity": "sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-arm64-musl": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.8.tgz", - "integrity": "sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz", - "integrity": "sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.8.tgz", - "integrity": "sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-riscv64-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.8.tgz", - "integrity": "sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-riscv64-musl": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.0.tgz", - "integrity": "sha512-ieQljaZKuJpmWvd8gW87ZmSFwid6AxMDk5bhONJ57U8zT77zpZ/TPKkU9HpnnFrM4zsgr4kiGuzbIbZTGi7u9A==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-linux-s390x-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.8.tgz", - "integrity": "sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-x64-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.8.tgz", - "integrity": "sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-x64-musl": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.8.tgz", - "integrity": "sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ==", - "dev": true, - "optional": true - }, - "@rollup/rollup-win32-arm64-msvc": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz", - "integrity": "sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ==", - "dev": true, - "optional": true - }, - "@rollup/rollup-win32-ia32-msvc": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.8.tgz", - "integrity": "sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w==", - "dev": true, - "optional": true - }, - "@rollup/rollup-win32-x64-msvc": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz", - "integrity": "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g==", - "dev": true, - "optional": true - }, - "@rollup/wasm-node": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/wasm-node/-/wasm-node-4.41.0.tgz", - "integrity": "sha512-G+y2Uj8XvsPWMA+kVfKPcrhOWtcwKaCCr8KNZPiADfJV4+g4HUeJKuT8Fz71F7PNVD3t+xqX8rlpIULAlAJ+sQ==", - "dev": true, - "requires": { - "@types/estree": "1.0.7", - "fsevents": "~2.3.2" - } - }, - "@schematics/angular": { - "version": "19.2.12", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.2.12.tgz", - "integrity": "sha512-6S6tclFctLrjMvhpi8eVvswIpXqlybRpZLCTWyVeWIC6PHYLEyFmFoOhuhcSmOdtnwudvzOt6xWnWEVb3qXZbQ==", - "requires": { - "@angular-devkit/core": "19.2.12", - "@angular-devkit/schematics": "19.2.12", - "jsonc-parser": "3.3.1" - } - }, - "@sigstore/bundle": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-3.1.0.tgz", - "integrity": "sha512-Mm1E3/CmDDCz3nDhFKTuYdB47EdRFRQMOE/EAbiG1MJW77/w1b3P7Qx7JSrVJs8PfwOLOVcKQCHErIwCTyPbag==", - "dev": true, - "requires": { - "@sigstore/protobuf-specs": "^0.4.0" - } - }, - "@sigstore/core": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-2.0.0.tgz", - "integrity": "sha512-nYxaSb/MtlSI+JWcwTHQxyNmWeWrUXJJ/G4liLrGG7+tS4vAz6LF3xRXqLH6wPIVUoZQel2Fs4ddLx4NCpiIYg==", - "dev": true - }, - "@sigstore/protobuf-specs": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.4.2.tgz", - "integrity": "sha512-F2ye+n1INNhqT0MW+LfUEvTUPc/nS70vICJcxorKl7/gV9CO39+EDCw+qHNKEqvsDWk++yGVKCbzK1qLPvmC8g==", - "dev": true - }, - "@sigstore/sign": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-3.1.0.tgz", - "integrity": "sha512-knzjmaOHOov1Ur7N/z4B1oPqZ0QX5geUfhrVaqVlu+hl0EAoL4o+l0MSULINcD5GCWe3Z0+YJO8ues6vFlW0Yw==", - "dev": true, - "requires": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.0", - "make-fetch-happen": "^14.0.2", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1" - } - }, - "@sigstore/tuf": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-3.1.1.tgz", - "integrity": "sha512-eFFvlcBIoGwVkkwmTi/vEQFSva3xs5Ot3WmBcjgjVdiaoelBLQaQ/ZBfhlG0MnG0cmTYScPpk7eDdGDWUcFUmg==", - "dev": true, - "requires": { - "@sigstore/protobuf-specs": "^0.4.1", - "tuf-js": "^3.0.1" - } - }, - "@sigstore/verify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-2.1.1.tgz", - "integrity": "sha512-hVJD77oT67aowHxwT4+M6PGOp+E2LtLdTK3+FC0lBO9T7sYwItDMXZ7Z07IDCvR1M717a4axbIWckrW67KMP/w==", - "dev": true, - "requires": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.1" - } - }, - "@sindresorhus/merge-streams": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", - "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", - "dev": true - }, - "@socket.io/component-emitter": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", - "dev": true - }, - "@tailwindcss/node": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.7.tgz", - "integrity": "sha512-9rsOpdY9idRI2NH6CL4wORFY0+Q6fnx9XP9Ju+iq/0wJwGD5IByIgFmwVbyy4ymuyprj8Qh4ErxMKTUL4uNh3g==", - "requires": { - "@ampproject/remapping": "^2.3.0", - "enhanced-resolve": "^5.18.1", - "jiti": "^2.4.2", - "lightningcss": "1.30.1", - "magic-string": "^0.30.17", - "source-map-js": "^1.2.1", - "tailwindcss": "4.1.7" - } - }, - "@tailwindcss/oxide": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.7.tgz", - "integrity": "sha512-5SF95Ctm9DFiUyjUPnDGkoKItPX/k+xifcQhcqX5RA85m50jw1pT/KzjdvlqxRja45Y52nR4MR9fD1JYd7f8NQ==", - "requires": { - "@tailwindcss/oxide-android-arm64": "4.1.7", - "@tailwindcss/oxide-darwin-arm64": "4.1.7", - "@tailwindcss/oxide-darwin-x64": "4.1.7", - "@tailwindcss/oxide-freebsd-x64": "4.1.7", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.7", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.7", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.7", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.7", - "@tailwindcss/oxide-linux-x64-musl": "4.1.7", - "@tailwindcss/oxide-wasm32-wasi": "4.1.7", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.7", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.7", - "detect-libc": "^2.0.4", - "tar": "^7.4.3" - } - }, - "@tailwindcss/oxide-android-arm64": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.7.tgz", - "integrity": "sha512-IWA410JZ8fF7kACus6BrUwY2Z1t1hm0+ZWNEzykKmMNM09wQooOcN/VXr0p/WJdtHZ90PvJf2AIBS/Ceqx1emg==", - "optional": true - }, - "@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.7.tgz", - "integrity": "sha512-81jUw9To7fimGGkuJ2W5h3/oGonTOZKZ8C2ghm/TTxbwvfSiFSDPd6/A/KE2N7Jp4mv3Ps9OFqg2fEKgZFfsvg==", - "optional": true - }, - "@tailwindcss/oxide-darwin-x64": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.7.tgz", - "integrity": "sha512-q77rWjEyGHV4PdDBtrzO0tgBBPlQWKY7wZK0cUok/HaGgbNKecegNxCGikuPJn5wFAlIywC3v+WMBt0PEBtwGw==", - "optional": true - }, - "@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.7.tgz", - "integrity": "sha512-RfmdbbK6G6ptgF4qqbzoxmH+PKfP4KSVs7SRlTwcbRgBwezJkAO3Qta/7gDy10Q2DcUVkKxFLXUQO6J3CRvBGw==", - "optional": true - }, - "@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.7.tgz", - "integrity": "sha512-OZqsGvpwOa13lVd1z6JVwQXadEobmesxQ4AxhrwRiPuE04quvZHWn/LnihMg7/XkN+dTioXp/VMu/p6A5eZP3g==", - "optional": true - }, - "@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.7.tgz", - "integrity": "sha512-voMvBTnJSfKecJxGkoeAyW/2XRToLZ227LxswLAwKY7YslG/Xkw9/tJNH+3IVh5bdYzYE7DfiaPbRkSHFxY1xA==", - "optional": true - }, - "@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.7.tgz", - "integrity": "sha512-PjGuNNmJeKHnP58M7XyjJyla8LPo+RmwHQpBI+W/OxqrwojyuCQ+GUtygu7jUqTEexejZHr/z3nBc/gTiXBj4A==", - "optional": true - }, - "@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.7.tgz", - "integrity": "sha512-HMs+Va+ZR3gC3mLZE00gXxtBo3JoSQxtu9lobbZd+DmfkIxR54NO7Z+UQNPsa0P/ITn1TevtFxXTpsRU7qEvWg==", - "optional": true - }, - "@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.7.tgz", - "integrity": "sha512-MHZ6jyNlutdHH8rd+YTdr3QbXrHXqwIhHw9e7yXEBcQdluGwhpQY2Eku8UZK6ReLaWtQ4gijIv5QoM5eE+qlsA==", - "optional": true - }, - "@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.7.tgz", - "integrity": "sha512-ANaSKt74ZRzE2TvJmUcbFQ8zS201cIPxUDm5qez5rLEwWkie2SkGtA4P+GPTj+u8N6JbPrC8MtY8RmJA35Oo+A==", - "optional": true, - "requires": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@emnapi/wasi-threads": "^1.0.2", - "@napi-rs/wasm-runtime": "^0.2.9", - "@tybys/wasm-util": "^0.9.0", - "tslib": "^2.8.0" - } - }, - "@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.7.tgz", - "integrity": "sha512-HUiSiXQ9gLJBAPCMVRk2RT1ZrBjto7WvqsPBwUrNK2BcdSxMnk19h4pjZjI7zgPhDxlAbJSumTC4ljeA9y0tEw==", - "optional": true - }, - "@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.7.tgz", - "integrity": "sha512-rYHGmvoHiLJ8hWucSfSOEmdCBIGZIq7SpkPRSqLsH2Ab2YUNgKeAPT1Fi2cx3+hnYOrAb0jp9cRyode3bBW4mQ==", - "optional": true - }, - "@tailwindcss/postcss": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.7.tgz", - "integrity": "sha512-88g3qmNZn7jDgrrcp3ZXEQfp9CVox7xjP1HN2TFKI03CltPVd/c61ydn5qJJL8FYunn0OqBaW5HNUga0kmPVvw==", - "requires": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.7", - "@tailwindcss/oxide": "4.1.7", - "postcss": "^8.4.41", - "tailwindcss": "4.1.7" - } - }, - "@tanstack/angular-form": { - "version": "0.42.1", - "resolved": "https://registry.npmjs.org/@tanstack/angular-form/-/angular-form-0.42.1.tgz", - "integrity": "sha512-7uMewhfDrCo8X+CZSMGBu6xifeIhvGsDpwZeXrUYDrS7ZzVzUysFLuZPbGLylmWTVBRhdK85A6xXjoiBiAYP2A==", - "dev": true, - "requires": { - "@tanstack/angular-store": "^0.7.0", - "@tanstack/form-core": "0.42.1", - "tslib": "^2.8.1" - } - }, - "@tanstack/angular-store": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@tanstack/angular-store/-/angular-store-0.7.0.tgz", - "integrity": "sha512-Ybl3fCZpfubPDQPbhhvpLGHFx2FRwQHv5bi5tluOtlkTZw3gVxuF+rMxVHfvm3CTI418W7VwiRfPz8//8Gxvkw==", - "requires": { - "@tanstack/store": "0.7.0", - "tslib": "^2.8.1" - } - }, - "@tanstack/form-core": { - "version": "0.42.1", - "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-0.42.1.tgz", - "integrity": "sha512-jTU0jyHqFceujdtPNv3jPVej1dTqBwa8TYdIyWB5BCwRVUBZEp1PiYEBkC9r92xu5fMpBiKc+JKud3eeVjuMiA==", - "dev": true, - "requires": { - "@tanstack/store": "^0.7.0" - } - }, - "@tanstack/store": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.0.tgz", - "integrity": "sha512-CNIhdoUsmD2NolYuaIs8VfWM467RK6oIBAW4nPEKZhg1smZ+/CwtCdpURgp7nxSqOaV9oKkzdWD80+bC66F/Jg==" - }, - "@tufjs/canonical-json": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", - "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", - "dev": true - }, - "@tufjs/models": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-3.0.1.tgz", - "integrity": "sha512-UUYHISyhCU3ZgN8yaear3cGATHb3SMuKHsQ/nVbHXcmnBf+LzQ/cQfhNG+rfaSHgqGKNEm2cOCLVLELStUQ1JA==", - "dev": true, - "requires": { - "@tufjs/canonical-json": "2.0.0", - "minimatch": "^9.0.5" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - }, - "minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - } - } - } - }, - "@types/body-parser": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, - "requires": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "@types/bonjour": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", - "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/connect-history-api-fallback": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", - "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", - "dev": true, - "requires": { - "@types/express-serve-static-core": "*", - "@types/node": "*" - } - }, - "@types/cors": { - "version": "2.8.18", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.18.tgz", - "integrity": "sha512-nX3d0sxJW41CqQvfOzVG1NCTXfFDrDWIghCZncpHeWlVFd81zxB/DLhg7avFg6eHLCRX7ckBmoIIcqa++upvJA==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "requires": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "requires": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, - "@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true - }, - "@types/express": { - "version": "4.17.22", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.22.tgz", - "integrity": "sha512-eZUmSnhRX9YRSkplpz0N+k6NljUUn5l3EWZIKZvYzhvMphEuNiyyy1viH/ejgt66JWgALwC/gtSUAeQKtSwW/w==", - "dev": true, - "requires": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "@types/express-serve-static-core": { - "version": "4.19.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", - "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", - "dev": true, - "requires": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true - }, - "@types/http-proxy": { - "version": "1.17.16", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz", - "integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/jasmine": { - "version": "5.1.8", - "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.8.tgz", - "integrity": "sha512-u7/CnvRdh6AaaIzYjCgUuVbREFgulhX05Qtf6ZtW+aOcjCKKVvKgpkPYJBFTZSHtFBYimzU4zP0V2vrEsq9Wcg==", - "dev": true - }, - "@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true - }, - "@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true - }, - "@types/node": { - "version": "18.19.101", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.101.tgz", - "integrity": "sha512-Ykg7fcE3+cOQlLUv2Ds3zil6DVjriGQaSN/kEpl5HQ3DIGM6W0F2n9+GkWV4bRt7KjLymgzNdTnSKCbFUUJ7Kw==", - "requires": { - "undici-types": "~5.26.4" - } - }, - "@types/node-forge": { - "version": "1.3.11", - "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.11.tgz", - "integrity": "sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true - }, - "@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true - }, - "@types/retry": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", - "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", - "dev": true - }, - "@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, - "requires": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "@types/serve-index": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", - "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", - "dev": true, - "requires": { - "@types/express": "*" - } - }, - "@types/serve-static": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", - "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dev": true, - "requires": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" - } - }, - "@types/sockjs": { - "version": "0.3.36", - "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", - "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@vitejs/plugin-basic-ssl": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.2.0.tgz", - "integrity": "sha512-mkQnxTkcldAzIsomk1UuLfAu9n+kpQ3JbHcpCp7d2Oo6ITtji8pHS3QToOWjhPFvNQSnhlkAjmGbhv2QvwO/7Q==", - "dev": true, - "requires": {} - }, - "@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "dev": true, - "requires": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "dev": true - }, - "@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "dev": true - }, - "@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "dev": true - }, - "@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "dev": true, - "requires": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "dev": true - }, - "@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "dev": true, - "requires": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "dev": true, - "requires": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "dev": true, - "requires": { - "@xtuc/long": "4.2.2" - } - }, - "@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "dev": true - }, - "@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "dev": true, - "requires": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "dev": true, - "requires": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "dev": true, - "requires": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "dev": true, - "requires": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "dev": true, - "requires": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, - "@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true - }, - "@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true - }, - "@yarnpkg/lockfile": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", - "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", - "dev": true - }, - "abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", - "dev": true - }, - "accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "requires": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - } - }, - "acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "dev": true - }, - "adjust-sourcemap-loader": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", - "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", - "dev": true, - "requires": { - "loader-utils": "^2.0.0", - "regex-parser": "^2.2.11" - }, - "dependencies": { - "loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", - "dev": true, - "requires": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - } - } - } - }, - "agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", - "dev": true - }, - "ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "requires": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - } - }, - "ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "requires": { - "ajv": "^8.0.0" - } - }, - "ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.3" - } - }, - "ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true - }, - "ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "requires": { - "type-fest": "^0.21.3" - } - }, - "ansi-html-community": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", - "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", - "dev": true - }, - "ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "requires": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "dependencies": { - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true - } - } - }, - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, - "autoprefixer": { - "version": "10.4.20", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", - "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", - "dev": true, - "requires": { - "browserslist": "^4.23.3", - "caniuse-lite": "^1.0.30001646", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.1", - "postcss-value-parser": "^4.2.0" - } - }, - "babel-loader": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz", - "integrity": "sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==", - "dev": true, - "requires": { - "find-cache-dir": "^4.0.0", - "schema-utils": "^4.0.0" - } - }, - "babel-plugin-polyfill-corejs2": { - "version": "0.4.13", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz", - "integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==", - "dev": true, - "requires": { - "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.6.4", - "semver": "^6.3.1" - }, - "dependencies": { - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "babel-plugin-polyfill-corejs3": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", - "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", - "dev": true, - "requires": { - "@babel/helper-define-polyfill-provider": "^0.6.3", - "core-js-compat": "^3.40.0" - } - }, - "babel-plugin-polyfill-regenerator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz", - "integrity": "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==", - "dev": true, - "requires": { - "@babel/helper-define-polyfill-provider": "^0.6.4" - } - }, - "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" - }, - "base64id": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", - "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", - "dev": true - }, - "batch": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", - "dev": true - }, - "beasties": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.3.2.tgz", - "integrity": "sha512-p4AF8uYzm9Fwu8m/hSVTCPXrRBPmB34hQpHsec2KOaR9CZmgoU8IOv4Cvwq4hgz2p4hLMNbsdNl5XeA6XbAQwA==", - "dev": true, - "requires": { - "css-select": "^5.1.0", - "css-what": "^6.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "htmlparser2": "^10.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.49", - "postcss-media-query-parser": "^0.2.3" - } - }, - "big.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", - "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", - "dev": true - }, - "binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true - }, - "bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "requires": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", - "requires": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - } - } - }, - "bonjour-service": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", - "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.3", - "multicast-dns": "^7.2.5" - } - }, - "boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "requires": { - "fill-range": "^7.1.1" - } - }, - "browserslist": { - "version": "4.24.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", - "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", - "dev": true, - "requires": { - "caniuse-lite": "^1.0.30001716", - "electron-to-chromium": "^1.5.149", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" - } - }, - "buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true - }, - "bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dev": true, - "requires": { - "run-applescript": "^7.0.0" - } - }, - "bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" - }, - "cacache": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", - "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", - "dev": true, - "requires": { - "@npmcli/fs": "^4.0.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - }, - "glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "requires": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - } - }, - "lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, - "minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - } - } - } - }, - "call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "requires": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - } - }, - "call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "requires": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - } - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true - }, - "caniuse-lite": { - "version": "1.0.30001718", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", - "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", - "dev": true - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", - "dev": true - }, - "chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "devOptional": true, - "requires": { - "readdirp": "^4.0.1" - } - }, - "chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==" - }, - "chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "dev": true - }, - "cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "requires": { - "restore-cursor": "^5.0.0" - } - }, - "cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==" - }, - "cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "dev": true, - "requires": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - } - }, - "cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true - }, - "cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" - }, - "string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "requires": { - "ansi-regex": "^5.0.1" - } - }, - "wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - } - } - }, - "clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==" - }, - "clone-deep": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", - "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", - "dev": true, - "requires": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" - }, - "dependencies": { - "is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "requires": { - "isobject": "^3.0.1" - } - } - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true - }, - "commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", - "dev": true - }, - "common-path-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", - "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", - "dev": true - }, - "commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "dev": true - }, - "compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "dev": true, - "requires": { - "mime-db": ">= 1.43.0 < 2" - } - }, - "compression": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", - "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", - "dev": true, - "requires": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.0.2", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "dev": true - } - } - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "connect": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", - "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", - "dev": true, - "requires": { - "debug": "2.6.9", - "finalhandler": "1.1.2", - "parseurl": "~1.3.3", - "utils-merge": "1.0.1" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true - }, - "finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", - "dev": true, - "requires": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "statuses": "~1.5.0", - "unpipe": "~1.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "dev": true, - "requires": { - "ee-first": "1.1.1" - } - }, - "statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true - } - } - }, - "connect-history-api-fallback": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", - "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", - "dev": true - }, - "content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "requires": { - "safe-buffer": "5.2.1" - } - }, - "content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" - }, - "convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true - }, - "cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==" - }, - "cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" - }, - "copy-anything": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", - "integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==", - "dev": true, - "requires": { - "is-what": "^3.14.1" - } - }, - "copy-webpack-plugin": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz", - "integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==", - "dev": true, - "requires": { - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.1", - "globby": "^14.0.0", - "normalize-path": "^3.0.0", - "schema-utils": "^4.2.0", - "serialize-javascript": "^6.0.2" - } - }, - "core-js-compat": { - "version": "3.42.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.42.0.tgz", - "integrity": "sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ==", - "dev": true, - "requires": { - "browserslist": "^4.24.4" - } - }, - "core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true - }, - "cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dev": true, - "requires": { - "object-assign": "^4", - "vary": "^1" - } - }, - "cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "dev": true, - "requires": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - } - }, - "cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "requires": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "dependencies": { - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - } - } - }, - "css-loader": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", - "integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==", - "dev": true, - "requires": { - "icss-utils": "^5.1.0", - "postcss": "^8.4.33", - "postcss-modules-extract-imports": "^3.1.0", - "postcss-modules-local-by-default": "^4.0.5", - "postcss-modules-scope": "^3.2.0", - "postcss-modules-values": "^4.0.0", - "postcss-value-parser": "^4.2.0", - "semver": "^7.5.4" - } - }, - "css-select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", - "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", - "dev": true, - "requires": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - } - }, - "css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", - "dev": true - }, - "cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true - }, - "custom-event": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", - "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", - "dev": true - }, - "date-format": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", - "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", - "dev": true - }, - "debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "requires": { - "ms": "^2.1.3" - } - }, - "default-browser": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", - "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", - "dev": true, - "requires": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - } - }, - "default-browser-id": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", - "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", - "dev": true - }, - "defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "requires": { - "clone": "^1.0.2" - } - }, - "define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true - }, - "depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" - }, - "dependency-graph": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz", - "integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==", - "dev": true - }, - "destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" - }, - "detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==" - }, - "detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true - }, - "di": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", - "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", - "dev": true - }, - "dns-packet": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", - "dev": true, - "requires": { - "@leichtgewicht/ip-codec": "^2.0.1" - } - }, - "dom-serialize": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", - "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", - "dev": true, - "requires": { - "custom-event": "~1.0.0", - "ent": "~2.2.0", - "extend": "^3.0.0", - "void-elements": "^2.0.0" - } - }, - "dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, - "requires": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - } - }, - "domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true - }, - "domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, - "requires": { - "domelementtype": "^2.3.0" - } - }, - "domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dev": true, - "requires": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - } - }, - "dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "requires": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - } - }, - "eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, - "ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "electron-to-chromium": { - "version": "1.5.155", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz", - "integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==", - "dev": true - }, - "emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true - }, - "emojis-list": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "dev": true - }, - "encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" - }, - "encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "optional": true, - "requires": { - "iconv-lite": "^0.6.2" - }, - "dependencies": { - "iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "optional": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - } - } - }, - "engine.io": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", - "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", - "dev": true, - "requires": { - "@types/cors": "^2.8.12", - "@types/node": ">=10.0.0", - "accepts": "~1.3.4", - "base64id": "2.0.0", - "cookie": "~0.7.2", - "cors": "~2.8.5", - "debug": "~4.3.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.17.1" - }, - "dependencies": { - "cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true - }, - "debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "requires": { - "ms": "^2.1.3" - } - } - } - }, - "engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", - "dev": true - }, - "enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", - "requires": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - } - }, - "ent": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", - "integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==", - "dev": true, - "requires": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "punycode": "^1.4.1", - "safe-regex-test": "^1.1.0" - } - }, - "entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true - }, - "env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true - }, - "environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true - }, - "err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true - }, - "errno": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", - "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", - "dev": true, - "optional": true, - "requires": { - "prr": "~1.0.1" - } - }, - "error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "requires": { - "is-arrayish": "^0.2.1" - } - }, - "es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" - }, - "es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" - }, - "es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true - }, - "es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "requires": { - "es-errors": "^1.3.0" - } - }, - "esbuild": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", - "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", - "dev": true, - "requires": { - "@esbuild/aix-ppc64": "0.25.4", - "@esbuild/android-arm": "0.25.4", - "@esbuild/android-arm64": "0.25.4", - "@esbuild/android-x64": "0.25.4", - "@esbuild/darwin-arm64": "0.25.4", - "@esbuild/darwin-x64": "0.25.4", - "@esbuild/freebsd-arm64": "0.25.4", - "@esbuild/freebsd-x64": "0.25.4", - "@esbuild/linux-arm": "0.25.4", - "@esbuild/linux-arm64": "0.25.4", - "@esbuild/linux-ia32": "0.25.4", - "@esbuild/linux-loong64": "0.25.4", - "@esbuild/linux-mips64el": "0.25.4", - "@esbuild/linux-ppc64": "0.25.4", - "@esbuild/linux-riscv64": "0.25.4", - "@esbuild/linux-s390x": "0.25.4", - "@esbuild/linux-x64": "0.25.4", - "@esbuild/netbsd-arm64": "0.25.4", - "@esbuild/netbsd-x64": "0.25.4", - "@esbuild/openbsd-arm64": "0.25.4", - "@esbuild/openbsd-x64": "0.25.4", - "@esbuild/sunos-x64": "0.25.4", - "@esbuild/win32-arm64": "0.25.4", - "@esbuild/win32-ia32": "0.25.4", - "@esbuild/win32-x64": "0.25.4" - } - }, - "esbuild-wasm": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.25.4.tgz", - "integrity": "sha512-2HlCS6rNvKWaSKhWaG/YIyRsTsL3gUrMP2ToZMBIjw9LM7vVcIs+rz8kE2vExvTJgvM8OKPqNpcHawY/BQc/qQ==", - "dev": true - }, - "escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" - }, - "escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - } - }, - "esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "requires": { - "estraverse": "^5.2.0" - }, - "dependencies": { - "estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true - } - } - }, - "estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true - }, - "estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true - }, - "esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true - }, - "etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" - }, - "eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true - }, - "events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true - }, - "exponential-backoff": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", - "integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==", - "dev": true - }, - "express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", - "requires": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - } - } - }, - "extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true - }, - "external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "dev": true, - "requires": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - } - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "requires": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "dependencies": { - "glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "requires": { - "is-glob": "^4.0.1" - } - } - } - }, - "fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==" - }, - "fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "requires": { - "reusify": "^1.0.4" - } - }, - "faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "requires": { - "websocket-driver": ">=0.5.1" - } - }, - "fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", - "dev": true, - "requires": {} - }, - "fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "requires": { - "to-regex-range": "^5.0.1" - } - }, - "finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", - "requires": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - } - } - }, - "find-cache-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", - "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", - "dev": true, - "requires": { - "common-path-prefix": "^3.0.0", - "pkg-dir": "^7.0.0" - } - }, - "find-up": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", - "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", - "dev": true, - "requires": { - "locate-path": "^7.1.0", - "path-exists": "^5.0.0" - } - }, - "firebase": { - "version": "11.8.0", - "resolved": "https://registry.npmjs.org/firebase/-/firebase-11.8.0.tgz", - "integrity": "sha512-zIv11czOqFayPllaJySKIKB2pS+xoWOnfI7j85SOiBKY1IW3NuZIaL+UgsZA+4PQZkPhFP8vmU2/oOun04ALbg==", - "requires": { - "@firebase/ai": "1.3.0", - "@firebase/analytics": "0.10.16", - "@firebase/analytics-compat": "0.2.22", - "@firebase/app": "0.13.0", - "@firebase/app-check": "0.10.0", - "@firebase/app-check-compat": "0.3.25", - "@firebase/app-compat": "0.4.0", - "@firebase/app-types": "0.9.3", - "@firebase/auth": "1.10.5", - "@firebase/auth-compat": "0.5.25", - "@firebase/data-connect": "0.3.8", - "@firebase/database": "1.0.18", - "@firebase/database-compat": "2.0.9", - "@firebase/firestore": "4.7.15", - "@firebase/firestore-compat": "0.3.50", - "@firebase/functions": "0.12.7", - "@firebase/functions-compat": "0.3.24", - "@firebase/installations": "0.6.17", - "@firebase/installations-compat": "0.2.17", - "@firebase/messaging": "0.12.21", - "@firebase/messaging-compat": "0.2.21", - "@firebase/performance": "0.7.6", - "@firebase/performance-compat": "0.2.19", - "@firebase/remote-config": "0.6.4", - "@firebase/remote-config-compat": "0.2.17", - "@firebase/storage": "0.13.11", - "@firebase/storage-compat": "0.3.21", - "@firebase/util": "1.12.0" - } - }, - "flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true - }, - "flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true - }, - "follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", - "dev": true - }, - "foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - } - }, - "forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" - }, - "fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true - }, - "fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" - }, - "fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "requires": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - } - }, - "fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "dev": true, - "requires": { - "minipass": "^7.0.3" - } - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "optional": true - }, - "function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" - }, - "gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true - }, - "get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" - }, - "get-east-asian-width": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", - "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", - "dev": true - }, - "get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "requires": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - } - }, - "get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "requires": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - } - }, - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "requires": { - "is-glob": "^4.0.3" - } - }, - "glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true - }, - "globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true - }, - "globby": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", - "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", - "dev": true, - "requires": { - "@sindresorhus/merge-streams": "^2.1.0", - "fast-glob": "^3.3.3", - "ignore": "^7.0.3", - "path-type": "^6.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.3.0" - } - }, - "gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" - }, - "graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" - }, - "handle-thing": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", - "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" - }, - "has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "requires": { - "has-symbols": "^1.0.3" - } - }, - "hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "requires": { - "function-bind": "^1.1.2" - } - }, - "hosted-git-info": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", - "integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==", - "dev": true, - "requires": { - "lru-cache": "^10.0.1" - }, - "dependencies": { - "lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - } - } - }, - "hpack.js": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", - "dev": true, - "requires": { - "inherits": "^2.0.1", - "obuf": "^1.0.0", - "readable-stream": "^2.0.1", - "wbuf": "^1.1.0" - }, - "dependencies": { - "readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, - "html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true - }, - "htmlparser2": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", - "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", - "dev": true, - "requires": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.1", - "entities": "^6.0.0" - }, - "dependencies": { - "entities": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", - "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", - "dev": true - } - } - }, - "http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true - }, - "http-deceiver": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", - "dev": true - }, - "http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "requires": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - } - }, - "http-parser-js": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==" - }, - "http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, - "requires": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - } - }, - "http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "requires": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - } - }, - "http-proxy-middleware": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", - "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", - "dev": true, - "requires": { - "@types/http-proxy": "^1.17.15", - "debug": "^4.3.6", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.3", - "is-plain-object": "^5.0.0", - "micromatch": "^4.0.8" - } - }, - "https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "requires": { - "agent-base": "^7.1.2", - "debug": "4" - } - }, - "hyperdyperid": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", - "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", - "dev": true - }, - "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "requires": { - "safer-buffer": ">= 2.1.2 < 3" - } - }, - "icss-utils": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", - "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", - "dev": true, - "requires": {} - }, - "idb": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", - "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" - }, - "ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" - }, - "ignore": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", - "integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==", - "dev": true - }, - "ignore-walk": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-7.0.0.tgz", - "integrity": "sha512-T4gbf83A4NH95zvhVYZc+qWocBBGlpzUXLPGurJggw/WIOwicfXJChLDP/iBZnN5WqROSu5Bm3hhle4z8a8YGQ==", - "dev": true, - "requires": { - "minimatch": "^9.0.0" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - }, - "minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - } - } - } - }, - "image-size": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", - "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", - "dev": true, - "optional": true - }, - "immutable": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz", - "integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==", - "dev": true - }, - "import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - } - }, - "imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "ini": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", - "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", - "dev": true - }, - "injection-js": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/injection-js/-/injection-js-2.5.0.tgz", - "integrity": "sha512-UpY2ONt4xbht4GhSqQ2zMJ1rBIQq4uOY+DlR6aOeYyqK7xadXt7UQbJIyxmgk288bPMkIZKjViieHm0O0i72Jw==", - "dev": true, - "requires": { - "tslib": "^2.0.0" - } - }, - "ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", - "dev": true, - "requires": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - } - }, - "ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" - }, - "is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true - }, - "is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "requires": { - "binary-extensions": "^2.0.0" - } - }, - "is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "requires": { - "hasown": "^2.0.2" - } - }, - "is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true - }, - "is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "dev": true - }, - "is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "requires": { - "is-extglob": "^2.1.1" - } - }, - "is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "requires": { - "is-docker": "^3.0.0" - } - }, - "is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==" - }, - "is-network-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.1.0.tgz", - "integrity": "sha512-tUdRRAnhT+OtCZR/LxZelH/C7QtjtFrTu5tXCA8pl55eTUElUHT+GPYV8MBMBvea/j+NxQqVt3LbWMRir7Gx9g==", - "dev": true - }, - "is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true - }, - "is-plain-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", - "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", - "dev": true - }, - "is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", - "dev": true - }, - "is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "requires": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - } - }, - "is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==" - }, - "is-what": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz", - "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==", - "dev": true - }, - "is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", - "dev": true, - "requires": { - "is-inside-container": "^1.0.0" - } - }, - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true - }, - "isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", - "dev": true - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true - }, - "istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true - }, - "istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "requires": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - } - }, - "istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "requires": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - } - }, - "istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "requires": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - } - } - }, - "istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", - "dev": true, - "requires": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - } - }, - "jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "requires": { - "@isaacs/cliui": "^8.0.2", - "@pkgjs/parseargs": "^0.11.0" - } - }, - "jasmine-core": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.5.0.tgz", - "integrity": "sha512-NHOvoPO6o9gVR6pwqEACTEpbgcH+JJ6QDypyymGbSUIFIFsMMbBJ/xsFNud8MSClfnWclXd7RQlAZBz7yVo5TQ==", - "dev": true - }, - "jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, - "requires": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "dependencies": { - "supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==" - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "dev": true - }, - "jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true - }, - "json-parse-even-better-errors": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", - "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", - "dev": true - }, - "json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true - }, - "jsonc-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==" - }, - "jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.6" - } - }, - "jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", - "dev": true - }, - "karma": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", - "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", - "dev": true, - "requires": { - "@colors/colors": "1.5.0", - "body-parser": "^1.19.0", - "braces": "^3.0.2", - "chokidar": "^3.5.1", - "connect": "^3.7.0", - "di": "^0.0.1", - "dom-serialize": "^2.2.1", - "glob": "^7.1.7", - "graceful-fs": "^4.2.6", - "http-proxy": "^1.18.1", - "isbinaryfile": "^4.0.8", - "lodash": "^4.17.21", - "log4js": "^6.4.1", - "mime": "^2.5.2", - "minimatch": "^3.0.4", - "mkdirp": "^0.5.5", - "qjobs": "^1.2.0", - "range-parser": "^1.2.1", - "rimraf": "^3.0.2", - "socket.io": "^4.7.2", - "source-map": "^0.6.1", - "tmp": "^0.2.1", - "ua-parser-js": "^0.7.30", - "yargs": "^16.1.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "requires": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "fsevents": "~2.3.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - } - }, - "cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "requires": { - "is-glob": "^4.0.1" - } - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true - }, - "readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "requires": { - "picomatch": "^2.2.1" - } - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - }, - "string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - }, - "tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", - "dev": true - }, - "wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - }, - "yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "requires": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - } - }, - "yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true - } - } - }, - "karma-chrome-launcher": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz", - "integrity": "sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==", - "dev": true, - "requires": { - "which": "^1.2.1" - } - }, - "karma-coverage": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz", - "integrity": "sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==", - "dev": true, - "requires": { - "istanbul-lib-coverage": "^3.2.0", - "istanbul-lib-instrument": "^5.1.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.1", - "istanbul-reports": "^3.0.5", - "minimatch": "^3.0.4" - }, - "dependencies": { - "istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "requires": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - } - }, - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "karma-jasmine": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz", - "integrity": "sha512-i/zQLFrfEpRyQoJF9fsCdTMOF5c2dK7C7OmsuKg2D0YSsuZSfQDiLuaiktbuio6F2wiCsZSnSnieIQ0ant/uzQ==", - "dev": true, - "requires": { - "jasmine-core": "^4.1.0" - }, - "dependencies": { - "jasmine-core": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.6.1.tgz", - "integrity": "sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==", - "dev": true - } - } - }, - "karma-jasmine-html-reporter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-2.1.0.tgz", - "integrity": "sha512-sPQE1+nlsn6Hwb5t+HHwyy0A1FNCVKuL1192b+XNauMYWThz2kweiBVW1DqloRpVvZIJkIoHVB7XRpK78n1xbQ==", - "dev": true, - "requires": {} - }, - "karma-source-map-support": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", - "integrity": "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A==", - "dev": true, - "requires": { - "source-map-support": "^0.5.5" - } - }, - "kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true - }, - "launch-editor": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.10.0.tgz", - "integrity": "sha512-D7dBRJo/qcGX9xlvt/6wUYzQxjh5G1RvZPgPv8vi4KRU99DVQL/oW7tnVOCCTm2HGeo3C5HvGE5Yrh6UBoZ0vA==", - "dev": true, - "requires": { - "picocolors": "^1.0.0", - "shell-quote": "^1.8.1" - } - }, - "less": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/less/-/less-4.2.2.tgz", - "integrity": "sha512-tkuLHQlvWUTeQ3doAqnHbNn8T6WX1KA8yvbKG9x4VtKtIjHsVKQZCH11zRgAfbDAXC2UNIg/K9BYAAcEzUIrNg==", - "dev": true, - "requires": { - "copy-anything": "^2.0.1", - "errno": "^0.1.1", - "graceful-fs": "^4.1.2", - "image-size": "~0.5.0", - "make-dir": "^2.1.0", - "mime": "^1.4.1", - "needle": "^3.1.0", - "parse-node-version": "^1.0.1", - "source-map": "~0.6.0", - "tslib": "^2.3.0" - }, - "dependencies": { - "make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "optional": true, - "requires": { - "pify": "^4.0.1", - "semver": "^5.6.0" - } - }, - "mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "optional": true - }, - "semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "optional": true - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true - } - } - }, - "less-loader": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-12.2.0.tgz", - "integrity": "sha512-MYUxjSQSBUQmowc0l5nPieOYwMzGPUaTzB6inNW/bdPEG9zOL3eAAD1Qw5ZxSPk7we5dMojHwNODYMV1hq4EVg==", - "dev": true, - "requires": {} - }, - "license-webpack-plugin": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", - "integrity": "sha512-771TFWFD70G1wLTC4oU2Cw4qvtmNrIw+wRvBtn+okgHl7slJVi7zfNcdmqDL72BojM30VNJ2UHylr1o77U37Jw==", - "dev": true, - "requires": { - "webpack-sources": "^3.0.0" - } - }, - "lightningcss": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", - "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", - "requires": { - "detect-libc": "^2.0.3", - "lightningcss-darwin-arm64": "1.30.1", - "lightningcss-darwin-x64": "1.30.1", - "lightningcss-freebsd-x64": "1.30.1", - "lightningcss-linux-arm-gnueabihf": "1.30.1", - "lightningcss-linux-arm64-gnu": "1.30.1", - "lightningcss-linux-arm64-musl": "1.30.1", - "lightningcss-linux-x64-gnu": "1.30.1", - "lightningcss-linux-x64-musl": "1.30.1", - "lightningcss-win32-arm64-msvc": "1.30.1", - "lightningcss-win32-x64-msvc": "1.30.1" - } - }, - "lightningcss-darwin-arm64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", - "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", - "optional": true - }, - "lightningcss-darwin-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", - "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", - "optional": true - }, - "lightningcss-freebsd-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", - "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", - "optional": true - }, - "lightningcss-linux-arm-gnueabihf": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", - "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", - "optional": true - }, - "lightningcss-linux-arm64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", - "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", - "optional": true - }, - "lightningcss-linux-arm64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", - "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", - "optional": true - }, - "lightningcss-linux-x64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", - "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", - "optional": true - }, - "lightningcss-linux-x64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", - "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", - "optional": true - }, - "lightningcss-win32-arm64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", - "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", - "optional": true - }, - "lightningcss-win32-x64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", - "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", - "optional": true - }, - "lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, - "listr2": { - "version": "8.2.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.5.tgz", - "integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==", - "dev": true, - "requires": { - "cli-truncate": "^4.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true - }, - "eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true - }, - "wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "dev": true, - "requires": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - } - } - } - }, - "lmdb": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.2.6.tgz", - "integrity": "sha512-SuHqzPl7mYStna8WRotY8XX/EUZBjjv3QyKIByeCLFfC9uXT/OIHByEcA07PzbMfQAM0KYJtLgtpMRlIe5dErQ==", - "dev": true, - "optional": true, - "requires": { - "@lmdb/lmdb-darwin-arm64": "3.2.6", - "@lmdb/lmdb-darwin-x64": "3.2.6", - "@lmdb/lmdb-linux-arm": "3.2.6", - "@lmdb/lmdb-linux-arm64": "3.2.6", - "@lmdb/lmdb-linux-x64": "3.2.6", - "@lmdb/lmdb-win32-x64": "3.2.6", - "msgpackr": "^1.11.2", - "node-addon-api": "^6.1.0", - "node-gyp-build-optional-packages": "5.2.2", - "ordered-binary": "^1.5.3", - "weak-lru-cache": "^1.2.2" - } - }, - "loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", - "dev": true - }, - "loader-utils": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", - "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", - "dev": true - }, - "locate-path": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "dev": true, - "requires": { - "p-locate": "^6.0.0" - } - }, - "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, - "lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" - }, - "lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true - }, - "log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "requires": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - } - }, - "log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "requires": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "dependencies": { - "ansi-escapes": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", - "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", - "dev": true, - "requires": { - "environment": "^1.0.0" - } - }, - "ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", - "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", - "dev": true, - "requires": { - "get-east-asian-width": "^1.0.0" - } - }, - "slice-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", - "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", - "dev": true, - "requires": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - } - }, - "wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "dev": true, - "requires": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - } - } - } - }, - "log4js": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", - "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", - "dev": true, - "requires": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "flatted": "^3.2.7", - "rfdc": "^1.3.0", - "streamroller": "^3.1.5" - } - }, - "long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" - }, - "lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "requires": { - "yallist": "^3.0.2" - } - }, - "magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "requires": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } - }, - "make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "requires": { - "semver": "^7.5.3" - } - }, - "make-fetch-happen": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", - "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", - "dev": true, - "requires": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "ssri": "^12.0.0" - }, - "dependencies": { - "negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true - } - } - }, - "math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" - }, - "media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" - }, - "memfs": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.2.tgz", - "integrity": "sha512-NgYhCOWgovOXSzvYgUW0LQ7Qy72rWQMGGFJDoWg4G30RHd3z77VbYdtJ4fembJXBy8pMIUA31XNAupobOQlwdg==", - "dev": true, - "requires": { - "@jsonjoy.com/json-pack": "^1.0.3", - "@jsonjoy.com/util": "^1.3.0", - "tree-dump": "^1.0.1", - "tslib": "^2.0.0" - } - }, - "merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==" - }, - "merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, - "merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true - }, - "methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" - }, - "micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "requires": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "dependencies": { - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true - } - } - }, - "mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true - }, - "mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" - }, - "mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "requires": { - "mime-db": "1.52.0" - } - }, - "mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" - }, - "mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true - }, - "mini-css-extract-plugin": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz", - "integrity": "sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w==", - "dev": true, - "requires": { - "schema-utils": "^4.0.0", - "tapable": "^2.2.1" - } - }, - "minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true - }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true - }, - "minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==" - }, - "minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", - "dev": true, - "requires": { - "minipass": "^7.0.3" - } - }, - "minipass-fetch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", - "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", - "dev": true, - "requires": { - "encoding": "^0.1.13", - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^3.0.1" - } - }, - "minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, - "requires": { - "minipass": "^3.0.0" - }, - "dependencies": { - "minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - } - } - }, - "minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "requires": { - "minipass": "^3.0.0" - }, - "dependencies": { - "minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - } - } - }, - "minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, - "requires": { - "minipass": "^3.0.0" - }, - "dependencies": { - "minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - } - } - }, - "minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", - "requires": { - "minipass": "^7.1.2" - } - }, - "mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "requires": { - "minimist": "^1.2.6" - } - }, - "mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true - }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "msgpackr": { - "version": "1.11.4", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.4.tgz", - "integrity": "sha512-uaff7RG9VIC4jacFW9xzL3jc0iM32DNHe4jYVycBcjUePT/Klnfj7pqtWJt9khvDFizmjN2TlYniYmSS2LIaZg==", - "dev": true, - "optional": true, - "requires": { - "msgpackr-extract": "^3.0.2" - } - }, - "msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", - "dev": true, - "optional": true, - "requires": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3", - "node-gyp-build-optional-packages": "5.2.2" - } - }, - "multicast-dns": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", - "dev": true, - "requires": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" - } - }, - "mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", - "dev": true - }, - "nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==" - }, - "nanostores": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-0.11.4.tgz", - "integrity": "sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ==" - }, - "needle": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/needle/-/needle-3.3.1.tgz", - "integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==", - "dev": true, - "optional": true, - "requires": { - "iconv-lite": "^0.6.3", - "sax": "^1.2.4" - }, - "dependencies": { - "iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "optional": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - } - } - }, - "negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" - }, - "neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true - }, - "ng-packagr": { - "version": "19.2.2", - "resolved": "https://registry.npmjs.org/ng-packagr/-/ng-packagr-19.2.2.tgz", - "integrity": "sha512-dFuwFsDJMBSd1YtmLLcX5bNNUCQUlRqgf34aXA+79PmkOP+0eF8GP2949wq3+jMjmFTNm80Oo8IUYiSLwklKCQ==", - "dev": true, - "requires": { - "@rollup/plugin-json": "^6.1.0", - "@rollup/wasm-node": "^4.24.0", - "ajv": "^8.17.1", - "ansi-colors": "^4.1.3", - "browserslist": "^4.22.1", - "chokidar": "^4.0.1", - "commander": "^13.0.0", - "convert-source-map": "^2.0.0", - "dependency-graph": "^1.0.0", - "esbuild": "^0.25.0", - "fast-glob": "^3.3.2", - "find-cache-dir": "^3.3.2", - "injection-js": "^2.4.0", - "jsonc-parser": "^3.3.1", - "less": "^4.2.0", - "ora": "^5.1.0", - "piscina": "^4.7.0", - "postcss": "^8.4.47", - "rollup": "^4.24.0", - "rxjs": "^7.8.1", - "sass": "^1.81.0" - }, - "dependencies": { - "convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "find-cache-dir": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", - "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", - "dev": true, - "requires": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" - } - }, - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "requires": { - "p-locate": "^4.1.0" - } - }, - "make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "requires": { - "semver": "^6.0.0" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "requires": { - "p-limit": "^2.2.0" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, - "pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "requires": { - "find-up": "^4.0.0" - } - }, - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", - "dev": true, - "optional": true - }, - "node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", - "dev": true - }, - "node-gyp": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.2.0.tgz", - "integrity": "sha512-T0S1zqskVUSxcsSTkAsLc7xCycrRYmtDHadDinzocrThjyQCn5kMlEBSj6H4qDbgsIOSLmmlRIeb0lZXj+UArA==", - "dev": true, - "requires": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "tar": "^7.4.3", - "tinyglobby": "^0.2.12", - "which": "^5.0.0" - }, - "dependencies": { - "isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "dev": true - }, - "which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "dev": true, - "requires": { - "isexe": "^3.1.1" - } - } - } - }, - "node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "dev": true, - "optional": true, - "requires": { - "detect-libc": "^2.0.1" - } - }, - "node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true - }, - "nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", - "dev": true, - "requires": { - "abbrev": "^3.0.0" - } - }, - "normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true - }, - "normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true - }, - "npm-bundled": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-4.0.0.tgz", - "integrity": "sha512-IxaQZDMsqfQ2Lz37VvyyEtKLe8FsRZuysmedy/N06TU1RyVppYKXrO4xIhR0F+7ubIBox6Q7nir6fQI3ej39iA==", - "dev": true, - "requires": { - "npm-normalize-package-bin": "^4.0.0" - } - }, - "npm-install-checks": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-7.1.1.tgz", - "integrity": "sha512-u6DCwbow5ynAX5BdiHQ9qvexme4U3qHW3MWe5NqH+NeBm0LbiH6zvGjNNew1fY+AZZUtVHbOPF3j7mJxbUzpXg==", - "dev": true, - "requires": { - "semver": "^7.1.1" - } - }, - "npm-normalize-package-bin": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", - "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", - "dev": true - }, - "npm-package-arg": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-12.0.2.tgz", - "integrity": "sha512-f1NpFjNI9O4VbKMOlA5QoBq/vSQPORHcTZ2feJpFkTHJ9eQkdlmZEKSjcAhxTGInC7RlEyScT9ui67NaOsjFWA==", - "dev": true, - "requires": { - "hosted-git-info": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^6.0.0" - } - }, - "npm-packlist": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-9.0.0.tgz", - "integrity": "sha512-8qSayfmHJQTx3nJWYbbUmflpyarbLMBc6LCAjYsiGtXxDB68HaZpb8re6zeaLGxZzDuMdhsg70jryJe+RrItVQ==", - "dev": true, - "requires": { - "ignore-walk": "^7.0.0" - } - }, - "npm-pick-manifest": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-10.0.0.tgz", - "integrity": "sha512-r4fFa4FqYY8xaM7fHecQ9Z2nE9hgNfJR+EmoKv0+chvzWkBcORX3r0FpTByP+CbOVJDladMXnPQGVN8PBLGuTQ==", - "dev": true, - "requires": { - "npm-install-checks": "^7.1.0", - "npm-normalize-package-bin": "^4.0.0", - "npm-package-arg": "^12.0.0", - "semver": "^7.3.5" - } - }, - "npm-registry-fetch": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-18.0.2.tgz", - "integrity": "sha512-LeVMZBBVy+oQb5R6FDV9OlJCcWDU+al10oKpe+nsvcHnG24Z3uM3SvJYKfGJlfGjVU8v9liejCrUR/M5HO5NEQ==", - "dev": true, - "requires": { - "@npmcli/redact": "^3.0.0", - "jsonparse": "^1.3.1", - "make-fetch-happen": "^14.0.0", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minizlib": "^3.0.1", - "npm-package-arg": "^12.0.0", - "proc-log": "^5.0.0" - } - }, - "nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "requires": { - "boolbase": "^1.0.0" - } - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true - }, - "object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" - }, - "obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "dev": true - }, - "on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "requires": { - "ee-first": "1.1.1" - } - }, - "on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", - "dev": true - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "requires": { - "mimic-function": "^5.0.0" - } - }, - "open": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", - "integrity": "sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==", - "dev": true, - "requires": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "is-wsl": "^3.1.0" - } - }, - "ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "requires": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" - }, - "cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "requires": { - "restore-cursor": "^3.1.0" - } - }, - "onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "requires": { - "mimic-fn": "^2.1.0" - } - }, - "restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "requires": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - } - }, - "signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "ordered-binary": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.5.3.tgz", - "integrity": "sha512-oGFr3T+pYdTGJ+YFEILMpS3es+GiIbs9h/XQrclBXUtd44ey7XwfsMzM31f64I1SQOawDoDr/D823kNCADI8TA==", - "dev": true, - "optional": true - }, - "os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true - }, - "p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "dev": true, - "requires": { - "yocto-queue": "^1.0.0" - } - }, - "p-locate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", - "dev": true, - "requires": { - "p-limit": "^4.0.0" - } - }, - "p-map": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", - "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", - "dev": true - }, - "p-retry": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", - "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", - "dev": true, - "requires": { - "@types/retry": "0.12.2", - "is-network-error": "^1.0.0", - "retry": "^0.13.1" - }, - "dependencies": { - "retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "dev": true - } - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true - }, - "pacote": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-20.0.0.tgz", - "integrity": "sha512-pRjC5UFwZCgx9kUFDVM9YEahv4guZ1nSLqwmWiLUnDbGsjs+U5w7z6Uc8HNR1a6x8qnu5y9xtGE6D1uAuYz+0A==", - "dev": true, - "requires": { - "@npmcli/git": "^6.0.0", - "@npmcli/installed-package-contents": "^3.0.0", - "@npmcli/package-json": "^6.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "@npmcli/run-script": "^9.0.0", - "cacache": "^19.0.0", - "fs-minipass": "^3.0.0", - "minipass": "^7.0.2", - "npm-package-arg": "^12.0.0", - "npm-packlist": "^9.0.0", - "npm-pick-manifest": "^10.0.0", - "npm-registry-fetch": "^18.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "sigstore": "^3.0.0", - "ssri": "^12.0.0", - "tar": "^6.1.11" - }, - "dependencies": { - "chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true - }, - "minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "requires": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "dependencies": { - "minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - } - } - }, - "mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true - }, - "tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dev": true, - "requires": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "dependencies": { - "fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "requires": { - "minipass": "^3.0.0" - }, - "dependencies": { - "minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - } - } - }, - "minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true - } - } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - } - } - }, - "parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "requires": { - "callsites": "^3.0.0" - } - }, - "parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "dependencies": { - "json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - } - } - }, - "parse-node-version": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", - "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", - "dev": true - }, - "parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, - "requires": { - "entities": "^6.0.0" - }, - "dependencies": { - "entities": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", - "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", - "dev": true - } - } - }, - "parse5-html-rewriting-stream": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-7.0.0.tgz", - "integrity": "sha512-mazCyGWkmCRWDI15Zp+UiCqMp/0dgEmkZRvhlsqqKYr4SsVm/TvnSpD9fCvqCA2zoWJcfRym846ejWBBHRiYEg==", - "dev": true, - "requires": { - "entities": "^4.3.0", - "parse5": "^7.0.0", - "parse5-sax-parser": "^7.0.0" - } - }, - "parse5-sax-parser": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-7.0.0.tgz", - "integrity": "sha512-5A+v2SNsq8T6/mG3ahcz8ZtQ0OUFTatxPbeidoMB7tkJSGDY3tdfl4MHovtLQHkEn5CGxijNWRQHhRQ6IRpXKg==", - "dev": true, - "requires": { - "parse5": "^7.0.0" - } - }, - "parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" - }, - "path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "dev": true - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true - }, - "path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "requires": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "dependencies": { - "lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - } - } - }, - "path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" - }, - "path-type": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", - "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", - "dev": true - }, - "picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" - }, - "picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==" - }, - "pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "optional": true - }, - "piscina": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.8.0.tgz", - "integrity": "sha512-EZJb+ZxDrQf3dihsUL7p42pjNyrNIFJCrRHPMgxu/svsj+P3xS3fuEWp7k2+rfsavfl1N0G29b1HGs7J0m8rZA==", - "dev": true, - "requires": { - "@napi-rs/nice": "^1.0.1" - } - }, - "pkg-dir": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", - "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", - "dev": true, - "requires": { - "find-up": "^6.3.0" - } - }, - "postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", - "requires": { - "nanoid": "^3.3.8", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - } - }, - "postcss-loader": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.1.tgz", - "integrity": "sha512-0IeqyAsG6tYiDRCYKQJLAmgQr47DX6N7sFSWvQxt6AcupX8DIdmykuk/o/tx0Lze3ErGHJEp5OSRxrelC6+NdQ==", - "dev": true, - "requires": { - "cosmiconfig": "^9.0.0", - "jiti": "^1.20.0", - "semver": "^7.5.4" - }, - "dependencies": { - "jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true - } - } - }, - "postcss-media-query-parser": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", - "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", - "dev": true - }, - "postcss-modules-extract-imports": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", - "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", - "dev": true, - "requires": {} - }, - "postcss-modules-local-by-default": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", - "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", - "dev": true, - "requires": { - "icss-utils": "^5.0.0", - "postcss-selector-parser": "^7.0.0", - "postcss-value-parser": "^4.1.0" - } - }, - "postcss-modules-scope": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", - "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", - "dev": true, - "requires": { - "postcss-selector-parser": "^7.0.0" - } - }, - "postcss-modules-values": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", - "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", - "dev": true, - "requires": { - "icss-utils": "^5.0.0" - } - }, - "postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dev": true, - "requires": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - } - }, - "postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true - }, - "proc-log": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", - "dev": true - }, - "process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true - }, - "promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, - "requires": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - } - }, - "protobufjs": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.2.tgz", - "integrity": "sha512-f2ls6rpO6G153Cy+o2XQ+Y0sARLOZ17+OGVLHrc3VUKcLHYKEKWbkSujdBWQXM7gKn5NTfp0XnRPZn1MIu8n9w==", - "requires": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - } - }, - "proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "requires": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - } - }, - "prr": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", - "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", - "dev": true, - "optional": true - }, - "punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "dev": true - }, - "qjobs": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", - "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", - "dev": true - }, - "qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", - "requires": { - "side-channel": "^1.0.6" - } - }, - "queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true - }, - "randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "requires": { - "safe-buffer": "^5.1.0" - } - }, - "range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" - }, - "raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "requires": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - } - }, - "readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "devOptional": true - }, - "reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true - }, - "regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "dev": true - }, - "regenerate-unicode-properties": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", - "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", - "dev": true, - "requires": { - "regenerate": "^1.4.2" - } - }, - "regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true - }, - "regex-parser": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz", - "integrity": "sha512-yXLRqatcCuKtVHsWrNg0JL3l1zGfdXeEvDa0bdu4tCDQw0RpMDZsqbkyRTUnKMR0tXF627V2oEWjBEaEdqTwtQ==", - "dev": true - }, - "regexpu-core": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", - "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", - "dev": true, - "requires": { - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.0", - "regjsgen": "^0.8.0", - "regjsparser": "^0.12.0", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.1.0" - } - }, - "regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "dev": true - }, - "regjsparser": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", - "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", - "dev": true, - "requires": { - "jsesc": "~3.0.2" - }, - "dependencies": { - "jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "dev": true - } - } - }, - "require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" - }, - "require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" - }, - "requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true - }, - "resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, - "requires": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true - }, - "resolve-url-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz", - "integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==", - "dev": true, - "requires": { - "adjust-sourcemap-loader": "^4.0.0", - "convert-source-map": "^1.7.0", - "loader-utils": "^2.0.0", - "postcss": "^8.2.14", - "source-map": "0.6.1" - }, - "dependencies": { - "loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", - "dev": true, - "requires": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - } - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - } - } - }, - "restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "requires": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - } - }, - "retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true - }, - "reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true - }, - "rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true - }, - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, - "rollup": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.8.tgz", - "integrity": "sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ==", - "dev": true, - "requires": { - "@rollup/rollup-android-arm-eabi": "4.34.8", - "@rollup/rollup-android-arm64": "4.34.8", - "@rollup/rollup-darwin-arm64": "4.34.8", - "@rollup/rollup-darwin-x64": "4.34.8", - "@rollup/rollup-freebsd-arm64": "4.34.8", - "@rollup/rollup-freebsd-x64": "4.34.8", - "@rollup/rollup-linux-arm-gnueabihf": "4.34.8", - "@rollup/rollup-linux-arm-musleabihf": "4.34.8", - "@rollup/rollup-linux-arm64-gnu": "4.34.8", - "@rollup/rollup-linux-arm64-musl": "4.34.8", - "@rollup/rollup-linux-loongarch64-gnu": "4.34.8", - "@rollup/rollup-linux-powerpc64le-gnu": "4.34.8", - "@rollup/rollup-linux-riscv64-gnu": "4.34.8", - "@rollup/rollup-linux-s390x-gnu": "4.34.8", - "@rollup/rollup-linux-x64-gnu": "4.34.8", - "@rollup/rollup-linux-x64-musl": "4.34.8", - "@rollup/rollup-win32-arm64-msvc": "4.34.8", - "@rollup/rollup-win32-ia32-msvc": "4.34.8", - "@rollup/rollup-win32-x64-msvc": "4.34.8", - "@types/estree": "1.0.6", - "fsevents": "~2.3.2" - }, - "dependencies": { - "@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true - } - } - }, - "run-applescript": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", - "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", - "dev": true - }, - "run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "requires": { - "queue-microtask": "^1.2.2" - } - }, - "rxfire": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/rxfire/-/rxfire-6.1.0.tgz", - "integrity": "sha512-NezdjeY32VZcCuGO0bbb8H8seBsJSCaWdUwGsHNzUcAOHR0VGpzgPtzjuuLXr8R/iemkqSzbx/ioS7VwV43ynA==", - "requires": {} - }, - "rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "requires": { - "tslib": "^2.1.0" - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" - }, - "safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "requires": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - } - }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "sass": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz", - "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==", - "dev": true, - "requires": { - "@parcel/watcher": "^2.4.1", - "chokidar": "^4.0.0", - "immutable": "^5.0.2", - "source-map-js": ">=0.6.2 <2.0.0" - } - }, - "sass-loader": { - "version": "16.0.5", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.5.tgz", - "integrity": "sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw==", - "dev": true, - "requires": { - "neo-async": "^2.6.2" - } - }, - "sax": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", - "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", - "dev": true, - "optional": true - }, - "schema-utils": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", - "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", - "dev": true, - "requires": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "dependencies": { - "ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, - "requires": { - "ajv": "^8.0.0" - } - } - } - }, - "select-hose": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", - "dev": true - }, - "selfsigned": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", - "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", - "dev": true, - "requires": { - "@types/node-forge": "^1.3.0", - "node-forge": "^1" - } - }, - "semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true - }, - "send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", - "requires": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - }, - "dependencies": { - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - } - } - }, - "encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" - }, - "mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" - } - } - }, - "serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "requires": { - "randombytes": "^2.1.0" - } - }, - "serve-index": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", - "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", - "dev": true, - "requires": { - "accepts": "~1.3.4", - "batch": "0.6.1", - "debug": "2.6.9", - "escape-html": "~1.0.3", - "http-errors": "~1.6.2", - "mime-types": "~2.1.17", - "parseurl": "~1.3.2" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "dev": true - }, - "http-errors": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", - "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", - "dev": true, - "requires": { - "depd": "~1.1.2", - "inherits": "2.0.3", - "setprototypeof": "1.1.0", - "statuses": ">= 1.4.0 < 2" - } - }, - "inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", - "dev": true - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "setprototypeof": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", - "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", - "dev": true - }, - "statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true - } - } - }, - "serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", - "requires": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" - } - }, - "setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "shallow-clone": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", - "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", - "dev": true, - "requires": { - "kind-of": "^6.0.2" - } - }, - "shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "requires": { - "shebang-regex": "^3.0.0" - } - }, - "shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true - }, - "shell-quote": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", - "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", - "dev": true - }, - "side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "requires": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - } - }, - "side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "requires": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - } - }, - "side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "requires": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - } - }, - "side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "requires": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - } - }, - "signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true - }, - "sigstore": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-3.1.0.tgz", - "integrity": "sha512-ZpzWAFHIFqyFE56dXqgX/DkDRZdz+rRcjoIk/RQU4IX0wiCv1l8S7ZrXDHcCc+uaf+6o7w3h2l3g6GYG5TKN9Q==", - "dev": true, - "requires": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.0", - "@sigstore/sign": "^3.1.0", - "@sigstore/tuf": "^3.1.0", - "@sigstore/verify": "^2.1.0" - } - }, - "slash": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", - "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", - "dev": true - }, - "slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "dev": true, - "requires": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true - } - } - }, - "smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true - }, - "socket.io": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", - "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", - "dev": true, - "requires": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.3.2", - "engine.io": "~6.6.0", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - }, - "dependencies": { - "debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "requires": { - "ms": "^2.1.3" - } - } - } - }, - "socket.io-adapter": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", - "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", - "dev": true, - "requires": { - "debug": "~4.3.4", - "ws": "~8.17.1" - }, - "dependencies": { - "debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "requires": { - "ms": "^2.1.3" - } - } - } - }, - "socket.io-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", - "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", - "dev": true, - "requires": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1" - }, - "dependencies": { - "debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "requires": { - "ms": "^2.1.3" - } - } - } - }, - "sockjs": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", - "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", - "dev": true, - "requires": { - "faye-websocket": "^0.11.3", - "uuid": "^8.3.2", - "websocket-driver": "^0.7.4" - } - }, - "socks": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", - "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", - "dev": true, - "requires": { - "ip-address": "^9.0.5", - "smart-buffer": "^4.2.0" - } - }, - "socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "requires": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - } - }, - "source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==" - }, - "source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" - }, - "source-map-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", - "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", - "dev": true, - "requires": { - "iconv-lite": "^0.6.3", - "source-map-js": "^1.0.2" - }, - "dependencies": { - "iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - } - } - }, - "source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "requires": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - } - } - }, - "spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "dev": true, - "requires": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true - }, - "spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dev": true, - "requires": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-license-ids": { - "version": "3.0.21", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", - "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", - "dev": true - }, - "spdy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", - "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", - "dev": true, - "requires": { - "debug": "^4.1.0", - "handle-thing": "^2.0.0", - "http-deceiver": "^1.2.7", - "select-hose": "^2.0.0", - "spdy-transport": "^3.0.0" - } - }, - "spdy-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", - "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", - "dev": true, - "requires": { - "debug": "^4.1.0", - "detect-node": "^2.0.4", - "hpack.js": "^2.1.6", - "obuf": "^1.1.2", - "readable-stream": "^3.0.6", - "wbuf": "^1.7.3" - } - }, - "sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true - }, - "ssri": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", - "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", - "dev": true, - "requires": { - "minipass": "^7.0.3" - } - }, - "statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" - }, - "streamroller": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", - "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", - "dev": true, - "requires": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "fs-extra": "^8.1.0" - } - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "requires": { - "safe-buffer": "~5.2.0" - } - }, - "string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "requires": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - } - }, - "string-width-cjs": { - "version": "npm:string-width@4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "requires": { - "ansi-regex": "^6.0.1" - } - }, - "strip-ansi-cjs": { - "version": "npm:strip-ansi@6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - } - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "requires": { - "has-flag": "^4.0.0" - } - }, - "supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true - }, - "symbol-observable": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", - "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", - "dev": true - }, - "tailwindcss": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz", - "integrity": "sha512-kr1o/ErIdNhTz8uzAYL7TpaUuzKIE6QPQ4qmSdxnoX/lo+5wmUHQA6h3L5yIqEImSRnAAURDirLu/BgiXGPAhg==" - }, - "tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==" - }, - "tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", - "requires": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" - }, - "dependencies": { - "mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==" - }, - "yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==" - } - } - }, - "terser": { - "version": "5.39.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", - "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", - "dev": true, - "requires": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "dependencies": { - "commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true - } - } - }, - "terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", - "dev": true, - "requires": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", - "terser": "^5.31.1" - } - }, - "thingies": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", - "integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==", - "dev": true, - "requires": {} - }, - "thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", - "dev": true - }, - "tinyglobby": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", - "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", - "dev": true, - "requires": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" - } - }, - "tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "requires": { - "os-tmpdir": "~1.0.2" - } - }, - "to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "requires": { - "is-number": "^7.0.0" - } - }, - "toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" - }, - "tree-dump": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.3.tgz", - "integrity": "sha512-il+Cv80yVHFBwokQSfd4bldvr1Md951DpgAGfmhydt04L+YzHgubm2tQ7zueWDcGENKHq0ZvGFR/hjvNXilHEg==", - "dev": true, - "requires": {} - }, - "tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true - }, - "tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - }, - "tuf-js": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.0.1.tgz", - "integrity": "sha512-+68OP1ZzSF84rTckf3FA95vJ1Zlx/uaXyiiKyPd1pA4rZNkpEvDAKmsu1xUSmbF/chCRYgZ6UZkDwC7PmzmAyA==", - "dev": true, - "requires": { - "@tufjs/models": "3.0.1", - "debug": "^4.3.6", - "make-fetch-happen": "^14.0.1" - } - }, - "type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true - }, - "type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "requires": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - } - }, - "typed-assert": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/typed-assert/-/typed-assert-1.0.9.tgz", - "integrity": "sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==", - "dev": true - }, - "typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", - "dev": true - }, - "ua-parser-js": { - "version": "0.7.40", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.40.tgz", - "integrity": "sha512-us1E3K+3jJppDBa3Tl0L3MOJiGhe1C6P0+nIvQAFYbxlMAx0h81eOwLmU57xgqToduDDPx3y5QsdjPfDu+FgOQ==", - "dev": true - }, - "undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" - }, - "unicode-canonical-property-names-ecmascript": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "dev": true - }, - "unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "dev": true, - "requires": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - } - }, - "unicode-match-property-value-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", - "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", - "dev": true - }, - "unicode-property-aliases-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", - "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", - "dev": true - }, - "unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true - }, - "unique-filename": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", - "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", - "dev": true, - "requires": { - "unique-slug": "^5.0.0" - } - }, - "unique-slug": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", - "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", - "dev": true, - "requires": { - "imurmurhash": "^0.1.4" - } - }, - "universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true - }, - "unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" - }, - "update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "dev": true, - "requires": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - } - }, - "util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" - }, - "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true - }, - "validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dev": true, - "requires": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "validate-npm-package-name": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.0.tgz", - "integrity": "sha512-d7KLgL1LD3U3fgnvWEY1cQXoO/q6EQ1BSz48Sa149V/5zVTAbgmZIpyI8TRi6U9/JNyeYLlTKsEMPtLC27RFUg==", - "dev": true - }, - "vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" - }, - "vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", - "dev": true, - "peer": true, - "requires": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "fsevents": "~2.3.3", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "dependencies": { - "@rollup/rollup-android-arm-eabi": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.0.tgz", - "integrity": "sha512-KxN+zCjOYHGwCl4UCtSfZ6jrq/qi88JDUtiEFk8LELEHq2Egfc/FgW+jItZiOLRuQfb/3xJSgFuNPC9jzggX+A==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-android-arm64": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.0.tgz", - "integrity": "sha512-yDvqx3lWlcugozax3DItKJI5j05B0d4Kvnjx+5mwiUpWramVvmAByYigMplaoAQ3pvdprGCTCE03eduqE/8mPQ==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-darwin-arm64": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.0.tgz", - "integrity": "sha512-2KOU574vD3gzcPSjxO0eyR5iWlnxxtmW1F5CkNOHmMlueKNCQkxR6+ekgWyVnz6zaZihpUNkGxjsYrkTJKhkaw==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-darwin-x64": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.0.tgz", - "integrity": "sha512-gE5ACNSxHcEZyP2BA9TuTakfZvULEW4YAOtxl/A/YDbIir/wPKukde0BNPlnBiP88ecaN4BJI2TtAd+HKuZPQQ==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-freebsd-arm64": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.0.tgz", - "integrity": "sha512-GSxU6r5HnWij7FoSo7cZg3l5GPg4HFLkzsFFh0N/b16q5buW1NAWuCJ+HMtIdUEi6XF0qH+hN0TEd78laRp7Dg==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-freebsd-x64": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.0.tgz", - "integrity": "sha512-KGiGKGDg8qLRyOWmk6IeiHJzsN/OYxO6nSbT0Vj4MwjS2XQy/5emsmtoqLAabqrohbgLWJ5GV3s/ljdrIr8Qjg==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.0.tgz", - "integrity": "sha512-46OzWeqEVQyX3N2/QdiU/CMXYDH/lSHpgfBkuhl3igpZiaB3ZIfSjKuOnybFVBQzjsLwkus2mjaESy8H41SzvA==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-linux-arm-musleabihf": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.0.tgz", - "integrity": "sha512-lfgW3KtQP4YauqdPpcUZHPcqQXmTmH4nYU0cplNeW583CMkAGjtImw4PKli09NFi2iQgChk4e9erkwlfYem6Lg==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-linux-arm64-gnu": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.0.tgz", - "integrity": "sha512-nn8mEyzMbdEJzT7cwxgObuwviMx6kPRxzYiOl6o/o+ChQq23gfdlZcUNnt89lPhhz3BYsZ72rp0rxNqBSfqlqw==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-linux-arm64-musl": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.0.tgz", - "integrity": "sha512-l+QK99je2zUKGd31Gh+45c4pGDAqZSuWQiuRFCdHYC2CSiO47qUWsCcenrI6p22hvHZrDje9QjwSMAFL3iwXwQ==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.0.tgz", - "integrity": "sha512-WbnJaxPv1gPIm6S8O/Wg+wfE/OzGSXlBMbOe4ie+zMyykMOeqmgD1BhPxZQuDqwUN+0T/xOFtL2RUWBspnZj3w==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.0.tgz", - "integrity": "sha512-eRDWR5t67/b2g8Q/S8XPi0YdbKcCs4WQ8vklNnUYLaSWF+Cbv2axZsp4jni6/j7eKvMLYCYdcsv8dcU+a6QNFg==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-linux-riscv64-gnu": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.0.tgz", - "integrity": "sha512-TWrZb6GF5jsEKG7T1IHwlLMDRy2f3DPqYldmIhnA2DVqvvhY2Ai184vZGgahRrg8k9UBWoSlHv+suRfTN7Ua4A==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-linux-s390x-gnu": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.0.tgz", - "integrity": "sha512-/L3pW48SxrWAlVsKCN0dGLB2bi8Nv8pr5S5ocSM+S0XCn5RCVCXqi8GVtHFsOBBCSeR+u9brV2zno5+mg3S4Aw==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-linux-x64-gnu": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.0.tgz", - "integrity": "sha512-XMLeKjyH8NsEDCRptf6LO8lJk23o9wvB+dJwcXMaH6ZQbbkHu2dbGIUindbMtRN6ux1xKi16iXWu6q9mu7gDhQ==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-linux-x64-musl": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.0.tgz", - "integrity": "sha512-m/P7LycHZTvSQeXhFmgmdqEiTqSV80zn6xHaQ1JSqwCtD1YGtwEK515Qmy9DcB2HK4dOUVypQxvhVSy06cJPEg==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-win32-arm64-msvc": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.0.tgz", - "integrity": "sha512-4yodtcOrFHpbomJGVEqZ8fzD4kfBeCbpsUy5Pqk4RluXOdsWdjLnjhiKy2w3qzcASWd04fp52Xz7JKarVJ5BTg==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-win32-ia32-msvc": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.0.tgz", - "integrity": "sha512-tmazCrAsKzdkXssEc65zIE1oC6xPHwfy9d5Ta25SRCDOZS+I6RypVVShWALNuU9bxIfGA0aqrmzlzoM5wO5SPQ==", - "dev": true, - "optional": true, - "peer": true - }, - "@rollup/rollup-win32-x64-msvc": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.0.tgz", - "integrity": "sha512-h1J+Yzjo/X+0EAvR2kIXJDuTuyT7drc+t2ALY0nIcGPbTatNOf0VWdhEA2Z4AAjv6X1NJV7SYo5oCTYRJhSlVA==", - "dev": true, - "optional": true, - "peer": true - }, - "rollup": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.0.tgz", - "integrity": "sha512-HqMFpUbWlf/tvcxBFNKnJyzc7Lk+XO3FGc3pbNBLqEbOz0gPLRgcrlS3UF4MfUrVlstOaP/q0kM6GVvi+LrLRg==", - "dev": true, - "peer": true, - "requires": { - "@rollup/rollup-android-arm-eabi": "4.41.0", - "@rollup/rollup-android-arm64": "4.41.0", - "@rollup/rollup-darwin-arm64": "4.41.0", - "@rollup/rollup-darwin-x64": "4.41.0", - "@rollup/rollup-freebsd-arm64": "4.41.0", - "@rollup/rollup-freebsd-x64": "4.41.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.41.0", - "@rollup/rollup-linux-arm-musleabihf": "4.41.0", - "@rollup/rollup-linux-arm64-gnu": "4.41.0", - "@rollup/rollup-linux-arm64-musl": "4.41.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.41.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.41.0", - "@rollup/rollup-linux-riscv64-gnu": "4.41.0", - "@rollup/rollup-linux-riscv64-musl": "4.41.0", - "@rollup/rollup-linux-s390x-gnu": "4.41.0", - "@rollup/rollup-linux-x64-gnu": "4.41.0", - "@rollup/rollup-linux-x64-musl": "4.41.0", - "@rollup/rollup-win32-arm64-msvc": "4.41.0", - "@rollup/rollup-win32-ia32-msvc": "4.41.0", - "@rollup/rollup-win32-x64-msvc": "4.41.0", - "@types/estree": "1.0.7", - "fsevents": "~2.3.2" - } - } - } - }, - "void-elements": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", - "integrity": "sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==", - "dev": true - }, - "watchpack": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", - "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", - "dev": true, - "requires": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - } - }, - "wbuf": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", - "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", - "dev": true, - "requires": { - "minimalistic-assert": "^1.0.0" - } - }, - "wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "requires": { - "defaults": "^1.0.3" - } - }, - "weak-lru-cache": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", - "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", - "dev": true, - "optional": true - }, - "web-vitals": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", - "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==" - }, - "webpack": { - "version": "5.98.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz", - "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", - "dev": true, - "requires": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.14.0", - "browserslist": "^4.24.0", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" - }, - "dependencies": { - "json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - } - } - }, - "webpack-dev-middleware": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.2.tgz", - "integrity": "sha512-xOO8n6eggxnwYpy1NlzUKpvrjfJTvae5/D6WOK0S2LSo7vjmo5gCM1DbLUmFqrMTJP+W/0YZNctm7jasWvLuBA==", - "dev": true, - "requires": { - "colorette": "^2.0.10", - "memfs": "^4.6.0", - "mime-types": "^2.1.31", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" - } - }, - "webpack-dev-server": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.0.tgz", - "integrity": "sha512-90SqqYXA2SK36KcT6o1bvwvZfJFcmoamqeJY7+boioffX9g9C0wjjJRGUrQIuh43pb0ttX7+ssavmj/WN2RHtA==", - "dev": true, - "requires": { - "@types/bonjour": "^3.5.13", - "@types/connect-history-api-fallback": "^1.5.4", - "@types/express": "^4.17.21", - "@types/serve-index": "^1.9.4", - "@types/serve-static": "^1.15.5", - "@types/sockjs": "^0.3.36", - "@types/ws": "^8.5.10", - "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.2.1", - "chokidar": "^3.6.0", - "colorette": "^2.0.10", - "compression": "^1.7.4", - "connect-history-api-fallback": "^2.0.0", - "express": "^4.21.2", - "graceful-fs": "^4.2.6", - "http-proxy-middleware": "^2.0.7", - "ipaddr.js": "^2.1.0", - "launch-editor": "^2.6.1", - "open": "^10.0.3", - "p-retry": "^6.2.0", - "schema-utils": "^4.2.0", - "selfsigned": "^2.4.1", - "serve-index": "^1.9.1", - "sockjs": "^0.3.24", - "spdy": "^4.0.2", - "webpack-dev-middleware": "^7.4.2", - "ws": "^8.18.0" - }, - "dependencies": { - "chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "requires": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "fsevents": "~2.3.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - } - }, - "glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "requires": { - "is-glob": "^4.0.1" - } - }, - "http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", - "dev": true, - "requires": { - "@types/http-proxy": "^1.17.8", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.1", - "is-plain-obj": "^3.0.0", - "micromatch": "^4.0.2" - } - }, - "ipaddr.js": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", - "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", - "dev": true - }, - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true - }, - "readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "requires": { - "picomatch": "^2.2.1" - } - }, - "ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", - "dev": true, - "requires": {} - } - } - }, - "webpack-merge": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", - "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", - "dev": true, - "requires": { - "clone-deep": "^4.0.1", - "flat": "^5.0.2", - "wildcard": "^2.0.1" - } - }, - "webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", - "dev": true - }, - "webpack-subresource-integrity": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/webpack-subresource-integrity/-/webpack-subresource-integrity-5.1.0.tgz", - "integrity": "sha512-sacXoX+xd8r4WKsy9MvH/q/vBtEHr86cpImXwyg74pFIpERKt6FmB8cXpeuh0ZLgclOlHI4Wcll7+R5L02xk9Q==", - "dev": true, - "requires": { - "typed-assert": "^1.0.8" - } - }, - "websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "requires": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - } - }, - "websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==" - }, - "which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "wildcard": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", - "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", - "dev": true - }, - "wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "wrap-ansi-cjs": { - "version": "npm:wrap-ansi@7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "dev": true, - "requires": {} - }, - "xhr2": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/xhr2/-/xhr2-0.2.1.tgz", - "integrity": "sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==" - }, - "y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" - }, - "yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true - }, - "yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "requires": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" - }, - "string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" - }, - "yocto-queue": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", - "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", - "dev": true - }, - "yoctocolors-cjs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", - "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", - "dev": true - }, - "zod": { - "version": "3.25.7", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.7.tgz", - "integrity": "sha512-YGdT1cVRmKkOg6Sq7vY7IkxdphySKnXhaUmFI4r4FcuFVNgpCb9tZfNwXbT6BPjD5oz0nubFsoo9pIqKrDcCvg==" - }, - "zone.js": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.0.tgz", - "integrity": "sha512-9oxn0IIjbCZkJ67L+LkhYWRyAy7axphb3VgE2MBDlOqnmHMPWGYMxJxBYFueFq/JGY2GMwS0rU+UCLunEmy5UA==" - } - } -} diff --git a/examples/angular/package.json b/examples/angular/package.json index 228b61556..9b12ae8df 100644 --- a/examples/angular/package.json +++ b/examples/angular/package.json @@ -8,27 +8,34 @@ "build:lib": "ng build firebaseui-angular", "build:local": "pnpm run build:lib && cd projects/firebaseui-angular && pnpm pack", "watch": "ng build --watch --configuration development", - "test": "ng test", - "test:unit": "ng test --exclude=\"**/integration/**\" --no-watch --no-progress --browsers=ChromeHeadless", - "test:integration": "ng test --include=\"**/tests/integration/**/*.spec.ts\" --no-watch --no-progress --browsers=ChromeHeadless", - "serve:ssr:angular-ssr": "node dist/angular-ssr/server/server.mjs" + "test": "vitest run", + "test:watch": "vitest", + "test:unit": "vitest run --exclude=\"**/integration/**\"", + "test:integration": "vitest run --include=\"**/tests/integration/**/*.spec.ts\"", + "test:ci": "vitest run --coverage", + "serve:ssr:angular-ssr": "node dist/angular-ssr/server/server.mjs", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "format": "prettier --write \"src/**/*.{ts,html,css,scss}\"", + "format:check": "prettier --check \"src/**/*.{ts,html,css,scss}\"", + "deploy": "pnpm run build && firebase deploy --only hosting:fir-ui-rework-angular" }, "dependencies": { - "@angular/animations": "^19.1.0", - "@angular/common": "^19.1.0", - "@angular/compiler": "^19.1.0", - "@angular/core": "^19.1.0", - "@angular/fire": "^19.0.0", - "@angular/forms": "^19.1.0", - "@angular/platform-browser": "^19.1.0", - "@angular/platform-browser-dynamic": "^19.1.0", - "@angular/platform-server": "^19.1.0", - "@angular/router": "^19.1.0", - "@angular/ssr": "^19.1.7", - "@firebase-ui/angular": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-angular-0.0.1.tgz", - "@firebase-ui/core": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-core-0.0.1.tgz", - "@firebase-ui/styles": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-styles-0.0.1.tgz", - "@firebase-ui/translations": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-translations-0.0.1.tgz", + "@angular/animations": "^20.2.2", + "@angular/common": "^20.2.2", + "@angular/compiler": "^20.2.2", + "@angular/core": "^20.2.2", + "@angular/fire": "^20.0.1", + "@angular/forms": "^20.2.2", + "@angular/platform-browser": "^20.2.2", + "@angular/platform-browser-dynamic": "^20.2.2", + "@angular/platform-server": "^20.2.2", + "@angular/router": "^20.2.2", + "@angular/ssr": "^20.2.2", + "@invertase/firebaseui-angular": "workspace:*", + "@invertase/firebaseui-core": "workspace:*", + "@invertase/firebaseui-styles": "workspace:*", + "@invertase/firebaseui-translations": "workspace:*", "@tailwindcss/postcss": "^4.0.6", "express": "^4.18.2", "postcss": "^8.5.2", @@ -38,22 +45,30 @@ "zone.js": "~0.15.0" }, "devDependencies": { - "@angular-devkit/build-angular": "^19.1.7", - "@angular/cli": "^19.1.7", - "@angular/compiler-cli": "^19.1.0", + "@angular-devkit/build-angular": "latest", + "@angular-devkit/core": "latest", + "@angular-devkit/architect": "latest", + "@angular/cli": "^20.2.2", + "@angular/compiler-cli": "^20.2.2", + "@eslint/js": "^9.22.0", "@tanstack/angular-form": "^0.42.0", "@types/express": "^4.17.17", - "@types/jasmine": "~5.1.0", - "@types/node": "^18.18.0", + "@types/node": "^20.19.0", + "@typescript-eslint/eslint-plugin": "^8.43.0", + "@typescript-eslint/parser": "^8.43.0", + "eslint": "^9.22.0", + "eslint-config-prettier": "^9.1.0", "firebase": "^11", - "jasmine-core": "~5.5.0", - "karma": "~6.4.0", - "karma-chrome-launcher": "~3.2.0", - "karma-coverage": "~2.2.0", - "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "~2.1.0", + "jsonc-parser": "^3.2.0", + "vite": "^6.2.2", + "vitest": "^3.2.0", + "@vitest/ui": "^3.2.0", + "@vitest/coverage-v8": "^3.2.0", + "jsdom": "^25.0.0", + "@testing-library/jest-dom": "^6.6.0", "nanostores": "^0.11.3", - "ng-packagr": "^19.1.0", - "typescript": "~5.7.2" + "ng-packagr": "^20.2.0", + "prettier": "^3.1.1", + "typescript": "^5.9.2" } } diff --git a/examples/angular/public/firebase-logo-inverted.png b/examples/angular/public/firebase-logo-inverted.png new file mode 100644 index 000000000..b6f4ef80a Binary files /dev/null and b/examples/angular/public/firebase-logo-inverted.png differ diff --git a/examples/angular/public/firebase-logo.png b/examples/angular/public/firebase-logo.png new file mode 100644 index 000000000..1cf731440 Binary files /dev/null and b/examples/angular/public/firebase-logo.png differ diff --git a/examples/angular/src/app/app.component.html b/examples/angular/src/app/app.component.html index a057c5171..a166cf8f1 100644 --- a/examples/angular/src/app/app.component.html +++ b/examples/angular/src/app/app.component.html @@ -14,6 +14,6 @@ limitations under the License. --> -
- -
\ No newline at end of file + + + \ No newline at end of file diff --git a/examples/angular/src/app/app.component.ts b/examples/angular/src/app/app.component.ts index 2aefe9a02..acd98aab5 100644 --- a/examples/angular/src/app/app.component.ts +++ b/examples/angular/src/app/app.component.ts @@ -14,35 +14,125 @@ * limitations under the License. */ -import { Component } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; -import { CommonModule } from '@angular/common'; -import { HeaderComponent } from './components/header'; +import { Component, computed, inject, input } from "@angular/core"; +import { RouterModule, Router } from "@angular/router"; +import { CommonModule } from "@angular/common"; +import { Auth, multiFactor, sendEmailVerification, signOut, type User } from "@angular/fire/auth"; +import { routes } from "./routes"; +import { ThemeToggleComponent } from "./components/theme-toggle/theme-toggle.component"; +import { PirateToggleComponent } from "./components/pirate-toggle/pirate-toggle.component"; +import { MultiFactorAuthAssertionScreenComponent } from "@invertase/firebaseui-angular"; +import { injectUI } from "@invertase/firebaseui-angular"; @Component({ - selector: 'app-root', + selector: "app-unauthenticated", standalone: true, - imports: [CommonModule, RouterOutlet, HeaderComponent], + imports: [CommonModule, RouterModule, MultiFactorAuthAssertionScreenComponent], template: ` - -
- + @if (mfaResolver()) { + + } @else { +
+
+ + Firebase UI +

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

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

{{ route.name }}

+

{{ route.description }}

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

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

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

Multi-factor Authentication

+ @for (factor of mfaFactors(); track factor.factorId) { +
{{ factor.factorId }} - {{ factor.displayName }}
+ } + +
+ +
`, - styles: [` - .app-container { - max-width: 1200px; - margin: 0 auto; - } - - :host { - display: block; - min-height: 100vh; - background-color: #f9fafb; - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - } - `] }) -export class AppComponent { - title = 'Firebase UI Angular Example'; +export class AuthenticatedAppComponent { + user = input.required(); + private auth = inject(Auth); + private router = inject(Router); + + mfaFactors = computed(() => { + const mfa = multiFactor(this.user()); + return mfa.enrolledFactors; + }); + + async verifyEmail() { + try { + await sendEmailVerification(this.user()); + alert("Email verification sent, please check your email"); + } catch (error) { + console.error(error); + alert("Error sending email verification, check console"); + } + } + + navigateToMfa() { + this.router.navigate(["/screens/mfa-enrollment-screen"]); + } + + async signOut() { + await signOut(this.auth); + } } + +@Component({ + selector: "app-root", + standalone: true, + imports: [CommonModule, RouterModule, ThemeToggleComponent, PirateToggleComponent], + templateUrl: "./app.component.html", +}) +export class AppComponent {} diff --git a/examples/angular/src/app/app.config.server.ts b/examples/angular/src/app/app.config.server.ts index dcbea9e11..bbc394cd8 100644 --- a/examples/angular/src/app/app.config.server.ts +++ b/examples/angular/src/app/app.config.server.ts @@ -14,14 +14,13 @@ * limitations under the License. */ -import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; -import { provideServerRendering } from '@angular/platform-server'; -import { appConfig } from './app.config'; +import { mergeApplicationConfig, type ApplicationConfig } from "@angular/core"; +import { provideServerRendering, withRoutes } from "@angular/ssr"; +import { serverRoutes } from "./app.routes.server"; +import { appConfig } from "./app.config"; const serverConfig: ApplicationConfig = { - providers: [ - provideServerRendering(), - ] + providers: [provideServerRendering(withRoutes(serverRoutes))], }; export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/examples/angular/src/app/app.config.ts b/examples/angular/src/app/app.config.ts index f146c704f..689e78ee3 100644 --- a/examples/angular/src/app/app.config.ts +++ b/examples/angular/src/app/app.config.ts @@ -14,29 +14,24 @@ * limitations under the License. */ -import { - ApplicationConfig, - provideZoneChangeDetection, - isDevMode, -} from '@angular/core'; -import { provideRouter } from '@angular/router'; +import { type ApplicationConfig, provideZoneChangeDetection, isDevMode } from "@angular/core"; +import { provideRouter } from "@angular/router"; -import { routes } from './app.routes'; -import { - provideClientHydration, - withEventReplay, -} from '@angular/platform-browser'; +import { routes } from "./app.routes"; +import { provideClientHydration, withEventReplay } from "@angular/platform-browser"; -import { provideFirebaseApp, initializeApp } from '@angular/fire/app'; -import { provideAuth, getAuth, connectAuthEmulator } from '@angular/fire/auth'; -import { - provideFirebaseUI, - provideFirebaseUIPolicies, -} from '@firebase-ui/angular'; -import { initializeUI } from '@firebase-ui/core'; +import { provideFirebaseApp, initializeApp } from "@angular/fire/app"; +import { provideAuth, getAuth, connectAuthEmulator } from "@angular/fire/auth"; +import { provideFirebaseUI, provideFirebaseUIPolicies } from "@invertase/firebaseui-angular"; +import { initializeUI } from "@invertase/firebaseui-core"; const firebaseConfig = { - // your Firebase config here + apiKey: "AIzaSyCvMftIUCD9lUQ3BzIrimfSfBbCUQYZf-I", + authDomain: "fir-ui-rework.firebaseapp.com", + projectId: "fir-ui-rework", + storageBucket: "fir-ui-rework.firebasestorage.app", + messagingSenderId: "200312857118", + appId: "1:200312857118:web:94e3f69b0e0a4a863f040f", }; export const appConfig: ApplicationConfig = { @@ -47,18 +42,16 @@ export const appConfig: ApplicationConfig = { provideFirebaseApp(() => initializeApp(firebaseConfig)), provideAuth(() => { const auth = getAuth(); - if (isDevMode()) { /** Enable emulators in development */ - connectAuthEmulator(auth, 'http://localhost:9099'); + connectAuthEmulator(auth, "http://localhost:9099"); } - return auth; }), provideFirebaseUI((apps) => initializeUI({ app: apps[0] })), provideFirebaseUIPolicies(() => ({ - termsOfServiceUrl: 'https://www.google.com', - privacyPolicyUrl: 'https://www.google.com', + termsOfServiceUrl: "https://www.google.com", + privacyPolicyUrl: "https://www.google.com", })), ], }; diff --git a/examples/angular/src/app/app.routes.server.ts b/examples/angular/src/app/app.routes.server.ts new file mode 100644 index 000000000..4d2d69b8a --- /dev/null +++ b/examples/angular/src/app/app.routes.server.ts @@ -0,0 +1,89 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RenderMode, type ServerRoute } from "@angular/ssr"; + +export const serverRoutes: ServerRoute[] = [ + /** Home page - perfect for SSG as it's a static landing page */ + { + path: "", + renderMode: RenderMode.Prerender, + }, + /** Static auth demos - good for SSG as they showcase Firebase UI components */ + { + path: "screens/sign-in-auth-screen", + renderMode: RenderMode.Prerender, + }, + { + path: "screens/oauth-screen", + renderMode: RenderMode.Prerender, + }, + /** Interactive auth routes - better as CSR for user interaction */ + { + path: "screens/sign-up-auth-screen", + renderMode: RenderMode.Client, + }, + { + path: "screens/forgot-password-auth-screen", + renderMode: RenderMode.Client, + }, + /** Dynamic auth routes - good for SSR as they may need server-side data */ + { + path: "screens/email-link-auth-screen", + renderMode: RenderMode.Server, + }, + { + path: "screens/email-link-auth-screen-w-oauth", + renderMode: RenderMode.Server, + }, + { + path: "screens/phone-auth-screen", + renderMode: RenderMode.Server, + }, + { + path: "screens/phone-auth-screen-w-oauth", + renderMode: RenderMode.Server, + }, + { + path: "screens/sign-in-auth-screen-w-oauth", + renderMode: RenderMode.Server, + }, + { + path: "screens/sign-up-auth-screen-w-oauth", + renderMode: RenderMode.Server, + }, + { + path: "screens/sign-in-auth-screen-w-handlers", + renderMode: RenderMode.Client, + }, + { + path: "screens/sign-up-auth-screen-w-handlers", + renderMode: RenderMode.Client, + }, + { + path: "screens/forgot-password-auth-screen-w-handlers", + renderMode: RenderMode.Client, + }, + { + path: "screens/mfa-enrollment-screen", + renderMode: RenderMode.Client, + }, + /** All other routes will be rendered on the server (SSR) */ + { + path: "**", + renderMode: RenderMode.Server, + }, +]; diff --git a/examples/angular/src/app/app.routes.ts b/examples/angular/src/app/app.routes.ts index 152315fb4..0902873c8 100644 --- a/examples/angular/src/app/app.routes.ts +++ b/examples/angular/src/app/app.routes.ts @@ -14,82 +14,27 @@ * limitations under the License. */ -import { Routes } from '@angular/router'; +import { type Routes } from "@angular/router"; +import { routes as routeConfigs, hiddenRoutes } from "./routes"; +import { ScreenRouteLayoutComponent } from "./components/screen-route-layout/screen-route-layout.component"; + +const allRoutes = [...routeConfigs, ...hiddenRoutes]; export const routes: Routes = [ { - path: '', - loadComponent: () => import('./home').then(m => m.HomeComponent) - }, - // Direct auth routes (matching NextJS paths) - { - path: 'sign-in', - loadComponent: () => import('./auth/sign-in').then(m => m.SignInComponent) - }, - { - path: 'register', - loadComponent: () => import('./auth/register').then(m => m.RegisterComponent) - }, - { - path: 'forgot-password', - loadComponent: () => import('./auth/forgot-password').then(m => m.ForgotPasswordComponent) - }, - // Sign-in subdirectories - { - path: 'sign-in/phone', - loadComponent: () => import('./auth/phone').then(m => m.PhoneComponent) - }, - { - path: 'sign-in/email', - loadComponent: () => import('./auth/email-link').then(m => m.EmailLinkComponent) - }, - // Screen routes - { - path: 'screens/sign-in-auth-screen', - loadComponent: () => import('./auth/sign-in-screen').then(m => m.SignInScreenComponent) - }, - { - path: 'screens/sign-in-auth-screen-w-handlers', - loadComponent: () => import('./auth/sign-in-handlers').then(m => m.SignInHandlersComponent) + path: "", + loadComponent: () => import("./home").then((m) => m.HomeComponent), }, { - path: 'screens/sign-in-auth-screen-w-oauth', - loadComponent: () => import('./auth/sign-in-oauth').then(m => m.SignInOAuthComponent) + path: "screens", + component: ScreenRouteLayoutComponent, + children: allRoutes.map((route) => ({ + path: route.path.replace(/^\/screens\//, ""), + loadComponent: route.loadComponent, + })), }, { - path: 'screens/email-link-auth-screen', - loadComponent: () => import('./auth/email-link-screen').then(m => m.EmailLinkScreenComponent) + path: "**", + redirectTo: "", }, - { - path: 'screens/email-link-auth-screen-w-oauth', - loadComponent: () => import('./auth/email-link-oauth').then(m => m.EmailLinkOAuthComponent) - }, - { - path: 'screens/phone-auth-screen', - loadComponent: () => import('./auth/phone-screen').then(m => m.PhoneScreenComponent) - }, - { - path: 'screens/phone-auth-screen-w-oauth', - loadComponent: () => import('./auth/phone-oauth').then(m => m.PhoneOAuthComponent) - }, - { - path: 'screens/sign-up-auth-screen', - loadComponent: () => import('./auth/sign-up').then(m => m.SignUpComponent) - }, - { - path: 'screens/sign-up-auth-screen-w-oauth', - loadComponent: () => import('./auth/register-oauth').then(m => m.RegisterOAuthComponent) - }, - { - path: 'screens/oauth-screen', - loadComponent: () => import('./auth/oauth').then(m => m.OAuthComponent) - }, - { - path: 'screens/password-reset-screen', - loadComponent: () => import('./auth/password-reset').then(m => m.PasswordResetComponent) - }, - { - path: '**', - redirectTo: '' - } ]; diff --git a/examples/angular/src/app/auth/email-link-oauth/email-link-oauth.component.ts b/examples/angular/src/app/auth/email-link-oauth/email-link-oauth.component.ts index 4093bb167..8e0459e96 100644 --- a/examples/angular/src/app/auth/email-link-oauth/email-link-oauth.component.ts +++ b/examples/angular/src/app/auth/email-link-oauth/email-link-oauth.component.ts @@ -14,24 +14,16 @@ * limitations under the License. */ -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { Router, RouterModule } from '@angular/router'; -import { Auth, User, authState } from '@angular/fire/auth'; -import { - EmailLinkAuthScreenComponent, - GoogleSignInButtonComponent, -} from '@firebase-ui/angular'; +import { Component, type OnInit, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router, RouterModule } from "@angular/router"; +import { Auth, type User, authState } from "@angular/fire/auth"; +import { EmailLinkAuthScreenComponent, GoogleSignInButtonComponent } from "@invertase/firebaseui-angular"; @Component({ - selector: 'app-email-link-oauth', + selector: "app-email-link-oauth", standalone: true, - imports: [ - CommonModule, - RouterModule, - EmailLinkAuthScreenComponent, - GoogleSignInButtonComponent, - ], + imports: [CommonModule, RouterModule, EmailLinkAuthScreenComponent, GoogleSignInButtonComponent], template: ` @@ -47,7 +39,7 @@ export class EmailLinkOAuthComponent implements OnInit { // Check if user is already authenticated and redirect to home page authState(this.auth).subscribe((user: User | null) => { if (user) { - this.router.navigate(['/']); + this.router.navigate(["/"]); } }); } diff --git a/examples/angular/src/app/auth/email-link-oauth/index.ts b/examples/angular/src/app/auth/email-link-oauth/index.ts index 1fc5413ff..c803a7ce1 100644 --- a/examples/angular/src/app/auth/email-link-oauth/index.ts +++ b/examples/angular/src/app/auth/email-link-oauth/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './email-link-oauth.component'; +export * from "./email-link-oauth.component"; diff --git a/examples/angular/src/app/auth/email-link-screen/email-link-screen.component.ts b/examples/angular/src/app/auth/email-link-screen/email-link-screen.component.ts deleted file mode 100644 index 4db81c51b..000000000 --- a/examples/angular/src/app/auth/email-link-screen/email-link-screen.component.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { Router, RouterModule } from '@angular/router'; -import { Auth, User, authState } from '@angular/fire/auth'; -import { EmailLinkAuthScreenComponent } from '@firebase-ui/angular'; - -@Component({ - selector: 'app-email-link-screen', - standalone: true, - imports: [CommonModule, RouterModule, EmailLinkAuthScreenComponent], - template: ` - - `, - styles: [] -}) -export class EmailLinkScreenComponent implements OnInit { - private auth = inject(Auth); - private router = inject(Router); - - ngOnInit() { - // Check if user is already authenticated and redirect to home page - authState(this.auth).subscribe((user: User | null) => { - if (user) { - this.router.navigate(['/']); - } - }); - } -} diff --git a/examples/angular/src/app/auth/email-link/email-link.component.ts b/examples/angular/src/app/auth/email-link/email-link.component.ts index b54882250..25b458c74 100644 --- a/examples/angular/src/app/auth/email-link/email-link.component.ts +++ b/examples/angular/src/app/auth/email-link/email-link.component.ts @@ -14,17 +14,17 @@ * limitations under the License. */ -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { Router, RouterModule } from '@angular/router'; -import { Auth, User, authState } from '@angular/fire/auth'; -import { EmailLinkAuthScreenComponent } from '@firebase-ui/angular'; +import { Component, type OnInit, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router, RouterModule } from "@angular/router"; +import { Auth, type User, authState } from "@angular/fire/auth"; +import { EmailLinkAuthScreenComponent } from "@invertase/firebaseui-angular"; @Component({ - selector: 'app-email-link', + selector: "app-email-link", standalone: true, imports: [CommonModule, RouterModule, EmailLinkAuthScreenComponent], - template: ` `, + template: ` `, styles: [], }) export class EmailLinkComponent implements OnInit { @@ -35,7 +35,7 @@ export class EmailLinkComponent implements OnInit { // Check if user is already authenticated and redirect to home page authState(this.auth).subscribe((user: User | null) => { if (user) { - this.router.navigate(['/']); + this.router.navigate(["/"]); } }); } diff --git a/examples/angular/src/app/auth/email-link/index.ts b/examples/angular/src/app/auth/email-link/index.ts index 25f838128..dc924e640 100644 --- a/examples/angular/src/app/auth/email-link/index.ts +++ b/examples/angular/src/app/auth/email-link/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './email-link.component'; +export * from "./email-link.component"; diff --git a/examples/angular/src/app/auth/forgot-password/forgot-password.component.ts b/examples/angular/src/app/auth/forgot-password/forgot-password.component.ts index 5f18863ee..96cbb297d 100644 --- a/examples/angular/src/app/auth/forgot-password/forgot-password.component.ts +++ b/examples/angular/src/app/auth/forgot-password/forgot-password.component.ts @@ -14,31 +14,33 @@ * limitations under the License. */ -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { Router, RouterModule } from '@angular/router'; -import { Auth, User, authState } from '@angular/fire/auth'; -import { PasswordResetScreenComponent } from '@firebase-ui/angular'; +import { Component, type OnInit, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router, RouterModule } from "@angular/router"; +import { Auth, type User, authState } from "@angular/fire/auth"; +import { ForgotPasswordAuthScreenComponent } from "@invertase/firebaseui-angular"; @Component({ - selector: 'app-forgot-password', + selector: "app-forgot-password", standalone: true, - imports: [CommonModule, RouterModule, PasswordResetScreenComponent], - template: ` - - `, - styles: [] + imports: [CommonModule, RouterModule, ForgotPasswordAuthScreenComponent], + template: ` `, + styles: [], }) export class ForgotPasswordComponent implements OnInit { private auth = inject(Auth); private router = inject(Router); - + ngOnInit() { // Check if user is already authenticated and redirect to home page authState(this.auth).subscribe((user: User | null) => { if (user) { - this.router.navigate(['/']); + this.router.navigate(["/"]); } }); } + + backToSignIn() { + this.router.navigate(["/sign-in"]); + } } diff --git a/examples/angular/src/app/auth/forgot-password/index.ts b/examples/angular/src/app/auth/forgot-password/index.ts index 002abd146..9b8b5f8bf 100644 --- a/examples/angular/src/app/auth/forgot-password/index.ts +++ b/examples/angular/src/app/auth/forgot-password/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './forgot-password.component'; +export * from "./forgot-password.component"; diff --git a/examples/angular/src/app/auth/oauth/index.ts b/examples/angular/src/app/auth/oauth/index.ts index 4d3a3495c..a85640fdf 100644 --- a/examples/angular/src/app/auth/oauth/index.ts +++ b/examples/angular/src/app/auth/oauth/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './oauth.component'; +export * from "./oauth.component"; diff --git a/examples/angular/src/app/auth/oauth/oauth.component.ts b/examples/angular/src/app/auth/oauth/oauth.component.ts index cfad53ece..cb70269e6 100644 --- a/examples/angular/src/app/auth/oauth/oauth.component.ts +++ b/examples/angular/src/app/auth/oauth/oauth.component.ts @@ -14,14 +14,14 @@ * limitations under the License. */ -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { Router, RouterModule } from '@angular/router'; -import { Auth, User, authState } from '@angular/fire/auth'; -import { OAuthScreenComponent, GoogleSignInButtonComponent } from '@firebase-ui/angular'; +import { Component, type OnInit, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router, RouterModule } from "@angular/router"; +import { Auth, type User, authState } from "@angular/fire/auth"; +import { OAuthScreenComponent, GoogleSignInButtonComponent } from "@invertase/firebaseui-angular"; @Component({ - selector: 'app-oauth', + selector: "app-oauth", standalone: true, imports: [CommonModule, RouterModule, OAuthScreenComponent, GoogleSignInButtonComponent], template: ` @@ -29,17 +29,17 @@ import { OAuthScreenComponent, GoogleSignInButtonComponent } from '@firebase-ui/ `, - styles: [] + styles: [], }) export class OAuthComponent implements OnInit { private auth = inject(Auth); private router = inject(Router); - + ngOnInit() { // Check if user is already authenticated and redirect to home page authState(this.auth).subscribe((user: User | null) => { if (user) { - this.router.navigate(['/']); + this.router.navigate(["/"]); } }); } diff --git a/examples/angular/src/app/auth/password-reset/password-reset.component.ts b/examples/angular/src/app/auth/password-reset/password-reset.component.ts deleted file mode 100644 index 56084b1c5..000000000 --- a/examples/angular/src/app/auth/password-reset/password-reset.component.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { Router, RouterModule } from '@angular/router'; -import { Auth, User, authState } from '@angular/fire/auth'; -import { PasswordResetScreenComponent } from '@firebase-ui/angular'; - -@Component({ - selector: 'app-password-reset', - standalone: true, - imports: [CommonModule, RouterModule, PasswordResetScreenComponent], - template: ` - - `, - styles: [], -}) -export class PasswordResetComponent implements OnInit { - private auth = inject(Auth); - private router = inject(Router); - - ngOnInit() { - // Check if user is already authenticated and redirect to home page - authState(this.auth).subscribe((user: User | null) => { - if (user) { - this.router.navigate(['/']); - } - }); - } -} diff --git a/examples/angular/src/app/auth/phone-oauth/index.ts b/examples/angular/src/app/auth/phone-oauth/index.ts index d18142d12..33d2bb1d0 100644 --- a/examples/angular/src/app/auth/phone-oauth/index.ts +++ b/examples/angular/src/app/auth/phone-oauth/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './phone-oauth.component'; +export * from "./phone-oauth.component"; diff --git a/examples/angular/src/app/auth/phone-oauth/phone-oauth.component.ts b/examples/angular/src/app/auth/phone-oauth/phone-oauth.component.ts index df3f2555c..fd6fb821f 100644 --- a/examples/angular/src/app/auth/phone-oauth/phone-oauth.component.ts +++ b/examples/angular/src/app/auth/phone-oauth/phone-oauth.component.ts @@ -14,32 +14,32 @@ * limitations under the License. */ -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { Router, RouterModule } from '@angular/router'; -import { Auth, User, authState } from '@angular/fire/auth'; -import { PhoneAuthScreenComponent, GoogleSignInButtonComponent } from '@firebase-ui/angular'; +import { Component, type OnInit, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router, RouterModule } from "@angular/router"; +import { Auth, type User, authState } from "@angular/fire/auth"; +import { PhoneAuthScreenComponent, GoogleSignInButtonComponent } from "@invertase/firebaseui-angular"; @Component({ - selector: 'app-phone-oauth', + selector: "app-phone-oauth", standalone: true, imports: [CommonModule, RouterModule, PhoneAuthScreenComponent, GoogleSignInButtonComponent], template: ` - + `, - styles: [] + styles: [], }) export class PhoneOAuthComponent implements OnInit { private auth = inject(Auth); private router = inject(Router); - + ngOnInit() { // Check if user is already authenticated and redirect to home page authState(this.auth).subscribe((user: User | null) => { if (user) { - this.router.navigate(['/']); + this.router.navigate(["/"]); } }); } diff --git a/examples/angular/src/app/auth/phone-screen/phone-screen.component.ts b/examples/angular/src/app/auth/phone-screen/phone-screen.component.ts deleted file mode 100644 index 1c836da8c..000000000 --- a/examples/angular/src/app/auth/phone-screen/phone-screen.component.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { Router, RouterModule } from '@angular/router'; -import { Auth, User, authState } from '@angular/fire/auth'; -import { PhoneAuthScreenComponent } from '@firebase-ui/angular'; - -@Component({ - selector: 'app-phone-screen', - standalone: true, - imports: [CommonModule, RouterModule, PhoneAuthScreenComponent], - template: ` - - `, - styles: [] -}) -export class PhoneScreenComponent implements OnInit { - private auth = inject(Auth); - private router = inject(Router); - - ngOnInit() { - // Check if user is already authenticated and redirect to home page - authState(this.auth).subscribe((user: User | null) => { - if (user) { - this.router.navigate(['/']); - } - }); - } -} diff --git a/examples/angular/src/app/auth/phone/index.ts b/examples/angular/src/app/auth/phone/index.ts index 4d53ab9ca..da351973c 100644 --- a/examples/angular/src/app/auth/phone/index.ts +++ b/examples/angular/src/app/auth/phone/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './phone.component'; +export * from "./phone-screen.component"; diff --git a/examples/angular/src/app/auth/phone/phone.component.ts b/examples/angular/src/app/auth/phone/phone-screen.component.ts similarity index 69% rename from examples/angular/src/app/auth/phone/phone.component.ts rename to examples/angular/src/app/auth/phone/phone-screen.component.ts index 31d134f59..fbe2f3f44 100644 --- a/examples/angular/src/app/auth/phone/phone.component.ts +++ b/examples/angular/src/app/auth/phone/phone-screen.component.ts @@ -14,30 +14,28 @@ * limitations under the License. */ -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { Router, RouterModule } from '@angular/router'; -import { Auth, User, authState } from '@angular/fire/auth'; -import { PhoneAuthScreenComponent } from '@firebase-ui/angular'; +import { Component, type OnInit, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router, RouterModule } from "@angular/router"; +import { Auth, type User, authState } from "@angular/fire/auth"; +import { PhoneAuthScreenComponent } from "@invertase/firebaseui-angular"; @Component({ - selector: 'app-phone', + selector: "app-phone", standalone: true, imports: [CommonModule, RouterModule, PhoneAuthScreenComponent], - template: ` - - `, - styles: [] + template: ` `, + styles: [], }) export class PhoneComponent implements OnInit { private auth = inject(Auth); private router = inject(Router); - + ngOnInit() { // Check if user is already authenticated and redirect to home page authState(this.auth).subscribe((user: User | null) => { if (user) { - this.router.navigate(['/']); + this.router.navigate(["/"]); } }); } diff --git a/examples/angular/src/app/auth/register-oauth/index.ts b/examples/angular/src/app/auth/register-oauth/index.ts deleted file mode 100644 index 3a01dfd02..000000000 --- a/examples/angular/src/app/auth/register-oauth/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './register-oauth.component'; diff --git a/examples/angular/src/app/auth/register/index.ts b/examples/angular/src/app/auth/register/index.ts index 098cc9212..766d67c36 100644 --- a/examples/angular/src/app/auth/register/index.ts +++ b/examples/angular/src/app/auth/register/index.ts @@ -1,17 +1 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './register.component'; +export * from "./register.component"; diff --git a/examples/angular/src/app/auth/register/register.component.ts b/examples/angular/src/app/auth/register/register.component.ts index 927def911..947fd0594 100644 --- a/examples/angular/src/app/auth/register/register.component.ts +++ b/examples/angular/src/app/auth/register/register.component.ts @@ -14,33 +14,33 @@ * limitations under the License. */ -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { Router, RouterModule } from '@angular/router'; -import { Auth, User, authState } from '@angular/fire/auth'; -import { SignUpAuthScreenComponent, GoogleSignInButtonComponent } from '@firebase-ui/angular'; +import { Component, type OnInit, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router, RouterModule } from "@angular/router"; +import { Auth, type User, authState } from "@angular/fire/auth"; +import { SignUpAuthScreenComponent } from "@invertase/firebaseui-angular"; @Component({ - selector: 'app-register', + selector: "app-register", standalone: true, - imports: [CommonModule, RouterModule, SignUpAuthScreenComponent, GoogleSignInButtonComponent], - template: ` - - - - `, - styles: [] + imports: [CommonModule, RouterModule, SignUpAuthScreenComponent], + template: ` `, + styles: [], }) export class RegisterComponent implements OnInit { private auth = inject(Auth); private router = inject(Router); - + ngOnInit() { // Check if user is already authenticated and redirect to home page authState(this.auth).subscribe((user: User | null) => { if (user) { - this.router.navigate(['/']); + this.router.navigate(["/"]); } }); } + + goToSignIn() { + this.router.navigate(["/sign-in"]); + } } diff --git a/examples/angular/src/app/auth/sign-in-handlers/index.ts b/examples/angular/src/app/auth/sign-in-handlers/index.ts deleted file mode 100644 index c64161dc4..000000000 --- a/examples/angular/src/app/auth/sign-in-handlers/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './sign-in-handlers.component'; diff --git a/examples/angular/src/app/auth/sign-in-handlers/sign-in-handlers.component.ts b/examples/angular/src/app/auth/sign-in-handlers/sign-in-handlers.component.ts deleted file mode 100644 index 09394061d..000000000 --- a/examples/angular/src/app/auth/sign-in-handlers/sign-in-handlers.component.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { Router, RouterModule } from '@angular/router'; -import { Auth, User, authState } from '@angular/fire/auth'; -import { SignInAuthScreenComponent } from '@firebase-ui/angular'; - -@Component({ - selector: 'app-sign-in-handlers', - standalone: true, - imports: [CommonModule, RouterModule, SignInAuthScreenComponent], - template: ` - - `, - styles: [], -}) -export class SignInHandlersComponent implements OnInit { - private auth = inject(Auth); - private router = inject(Router); - - ngOnInit() { - // Check if user is already authenticated and redirect to home page - authState(this.auth).subscribe((user: User | null) => { - if (user) { - this.router.navigate(['/']); - } - }); - } -} diff --git a/examples/angular/src/app/auth/sign-in-oauth/index.ts b/examples/angular/src/app/auth/sign-in-oauth/index.ts index 469faac8d..8fff1e0dd 100644 --- a/examples/angular/src/app/auth/sign-in-oauth/index.ts +++ b/examples/angular/src/app/auth/sign-in-oauth/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './sign-in-oauth.component'; +export * from "./sign-in-oauth.component"; diff --git a/examples/angular/src/app/auth/sign-in-oauth/sign-in-oauth.component.ts b/examples/angular/src/app/auth/sign-in-oauth/sign-in-oauth.component.ts index d4b10a764..2347ca24f 100644 --- a/examples/angular/src/app/auth/sign-in-oauth/sign-in-oauth.component.ts +++ b/examples/angular/src/app/auth/sign-in-oauth/sign-in-oauth.component.ts @@ -14,29 +14,18 @@ * limitations under the License. */ -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { Router, RouterModule } from '@angular/router'; -import { Auth, User, authState } from '@angular/fire/auth'; -import { - SignInAuthScreenComponent, - GoogleSignInButtonComponent, -} from '@firebase-ui/angular'; +import { Component, type OnInit, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router, RouterModule } from "@angular/router"; +import { Auth, type User, authState } from "@angular/fire/auth"; +import { SignInAuthScreenComponent, GoogleSignInButtonComponent } from "@invertase/firebaseui-angular"; @Component({ - selector: 'app-sign-in-oauth', + selector: "app-sign-in-oauth", standalone: true, - imports: [ - CommonModule, - RouterModule, - SignInAuthScreenComponent, - GoogleSignInButtonComponent, - ], + imports: [CommonModule, RouterModule, SignInAuthScreenComponent, GoogleSignInButtonComponent], template: ` - + `, @@ -45,13 +34,21 @@ import { export class SignInOAuthComponent implements OnInit { private auth = inject(Auth); private router = inject(Router); - + ngOnInit() { // Check if user is already authenticated and redirect to home page authState(this.auth).subscribe((user: User | null) => { if (user) { - this.router.navigate(['/']); + this.router.navigate(["/"]); } }); } + + goToForgotPassword() { + this.router.navigate(["/forgot-password"]); + } + + goToRegister() { + this.router.navigate(["/sign-up"]); + } } diff --git a/examples/angular/src/app/auth/sign-in-screen/index.ts b/examples/angular/src/app/auth/sign-in-screen/index.ts deleted file mode 100644 index 8ebca5e9f..000000000 --- a/examples/angular/src/app/auth/sign-in-screen/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './sign-in-screen.component'; diff --git a/examples/angular/src/app/auth/sign-in-screen/sign-in-screen.component.ts b/examples/angular/src/app/auth/sign-in-screen/sign-in-screen.component.ts deleted file mode 100644 index 654a0b91e..000000000 --- a/examples/angular/src/app/auth/sign-in-screen/sign-in-screen.component.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { Router, RouterModule } from '@angular/router'; -import { Auth, User, authState } from '@angular/fire/auth'; -import { SignInAuthScreenComponent } from '@firebase-ui/angular'; - -@Component({ - selector: 'app-sign-in-screen', - standalone: true, - imports: [CommonModule, RouterModule, SignInAuthScreenComponent], - template: ` - - `, - styles: [] -}) -export class SignInScreenComponent implements OnInit { - private auth = inject(Auth); - private router = inject(Router); - - ngOnInit() { - // Check if user is already authenticated and redirect to home page - authState(this.auth).subscribe((user: User | null) => { - if (user) { - this.router.navigate(['/']); - } - }); - } -} diff --git a/examples/angular/src/app/auth/sign-in/index.ts b/examples/angular/src/app/auth/sign-in/index.ts index 174cbf53b..795feab0b 100644 --- a/examples/angular/src/app/auth/sign-in/index.ts +++ b/examples/angular/src/app/auth/sign-in/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './sign-in.component'; +export * from "./sign-in.component"; diff --git a/examples/angular/src/app/auth/sign-in/sign-in.component.ts b/examples/angular/src/app/auth/sign-in/sign-in.component.ts index 6840eef78..cb3c7af51 100644 --- a/examples/angular/src/app/auth/sign-in/sign-in.component.ts +++ b/examples/angular/src/app/auth/sign-in/sign-in.component.ts @@ -14,30 +14,19 @@ * limitations under the License. */ -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { Router, RouterModule } from '@angular/router'; -import { Auth, User, authState } from '@angular/fire/auth'; -import { - SignInAuthScreenComponent, - GoogleSignInButtonComponent, -} from '@firebase-ui/angular'; +import { Component, type OnInit, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router, RouterModule } from "@angular/router"; +import { Auth, type User, authState } from "@angular/fire/auth"; +import { SignInAuthScreenComponent, GoogleSignInButtonComponent } from "@invertase/firebaseui-angular"; @Component({ - selector: 'app-sign-in', + selector: "app-sign-in", standalone: true, - imports: [ - CommonModule, - RouterModule, - SignInAuthScreenComponent, - GoogleSignInButtonComponent, - ], + imports: [CommonModule, RouterModule, SignInAuthScreenComponent, GoogleSignInButtonComponent], template: ` - - + + @@ -51,13 +40,21 @@ import { export class SignInComponent implements OnInit { private auth = inject(Auth); private router = inject(Router); - + ngOnInit() { // Check if user is already authenticated and redirect to home page authState(this.auth).subscribe((user: User | null) => { if (user) { - this.router.navigate(['/']); + this.router.navigate(["/"]); } }); } + + goToForgotPassword() { + this.router.navigate(["/forgot-password"]); + } + + goToSignUp() { + this.router.navigate(["/sign-up"]); + } } diff --git a/examples/angular/src/app/components/header/index.ts b/examples/angular/src/app/auth/sign-up-oauth/index.ts similarity index 93% rename from examples/angular/src/app/components/header/index.ts rename to examples/angular/src/app/auth/sign-up-oauth/index.ts index ee1985934..316d32b3b 100644 --- a/examples/angular/src/app/components/header/index.ts +++ b/examples/angular/src/app/auth/sign-up-oauth/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './header.component'; +export * from "./sign-up-oauth.component"; diff --git a/examples/angular/src/app/auth/register-oauth/register-oauth.component.ts b/examples/angular/src/app/auth/sign-up-oauth/sign-up-oauth.component.ts similarity index 74% rename from examples/angular/src/app/auth/register-oauth/register-oauth.component.ts rename to examples/angular/src/app/auth/sign-up-oauth/sign-up-oauth.component.ts index c92c0f7a8..1ab32d402 100644 --- a/examples/angular/src/app/auth/register-oauth/register-oauth.component.ts +++ b/examples/angular/src/app/auth/sign-up-oauth/sign-up-oauth.component.ts @@ -14,14 +14,14 @@ * limitations under the License. */ -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { Router, RouterModule } from '@angular/router'; -import { Auth, User, authState } from '@angular/fire/auth'; -import { SignUpAuthScreenComponent, GoogleSignInButtonComponent } from '@firebase-ui/angular'; +import { Component, type OnInit, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router, RouterModule } from "@angular/router"; +import { Auth, type User, authState } from "@angular/fire/auth"; +import { SignUpAuthScreenComponent, GoogleSignInButtonComponent } from "@invertase/firebaseui-angular"; @Component({ - selector: 'app-register-oauth', + selector: "app-sign-up-oauth", standalone: true, imports: [CommonModule, RouterModule, SignUpAuthScreenComponent, GoogleSignInButtonComponent], template: ` @@ -29,17 +29,17 @@ import { SignUpAuthScreenComponent, GoogleSignInButtonComponent } from '@firebas `, - styles: [] + styles: [], }) -export class RegisterOAuthComponent implements OnInit { +export class SignUpOAuthComponent implements OnInit { private auth = inject(Auth); private router = inject(Router); - + ngOnInit() { // Check if user is already authenticated and redirect to home page authState(this.auth).subscribe((user: User | null) => { if (user) { - this.router.navigate(['/']); + this.router.navigate(["/"]); } }); } diff --git a/examples/angular/src/app/auth/sign-up/index.ts b/examples/angular/src/app/auth/sign-up/index.ts index 6da9f31f8..9c6736ce7 100644 --- a/examples/angular/src/app/auth/sign-up/index.ts +++ b/examples/angular/src/app/auth/sign-up/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './sign-up.component'; +export * from "./sign-up.component"; diff --git a/examples/angular/src/app/auth/sign-up/sign-up.component.ts b/examples/angular/src/app/auth/sign-up/sign-up.component.ts index 09e4b96ec..e309166cc 100644 --- a/examples/angular/src/app/auth/sign-up/sign-up.component.ts +++ b/examples/angular/src/app/auth/sign-up/sign-up.component.ts @@ -14,31 +14,33 @@ * limitations under the License. */ -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { Router, RouterModule } from '@angular/router'; -import { Auth, User, authState } from '@angular/fire/auth'; -import { SignUpAuthScreenComponent } from '@firebase-ui/angular'; +import { Component, type OnInit, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router, RouterModule } from "@angular/router"; +import { Auth, type User, authState } from "@angular/fire/auth"; +import { SignUpAuthScreenComponent } from "@invertase/firebaseui-angular"; @Component({ - selector: 'app-sign-up', + selector: "app-sign-up", standalone: true, imports: [CommonModule, RouterModule, SignUpAuthScreenComponent], - template: ` - - `, - styles: [] + template: ` `, + styles: [], }) export class SignUpComponent implements OnInit { private auth = inject(Auth); private router = inject(Router); - + ngOnInit() { // Check if user is already authenticated and redirect to home page authState(this.auth).subscribe((user: User | null) => { if (user) { - this.router.navigate(['/']); + this.router.navigate(["/"]); } }); } + + goToSignIn() { + this.router.navigate(["/sign-in"]); + } } diff --git a/examples/angular/src/app/components/header/header.component.ts b/examples/angular/src/app/components/header/header.component.ts deleted file mode 100644 index ec66f1455..000000000 --- a/examples/angular/src/app/components/header/header.component.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { RouterModule } from '@angular/router'; -import { Auth, User, authState, signOut } from '@angular/fire/auth'; -import { Router } from '@angular/router'; -import { Observable } from 'rxjs'; - -@Component({ - selector: 'app-header', - standalone: true, - imports: [CommonModule, RouterModule], - template: ` -
-
- -
- -
-
-
- `, - styles: [` - .border-b { - border-bottom-width: 1px; - } - .border-gray-200 { - border-color: #e5e7eb; - } - .max-w-6xl { - max-width: 72rem; - } - .mx-auto { - margin-left: auto; - margin-right: auto; - } - .h-12 { - height: 3rem; - } - .px-4 { - padding-left: 1rem; - padding-right: 1rem; - } - .flex { - display: flex; - } - .items-center { - align-items: center; - } - .font-bold { - font-weight: 700; - } - .flex-grow { - flex-grow: 1; - } - .justify-end { - justify-content: flex-end; - } - .text-sm { - font-size: 0.875rem; - line-height: 1.25rem; - } - .gap-6 { - gap: 1.5rem; - } - button { - background: none; - border: none; - cursor: pointer; - font: inherit; - color: inherit; - } - a { - text-decoration: none; - color: inherit; - } - *:hover { - opacity: 0.75; - } - `] -}) -export class HeaderComponent { - private auth = inject(Auth); - private router = inject(Router); - user$: Observable = authState(this.auth); - - async onSignOut() { - await signOut(this.auth); - this.router.navigate(['/auth/sign-in']); - } -} diff --git a/examples/angular/src/app/components/pirate-toggle/pirate-toggle.component.ts b/examples/angular/src/app/components/pirate-toggle/pirate-toggle.component.ts new file mode 100644 index 000000000..77401b370 --- /dev/null +++ b/examples/angular/src/app/components/pirate-toggle/pirate-toggle.component.ts @@ -0,0 +1,51 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, computed } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { injectUI } from "@invertase/firebaseui-angular"; +import { enUs } from "@invertase/firebaseui-translations"; +import { pirate } from "../../pirate"; + +@Component({ + selector: "app-pirate-toggle", + standalone: true, + imports: [CommonModule], + template: ` + + `, + styles: [], +}) +export class PirateToggleComponent { + private ui = injectUI(); + + isPirate = computed(() => this.ui().locale.locale === "pirate"); + + toggleLocale() { + const currentUI = this.ui(); + if (this.isPirate()) { + currentUI.setLocale(enUs); + } else { + currentUI.setLocale(pirate); + } + } +} diff --git a/examples/angular/src/app/components/screen-route-layout/screen-route-layout.component.ts b/examples/angular/src/app/components/screen-route-layout/screen-route-layout.component.ts new file mode 100644 index 000000000..ec6b361b1 --- /dev/null +++ b/examples/angular/src/app/components/screen-route-layout/screen-route-layout.component.ts @@ -0,0 +1,40 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { RouterModule } from "@angular/router"; + +@Component({ + selector: "app-screen-route-layout", + standalone: true, + imports: [CommonModule, RouterModule], + template: ` + + `, + styles: [], +}) +export class ScreenRouteLayoutComponent {} diff --git a/examples/angular/src/app/components/theme-toggle/theme-toggle.component.ts b/examples/angular/src/app/components/theme-toggle/theme-toggle.component.ts new file mode 100644 index 000000000..dbfe6ed8e --- /dev/null +++ b/examples/angular/src/app/components/theme-toggle/theme-toggle.component.ts @@ -0,0 +1,62 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component } from "@angular/core"; +import { CommonModule } from "@angular/common"; + +@Component({ + selector: "app-theme-toggle", + standalone: true, + imports: [CommonModule], + template: ` + + `, + styles: [], +}) +export class ThemeToggleComponent { + toggleTheme() { + const htmlElement = document.documentElement; + const isDark = htmlElement.classList.contains("dark"); + htmlElement.classList.toggle("dark", !isDark); + localStorage["theme"] = htmlElement.classList.contains("dark") ? "dark" : "light"; + } +} diff --git a/examples/angular/src/app/home/home.component.ts b/examples/angular/src/app/home/home.component.ts index d14d89096..71e2037d8 100644 --- a/examples/angular/src/app/home/home.component.ts +++ b/examples/angular/src/app/home/home.component.ts @@ -14,124 +14,26 @@ * limitations under the License. */ -import { Component, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { RouterModule } from '@angular/router'; -import { Auth, User, authState } from '@angular/fire/auth'; -import { Observable } from 'rxjs'; +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { AsyncPipe } from "@angular/common"; +import { UserService } from "../services/user.service"; +import { UnauthenticatedAppComponent } from "../app.component"; +import { AuthenticatedAppComponent } from "../app.component"; @Component({ - selector: 'app-home', + selector: "app-home", standalone: true, - imports: [CommonModule, RouterModule], + imports: [CommonModule, AsyncPipe, UnauthenticatedAppComponent, AuthenticatedAppComponent], template: ` - + @if (user$ | async; as user) { + + } @else { + + } `, - styles: [] }) export class HomeComponent { - private auth = inject(Auth); - user$: Observable = authState(this.auth); - - signOut() { - this.auth.signOut(); - } + private userService = inject(UserService); + user$ = this.userService.getUser(); } diff --git a/examples/angular/src/app/home/index.ts b/examples/angular/src/app/home/index.ts index 328ded550..d7102cdb5 100644 --- a/examples/angular/src/app/home/index.ts +++ b/examples/angular/src/app/home/index.ts @@ -1,17 +1 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './home.component'; +export * from "./home.component"; diff --git a/examples/angular/src/app/pirate.ts b/examples/angular/src/app/pirate.ts new file mode 100644 index 000000000..aa92433ce --- /dev/null +++ b/examples/angular/src/app/pirate.ts @@ -0,0 +1,95 @@ +import { registerLocale } from "@invertase/firebaseui-translations"; + +export const pirate = registerLocale("pirate", { + errors: { + userNotFound: "Arrr! No account found with this email address, matey", + wrongPassword: "Arrr! Incorrect password, ye scallywag", + invalidEmail: "Avast! Enter a valid email address, ye bilge rat", + userDisabled: "This account has been marooned, arrr!", + networkRequestFailed: "Can't connect to the server, ye land lubber! Check yer internet connection", + tooManyRequests: "Too many failed attempts, ye scurvy dog! Try again later", + missingVerificationCode: "Enter the verification code, ye scallywag", + emailAlreadyInUse: "An account already exists with this email, arrr!", + invalidCredential: "The credentials ye provided be invalid, matey", + weakPassword: "Ye password ain't long enough! It should be at least 8 characters", + unverifiedEmail: "Verify yer email address to continue, ye scallywag", + operationNotAllowed: "This operation ain't allowed, arrr! Contact support, matey", + invalidPhoneNumber: "The phone number be invalid, ye bilge rat", + missingPhoneNumber: "Provide a phone number, ye scallywag", + quotaExceeded: "SMS quota exceeded, arrr! Try again later, matey", + codeExpired: "The verification code has expired, ye scurvy dog", + captchaCheckFailed: "reCAPTCHA verification failed, arrr! Try again, matey", + missingVerificationId: "Complete the reCAPTCHA verification first, ye scallywag", + missingEmail: "Provide an email address, ye bilge rat", + invalidActionCode: "The password reset link be invalid or has expired, arrr!", + credentialAlreadyInUse: "An account already exists with this email, arrr! Sign in with that account, matey", + requiresRecentLogin: "This operation requires a recent login, ye scallywag! Sign in again", + providerAlreadyLinked: "This phone number be already linked to another account, arrr!", + invalidVerificationCode: "Invalid verification code, ye scurvy dog! Try again", + unknownError: "An unexpected error occurred, arrr!", + popupClosed: "The sign-in popup was closed, ye scallywag! Try again", + accountExistsWithDifferentCredential: + "An account already exists with this email, arrr! Sign in with the original provider, matey", + displayNameRequired: "Provide a display name, ye bilge rat", + secondFactorAlreadyInUse: "This phone number be already enrolled with this account, arrr!", + }, + messages: { + passwordResetEmailSent: "Password reset email sent successfully, arrr!", + signInLinkSent: "Sign-in link sent successfully, matey!", + verificationCodeFirst: "Request a verification code first, ye scallywag", + checkEmailForReset: "Check yer email for password reset instructions, ye bilge rat", + dividerOr: "or", + termsAndPrivacy: "By continuing, ye agree to our {tos} and {privacy}, arrr!", + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process, matey.", + }, + labels: { + emailAddress: "Email Address, ye bilge rat", + password: "Password, ye scallywag", + displayName: "Display Name, ye bilge rat", + forgotPassword: "Forgot Password, ye scallywag?", + signUp: "Sign Up, Matey", + signIn: "Sign In, Matey", + resetPassword: "Reset Password, ye scallywag", + createAccount: "Create Account, ye bilge rat", + backToSignIn: "Back to Sign In, ye scallywag", + signInWithPhone: "Sign in with Phone, ye scallywag", + phoneNumber: "Phone Number, ye bilge rat", + verificationCode: "Verification Code, ye scallywag", + sendCode: "Send Code, ye scallywag", + verifyCode: "Verify Code, ye scallywag", + signInWithGoogle: "Sign in with ye Google Account", + signInWithFacebook: "Sign in with ye Facebook Account", + signInWithApple: "Sign in with ye Apple Account", + signInWithMicrosoft: "Sign in with ye Microsoft Account", + signInWithGitHub: "Sign in with ye GitHub Account", + signInWithTwitter: "Sign in with ye X Account", + signInWithEmailLink: "Sign in with Email Link", + sendSignInLink: "Send Sign-in Link", + termsOfService: "Terms of Service", + privacyPolicy: "Privacy Policy", + resendCode: "Resend ye Code", + sending: "Firing...", + multiFactorEnrollment: "Multi-factor Enrrrrrrollment!", + multiFactorAssertion: "Multi-factor Authentication, arrr!", + mfaTotpVerification: "TOTP Verification, arrr!", + mfaSmsVerification: "SMS Verification, arrr!", + generateQrCode: "Generate ye QR Code", + }, + prompts: { + noAccount: "Don't have an account, ye scallywag?", + haveAccount: "Already have an account, matey?", + enterEmailToReset: "Enter yer email address to reset yer password, ye bilge rat", + signInToAccount: "Sign in to yer account, matey", + smsVerificationPrompt: "Enter the verification code sent to yer phone number, ye scallywag", + enterDetailsToCreate: "Enter yer details to create a new account, ye bilge rat", + enterPhoneNumber: "Enter yer phone number, matey", + enterVerificationCode: "Enter the verification code, ye scallywag", + enterEmailForLink: "Enter yer email to receive a sign-in link, ye bilge rat", + mfaEnrollmentPrompt: "Select a new multi-factor enrollment method, arrr!", + mfaAssertionPrompt: "Complete the multi-factor authentication process, ye scallywag", + mfaAssertionFactorPrompt: "Choose a multi-factor authentication method, matey", + mfaTotpQrCodePrompt: "Scan this QR code with yer authenticator app, ye bilge rat", + mfaTotpEnrollmentVerificationPrompt: "Add the code generated by yer authenticator app, arrr!", + }, +}); diff --git a/examples/angular/src/app/policies/policy.config.ts b/examples/angular/src/app/policies/policy.config.ts index b4ad6815d..f1db00d73 100644 --- a/examples/angular/src/app/policies/policy.config.ts +++ b/examples/angular/src/app/policies/policy.config.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { InjectionToken } from '@angular/core'; +import { InjectionToken } from "@angular/core"; export interface PolicyConfig { termsOfServiceUrl: string; privacyPolicyUrl: string; } -export const POLICY_CONFIG = new InjectionToken('PolicyConfig'); +export const POLICY_CONFIG = new InjectionToken("PolicyConfig"); diff --git a/examples/angular/src/app/policies/providePolicies.ts b/examples/angular/src/app/policies/providePolicies.ts index 754f6f39e..ff5a2d409 100644 --- a/examples/angular/src/app/policies/providePolicies.ts +++ b/examples/angular/src/app/policies/providePolicies.ts @@ -15,15 +15,15 @@ */ // src/app/policies/providePolicies.ts -import { Provider } from '@angular/core'; -import { POLICY_CONFIG, PolicyConfig } from './policy.config'; +import { type Provider } from "@angular/core"; +import { POLICY_CONFIG, type PolicyConfig } from "./policy.config"; export function providePolicies(): Provider { return { provide: POLICY_CONFIG, useValue: { - termsOfServiceUrl: 'https://yourdomain.com/terms', - privacyPolicyUrl: 'https://yourdomain.com/privacy', + termsOfServiceUrl: "https://yourdomain.com/terms", + privacyPolicyUrl: "https://yourdomain.com/privacy", } satisfies PolicyConfig, }; } diff --git a/examples/angular/src/app/routes.ts b/examples/angular/src/app/routes.ts new file mode 100644 index 000000000..fcafffd4a --- /dev/null +++ b/examples/angular/src/app/routes.ts @@ -0,0 +1,123 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Type } from "@angular/core"; + +export interface RouteConfig { + name: string; + description: string; + path: string; + loadComponent: () => Promise<{ default: Type } | Type>; +} + +export const routes: RouteConfig[] = [ + { + name: "Sign In Screen", + description: "A sign in screen with email and password.", + path: "/screens/sign-in-auth-screen", + loadComponent: () => import("./screens/sign-in-auth-screen").then((m) => m.SignInAuthScreenWrapperComponent), + }, + { + name: "Sign In Screen (with handlers)", + description: "A sign in screen with email and password, with forgot password and register handlers.", + path: "/screens/sign-in-auth-screen-w-handlers", + loadComponent: () => + import("./screens/sign-in-auth-screen-w-handlers").then((m) => m.SignInAuthScreenWithHandlersComponent), + }, + { + name: "Sign In Screen (with OAuth)", + description: "A sign in screen with email and password, with oAuth buttons.", + path: "/screens/sign-in-auth-screen-w-oauth", + loadComponent: () => + import("./screens/sign-in-auth-screen-w-oauth").then((m) => m.SignInAuthScreenWithOAuthComponent), + }, + { + name: "Sign Up Screen", + description: "A sign up screen with email and password.", + path: "/screens/sign-up-auth-screen", + loadComponent: () => import("./screens/sign-up-auth-screen").then((m) => m.SignUpAuthScreenWrapperComponent), + }, + { + name: "Sign Up Screen (with handlers)", + description: "A sign up screen with email and password, sign in handlers.", + path: "/screens/sign-up-auth-screen-w-handlers", + loadComponent: () => + import("./screens/sign-up-auth-screen-w-handlers").then((m) => m.SignUpAuthScreenWithHandlersComponent), + }, + { + name: "Sign Up Screen (with OAuth)", + description: "A sign in screen with email and password, with oAuth buttons.", + path: "/screens/sign-up-auth-screen-w-oauth", + loadComponent: () => + import("./screens/sign-up-auth-screen-w-oauth").then((m) => m.SignUpAuthScreenWithOAuthComponent), + }, + { + name: "Email Link Auth Screen", + description: "A screen allowing a user to send an email link for sign in.", + path: "/screens/email-link-auth-screen", + loadComponent: () => import("./screens/email-link-auth-screen").then((m) => m.EmailLinkAuthScreenWrapperComponent), + }, + { + name: "Email Link Auth Screen (with OAuth)", + description: "A screen allowing a user to send an email link for sign in, with oAuth buttons.", + path: "/screens/email-link-auth-screen-w-oauth", + loadComponent: () => + import("./screens/email-link-auth-screen-w-oauth").then((m) => m.EmailLinkAuthScreenWithOAuthComponent), + }, + { + name: "Forgot Password Screen", + description: "A screen allowing a user to reset their password.", + path: "/screens/forgot-password-auth-screen", + loadComponent: () => + import("./screens/forgot-password-auth-screen").then((m) => m.ForgotPasswordAuthScreenWrapperComponent), + }, + { + name: "Forgot Password Screen (with handlers)", + description: "A screen allowing a user to reset their password, with forgot password and register handlers.", + path: "/screens/forgot-password-auth-screen-w-handlers", + loadComponent: () => + import("./screens/forgot-password-auth-screen-w-handlers").then( + (m) => m.ForgotPasswordAuthScreenWithHandlersComponent + ), + }, + { + name: "OAuth Screen", + description: "A screen which allows a user to sign in with OAuth only.", + path: "/screens/oauth-screen", + loadComponent: () => import("./screens/oauth-screen").then((m) => m.OAuthScreenWrapperComponent), + }, + { + name: "Phone Auth Screen", + description: "A screen allowing a user to sign in with a phone number.", + path: "/screens/phone-auth-screen", + loadComponent: () => import("./screens/phone-auth-screen").then((m) => m.PhoneAuthScreenWrapperComponent), + }, + { + name: "Phone Auth Screen (with OAuth)", + description: "A screen allowing a user to sign in with a phone number, with oAuth buttons.", + path: "/screens/phone-auth-screen-w-oauth", + loadComponent: () => import("./screens/phone-auth-screen-w-oauth").then((m) => m.PhoneAuthScreenWithOAuthComponent), + }, +] as const; + +export const hiddenRoutes: RouteConfig[] = [ + { + name: "MFA Enrollment Screen", + description: "A screen allowing a user to enroll in multi-factor authentication.", + path: "/screens/mfa-enrollment-screen", + loadComponent: () => import("./screens/mfa-enrollment-screen").then((m) => m.MfaEnrollmentScreenComponent), + }, +] as const; diff --git a/examples/angular/src/app/screens/email-link-auth-screen-w-oauth.ts b/examples/angular/src/app/screens/email-link-auth-screen-w-oauth.ts new file mode 100644 index 000000000..8fb5c0263 --- /dev/null +++ b/examples/angular/src/app/screens/email-link-auth-screen-w-oauth.ts @@ -0,0 +1,43 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { EmailLinkAuthScreenComponent, GoogleSignInButtonComponent } from "@invertase/firebaseui-angular"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-email-link-auth-screen-w-oauth", + standalone: true, + imports: [CommonModule, EmailLinkAuthScreenComponent, GoogleSignInButtonComponent], + template: ` + + + + `, + styles: [], +}) +export class EmailLinkAuthScreenWithOAuthComponent { + private router = inject(Router); + + onEmailSent() { + alert("email sent - please check your email"); + } + + onSignIn() { + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/email-link-auth-screen.ts b/examples/angular/src/app/screens/email-link-auth-screen.ts new file mode 100644 index 000000000..3a702d5d5 --- /dev/null +++ b/examples/angular/src/app/screens/email-link-auth-screen.ts @@ -0,0 +1,39 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { EmailLinkAuthScreenComponent } from "@invertase/firebaseui-angular"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-email-link-auth-screen", + standalone: true, + imports: [CommonModule, EmailLinkAuthScreenComponent], + template: ` `, + styles: [], +}) +export class EmailLinkAuthScreenWrapperComponent { + private router = inject(Router); + + onEmailSent() { + alert("email sent - please check your email"); + } + + onSignIn() { + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers.ts b/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers.ts new file mode 100644 index 000000000..e8c09a3ca --- /dev/null +++ b/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers.ts @@ -0,0 +1,49 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router } from "@angular/router"; +import { ForgotPasswordAuthScreenComponent } from "@invertase/firebaseui-angular"; + +@Component({ + selector: "app-forgot-password-auth-screen-w-handlers", + standalone: true, + imports: [CommonModule, ForgotPasswordAuthScreenComponent], + template: ` + + `, + styles: [], +}) +export class ForgotPasswordAuthScreenWithHandlersComponent { + private router = inject(Router); + + goToSignIn() { + this.router.navigate(["/screens/sign-in-auth-screen"]); + } + + goToForgotPassword() { + this.router.navigate(["/screens/forgot-password-auth-screen"]); + } + + goToSignUp() { + this.router.navigate(["/screens/sign-up-auth-screen"]); + } +} diff --git a/examples/angular/src/app/screens/forgot-password-auth-screen.ts b/examples/angular/src/app/screens/forgot-password-auth-screen.ts new file mode 100644 index 000000000..ceb20bc35 --- /dev/null +++ b/examples/angular/src/app/screens/forgot-password-auth-screen.ts @@ -0,0 +1,32 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { ForgotPasswordAuthScreenComponent } from "@invertase/firebaseui-angular"; + +@Component({ + selector: "app-forgot-password-auth-screen", + standalone: true, + imports: [CommonModule, ForgotPasswordAuthScreenComponent], + template: ` `, + styles: [], +}) +export class ForgotPasswordAuthScreenWrapperComponent { + onPasswordSent() { + alert("password reset email sent - please check your email"); + } +} diff --git a/examples/angular/src/app/screens/mfa-enrollment-screen.ts b/examples/angular/src/app/screens/mfa-enrollment-screen.ts new file mode 100644 index 000000000..4e7c7fd8e --- /dev/null +++ b/examples/angular/src/app/screens/mfa-enrollment-screen.ts @@ -0,0 +1,42 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router } from "@angular/router"; +import { MultiFactorAuthEnrollmentScreenComponent } from "@invertase/firebaseui-angular"; +import { FactorId } from "firebase/auth"; + +@Component({ + selector: "app-mfa-enrollment-screen", + standalone: true, + imports: [CommonModule, MultiFactorAuthEnrollmentScreenComponent], + template: ` + + `, + styles: [], +}) +export class MfaEnrollmentScreenComponent { + FactorId = FactorId; + private router = inject(Router); + + onEnrollment() { + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/oauth-screen.ts b/examples/angular/src/app/screens/oauth-screen.ts new file mode 100644 index 000000000..1424a5e70 --- /dev/null +++ b/examples/angular/src/app/screens/oauth-screen.ts @@ -0,0 +1,68 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject, signal } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + OAuthScreenComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, +} from "@invertase/firebaseui-angular"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-oauth-screen", + standalone: true, + imports: [ + CommonModule, + OAuthScreenComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, + ], + template: ` + + + + + + + + +
+ +
+ `, + styles: [], +}) +export class OAuthScreenWrapperComponent { + themed = signal(false); + private router = inject(Router); + + onSignIn() { + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/phone-auth-screen-w-oauth.ts b/examples/angular/src/app/screens/phone-auth-screen-w-oauth.ts new file mode 100644 index 000000000..f5e50ef68 --- /dev/null +++ b/examples/angular/src/app/screens/phone-auth-screen-w-oauth.ts @@ -0,0 +1,41 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { PhoneAuthScreenComponent, GoogleSignInButtonComponent, ContentComponent } from "@invertase/firebaseui-angular"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-phone-auth-screen-w-oauth", + standalone: true, + imports: [CommonModule, PhoneAuthScreenComponent, GoogleSignInButtonComponent, ContentComponent], + template: ` + + + + + + `, + styles: [], +}) +export class PhoneAuthScreenWithOAuthComponent { + private router = inject(Router); + + onSignIn() { + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/phone-auth-screen.ts b/examples/angular/src/app/screens/phone-auth-screen.ts new file mode 100644 index 000000000..e0ff32d21 --- /dev/null +++ b/examples/angular/src/app/screens/phone-auth-screen.ts @@ -0,0 +1,35 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { PhoneAuthScreenComponent } from "@invertase/firebaseui-angular"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-phone-auth-screen", + standalone: true, + imports: [CommonModule, PhoneAuthScreenComponent], + template: ` `, + styles: [], +}) +export class PhoneAuthScreenWrapperComponent { + private router = inject(Router); + + onSignIn() { + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers.ts b/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers.ts new file mode 100644 index 000000000..063c52c62 --- /dev/null +++ b/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers.ts @@ -0,0 +1,45 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router } from "@angular/router"; +import { SignInAuthScreenComponent } from "@invertase/firebaseui-angular"; + +@Component({ + selector: "app-sign-in-auth-screen-w-handlers", + standalone: true, + imports: [CommonModule, SignInAuthScreenComponent], + template: ` + + `, + styles: [], +}) +export class SignInAuthScreenWithHandlersComponent { + private router = inject(Router); + + goToForgotPassword() { + this.router.navigate(["/screens/forgot-password-auth-screen"]); + } + + goToSignUp() { + this.router.navigate(["/screens/sign-up-auth-screen"]); + } + + onSignIn() { + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth.ts b/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth.ts new file mode 100644 index 000000000..56cf346ba --- /dev/null +++ b/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth.ts @@ -0,0 +1,65 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + SignInAuthScreenComponent, + ContentComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, +} from "@invertase/firebaseui-angular"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-sign-in-auth-screen-w-oauth", + standalone: true, + imports: [ + CommonModule, + SignInAuthScreenComponent, + ContentComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, + ], + template: ` + + + + + + + + + + + `, + styles: [], +}) +export class SignInAuthScreenWithOAuthComponent { + private router = inject(Router); + + onSignIn() { + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/sign-in-auth-screen.ts b/examples/angular/src/app/screens/sign-in-auth-screen.ts new file mode 100644 index 000000000..b87cee771 --- /dev/null +++ b/examples/angular/src/app/screens/sign-in-auth-screen.ts @@ -0,0 +1,35 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { SignInAuthScreenComponent } from "@invertase/firebaseui-angular"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-sign-in-auth-screen", + standalone: true, + imports: [CommonModule, SignInAuthScreenComponent], + template: ` `, + styles: [], +}) +export class SignInAuthScreenWrapperComponent { + private router = inject(Router); + + onSignIn() { + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers.ts b/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers.ts new file mode 100644 index 000000000..f3b0b73d9 --- /dev/null +++ b/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers.ts @@ -0,0 +1,39 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router } from "@angular/router"; +import { SignUpAuthScreenComponent } from "@invertase/firebaseui-angular"; + +@Component({ + selector: "app-sign-up-auth-screen-w-handlers", + standalone: true, + imports: [CommonModule, SignUpAuthScreenComponent], + template: ``, + styles: [], +}) +export class SignUpAuthScreenWithHandlersComponent { + private router = inject(Router); + + goToSignIn() { + this.router.navigate(["/screens/sign-in-auth-screen"]); + } + + onSignUp() { + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth.ts b/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth.ts new file mode 100644 index 000000000..450120e07 --- /dev/null +++ b/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth.ts @@ -0,0 +1,65 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + SignUpAuthScreenComponent, + ContentComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, +} from "@invertase/firebaseui-angular"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-sign-up-auth-screen-w-oauth", + standalone: true, + imports: [ + CommonModule, + SignUpAuthScreenComponent, + ContentComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, + ], + template: ` + + + + + + + + + + + `, + styles: [], +}) +export class SignUpAuthScreenWithOAuthComponent { + private router = inject(Router); + + onSignUp() { + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/sign-up-auth-screen.ts b/examples/angular/src/app/screens/sign-up-auth-screen.ts new file mode 100644 index 000000000..d235c6800 --- /dev/null +++ b/examples/angular/src/app/screens/sign-up-auth-screen.ts @@ -0,0 +1,35 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { SignUpAuthScreenComponent } from "@invertase/firebaseui-angular"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-sign-up-auth-screen", + standalone: true, + imports: [CommonModule, SignUpAuthScreenComponent], + template: ` `, + styles: [], +}) +export class SignUpAuthScreenWrapperComponent { + private router = inject(Router); + + onSignUp() { + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/services/user.service.ts b/examples/angular/src/app/services/user.service.ts new file mode 100644 index 000000000..50548a8b1 --- /dev/null +++ b/examples/angular/src/app/services/user.service.ts @@ -0,0 +1,30 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Injectable, inject } from "@angular/core"; +import { Auth, type User, authState } from "@angular/fire/auth"; +import type { Observable } from "rxjs"; + +@Injectable({ + providedIn: "root", +}) +export class UserService { + private auth = inject(Auth); + + getUser(): Observable { + return authState(this.auth); + } +} diff --git a/examples/angular/src/index.html b/examples/angular/src/index.html index 781564eb6..e53fbd415 100644 --- a/examples/angular/src/index.html +++ b/examples/angular/src/index.html @@ -18,12 +18,15 @@ - AngularSsr + Firebase UI for Angular - + + diff --git a/examples/angular/src/main.server.ts b/examples/angular/src/main.server.ts index 0577da242..37980922e 100644 --- a/examples/angular/src/main.server.ts +++ b/examples/angular/src/main.server.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { bootstrapApplication } from '@angular/platform-browser'; -import { AppComponent } from './app/app.component'; -import { config } from './app/app.config.server'; +import { bootstrapApplication, type BootstrapContext } from "@angular/platform-browser"; +import { AppComponent } from "./app/app.component"; +import { config } from "./app/app.config.server"; -const bootstrap = () => bootstrapApplication(AppComponent, config); +const bootstrap = (context: BootstrapContext) => bootstrapApplication(AppComponent, config, context); export default bootstrap; diff --git a/examples/angular/src/main.ts b/examples/angular/src/main.ts index 0e450bde3..c846a5760 100644 --- a/examples/angular/src/main.ts +++ b/examples/angular/src/main.ts @@ -14,9 +14,8 @@ * limitations under the License. */ -import { bootstrapApplication } from '@angular/platform-browser'; -import { appConfig } from './app/app.config'; -import { AppComponent } from './app/app.component'; +import { bootstrapApplication } from "@angular/platform-browser"; +import { appConfig } from "./app/app.config"; +import { AppComponent } from "./app/app.component"; -bootstrapApplication(AppComponent, appConfig) - .catch((err) => console.error(err)); +bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err)); diff --git a/examples/angular/src/server.ts b/examples/angular/src/server.ts index 8ded1a834..0e635d44d 100644 --- a/examples/angular/src/server.ts +++ b/examples/angular/src/server.ts @@ -14,16 +14,16 @@ * limitations under the License. */ -import { APP_BASE_HREF } from '@angular/common'; -import { CommonEngine, isMainModule } from '@angular/ssr/node'; -import express from 'express'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import bootstrap from './main.server'; +import { APP_BASE_HREF } from "@angular/common"; +import { CommonEngine, isMainModule } from "@angular/ssr/node"; +import express from "express"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import bootstrap from "./main.server"; const serverDistFolder = dirname(fileURLToPath(import.meta.url)); -const browserDistFolder = resolve(serverDistFolder, '../browser'); -const indexHtml = join(serverDistFolder, 'index.server.html'); +const browserDistFolder = resolve(serverDistFolder, "../browser"); +const indexHtml = join(serverDistFolder, "index.server.html"); const app = express(); const commonEngine = new CommonEngine(); @@ -44,17 +44,17 @@ const commonEngine = new CommonEngine(); * Serve static files from /browser */ app.get( - '**', + "**", express.static(browserDistFolder, { - maxAge: '1y', - index: 'index.html' - }), + maxAge: "1y", + index: "index.html", + }) ); /** * Handle all other requests by rendering the Angular application. */ -app.get('**', (req, res, next) => { +app.get("**", (req, res, next) => { const { protocol, originalUrl, baseUrl, headers } = req; commonEngine @@ -74,7 +74,7 @@ app.get('**', (req, res, next) => { * The server listens on the port defined by the `PORT` environment variable, or defaults to 4000. */ if (isMainModule(import.meta.url)) { - const port = process.env['PORT'] || 4000; + const port = process.env["PORT"] || 4000; app.listen(port, () => { console.log(`Node Express server listening on http://localhost:${port}`); }); diff --git a/examples/angular/src/styles.css b/examples/angular/src/styles.css index 2929acf14..c0f242db4 100644 --- a/examples/angular/src/styles.css +++ b/examples/angular/src/styles.css @@ -16,4 +16,5 @@ /* You can add global styles to this file, and also import other style files */ @import "tailwindcss"; -@import "@firebase-ui/styles/src/base.css"; \ No newline at end of file +@custom-variant dark (&:where(.dark, .dark *)); +@import "@invertase/firebaseui-styles/tailwind"; diff --git a/examples/angular/src/test-setup.ts b/examples/angular/src/test-setup.ts new file mode 100644 index 000000000..cd4ef8960 --- /dev/null +++ b/examples/angular/src/test-setup.ts @@ -0,0 +1,84 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This file is required by vitest.config.ts and sets up the Angular testing environment + +// Import Zone.js testing utilities first +import "zone.js"; +import "zone.js/testing"; + +// Import Angular testing utilities +import { TestBed } from "@angular/core/testing"; + +// Ensure Zone.js testing environment is properly configured +beforeEach(() => { + // Reset Zone.js state before each test + if (typeof Zone !== "undefined") { + Zone.current.fork({ name: "test-zone" }).run(() => { + // Run each test in a fresh zone + }); + } +}); + +// Import Vitest utilities +import { expect, vi, afterEach, beforeEach } from "vitest"; +import * as matchers from "@testing-library/jest-dom/matchers"; + +// Extend Vitest's expect with jest-dom matchers +expect.extend(matchers); + +// Reset TestBed after each test to prevent configuration conflicts +afterEach(() => { + TestBed.resetTestingModule(); +}); + +// Make Vitest globals available +declare global { + const spyOn: typeof vi.spyOn; + const pending: (reason?: string) => void; +} + +// Define global test utilities +(globalThis as any).spyOn = (obj: any, method: string) => { + const spy = vi.spyOn(obj, method); + // Add Jasmine-compatible methods + (spy as any).and = { + callFake: (fn: (...args: any[]) => any) => { + spy.mockImplementation(fn); + return spy; + }, + returnValue: (value: any) => { + spy.mockReturnValue(value); + return spy; + }, + callThrough: () => { + spy.mockImplementation((...args: any[]) => obj[method](...args)); + return spy; + }, + }; + (spy as any).calls = { + reset: () => spy.mockClear(), + all: () => spy.mock.calls, + count: () => spy.mock.calls.length, + mostRecent: () => spy.mock.calls[spy.mock.calls.length - 1] || { args: [] }, + first: () => spy.mock.calls[0] || { args: [] }, + }; + return spy; +}; +(globalThis as any).pending = (reason?: string) => { + throw new Error(`Test pending: ${reason || "No reason provided"}`); +}; diff --git a/examples/angular/tsconfig.app.json b/examples/angular/tsconfig.app.json index 9ab8527bf..d2329c51e 100644 --- a/examples/angular/tsconfig.app.json +++ b/examples/angular/tsconfig.app.json @@ -4,16 +4,8 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/app", - "types": [ - "node" - ] + "types": ["node"] }, - "files": [ - "src/main.ts", - "src/main.server.ts", - "src/server.ts" - ], - "include": [ - "src/**/*.d.ts" - ] + "files": ["src/main.ts", "src/main.server.ts", "src/server.ts"], + "include": ["src/**/*.d.ts"] } diff --git a/examples/angular/tsconfig.json b/examples/angular/tsconfig.json index 414ea278e..c5850139b 100644 --- a/examples/angular/tsconfig.json +++ b/examples/angular/tsconfig.json @@ -17,7 +17,7 @@ "importHelpers": true, "target": "ES2022", "module": "ES2022", - "baseUrl": ".", + "baseUrl": "." }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, diff --git a/examples/angular/tsconfig.spec.json b/examples/angular/tsconfig.spec.json index abe459758..ce6114106 100644 --- a/examples/angular/tsconfig.spec.json +++ b/examples/angular/tsconfig.spec.json @@ -4,14 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", - "types": [ - "jasmine" - ] + "types": ["vitest/globals"] }, - "include": [ - "src/**/*.spec.ts", - "src/**/*.d.ts", - "projects/**/*.spec.ts", - "projects/**/*.d.ts" - ] -} \ No newline at end of file + "include": ["src/**/*.spec.ts", "src/**/*.d.ts", "projects/**/*.spec.ts", "projects/**/*.d.ts"] +} diff --git a/examples/angular/vitest.config.ts b/examples/angular/vitest.config.ts new file mode 100644 index 000000000..df1c9312a --- /dev/null +++ b/examples/angular/vitest.config.ts @@ -0,0 +1,45 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // Use jsdom environment for Angular component testing + environment: "jsdom", + // Include Angular test files + include: ["src/**/*.{test,spec}.{js,ts}"], + // Exclude build output and node_modules + exclude: ["node_modules/**/*", "dist/**/*"], + // Enable globals for Angular testing utilities + globals: true, + // Use the setup file for Angular testing environment + setupFiles: ["./src/test-setup.ts"], + // Mock modules + mockReset: false, + // Use tsconfig.spec.json for TypeScript + typecheck: { + enabled: true, + tsconfig: "./tsconfig.spec.json", + include: ["src/**/*.{ts}"], + }, + // Increase test timeout for Angular operations + testTimeout: 15000, + // Coverage configuration + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + reportsDirectory: "./coverage", + exclude: ["**/*.spec.ts", "**/*.test.ts"], + }, + // Pool options for better Zone.js compatibility + pool: "forks", + poolOptions: { + forks: { + singleFork: true, + }, + }, + // Better isolation for Angular tests + isolate: true, + // Reset modules between tests + clearMocks: true, + restoreMocks: true, + }, +}); diff --git a/examples/nextjs-ssr/.eslintrc.json b/examples/nextjs-ssr/.eslintrc.json new file mode 100644 index 000000000..c9515dbe1 --- /dev/null +++ b/examples/nextjs-ssr/.eslintrc.json @@ -0,0 +1,9 @@ +{ + "extends": ["next/core-web-vitals"], + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "rules": { + "@typescript-eslint/no-explicit-any": "warn", + "no-console": "off" + } +} diff --git a/examples/nextjs-ssr/.firebaserc b/examples/nextjs-ssr/.firebaserc new file mode 100644 index 000000000..043e32416 --- /dev/null +++ b/examples/nextjs-ssr/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "fir-ui-rework" + } +} diff --git a/examples/nextjs-ssr/.gitignore b/examples/nextjs-ssr/.gitignore new file mode 100644 index 000000000..577f1099d --- /dev/null +++ b/examples/nextjs-ssr/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# firebase +.firebase + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/examples/nextjs-ssr/.prettierrc b/examples/nextjs-ssr/.prettierrc new file mode 100644 index 000000000..37702140f --- /dev/null +++ b/examples/nextjs-ssr/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "endOfLine": "auto" +} diff --git a/examples/nextjs-ssr/README.md b/examples/nextjs-ssr/README.md new file mode 100644 index 000000000..d3ba7a191 --- /dev/null +++ b/examples/nextjs-ssr/README.md @@ -0,0 +1,54 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app) configured for **Server-Side Rendering (SSR)**. + +This example demonstrates how to use Firebase UI with Next.js App Router using server-side rendering. Unlike the static export version (`nextjs`), this version uses Next.js SSR capabilities including: + +- Server Components for initial page rendering +- Server-side authentication state checking using `getCurrentUser()` from `serverApp.ts` +- Server-side redirects using `redirect()` from `next/navigation` + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Differences from Static Export Version + +- **No `output: "export"`** in `next.config.ts` - enables SSR +- **Server Components** - Pages use `async` functions and `getCurrentUser()` from `serverApp.ts` +- **Server-side redirects** - Uses `redirect()` instead of client-side `useRouter().push()` +- **Server-side auth checks** - Authentication state is checked on the server before rendering + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Next.js Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components) - learn about server-side rendering +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy + +This app can be deployed to Firebase Hosting or any Node.js hosting platform that supports Next.js SSR: + +```bash +pnpm run deploy +``` + +For Firebase Hosting, ensure you have configured the hosting site `fir-ui-2025-nextjs-ssr` in your Firebase project. diff --git a/examples/nextjs-ssr/app/authenticated-app.tsx b/examples/nextjs-ssr/app/authenticated-app.tsx new file mode 100644 index 000000000..ed6a7f0cb --- /dev/null +++ b/examples/nextjs-ssr/app/authenticated-app.tsx @@ -0,0 +1,89 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { multiFactor, sendEmailVerification, signOut } from "firebase/auth"; +import { useRouter } from "next/navigation"; +import { useUser } from "@/lib/firebase/hooks"; +import { auth } from "@/lib/firebase/clientApp"; +import { type User } from "firebase/auth"; + +export function AuthenticatedApp({ initialUser }: { initialUser: User | null }) { + const user = useUser(initialUser); + const router = useRouter(); + + if (!user) { + return null; + } + + const mfa = multiFactor(user); + + return ( +
+
+

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

+ {user.email ? ( + <> + {user.emailVerified ? ( +
Email verified
+ ) : ( + + )} + + ) : null} + +
+

Multi-factor Authentication

+ {mfa.enrolledFactors.map((factor) => { + return ( +
+ {factor.factorId} - {factor.displayName} +
+ ); + })} + +
+ +
+
+ ); +} diff --git a/examples/nextjs-ssr/app/favicon.ico b/examples/nextjs-ssr/app/favicon.ico new file mode 100644 index 000000000..718d6fea4 Binary files /dev/null and b/examples/nextjs-ssr/app/favicon.ico differ diff --git a/examples/nextjs-ssr/app/forgot-password/page.tsx b/examples/nextjs-ssr/app/forgot-password/page.tsx new file mode 100644 index 000000000..2bcef1e86 --- /dev/null +++ b/examples/nextjs-ssr/app/forgot-password/page.tsx @@ -0,0 +1,29 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getCurrentUser } from "@/lib/firebase/serverApp"; +import { redirect } from "next/navigation"; +import ForgotPasswordScreen from "./screen"; + +export default async function ForgotPasswordPage() { + const { currentUser } = await getCurrentUser(); + + if (currentUser) { + redirect("/"); + } + + return ; +} diff --git a/examples/nextjs-ssr/app/forgot-password/screen.tsx b/examples/nextjs-ssr/app/forgot-password/screen.tsx new file mode 100644 index 000000000..acda21245 --- /dev/null +++ b/examples/nextjs-ssr/app/forgot-password/screen.tsx @@ -0,0 +1,26 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { ForgotPasswordAuthScreen } from "@invertase/firebaseui-react"; +import { useRouter } from "next/navigation"; + +export default function Screen() { + const router = useRouter(); + + return router.push("/sign-in")} />; +} diff --git a/examples/nextjs-ssr/app/globals.css b/examples/nextjs-ssr/app/globals.css new file mode 100644 index 000000000..1fda7fe64 --- /dev/null +++ b/examples/nextjs-ssr/app/globals.css @@ -0,0 +1,39 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import "tailwindcss"; +@custom-variant dark (&:where(.dark, .dark *)); +@import "@invertase/firebaseui-styles/tailwind"; + +/* Prevent flash by hiding content until theme is loaded */ +html:not(.theme-loaded) body { + visibility: hidden; +} + +html.theme-loaded body { + visibility: visible; +} + +.fui-provider__button[data-provider="oidc.line"][data-themed="true"] { + --line-primary: #07B53B; + --color-primary: var(--line-primary); + --color-primary-hover: --alpha(var(--line-primary) / 85%); + --color-primary-surface: #FFFFFF; + --color-border: var(--line-primary); +} + +/* @import "@invertase/firebaseui-styles/themes/dark.css"; */ +/* @import "@invertase/firebaseui-styles/themes/brutalist.css"; */ diff --git a/examples/nextjs-ssr/app/layout.tsx b/examples/nextjs-ssr/app/layout.tsx new file mode 100644 index 000000000..425dab048 --- /dev/null +++ b/examples/nextjs-ssr/app/layout.tsx @@ -0,0 +1,67 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Metadata } from "next"; +import Script from "next/script"; +import { FirebaseUIProviderHoc } from "@/lib/firebase/ui"; +import { ThemeToggle } from "@/lib/components/theme-toggle"; +import { PirateToggle } from "@/lib/components/pirate-toggle"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Create Next App (SSR)", + description: "Generated by create next app with SSR", +}; + +export default async function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + +
- + diff --git a/examples/react/package.json b/examples/react/package.json index 906986805..43d19dba1 100644 --- a/examples/react/package.json +++ b/examples/react/package.json @@ -7,29 +7,31 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "deploy": "pnpm run build && firebase deploy --only hosting:fir-ui-rework" }, "dependencies": { - "@firebase-ui/react": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-react-0.0.1.tgz", - "@firebase-ui/core": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-core-0.0.1.tgz", - "@firebase-ui/styles": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-styles-0.0.1.tgz", - "@firebase-ui/translations": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-translations-0.0.1.tgz", + "@invertase/firebaseui-react": "workspace:*", + "@invertase/firebaseui-core": "workspace:*", + "@invertase/firebaseui-styles": "workspace:*", + "@invertase/firebaseui-translations": "workspace:*", "firebase": "^11.6.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "catalog:", + "react-dom": "catalog:", "react-router": "^7.5.1" }, "devDependencies": { "@tailwindcss/vite": "^4.1.4", "@eslint/js": "^9.22.0", - "@types/react": "^19.0.10", - "@types/react-dom": "^19.0.4", - "@vitejs/plugin-react": "^4.3.4", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", "eslint": "^9.22.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", - "vite": "^6.3.1", - "tailwindcss": "^4.1.4" + "prettier": "^3.1.1", + "vite": "catalog:", + "tailwindcss": "catalog:" } } diff --git a/examples/react/public/firebase-logo-inverted.png b/examples/react/public/firebase-logo-inverted.png new file mode 100644 index 000000000..b6f4ef80a Binary files /dev/null and b/examples/react/public/firebase-logo-inverted.png differ diff --git a/examples/react/public/firebase-logo.png b/examples/react/public/firebase-logo.png new file mode 100644 index 000000000..1cf731440 Binary files /dev/null and b/examples/react/public/firebase-logo.png differ diff --git a/examples/react/src/App.css b/examples/react/src/App.css deleted file mode 100644 index e1ea07bd5..000000000 --- a/examples/react/src/App.css +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - diff --git a/examples/react/src/App.jsx b/examples/react/src/App.jsx deleted file mode 100644 index 85e3ee641..000000000 --- a/examples/react/src/App.jsx +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NavLink } from "react-router"; -import { useUser } from "../lib/firebase/hooks"; - -function App() { - const user = useUser(); - - return ( -
-

Firebase UI Demo

-
- {user &&
Welcome: {user.email || user.phoneNumber}
} -
-
-

Auth Screens

-
    -
  • - - Sign In Auth Screen - -
  • -
  • - - Sign In Auth Screen with Handlers - -
  • -
  • - - Sign In Auth Screen with OAuth - -
  • -
  • - - Email Link Auth Screen - -
  • -
  • - - Email Link Auth Screen with OAuth - -
  • -
  • - - Phone Auth Screen - -
  • -
  • - - Phone Auth Screen with OAuth - -
  • -
  • - - Sign Up Auth Screen - -
  • -
  • - - Sign Up Auth Screen with OAuth - -
  • -
  • - - OAuth Screen - -
  • -
  • - - Password Reset Screen - -
  • -
-
-
- ); -} - -export default App; diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx new file mode 100644 index 000000000..70433ee0e --- /dev/null +++ b/examples/react/src/App.tsx @@ -0,0 +1,133 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MultiFactorAuthAssertionScreen, useUI } from "@invertase/firebaseui-react"; +import { multiFactor, sendEmailVerification, signOut } from "firebase/auth"; +import { Link, useNavigate } from "react-router"; +import { auth } from "./firebase/firebase"; +import { useUser } from "./firebase/hooks"; +import { routes } from "./routes"; + +function App() { + const user = useUser(); + + if (user) { + return ; + } + + return ; +} + +function UnauthenticatedApp() { + const ui = useUI(); + + // This can trigger if the user is not on a screen already, and gets an MFA challenge - e.g. on One-Tap sign in. + if (ui.multiFactorResolver) { + return ; + } + + return ( +
+
+ Firebase UI + Firebase UI +

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

+
+
+ {routes.map((route) => ( + +
+

{route.name}

+

{route.description}

+
+
+ +
+ + ))} +
+
+ ); +} + +function AuthenticatedApp() { + const user = useUser()!; + const mfa = multiFactor(user); + const navigate = useNavigate(); + + return ( +
+
+

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

+ {user.email ? ( + <> + {user.emailVerified ? ( +
Email verified
+ ) : ( + + )} + + ) : null} + +
+

Multi-factor Authentication

+ {mfa.enrolledFactors.map((factor) => { + return ( +
+ {factor.factorId} - {factor.displayName} +
+ ); + })} + +
+ +
+
+ ); +} + +export default App; diff --git a/examples/react/src/firebase/config.ts b/examples/react/src/firebase/config.ts new file mode 100644 index 000000000..90abb8628 --- /dev/null +++ b/examples/react/src/firebase/config.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const firebaseConfig = { + apiKey: "AIzaSyCvMftIUCD9lUQ3BzIrimfSfBbCUQYZf-I", + authDomain: "fir-ui-rework.firebaseapp.com", + projectId: "fir-ui-rework", + storageBucket: "fir-ui-rework.firebasestorage.app", + messagingSenderId: "200312857118", + appId: "1:200312857118:web:94e3f69b0e0a4a863f040f", +}; diff --git a/examples/react/src/firebase/firebase.ts b/examples/react/src/firebase/firebase.ts new file mode 100644 index 000000000..bf8d592d1 --- /dev/null +++ b/examples/react/src/firebase/firebase.ts @@ -0,0 +1,46 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { countryCodes, initializeUI, oneTapSignIn } from "@invertase/firebaseui-core"; +import { getApps, initializeApp } from "firebase/app"; +import { connectAuthEmulator, getAuth } from "firebase/auth"; + +import { firebaseConfig } from "./config"; + +export const firebaseApp = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0]; + +export const auth = getAuth(firebaseApp); + +export const ui = initializeUI({ + app: firebaseApp, + behaviors: [ + // autoAnonymousLogin(), + oneTapSignIn({ + clientId: "616577669988-led6l3rqek9ckn9t1unj4l8l67070fhp.apps.googleusercontent.com", + }), + countryCodes({ + allowedCountries: ["US", "CA", "GB"], + defaultCountry: "GB", + }), + // providerPopupStrategy(), + ], +}); + +if (import.meta.env.MODE === "development") { + connectAuthEmulator(auth, "http://localhost:9099"); +} diff --git a/examples/react/src/firebase/hooks.ts b/examples/react/src/firebase/hooks.ts new file mode 100644 index 000000000..930bb0ea8 --- /dev/null +++ b/examples/react/src/firebase/hooks.ts @@ -0,0 +1,30 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useState } from "react"; + +import { type User } from "firebase/auth"; +import { auth } from "./firebase"; + +export function useUser() { + const [user, setUser] = useState(auth.currentUser); + + useEffect(() => { + return auth.onAuthStateChanged(setUser); + }, []); + + return user; +} diff --git a/examples/react/src/index.css b/examples/react/src/index.css index fd915417c..9de588ee1 100644 --- a/examples/react/src/index.css +++ b/examples/react/src/index.css @@ -15,7 +15,16 @@ */ @import "tailwindcss"; -@import "@firebase-ui/styles/src/base.css"; +@custom-variant dark (&:where(.dark, .dark *)); +@import "@invertase/firebaseui-styles/tailwind"; -/* @import "@firebase-ui/styles/src/themes/dark.css"; */ -/* @import "@firebase-ui/styles/src/themes/brutalist.css"; */ \ No newline at end of file +.fui-provider__button[data-provider="oidc.line"][data-themed="true"] { + --line-primary: #07B53B; + --color-primary: var(--line-primary); + --color-primary-hover: --alpha(var(--line-primary) / 85%); + --color-primary-surface: #FFFFFF; + --color-border: var(--line-primary); +} + +/* @import "@invertase/firebaseui-styles/src/themes/dark.css"; */ +/* @import "@invertase/firebaseui-styles/src/themes/brutalist.css"; */ diff --git a/examples/react/src/main.jsx b/examples/react/src/main.jsx deleted file mode 100644 index a841f510c..000000000 --- a/examples/react/src/main.jsx +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { BrowserRouter, RouterProvider, Routes, Route } from "react-router"; - -import React from "react"; -import ReactDOM from "react-dom/client"; - -import App from "./App"; -import { Header } from "../lib/components/header"; -import { FirebaseUIProvider } from "../lib/firebase/ui"; - -/** Sign In */ -import SignInAuthScreenPage from "./screens/sign-in-auth-screen"; -import SignInAuthScreenWithHandlersPage from "./screens/sign-in-auth-screen-w-handlers"; -import SignInAuthScreenWithOAuthPage from "./screens/sign-in-auth-screen-w-oauth"; - -/** Email */ -import EmailLinkAuthScreenPage from "./screens/email-link-auth-screen"; -import EmailLinkAuthScreenWithOAuthPage from "./screens/email-link-auth-screen-w-oauth"; - -/** Phone Auth */ -import PhoneAuthScreenPage from "./screens/phone-auth-screen"; -import PhoneAuthScreenWithOAuthPage from "./screens/phone-auth-screen-w-oauth"; - -/** Sign up */ -import SignUpAuthScreenPage from "./screens/sign-up-auth-screen"; -import SignUpAuthScreenWithOAuthPage from "./screens/sign-up-auth-screen"; - -/** oAuth */ -import OAuthScreenPage from "./screens/oauth-screen"; - -/** Password Reset */ -import PasswordResetScreenPage from "./screens/password-reset-screen"; - -const root = document.getElementById("root"); - -ReactDOM.createRoot(root).render( - -
- - - } /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } /> - } - /> - - - -); diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx new file mode 100644 index 000000000..ef5e0494a --- /dev/null +++ b/examples/react/src/main.tsx @@ -0,0 +1,125 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BrowserRouter, Routes, Route, Outlet, NavLink } from "react-router"; + +import ReactDOM from "react-dom/client"; +import { FirebaseUIProvider, useUI } from "@invertase/firebaseui-react"; +import { ui, auth } from "./firebase/firebase"; +import App from "./App"; +import { hiddenRoutes, routes } from "./routes"; +import { enUs } from "@invertase/firebaseui-translations"; +import { pirate } from "./pirate"; + +const root = document.getElementById("root")!; + +const allRoutes = [...routes, ...hiddenRoutes]; + +// Hacky way to ensure we have an auth state before showing the app... +auth.authStateReady().then(() => { + ReactDOM.createRoot(root).render( + + + + + + } /> + }> + {allRoutes.map((route) => ( + } /> + ))} + + + + + ); +}); + +function ScreenRoute() { + return ( +
+ + ← Back to overview + +
+ +
+
+ ); +} + +function ThemeToggle() { + return ( + + ); +} + +function PirateToggle() { + const ui = useUI(); + const isPirate = ui.locale.locale === "pirate"; + + return ( + + ); +} diff --git a/examples/react/src/pirate.ts b/examples/react/src/pirate.ts new file mode 100644 index 000000000..aa92433ce --- /dev/null +++ b/examples/react/src/pirate.ts @@ -0,0 +1,95 @@ +import { registerLocale } from "@invertase/firebaseui-translations"; + +export const pirate = registerLocale("pirate", { + errors: { + userNotFound: "Arrr! No account found with this email address, matey", + wrongPassword: "Arrr! Incorrect password, ye scallywag", + invalidEmail: "Avast! Enter a valid email address, ye bilge rat", + userDisabled: "This account has been marooned, arrr!", + networkRequestFailed: "Can't connect to the server, ye land lubber! Check yer internet connection", + tooManyRequests: "Too many failed attempts, ye scurvy dog! Try again later", + missingVerificationCode: "Enter the verification code, ye scallywag", + emailAlreadyInUse: "An account already exists with this email, arrr!", + invalidCredential: "The credentials ye provided be invalid, matey", + weakPassword: "Ye password ain't long enough! It should be at least 8 characters", + unverifiedEmail: "Verify yer email address to continue, ye scallywag", + operationNotAllowed: "This operation ain't allowed, arrr! Contact support, matey", + invalidPhoneNumber: "The phone number be invalid, ye bilge rat", + missingPhoneNumber: "Provide a phone number, ye scallywag", + quotaExceeded: "SMS quota exceeded, arrr! Try again later, matey", + codeExpired: "The verification code has expired, ye scurvy dog", + captchaCheckFailed: "reCAPTCHA verification failed, arrr! Try again, matey", + missingVerificationId: "Complete the reCAPTCHA verification first, ye scallywag", + missingEmail: "Provide an email address, ye bilge rat", + invalidActionCode: "The password reset link be invalid or has expired, arrr!", + credentialAlreadyInUse: "An account already exists with this email, arrr! Sign in with that account, matey", + requiresRecentLogin: "This operation requires a recent login, ye scallywag! Sign in again", + providerAlreadyLinked: "This phone number be already linked to another account, arrr!", + invalidVerificationCode: "Invalid verification code, ye scurvy dog! Try again", + unknownError: "An unexpected error occurred, arrr!", + popupClosed: "The sign-in popup was closed, ye scallywag! Try again", + accountExistsWithDifferentCredential: + "An account already exists with this email, arrr! Sign in with the original provider, matey", + displayNameRequired: "Provide a display name, ye bilge rat", + secondFactorAlreadyInUse: "This phone number be already enrolled with this account, arrr!", + }, + messages: { + passwordResetEmailSent: "Password reset email sent successfully, arrr!", + signInLinkSent: "Sign-in link sent successfully, matey!", + verificationCodeFirst: "Request a verification code first, ye scallywag", + checkEmailForReset: "Check yer email for password reset instructions, ye bilge rat", + dividerOr: "or", + termsAndPrivacy: "By continuing, ye agree to our {tos} and {privacy}, arrr!", + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process, matey.", + }, + labels: { + emailAddress: "Email Address, ye bilge rat", + password: "Password, ye scallywag", + displayName: "Display Name, ye bilge rat", + forgotPassword: "Forgot Password, ye scallywag?", + signUp: "Sign Up, Matey", + signIn: "Sign In, Matey", + resetPassword: "Reset Password, ye scallywag", + createAccount: "Create Account, ye bilge rat", + backToSignIn: "Back to Sign In, ye scallywag", + signInWithPhone: "Sign in with Phone, ye scallywag", + phoneNumber: "Phone Number, ye bilge rat", + verificationCode: "Verification Code, ye scallywag", + sendCode: "Send Code, ye scallywag", + verifyCode: "Verify Code, ye scallywag", + signInWithGoogle: "Sign in with ye Google Account", + signInWithFacebook: "Sign in with ye Facebook Account", + signInWithApple: "Sign in with ye Apple Account", + signInWithMicrosoft: "Sign in with ye Microsoft Account", + signInWithGitHub: "Sign in with ye GitHub Account", + signInWithTwitter: "Sign in with ye X Account", + signInWithEmailLink: "Sign in with Email Link", + sendSignInLink: "Send Sign-in Link", + termsOfService: "Terms of Service", + privacyPolicy: "Privacy Policy", + resendCode: "Resend ye Code", + sending: "Firing...", + multiFactorEnrollment: "Multi-factor Enrrrrrrollment!", + multiFactorAssertion: "Multi-factor Authentication, arrr!", + mfaTotpVerification: "TOTP Verification, arrr!", + mfaSmsVerification: "SMS Verification, arrr!", + generateQrCode: "Generate ye QR Code", + }, + prompts: { + noAccount: "Don't have an account, ye scallywag?", + haveAccount: "Already have an account, matey?", + enterEmailToReset: "Enter yer email address to reset yer password, ye bilge rat", + signInToAccount: "Sign in to yer account, matey", + smsVerificationPrompt: "Enter the verification code sent to yer phone number, ye scallywag", + enterDetailsToCreate: "Enter yer details to create a new account, ye bilge rat", + enterPhoneNumber: "Enter yer phone number, matey", + enterVerificationCode: "Enter the verification code, ye scallywag", + enterEmailForLink: "Enter yer email to receive a sign-in link, ye bilge rat", + mfaEnrollmentPrompt: "Select a new multi-factor enrollment method, arrr!", + mfaAssertionPrompt: "Complete the multi-factor authentication process, ye scallywag", + mfaAssertionFactorPrompt: "Choose a multi-factor authentication method, matey", + mfaTotpQrCodePrompt: "Scan this QR code with yer authenticator app, ye bilge rat", + mfaTotpEnrollmentVerificationPrompt: "Add the code generated by yer authenticator app, arrr!", + }, +}); diff --git a/examples/react/src/routes.ts b/examples/react/src/routes.ts new file mode 100644 index 000000000..f3b958586 --- /dev/null +++ b/examples/react/src/routes.ts @@ -0,0 +1,97 @@ +import SignInAuthScreenPage from "./screens/sign-in-auth-screen"; +import SignInAuthScreenWithHandlersPage from "./screens/sign-in-auth-screen-w-handlers"; +import SignInAuthScreenWithOAuthPage from "./screens/sign-in-auth-screen-w-oauth"; +import SignUpAuthScreenPage from "./screens/sign-up-auth-screen"; +import SignUpAuthScreenWithHandlersPage from "./screens/sign-up-auth-screen-w-handlers"; +import SignUpAuthScreenWithOAuthPage from "./screens/sign-up-auth-screen-w-oauth"; +import EmailLinkAuthScreenPage from "./screens/email-link-auth-screen"; +import EmailLinkAuthScreenWithOAuthPage from "./screens/email-link-auth-screen-w-oauth"; +import ForgotPasswordAuthScreenPage from "./screens/forgot-password-auth-screen"; +import OAuthScreenPage from "./screens/oauth-screen"; +import PhoneAuthScreenPage from "./screens/phone-auth-screen"; +import PhoneAuthScreenWithOAuthPage from "./screens/phone-auth-screen-w-oauth"; +import MultiFactorAuthEnrollmentScreenPage from "./screens/mfa-enrollment-screen"; + +export const routes = [ + { + name: "Sign In Screen", + description: "A sign in screen with email and password.", + path: "/screens/sign-in-auth-screen", + component: SignInAuthScreenPage, + }, + { + name: "Sign In Screen (with handlers)", + description: "A sign in screen with email and password, with forgot password and register handlers.", + path: "/screens/sign-in-auth-screen-w-handlers", + component: SignInAuthScreenWithHandlersPage, + }, + { + name: "Sign In Screen (with OAuth)", + description: "A sign in screen with email and password, with oAuth buttons.", + path: "/screens/sign-in-auth-screen-w-oauth", + component: SignInAuthScreenWithOAuthPage, + }, + { + name: "Sign Up Screen", + description: "A sign up screen with email and password.", + path: "/screens/sign-up-auth-screen", + component: SignUpAuthScreenPage, + }, + { + name: "Sign Up Screen (with handlers)", + description: "A sign up screen with email and password, sign in handlers.", + path: "/screens/sign-up-auth-screen-w-handlers", + component: SignUpAuthScreenWithHandlersPage, + }, + { + name: "Sign Up Screen (with OAuth)", + description: "A sign in screen with email and password, with oAuth buttons.", + path: "/screens/sign-up-auth-screen-w-oauth", + component: SignUpAuthScreenWithOAuthPage, + }, + { + name: "Email Link Auth Screen", + description: "A screen allowing a user to send an email link for sign in.", + path: "/screens/email-link-auth-screen", + component: EmailLinkAuthScreenPage, + }, + { + name: "Email Link Auth Screen (with OAuth)", + description: "A screen allowing a user to send an email link for sign in, with oAuth buttons.", + path: "/screens/email-link-auth-screen-w-oauth", + component: EmailLinkAuthScreenWithOAuthPage, + }, + { + name: "Forgot Password Screen", + description: "A screen allowing a user to reset their password.", + path: "/screens/forgot-password-auth-screen", + component: ForgotPasswordAuthScreenPage, + }, + { + name: "OAuth Screen", + description: "A screen which allows a user to sign in with OAuth only.", + path: "/screens/oauth-screen", + component: OAuthScreenPage, + }, + { + name: "Phone Auth Screen", + description: "A screen allowing a user to sign in with a phone number.", + path: "/screens/phone-auth-screen", + component: PhoneAuthScreenPage, + }, + { + name: "Phone Auth Screen (with OAuth)", + description: "A screen allowing a user to sign in with a phone number, with oAuth buttons.", + path: "/screens/phone-auth-screen-w-oauth", + component: PhoneAuthScreenWithOAuthPage, + }, +] as const; + +export const hiddenRoutes = [ + { + name: "MFA Enrollment Screen", + description: "A screen allowing a user to enroll in multi-factor authentication.", + path: "/screens/mfa-enrollment-screen", + component: MultiFactorAuthEnrollmentScreenPage, + }, +] as const; 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 b1e15278a..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 @@ -16,12 +16,35 @@ "use client"; -import { EmailLinkAuthScreen, GoogleSignInButton } from "@firebase-ui/react"; +import { + AppleSignInButton, + EmailLinkAuthScreen, + FacebookSignInButton, + GitHubSignInButton, + GoogleSignInButton, + MicrosoftSignInButton, + TwitterSignInButton, +} from "@invertase/firebaseui-react"; +import { useNavigate } from "react-router"; export default function EmailLinkAuthScreenWithOAuthPage() { + const navigate = useNavigate(); + return ( - + { + alert("Email has been sent - please check your email"); + }} + 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 6f5f03912..1a7d7a1cf 100644 --- a/examples/react/src/screens/email-link-auth-screen.tsx +++ b/examples/react/src/screens/email-link-auth-screen.tsx @@ -16,8 +16,20 @@ "use client"; -import { EmailLinkAuthScreen } from "@firebase-ui/react"; +import { EmailLinkAuthScreen } from "@invertase/firebaseui-react"; +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/react/src/screens/forgot-password-auth-screen.tsx b/examples/react/src/screens/forgot-password-auth-screen.tsx new file mode 100644 index 000000000..7488fc46b --- /dev/null +++ b/examples/react/src/screens/forgot-password-auth-screen.tsx @@ -0,0 +1,32 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { ForgotPasswordAuthScreen } from "@invertase/firebaseui-react"; +import { useNavigate } from "react-router"; + +export default function ForgotPasswordAuthScreenPage() { + const navigate = useNavigate(); + + return ( + { + navigate("/screens/sign-in-auth-screen"); + }} + /> + ); +} diff --git a/packages/firebaseui-translations/src/index.ts b/examples/react/src/screens/mfa-enrollment-screen.tsx similarity index 57% rename from packages/firebaseui-translations/src/index.ts rename to examples/react/src/screens/mfa-enrollment-screen.tsx index 1b425fb2e..2eec5af26 100644 --- a/packages/firebaseui-translations/src/index.ts +++ b/examples/react/src/screens/mfa-enrollment-screen.tsx @@ -14,21 +14,21 @@ * limitations under the License. */ -import { enUS } from "./locales/en-us"; -import { Translations } from "./types"; +"use client"; -export type * from "./types"; -export * from "./mapping"; +import { MultiFactorAuthEnrollmentScreen } from "@invertase/firebaseui-react"; +import { FactorId } from "firebase/auth"; +import { useNavigate } from "react-router"; -export type Locale = "en-US" | `${string}-${string}`; +export default function MultiFactorAuthEnrollmentScreenPage() { + const navigate = useNavigate(); -export function customLanguage(locale: Locale, translations: Translations) { - return { - locale, - translations, - }; + return ( + { + navigate("/"); + }} + /> + ); } - -export const english = customLanguage("en-US", enUS); - -export type RegisteredTranslations = ReturnType; diff --git a/examples/react/src/screens/oauth-screen.tsx b/examples/react/src/screens/oauth-screen.tsx index 662ccecd6..b85a1fb30 100644 --- a/examples/react/src/screens/oauth-screen.tsx +++ b/examples/react/src/screens/oauth-screen.tsx @@ -14,14 +14,63 @@ * limitations under the License. */ -"use client"; - -import { GoogleSignInButton, OAuthScreen } from "@firebase-ui/react"; +import { useState } from "react"; +import { OAuthProvider } from "firebase/auth"; +import { + OAuthButton, + FacebookSignInButton, + AppleSignInButton, + GitHubSignInButton, + GoogleSignInButton, + MicrosoftSignInButton, + 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("/"); + }} + > + + + + + + + + +
+ setThemed(!themed)} /> + +
+ + ); +} + +function LineSignInButton({ themed }: { themed?: boolean | string }) { + const provider = new OAuthProvider("oidc.line"); + return ( - - - + + + + + + Sign in with Line + ); } 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 45637cc5c..a8f892732 100644 --- a/examples/react/src/screens/phone-auth-screen-w-oauth.tsx +++ b/examples/react/src/screens/phone-auth-screen-w-oauth.tsx @@ -16,12 +16,32 @@ "use client"; -import { GoogleSignInButton, PhoneAuthScreen } from "@firebase-ui/react"; +import { + FacebookSignInButton, + GitHubSignInButton, + AppleSignInButton, + GoogleSignInButton, + PhoneAuthScreen, + TwitterSignInButton, + MicrosoftSignInButton, +} from "@invertase/firebaseui-react"; +import { useNavigate } from "react-router"; export default function PhoneAuthScreenWithOAuthPage() { + const navigate = useNavigate(); + return ( - + { + navigate("/"); + }} + > + + + + + ); } diff --git a/examples/react/src/screens/phone-auth-screen.tsx b/examples/react/src/screens/phone-auth-screen.tsx index 032a66dff..c244f99f6 100644 --- a/examples/react/src/screens/phone-auth-screen.tsx +++ b/examples/react/src/screens/phone-auth-screen.tsx @@ -16,8 +16,17 @@ "use client"; -import { PhoneAuthScreen } from "@firebase-ui/react"; +import { PhoneAuthScreen } from "@invertase/firebaseui-react"; +import { useNavigate } from "react-router"; export default function PhoneAuthScreenPage() { - return ; + const navigate = useNavigate(); + + return ( + { + 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 a968d8e74..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 @@ -16,13 +16,23 @@ "use client"; -import { SignInAuthScreen } from "@firebase-ui/react"; +import { SignInAuthScreen } from "@invertase/firebaseui-react"; +import { useNavigate } from "react-router"; export default function SignInAuthScreenWithHandlersPage() { + const navigate = useNavigate(); + return ( {}} - onRegisterClick={() => {}} + onForgotPasswordClick={() => { + navigate("/screens/forgot-password-auth-screen"); + }} + 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 81de36e70..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 @@ -14,20 +14,34 @@ * limitations under the License. */ -"use client"; - -import { GoogleSignInButton, SignInAuthScreen } from "@firebase-ui/react"; +import { + AppleSignInButton, + GoogleSignInButton, + SignInAuthScreen, + FacebookSignInButton, + GitHubSignInButton, + MicrosoftSignInButton, + TwitterSignInButton, +} from "@invertase/firebaseui-react"; import { useNavigate } from "react-router"; export default function SignInAuthScreenWithOAuthPage() { - let navigate = useNavigate(); + const navigate = useNavigate(); return ( navigate("/password-reset-screen")} - onRegisterClick={() => navigate("/sign-up-auth-screen")} + 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 01bac68ef..3392f9a1b 100644 --- a/examples/react/src/screens/sign-in-auth-screen.tsx +++ b/examples/react/src/screens/sign-in-auth-screen.tsx @@ -14,10 +14,17 @@ * limitations under the License. */ -"use client"; - -import { SignInAuthScreen } from "@firebase-ui/react"; +import { SignInAuthScreen } from "@invertase/firebaseui-react"; +import { useNavigate } from "react-router"; export default function SignInAuthScreenPage() { - return ; + const navigate = useNavigate(); + + return ( + { + 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 new file mode 100644 index 000000000..2f8d3ddab --- /dev/null +++ b/examples/react/src/screens/sign-up-auth-screen-w-handlers.tsx @@ -0,0 +1,35 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { SignUpAuthScreen } from "@invertase/firebaseui-react"; +import { useNavigate } from "react-router"; + +export default function SignUpAuthScreenWithHandlersPage() { + const navigate = useNavigate(); + + return ( + { + navigate("/screens/sign-in-auth-screen"); + }} + 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 98a803bdf..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 @@ -16,12 +16,32 @@ "use client"; -import { GoogleSignInButton, SignUpAuthScreen } from "@firebase-ui/react"; +import { + FacebookSignInButton, + GitHubSignInButton, + AppleSignInButton, + GoogleSignInButton, + SignUpAuthScreen, + TwitterSignInButton, + MicrosoftSignInButton, +} from "@invertase/firebaseui-react"; +import { useNavigate } from "react-router"; export default function SignUpAuthScreenWithOAuthPage() { + const navigate = useNavigate(); + return ( - + { + navigate("/"); + }} + > + + + + + ); } diff --git a/examples/react/src/screens/sign-up-auth-screen.tsx b/examples/react/src/screens/sign-up-auth-screen.tsx index 0af1806ae..d0a902c79 100644 --- a/examples/react/src/screens/sign-up-auth-screen.tsx +++ b/examples/react/src/screens/sign-up-auth-screen.tsx @@ -16,8 +16,17 @@ "use client"; -import { SignUpAuthScreen } from "@firebase-ui/react"; +import { SignUpAuthScreen } from "@invertase/firebaseui-react"; +import { useNavigate } from "react-router"; export default function SignUpAuthScreenPage() { - return ; + const navigate = useNavigate(); + + return ( + { + navigate("/"); + }} + /> + ); } diff --git a/packages/firebaseui-react/tsconfig.app.json b/examples/react/tsconfig.json similarity index 81% rename from packages/firebaseui-react/tsconfig.app.json rename to examples/react/tsconfig.json index 3a8c44ee1..66949f0f6 100644 --- a/packages/firebaseui-react/tsconfig.app.json +++ b/examples/react/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], @@ -15,6 +14,8 @@ "noEmit": true, "jsx": "react-jsx", + "types": ["vite/client"], + /* Linting */ "strict": true, "noUnusedLocals": true, @@ -24,8 +25,7 @@ "baseUrl": ".", "paths": { "~/*": ["./src/*"], - "@firebase-ui/core": ["../firebaseui-core/src"] } }, - "include": ["src"] + "include": ["src", "vite.config.ts"] } diff --git a/examples/react/vite.config.js b/examples/react/vite.config.ts similarity index 80% rename from examples/react/vite.config.js rename to examples/react/vite.config.ts index b86fbcddc..8276a9f3c 100644 --- a/examples/react/vite.config.js +++ b/examples/react/vite.config.ts @@ -17,7 +17,14 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; export default defineConfig({ plugins: [tailwindcss(), react()], + resolve: { + alias: { + "~": path.resolve(path.dirname(fileURLToPath(import.meta.url)), "./src"), + }, + }, }); diff --git a/examples/shadcn/.firebaserc b/examples/shadcn/.firebaserc new file mode 100644 index 000000000..043e32416 --- /dev/null +++ b/examples/shadcn/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "fir-ui-rework" + } +} diff --git a/packages/firebaseui-react/.gitignore b/examples/shadcn/.gitignore similarity index 100% rename from packages/firebaseui-react/.gitignore rename to examples/shadcn/.gitignore diff --git a/examples/shadcn/README.md b/examples/shadcn/README.md new file mode 100644 index 000000000..30404ce4c --- /dev/null +++ b/examples/shadcn/README.md @@ -0,0 +1 @@ +TODO \ No newline at end of file diff --git a/examples/shadcn/add-all.ts b/examples/shadcn/add-all.ts new file mode 100644 index 000000000..cc9faa12a --- /dev/null +++ b/examples/shadcn/add-all.ts @@ -0,0 +1,41 @@ +import parser from "yargs-parser"; +import readline from "node:readline"; +import registryJson from "../../packages/shadcn/registry-spec.json"; +import { execSync } from "node:child_process"; + +const components = registryJson.items.map((item) => item.name); +const args = parser(process.argv.slice(2)); +const prefix = args.prefix ? String(args.prefix) : "@dev"; + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); + +const items = components + .map((component) => { + return `${prefix}/${component}`; + }) + .join(" "); + +console.log(items); + +rl.question( + `Add ${components.length} components. This will overrwrite all existing files. Continue? (y/N) `, + (answer: unknown) => { + const answerString = String(answer || "n").toLowerCase(); + + if (answerString === "y") { + try { + execSync(`pnpm dlx shadcn@latest add -y -o -a ${items}`, { stdio: "inherit" }); + process.exit(0); + } catch (error) { + console.error(error); + process.exit(1); + } + } + + console.log("Aborting..."); + process.exit(0); + } +); diff --git a/examples/shadcn/components.json b/examples/shadcn/components.json new file mode 100644 index 000000000..58980130d --- /dev/null +++ b/examples/shadcn/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": { + "@dev": "http://localhost:5177/r/{name}.json", + "@firebase": "https://fir-ui-shadcn-registry.web.app/r/{name}.json" + } +} diff --git a/examples/shadcn/index.html b/examples/shadcn/index.html new file mode 100644 index 000000000..039621e43 --- /dev/null +++ b/examples/shadcn/index.html @@ -0,0 +1,17 @@ + + + + + + + shadcn + + + + +
+ + + diff --git a/examples/shadcn/package.json b/examples/shadcn/package.json new file mode 100644 index 000000000..d09596348 --- /dev/null +++ b/examples/shadcn/package.json @@ -0,0 +1,82 @@ +{ + "name": "shadcn", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview", + "deploy": "pnpm run build && firebase deploy --only hosting:fir-ui-shadcn", + "shadcn:add-all": "tsx add-all.ts" + }, + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@invertase/firebaseui-core": "workspace:*", + "@invertase/firebaseui-react": "workspace:*", + "@invertase/firebaseui-styles": "workspace:*", + "@invertase/firebaseui-translations": "workspace:*", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-aspect-ratio": "^1.1.7", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-menubar": "^1.1.16", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "firebase": "catalog:", + "input-otp": "^1.4.2", + "lucide-react": "^0.544.0", + "next-themes": "^0.4.6", + "react": "catalog:", + "react-day-picker": "^9.11.1", + "react-dom": "catalog:", + "react-hook-form": "^7.65.0", + "react-resizable-panels": "^3.0.6", + "react-router": "^7.9.3", + "react-router-dom": "^6.28.0", + "recharts": "2.15.4", + "sonner": "^2.0.7", + "tailwind-merge": "^3.3.1", + "vaul": "^1.1.2", + "zod": "catalog:" + }, + "devDependencies": { + "@tailwindcss/vite": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@types/yargs-parser": "^21.0.3", + "@vitejs/plugin-react": "catalog:", + "tailwindcss": "catalog:", + "tsx": "^4.20.6", + "tw-animate-css": "^1.4.0", + "typescript": "catalog:", + "vite": "catalog:", + "yargs-parser": "^22.0.0" + } +} diff --git a/examples/shadcn/public/firebase-logo-inverted.png b/examples/shadcn/public/firebase-logo-inverted.png new file mode 100644 index 000000000..b6f4ef80a Binary files /dev/null and b/examples/shadcn/public/firebase-logo-inverted.png differ diff --git a/examples/shadcn/public/firebase-logo.png b/examples/shadcn/public/firebase-logo.png new file mode 100644 index 000000000..1cf731440 Binary files /dev/null and b/examples/shadcn/public/firebase-logo.png differ diff --git a/examples/react/public/vite.svg b/examples/shadcn/public/vite.svg similarity index 100% rename from examples/react/public/vite.svg rename to examples/shadcn/public/vite.svg diff --git a/examples/shadcn/src/App.tsx b/examples/shadcn/src/App.tsx new file mode 100644 index 000000000..46aa27dfc --- /dev/null +++ b/examples/shadcn/src/App.tsx @@ -0,0 +1,169 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Link, useNavigate } from "react-router"; +import { routes } from "./routes"; +import { useUser } from "./firebase/hooks"; +import { auth } from "./firebase/firebase"; +import { multiFactor, sendEmailVerification, signOut } from "firebase/auth"; + +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemFooter, + ItemGroup, + ItemMedia, + ItemSeparator, + ItemTitle, +} from "@/components/ui/item"; +import { Button } from "./components/ui/button"; +import { ArrowRightIcon, LockIcon, UserIcon } from "lucide-react"; +import React from "react"; + +function App() { + const user = useUser(); + + if (user) { + return ; + } + + return ; +} + +function UnauthenticatedApp() { + return ( +
+
+ Firebase UI + Firebase UI +

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

+
+ + {routes.map((route) => ( + + + + {route.name} + {route.description} + + + + + + + + + + ))} + +
+ ); +} + +function AuthenticatedApp() { + const user = useUser()!; + console.log(user); + const mfa = multiFactor(user); + const navigate = useNavigate(); + + return ( +
+ + + + + + + Welcome, {user.displayName || user.email || user.phoneNumber} + New login detected from unknown device. + + + + + {user.email ? ( + + {user.emailVerified ? ( + + Your email is verified. + + ) : ( + <> + Your email is not verified. + + + + + )} + + ) : null} + + + + + + + + Multi-factor Authentication + + Any multi-factor authentication factors you have enrolled will be listed here. + + + + + + {mfa.enrolledFactors.length > 0 && ( + + {mfa.enrolledFactors.map((factor) => { + return ( +
+ {factor.factorId} - {factor.displayName} +
+ ); + })} +
+ )} +
+
+
+ ); +} + +export default App; diff --git a/examples/react/src/assets/react.svg b/examples/shadcn/src/assets/react.svg similarity index 100% rename from examples/react/src/assets/react.svg rename to examples/shadcn/src/assets/react.svg diff --git a/examples/shadcn/src/components/apple-sign-in-button.tsx b/examples/shadcn/src/components/apple-sign-in-button.tsx new file mode 100644 index 000000000..ad310636c --- /dev/null +++ b/examples/shadcn/src/components/apple-sign-in-button.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { OAuthProvider } from "firebase/auth"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type AppleSignInButtonProps, AppleLogo } from "@invertase/firebaseui-react"; + +import { OAuthButton } from "@/components/oauth-button"; + +export type { AppleSignInButtonProps }; + +export function AppleSignInButton({ provider, themed }: AppleSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithApple")} + + ); +} diff --git a/examples/shadcn/src/components/country-selector.tsx b/examples/shadcn/src/components/country-selector.tsx new file mode 100644 index 000000000..371c48407 --- /dev/null +++ b/examples/shadcn/src/components/country-selector.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { forwardRef, useCallback, useImperativeHandle, useState } from "react"; +import type { CountryCode, CountryData } from "@invertase/firebaseui-core"; +import { + type CountrySelectorRef, + type CountrySelectorProps, + useCountries, + useDefaultCountry, +} from "@invertase/firebaseui-react"; + +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +export type { CountrySelectorRef }; + +export const CountrySelector = forwardRef((_props, ref) => { + const countries = useCountries(); + const defaultCountry = useDefaultCountry(); + const [selected, setSelected] = useState(defaultCountry); + + const setCountry = useCallback( + (code: CountryCode) => { + const foundCountry = countries.find((country) => country.code === code); + setSelected(foundCountry!); + }, + [countries] + ); + + useImperativeHandle( + ref, + () => ({ + getCountry: () => selected, + setCountry, + }), + [selected, setCountry] + ); + + return ( + + ); +}); + +CountrySelector.displayName = "CountrySelector"; diff --git a/examples/shadcn/src/components/email-link-auth-form.tsx b/examples/shadcn/src/components/email-link-auth-form.tsx new file mode 100644 index 000000000..eea16df10 --- /dev/null +++ b/examples/shadcn/src/components/email-link-auth-form.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import type { EmailLinkAuthFormSchema } from "@invertase/firebaseui-core"; +import { FirebaseUIError, getTranslation } from "@invertase/firebaseui-core"; +import { + useEmailLinkAuthFormAction, + useEmailLinkAuthFormCompleteSignIn, + useEmailLinkAuthFormSchema, + useUI, + type EmailLinkAuthFormProps, +} from "@invertase/firebaseui-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +import { Policies } from "@/components/policies"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +export type { EmailLinkAuthFormProps }; + +export function EmailLinkAuthForm(props: EmailLinkAuthFormProps) { + const { onEmailSent, onSignIn } = props; + const ui = useUI(); + const schema = useEmailLinkAuthFormSchema(); + const action = useEmailLinkAuthFormAction(); + const [emailSent, setEmailSent] = useState(false); + + const form = useForm({ + resolver: standardSchemaResolver(schema), + defaultValues: { + email: "", + }, + }); + + useEmailLinkAuthFormCompleteSignIn(onSignIn); + + async function onSubmit(values: EmailLinkAuthFormSchema) { + try { + await action(values); + setEmailSent(true); + onEmailSent?.(); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + } + + if (emailSent) { + return ( + + {getTranslation(ui, "messages", "signInLinkSent")} + + ); + } + + return ( +
+ + ( + + {getTranslation(ui, "labels", "emailAddress")} + + + + + + )} + /> + + + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} diff --git a/examples/shadcn/src/components/email-link-auth-screen.tsx b/examples/shadcn/src/components/email-link-auth-screen.tsx new file mode 100644 index 000000000..171a55de7 --- /dev/null +++ b/examples/shadcn/src/components/email-link-auth-screen.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +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"; +import { EmailLinkAuthForm } from "@/components/email-link-auth-form"; +import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen"; +import { RedirectError } from "@/components/redirect-error"; + +export type { EmailLinkAuthScreenProps }; + +export function EmailLinkAuthScreen({ children, onSignIn, ...props }: EmailLinkAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signIn"); + const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; + } + + return ( +
+ + + {titleText} + {subtitleText} + + + + {children ? ( + <> + +
+ {children} + +
+ + ) : null} +
+
+
+ ); +} diff --git a/examples/shadcn/src/components/facebook-sign-in-button.tsx b/examples/shadcn/src/components/facebook-sign-in-button.tsx new file mode 100644 index 000000000..3bd0bc52c --- /dev/null +++ b/examples/shadcn/src/components/facebook-sign-in-button.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { FacebookAuthProvider } from "firebase/auth"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type FacebookSignInButtonProps, FacebookLogo } from "@invertase/firebaseui-react"; + +import { OAuthButton } from "@/components/oauth-button"; + +export type { FacebookSignInButtonProps }; + +export function FacebookSignInButton({ provider, themed }: FacebookSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithFacebook")} + + ); +} diff --git a/examples/shadcn/src/components/forgot-password-auth-form.tsx b/examples/shadcn/src/components/forgot-password-auth-form.tsx new file mode 100644 index 000000000..28c721b68 --- /dev/null +++ b/examples/shadcn/src/components/forgot-password-auth-form.tsx @@ -0,0 +1,83 @@ +"use client"; + +import type { ForgotPasswordAuthFormSchema } from "@invertase/firebaseui-core"; +import { + useForgotPasswordAuthFormAction, + useForgotPasswordAuthFormSchema, + useUI, + type ForgotPasswordAuthFormProps, +} from "@invertase/firebaseui-react"; +import { useForm } from "react-hook-form"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import { FirebaseUIError, getTranslation } from "@invertase/firebaseui-core"; +import { useState } from "react"; + +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Policies } from "./policies"; + +export type { ForgotPasswordAuthFormProps }; + +export function ForgotPasswordAuthForm(props: ForgotPasswordAuthFormProps) { + const ui = useUI(); + const schema = useForgotPasswordAuthFormSchema(); + const action = useForgotPasswordAuthFormAction(); + const [emailSent, setEmailSent] = useState(false); + + const form = useForm({ + resolver: standardSchemaResolver(schema), + defaultValues: { + email: "", + }, + }); + + async function onSubmit(values: ForgotPasswordAuthFormSchema) { + try { + await action(values); + setEmailSent(true); + props.onPasswordSent?.(); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + } + + if (emailSent) { + return ( +
+
{getTranslation(ui, "messages", "checkEmailForReset")}
+
+ ); + } + + return ( +
+ + ( + + {getTranslation(ui, "labels", "emailAddress")} + + + + + + )} + /> + + + {form.formState.errors.root && {form.formState.errors.root.message}} + {props.onBackToSignInClick ? ( + + ) : null} + + + ); +} diff --git a/examples/shadcn/src/components/forgot-password-auth-screen.tsx b/examples/shadcn/src/components/forgot-password-auth-screen.tsx new file mode 100644 index 000000000..98991d51d --- /dev/null +++ b/examples/shadcn/src/components/forgot-password-auth-screen.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type ForgotPasswordAuthScreenProps } from "@invertase/firebaseui-react"; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ForgotPasswordAuthForm } from "@/components/forgot-password-auth-form"; + +export type { ForgotPasswordAuthScreenProps }; + +export function ForgotPasswordAuthScreen(props: ForgotPasswordAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "resetPassword"); + const subtitleText = getTranslation(ui, "prompts", "enterEmailToReset"); + + return ( +
+ + + {titleText} + {subtitleText} + + + + + +
+ ); +} diff --git a/examples/shadcn/src/components/github-sign-in-button.tsx b/examples/shadcn/src/components/github-sign-in-button.tsx new file mode 100644 index 000000000..a2b92a65b --- /dev/null +++ b/examples/shadcn/src/components/github-sign-in-button.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { GithubAuthProvider } from "firebase/auth"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type GitHubSignInButtonProps, GitHubLogo } from "@invertase/firebaseui-react"; + +import { OAuthButton } from "@/components/oauth-button"; + +export type { GitHubSignInButtonProps }; + +export function GitHubSignInButton({ provider, themed }: GitHubSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithGitHub")} + + ); +} diff --git a/examples/shadcn/src/components/google-sign-in-button.tsx b/examples/shadcn/src/components/google-sign-in-button.tsx new file mode 100644 index 000000000..4d0796c70 --- /dev/null +++ b/examples/shadcn/src/components/google-sign-in-button.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { GoogleAuthProvider } from "firebase/auth"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type GoogleSignInButtonProps, GoogleLogo } from "@invertase/firebaseui-react"; + +import { OAuthButton } from "@/components/oauth-button"; + +export type { GoogleSignInButtonProps }; + +export function GoogleSignInButton({ provider, themed }: GoogleSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithGoogle")} + + ); +} diff --git a/examples/react/lib/components/header.tsx b/examples/shadcn/src/components/header.tsx similarity index 73% rename from examples/react/lib/components/header.tsx rename to examples/shadcn/src/components/header.tsx index 0f69d015f..5f231c301 100644 --- a/examples/react/lib/components/header.tsx +++ b/examples/shadcn/src/components/header.tsx @@ -14,19 +14,20 @@ * limitations under the License. */ -'use client'; +"use client"; import { NavLink } from "react-router"; import { useUser } from "../firebase/hooks"; -import { signOut, type User } from "firebase/auth"; -import { auth } from "../firebase/clientApp"; +import { signOut } from "firebase/auth"; +import { auth } from "../firebase/firebase"; export function Header() { const user = useUser(); async function onSignOut() { await signOut(auth); - router.push("/sign-in"); + // TODO: Use the router instead of window.location.href + window.location.href = "/"; } return ( @@ -37,10 +38,18 @@ export function Header() {
    - {user ?
  • :
  • Sign In
  • } + {user ? ( +
  • + +
  • + ) : ( +
  • + Sign In +
  • + )}
); -} \ No newline at end of file +} diff --git a/examples/shadcn/src/components/microsoft-sign-in-button.tsx b/examples/shadcn/src/components/microsoft-sign-in-button.tsx new file mode 100644 index 000000000..f5288b6a1 --- /dev/null +++ b/examples/shadcn/src/components/microsoft-sign-in-button.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { OAuthProvider } from "firebase/auth"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type MicrosoftSignInButtonProps, MicrosoftLogo } from "@invertase/firebaseui-react"; + +import { OAuthButton } from "@/components/oauth-button"; + +export type { MicrosoftSignInButtonProps }; + +export function MicrosoftSignInButton({ provider, themed }: MicrosoftSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithMicrosoft")} + + ); +} diff --git a/examples/shadcn/src/components/multi-factor-auth-assertion-form.tsx b/examples/shadcn/src/components/multi-factor-auth-assertion-form.tsx new file mode 100644 index 000000000..4ad2eeeb8 --- /dev/null +++ b/examples/shadcn/src/components/multi-factor-auth-assertion-form.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI } from "@invertase/firebaseui-react"; +import { + PhoneMultiFactorGenerator, + TotpMultiFactorGenerator, + type MultiFactorInfo, + type UserCredential, +} from "firebase/auth"; +import { useState, type ComponentProps } from "react"; +import { useMultiFactorAssertionCleanup } from "@invertase/firebaseui-react"; + +import { SmsMultiFactorAssertionForm } from "@/components/sms-multi-factor-assertion-form"; +import { TotpMultiFactorAssertionForm } from "@/components/totp-multi-factor-assertion-form"; +import { Button } from "@/components/ui/button"; + +export type MultiFactorAuthAssertionFormProps = { + onSuccess?: (credential: UserCredential) => void; +}; + +export function MultiFactorAuthAssertionForm({ onSuccess }: MultiFactorAuthAssertionFormProps) { + const ui = useUI(); + const resolver = ui.multiFactorResolver; + const mfaAssertionFactorPrompt = getTranslation(ui, "prompts", "mfaAssertionFactorPrompt"); + + useMultiFactorAssertionCleanup(); + + if (!resolver) { + throw new Error("MultiFactorAuthAssertionForm requires a multi-factor resolver"); + } + + // If only a single hint is provided, select it by default to improve UX. + const [hint, setHint] = useState( + resolver.hints.length === 1 ? resolver.hints[0] : undefined + ); + + if (hint) { + if (hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID) { + return ; + } + + if (hint.factorId === TotpMultiFactorGenerator.FACTOR_ID) { + return ; + } + } + + return ( +
+

{mfaAssertionFactorPrompt}

+ {resolver.hints.map((hint) => { + if (hint.factorId === TotpMultiFactorGenerator.FACTOR_ID) { + return setHint(hint)} />; + } + + if (hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID) { + return setHint(hint)} />; + } + + return null; + })} +
+ ); +} + +function TotpButton(props: ComponentProps) { + const ui = useUI(); + const labelText = getTranslation(ui, "labels", "mfaTotpVerification"); + return ; +} + +function SmsButton(props: ComponentProps) { + const ui = useUI(); + const labelText = getTranslation(ui, "labels", "mfaSmsVerification"); + return ; +} diff --git a/examples/shadcn/src/components/multi-factor-auth-assertion-screen.tsx b/examples/shadcn/src/components/multi-factor-auth-assertion-screen.tsx new file mode 100644 index 000000000..17a1dda7d --- /dev/null +++ b/examples/shadcn/src/components/multi-factor-auth-assertion-screen.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type MultiFactorAuthAssertionScreenProps } from "@invertase/firebaseui-react"; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { MultiFactorAuthAssertionForm } from "@/components/multi-factor-auth-assertion-form"; + +export type MultiFactorAuthEnrollmentScreenProps = MultiFactorAuthAssertionScreenProps; + +export function MultiFactorAuthAssertionScreen(props: MultiFactorAuthEnrollmentScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "multiFactorAssertion"); + const subtitleText = getTranslation(ui, "prompts", "mfaAssertionPrompt"); + + return ( +
+ + + {titleText} + {subtitleText} + + + + + +
+ ); +} diff --git a/examples/shadcn/src/components/multi-factor-auth-enrollment-form.tsx b/examples/shadcn/src/components/multi-factor-auth-enrollment-form.tsx new file mode 100644 index 000000000..5935c159e --- /dev/null +++ b/examples/shadcn/src/components/multi-factor-auth-enrollment-form.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { type ComponentProps, useState } from "react"; +import { FactorId } from "firebase/auth"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI } from "@invertase/firebaseui-react"; + +import { SmsMultiFactorEnrollmentForm } from "@/components/sms-multi-factor-enrollment-form"; +import { TotpMultiFactorEnrollmentForm } from "@/components/totp-multi-factor-enrollment-form"; +import { Button } from "@/components/ui/button"; + +type Hint = (typeof FactorId)[keyof typeof FactorId]; + +export type MultiFactorAuthEnrollmentFormProps = { + onEnrollment?: () => void; + hints?: Hint[]; +}; + +const DEFAULT_HINTS = [FactorId.TOTP, FactorId.PHONE] as const; + +export function MultiFactorAuthEnrollmentForm(props: MultiFactorAuthEnrollmentFormProps) { + const hints = props.hints ?? DEFAULT_HINTS; + + if (hints.length === 0) { + throw new Error("MultiFactorAuthEnrollmentForm must have at least one hint"); + } + + // If only a single hint is provided, select it by default to improve UX. + const [hint, setHint] = useState(hints.length === 1 ? hints[0] : undefined); + + if (hint) { + if (hint === FactorId.TOTP) { + return ; + } + + if (hint === FactorId.PHONE) { + return ; + } + + throw new Error(`Unknown multi-factor enrollment type: ${hint}`); + } + + return ( +
+ {hints.map((hint) => { + if (hint === FactorId.TOTP) { + return setHint(hint)} />; + } + + if (hint === FactorId.PHONE) { + return setHint(hint)} />; + } + + return null; + })} +
+ ); +} + +function TotpButton(props: ComponentProps) { + const ui = useUI(); + const labelText = getTranslation(ui, "labels", "mfaTotpVerification"); + return ( + + ); +} + +function SmsButton(props: ComponentProps) { + const ui = useUI(); + const labelText = getTranslation(ui, "labels", "mfaSmsVerification"); + return ( + + ); +} diff --git a/examples/shadcn/src/components/multi-factor-auth-enrollment-screen.tsx b/examples/shadcn/src/components/multi-factor-auth-enrollment-screen.tsx new file mode 100644 index 000000000..c226b87ea --- /dev/null +++ b/examples/shadcn/src/components/multi-factor-auth-enrollment-screen.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type MultiFactorAuthEnrollmentFormProps } from "@invertase/firebaseui-react"; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { MultiFactorAuthEnrollmentForm } from "@/components/multi-factor-auth-enrollment-form"; + +export type MultiFactorAuthEnrollmentScreenProps = MultiFactorAuthEnrollmentFormProps; + +export function MultiFactorAuthEnrollmentScreen(props: MultiFactorAuthEnrollmentScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "multiFactorEnrollment"); + const subtitleText = getTranslation(ui, "prompts", "mfaEnrollmentPrompt"); + + return ( +
+ + + {titleText} + {subtitleText} + + + + + +
+ ); +} diff --git a/examples/shadcn/src/components/oauth-button.tsx b/examples/shadcn/src/components/oauth-button.tsx new file mode 100644 index 000000000..3707c2012 --- /dev/null +++ b/examples/shadcn/src/components/oauth-button.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useUI, type OAuthButtonProps, useSignInWithProvider } from "@invertase/firebaseui-react"; +import { Button } from "@/components/ui/button"; + +export type { OAuthButtonProps }; + +export function OAuthButton({ provider, children, themed }: OAuthButtonProps) { + const ui = useUI(); + + const { error, callback } = useSignInWithProvider(provider); + + return ( +
+ + {error &&
{error}
} +
+ ); +} diff --git a/examples/shadcn/src/components/oauth-screen.tsx b/examples/shadcn/src/components/oauth-screen.tsx new file mode 100644 index 000000000..a586527d2 --- /dev/null +++ b/examples/shadcn/src/components/oauth-screen.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { type User } from "firebase/auth"; +import { type PropsWithChildren } from "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?: (user: User) => void; +}>; + +export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signIn"); + const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; + } + + return ( +
+ + + {titleText} + {subtitleText} + + +
{children}
+
+ + +
+
+
+
+ ); +} diff --git a/examples/shadcn/src/components/phone-auth-form.tsx b/examples/shadcn/src/components/phone-auth-form.tsx new file mode 100644 index 000000000..c1a4dd1d1 --- /dev/null +++ b/examples/shadcn/src/components/phone-auth-form.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { + type PhoneAuthFormProps, + usePhoneAuthNumberFormSchema, + usePhoneAuthVerifyFormSchema, + usePhoneNumberFormAction, + useRecaptchaVerifier, + useUI, + useVerifyPhoneNumberFormAction, +} from "@invertase/firebaseui-react"; +import { useState } from "react"; +import type { UserCredential } from "firebase/auth"; +import { useRef } from "react"; +import { useForm } from "react-hook-form"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import { + FirebaseUIError, + formatPhoneNumber, + getTranslation, + type PhoneAuthNumberFormSchema, + type PhoneAuthVerifyFormSchema, +} from "@invertase/firebaseui-core"; + +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Policies } from "@/components/policies"; +import { CountrySelector, type CountrySelectorRef } from "@/components/country-selector"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; + +type VerifyPhoneNumberFormProps = { + verificationId: string; + onSuccess: (credential: UserCredential) => void; +}; + +function VerifyPhoneNumberForm(props: VerifyPhoneNumberFormProps) { + const ui = useUI(); + const schema = usePhoneAuthVerifyFormSchema(); + const action = useVerifyPhoneNumberFormAction(); + + const form = useForm({ + resolver: standardSchemaResolver(schema), + defaultValues: { + verificationId: props.verificationId, + verificationCode: "", + }, + }); + + async function onSubmit(values: PhoneAuthVerifyFormSchema) { + try { + const credential = await action(values); + props.onSuccess(credential); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + } + + return ( +
+ + ( + + {getTranslation(ui, "labels", "verificationCode")} + {getTranslation(ui, "prompts", "smsVerificationPrompt")} + + + + + + + + + + + + + + + )} + /> + + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} + +type PhoneNumberFormProps = { + onSubmit: (verificationId: string) => void; +}; + +function PhoneNumberForm(props: PhoneNumberFormProps) { + const ui = useUI(); + const recaptchaContainerRef = useRef(null); + const recaptchaVerifier = useRecaptchaVerifier(recaptchaContainerRef); + const countrySelector = useRef(null); + const action = usePhoneNumberFormAction(); + const schema = usePhoneAuthNumberFormSchema(); + + const form = useForm({ + resolver: standardSchemaResolver(schema), + defaultValues: { + phoneNumber: "", + }, + }); + + async function onSubmit(values: PhoneAuthNumberFormSchema) { + try { + const formatted = formatPhoneNumber(values.phoneNumber, countrySelector.current!.getCountry()); + const verificationId = await action({ phoneNumber: formatted, recaptchaVerifier: recaptchaVerifier! }); + props.onSubmit(verificationId); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + } + + return ( +
+ + ( + + {getTranslation(ui, "labels", "phoneNumber")} + +
+ + +
+
+ +
+ )} + /> +
+ + + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} + +export type { PhoneAuthFormProps }; + +export function PhoneAuthForm(props: PhoneAuthFormProps) { + const [verificationId, setVerificationId] = useState(null); + + if (!verificationId) { + return ; + } + + return ( + { + props.onSignIn?.(credential); + }} + /> + ); +} diff --git a/examples/shadcn/src/components/phone-auth-screen.tsx b/examples/shadcn/src/components/phone-auth-screen.tsx new file mode 100644 index 000000000..7908c58c8 --- /dev/null +++ b/examples/shadcn/src/components/phone-auth-screen.tsx @@ -0,0 +1,51 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { getTranslation } from "@invertase/firebaseui-core"; +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 } 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<{ + onSignIn?: (user: User) => void; +}>; + +export function PhoneAuthScreen({ children, onSignIn }: PhoneAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signIn"); + const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; + } + + return ( +
+ + + {titleText} + {subtitleText} + + + + {children ? ( + <> + +
+ {children} + +
+ + ) : null} +
+
+
+ ); +} diff --git a/examples/shadcn/src/components/policies.tsx b/examples/shadcn/src/components/policies.tsx new file mode 100644 index 000000000..b0cfef637 --- /dev/null +++ b/examples/shadcn/src/components/policies.tsx @@ -0,0 +1,50 @@ +import { cn } from "@/lib/utils"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, PolicyContext } from "@invertase/firebaseui-react"; +import { cloneElement, useContext } from "react"; + +export function Policies() { + const ui = useUI(); + const policies = useContext(PolicyContext); + + if (!policies) { + return null; + } + + const { termsOfServiceUrl, privacyPolicyUrl, onNavigate } = policies; + const termsAndPrivacyText = getTranslation(ui, "messages", "termsAndPrivacy"); + const parts = termsAndPrivacyText.split(/(\{tos\}|\{privacy\})/); + + const className = cn("hover:underline font-semibold"); + const Handler = onNavigate ? ( + + ) : null} + + + + + + + )} + /> + + + {form.formState.errors.root && {form.formState.errors.root.message}} + {props.onSignUpClick ? ( + <> + + + ) : null} + + + ); +} diff --git a/examples/shadcn/src/components/sign-in-auth-screen.tsx b/examples/shadcn/src/components/sign-in-auth-screen.tsx new file mode 100644 index 000000000..3397fac0d --- /dev/null +++ b/examples/shadcn/src/components/sign-in-auth-screen.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +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"; +import { SignInAuthForm } from "@/components/sign-in-auth-form"; +import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen"; + +export type { SignInAuthScreenProps }; + +export function SignInAuthScreen({ children, onSignIn, ...props }: SignInAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signIn"); + const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; + } + + return ( +
+ + + {titleText} + {subtitleText} + + + + {children ? ( + <> + +
{children}
+ + ) : null} +
+
+
+ ); +} diff --git a/examples/shadcn/src/components/sign-up-auth-form.tsx b/examples/shadcn/src/components/sign-up-auth-form.tsx new file mode 100644 index 000000000..565ca3430 --- /dev/null +++ b/examples/shadcn/src/components/sign-up-auth-form.tsx @@ -0,0 +1,106 @@ +"use client"; + +import type { SignUpAuthFormSchema } from "@invertase/firebaseui-core"; +import { + useSignUpAuthFormAction, + useSignUpAuthFormSchema, + useUI, + type SignUpAuthFormProps, + useRequireDisplayName, +} from "@invertase/firebaseui-react"; +import { useForm } from "react-hook-form"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import { FirebaseUIError, getTranslation } from "@invertase/firebaseui-core"; + +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Policies } from "./policies"; + +export type { SignUpAuthFormProps }; + +export function SignUpAuthForm(props: SignUpAuthFormProps) { + const ui = useUI(); + const schema = useSignUpAuthFormSchema(); + const action = useSignUpAuthFormAction(); + const requireDisplayName = useRequireDisplayName(); + + const form = useForm({ + resolver: standardSchemaResolver(schema), + defaultValues: { + email: "", + password: "", + displayName: requireDisplayName ? "" : undefined, + }, + }); + + async function onSubmit(values: SignUpAuthFormSchema) { + try { + const credential = await action(values); + props.onSignUp?.(credential); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + } + + return ( +
+ + {requireDisplayName ? ( + ( + + {getTranslation(ui, "labels", "displayName")} + + + + + + )} + /> + ) : null} + ( + + {getTranslation(ui, "labels", "emailAddress")} + + + + + + )} + /> + ( + + {getTranslation(ui, "labels", "password")} + + + + + + )} + /> + + + {form.formState.errors.root && {form.formState.errors.root.message}} + {props.onSignInClick ? ( + + ) : null} + + + ); +} diff --git a/examples/shadcn/src/components/sign-up-auth-screen.tsx b/examples/shadcn/src/components/sign-up-auth-screen.tsx new file mode 100644 index 000000000..f358a163e --- /dev/null +++ b/examples/shadcn/src/components/sign-up-auth-screen.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +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"; +import { SignUpAuthForm } from "@/components/sign-up-auth-form"; +import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen"; + +export type { SignUpAuthScreenProps }; + +export function SignUpAuthScreen({ children, onSignUp, ...props }: SignUpAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signUp"); + const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate"); + + useOnUserAuthenticated(onSignUp); + + if (ui.multiFactorResolver) { + return ; + } + + return ( +
+ + + {titleText} + {subtitleText} + + + + {children ? ( + <> + +
{children}
+ + ) : null} +
+
+
+ ); +} diff --git a/examples/shadcn/src/components/sms-multi-factor-assertion-form.tsx b/examples/shadcn/src/components/sms-multi-factor-assertion-form.tsx new file mode 100644 index 000000000..7f6c424b0 --- /dev/null +++ b/examples/shadcn/src/components/sms-multi-factor-assertion-form.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useRef, useState } from "react"; +import { type UserCredential, type MultiFactorInfo } from "firebase/auth"; + +import { FirebaseUIError, getTranslation } from "@invertase/firebaseui-core"; +import { + useMultiFactorPhoneAuthVerifyFormSchema, + useRecaptchaVerifier, + useUI, + useSmsMultiFactorAssertionPhoneFormAction, + useSmsMultiFactorAssertionVerifyFormAction, +} from "@invertase/firebaseui-react"; +import { useForm } from "react-hook-form"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; + +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Button } from "@/components/ui/button"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; + +type PhoneMultiFactorInfo = MultiFactorInfo & { + phoneNumber?: string; +}; + +type SmsMultiFactorAssertionPhoneFormProps = { + hint: MultiFactorInfo; + onSubmit: (verificationId: string) => void; +}; + +function SmsMultiFactorAssertionPhoneForm(props: SmsMultiFactorAssertionPhoneFormProps) { + const ui = useUI(); + const recaptchaContainerRef = useRef(null); + const recaptchaVerifier = useRecaptchaVerifier(recaptchaContainerRef); + const action = useSmsMultiFactorAssertionPhoneFormAction(); + const [error, setError] = useState(null); + + const onSubmit = async () => { + try { + setError(null); + const verificationId = await action({ hint: props.hint, recaptchaVerifier: recaptchaVerifier! }); + props.onSubmit(verificationId); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + setError(message); + } + }; + + return ( +
+ + {getTranslation(ui, "labels", "phoneNumber")} + + {getTranslation(ui, "messages", "mfaSmsAssertionPrompt", { + phoneNumber: (props.hint as PhoneMultiFactorInfo).phoneNumber || "", + })} + + +
+ + {error &&
{error}
} +
+ ); +} + +type SmsMultiFactorAssertionVerifyFormProps = { + verificationId: string; + onSuccess: (credential: UserCredential) => void; +}; + +function SmsMultiFactorAssertionVerifyForm(props: SmsMultiFactorAssertionVerifyFormProps) { + const ui = useUI(); + const schema = useMultiFactorPhoneAuthVerifyFormSchema(); + const action = useSmsMultiFactorAssertionVerifyFormAction(); + + const form = useForm<{ verificationId: string; verificationCode: string }>({ + resolver: standardSchemaResolver(schema), + defaultValues: { + verificationId: props.verificationId, + verificationCode: "", + }, + }); + + const onSubmit = async (values: { verificationId: string; verificationCode: string }) => { + try { + const credential = await action({ + verificationId: values.verificationId, + verificationCode: values.verificationCode, + }); + props.onSuccess(credential); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + }; + + return ( +
+ + ( + + {getTranslation(ui, "labels", "verificationCode")} + {getTranslation(ui, "prompts", "smsVerificationPrompt")} + + + + + + + + + + + + + + + )} + /> + + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} + +export type SmsMultiFactorAssertionFormProps = { + hint: MultiFactorInfo; + onSuccess?: (credential: UserCredential) => void; +}; + +export function SmsMultiFactorAssertionForm(props: SmsMultiFactorAssertionFormProps) { + const [verification, setVerification] = useState<{ + verificationId: string; + } | null>(null); + + if (!verification) { + return ( + setVerification({ verificationId })} + /> + ); + } + + return ( + { + props.onSuccess?.(credential); + }} + /> + ); +} diff --git a/examples/shadcn/src/components/sms-multi-factor-enrollment-form.tsx b/examples/shadcn/src/components/sms-multi-factor-enrollment-form.tsx new file mode 100644 index 000000000..db6d3ddc7 --- /dev/null +++ b/examples/shadcn/src/components/sms-multi-factor-enrollment-form.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useRef, useState } from "react"; +import { multiFactor, PhoneAuthProvider, PhoneMultiFactorGenerator } from "firebase/auth"; +import { + enrollWithMultiFactorAssertion, + FirebaseUIError, + formatPhoneNumber, + getTranslation, + verifyPhoneNumber, +} from "@invertase/firebaseui-core"; +import { CountrySelector, type CountrySelectorRef } from "@/components/country-selector"; +import { + useMultiFactorPhoneAuthNumberFormSchema, + useMultiFactorPhoneAuthVerifyFormSchema, + useRecaptchaVerifier, + useUI, +} from "@invertase/firebaseui-react"; +import { useForm } from "react-hook-form"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; + +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; + +type MultiFactorEnrollmentPhoneNumberFormProps = { + onSubmit: (verificationId: string, displayName?: string) => void; +}; + +function MultiFactorEnrollmentPhoneNumberForm(props: MultiFactorEnrollmentPhoneNumberFormProps) { + const ui = useUI(); + const recaptchaContainerRef = useRef(null); + const recaptchaVerifier = useRecaptchaVerifier(recaptchaContainerRef); + const countrySelector = useRef(null); + const schema = useMultiFactorPhoneAuthNumberFormSchema(); + + const form = useForm<{ displayName: string; phoneNumber: string }>({ + resolver: standardSchemaResolver(schema), + defaultValues: { + displayName: "", + phoneNumber: "", + }, + }); + + const onSubmit = async (values: { displayName: string; phoneNumber: string }) => { + try { + const formatted = formatPhoneNumber(values.phoneNumber, countrySelector.current!.getCountry()); + const mfaUser = multiFactor(ui.auth.currentUser!); + const confirmationResult = await verifyPhoneNumber(ui, formatted, recaptchaVerifier!, mfaUser); + props.onSubmit(confirmationResult, values.displayName); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + }; + + return ( +
+ + ( + + {getTranslation(ui, "labels", "displayName")} + + + + + + )} + /> + ( + + {getTranslation(ui, "labels", "phoneNumber")} + +
+ + +
+
+ +
+ )} + /> +
+ + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} + +type MultiFactorEnrollmentVerifyPhoneNumberFormProps = { + verificationId: string; + displayName?: string; + onSuccess: () => void; +}; + +export function MultiFactorEnrollmentVerifyPhoneNumberForm(props: MultiFactorEnrollmentVerifyPhoneNumberFormProps) { + const ui = useUI(); + const schema = useMultiFactorPhoneAuthVerifyFormSchema(); + + const form = useForm<{ verificationId: string; verificationCode: string }>({ + resolver: standardSchemaResolver(schema), + defaultValues: { + verificationId: props.verificationId, + verificationCode: "", + }, + }); + + const onSubmit = async (values: { verificationId: string; verificationCode: string }) => { + try { + const credential = PhoneAuthProvider.credential(values.verificationId, values.verificationCode); + const assertion = PhoneMultiFactorGenerator.assertion(credential); + await enrollWithMultiFactorAssertion(ui, assertion, props.displayName); + props.onSuccess(); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + }; + + return ( +
+ + ( + + {getTranslation(ui, "labels", "verificationCode")} + {getTranslation(ui, "prompts", "smsVerificationPrompt")} + + + + + + + + + + + + + + + )} + /> + + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} + +export type SmsMultiFactorEnrollmentFormProps = { + onSuccess?: () => void; +}; + +export function SmsMultiFactorEnrollmentForm(props: SmsMultiFactorEnrollmentFormProps) { + const ui = useUI(); + + const [verification, setVerification] = useState<{ + verificationId: string; + displayName?: string; + } | null>(null); + + if (!ui.auth.currentUser) { + throw new Error("User must be authenticated to enroll with multi-factor authentication"); + } + + if (!verification) { + return ( + setVerification({ verificationId, displayName })} + /> + ); + } + + return ( + { + props.onSuccess?.(); + }} + /> + ); +} diff --git a/examples/shadcn/src/components/totp-multi-factor-assertion-form.tsx b/examples/shadcn/src/components/totp-multi-factor-assertion-form.tsx new file mode 100644 index 000000000..1c0324c52 --- /dev/null +++ b/examples/shadcn/src/components/totp-multi-factor-assertion-form.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { type UserCredential, type MultiFactorInfo } from "firebase/auth"; +import { FirebaseUIError, getTranslation } from "@invertase/firebaseui-core"; +import { + useMultiFactorTotpAuthVerifyFormSchema, + useUI, + useTotpMultiFactorAssertionFormAction, +} from "@invertase/firebaseui-react"; +import { useForm } from "react-hook-form"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; + +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Button } from "@/components/ui/button"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; + +type TotpMultiFactorAssertionFormProps = { + hint: MultiFactorInfo; + onSuccess?: (credential: UserCredential) => void; +}; + +export function TotpMultiFactorAssertionForm(props: TotpMultiFactorAssertionFormProps) { + const ui = useUI(); + const schema = useMultiFactorTotpAuthVerifyFormSchema(); + const action = useTotpMultiFactorAssertionFormAction(); + + const form = useForm<{ verificationCode: string }>({ + resolver: standardSchemaResolver(schema), + defaultValues: { + verificationCode: "", + }, + }); + + const onSubmit = async (values: { verificationCode: string }) => { + try { + const credential = await action({ verificationCode: values.verificationCode, hint: props.hint }); + props.onSuccess?.(credential); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + }; + + return ( +
+ + ( + + {getTranslation(ui, "labels", "verificationCode")} + + + + + + + + + + + + + + + )} + /> + + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} diff --git a/examples/shadcn/src/components/totp-multi-factor-enrollment-form.tsx b/examples/shadcn/src/components/totp-multi-factor-enrollment-form.tsx new file mode 100644 index 000000000..bd9aa2f84 --- /dev/null +++ b/examples/shadcn/src/components/totp-multi-factor-enrollment-form.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useState } from "react"; +import { TotpMultiFactorGenerator, type TotpSecret } from "firebase/auth"; +import { + enrollWithMultiFactorAssertion, + FirebaseUIError, + generateTotpQrCode, + generateTotpSecret, + getTranslation, +} from "@invertase/firebaseui-core"; +import { + useMultiFactorTotpAuthNumberFormSchema, + useMultiFactorTotpAuthVerifyFormSchema, + useUI, +} from "@invertase/firebaseui-react"; +import { useForm } from "react-hook-form"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; + +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; + +type TotpMultiFactorSecretGenerationFormProps = { + onSubmit: (secret: TotpSecret, displayName: string) => void; +}; + +function TotpMultiFactorSecretGenerationForm(props: TotpMultiFactorSecretGenerationFormProps) { + const ui = useUI(); + const schema = useMultiFactorTotpAuthNumberFormSchema(); + + const form = useForm<{ displayName: string }>({ + resolver: standardSchemaResolver(schema), + defaultValues: { + displayName: "", + }, + }); + + const onSubmit = async (values: { displayName: string }) => { + try { + const secret = await generateTotpSecret(ui); + props.onSubmit(secret, values.displayName); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + }; + + return ( +
+ + ( + + {getTranslation(ui, "labels", "displayName")} + + + + + + )} + /> + + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} + +type MultiFactorEnrollmentVerifyTotpFormProps = { + secret: TotpSecret; + displayName: string; + onSuccess: () => void; +}; + +export function MultiFactorEnrollmentVerifyTotpForm(props: MultiFactorEnrollmentVerifyTotpFormProps) { + const ui = useUI(); + const schema = useMultiFactorTotpAuthVerifyFormSchema(); + + const form = useForm<{ verificationCode: string }>({ + resolver: standardSchemaResolver(schema), + defaultValues: { + verificationCode: "", + }, + }); + + const onSubmit = async (values: { verificationCode: string }) => { + try { + const assertion = TotpMultiFactorGenerator.assertionForEnrollment(props.secret, values.verificationCode); + await enrollWithMultiFactorAssertion(ui, assertion, values.verificationCode); + props.onSuccess(); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + }; + + const qrCodeDataUrl = generateTotpQrCode(ui, props.secret, props.displayName); + + return ( +
+
+ TOTP QR Code + {props.secret.secretKey.toString()} +

+ {getTranslation(ui, "prompts", "mfaTotpQrCodePrompt")} +

+
+
+ + ( + + {getTranslation(ui, "labels", "verificationCode")} + + + + + + + + + + + + + + + )} + /> + + {form.formState.errors.root && {form.formState.errors.root.message}} + + +
+ ); +} + +export type TotpMultiFactorEnrollmentFormProps = { + onSuccess?: () => void; +}; + +export function TotpMultiFactorEnrollmentForm(props: TotpMultiFactorEnrollmentFormProps) { + const ui = useUI(); + + const [enrollment, setEnrollment] = useState<{ + secret: TotpSecret; + displayName: string; + } | null>(null); + + if (!ui.auth.currentUser) { + throw new Error("User must be authenticated to enroll with multi-factor authentication"); + } + + if (!enrollment) { + return ( + setEnrollment({ secret, displayName })} /> + ); + } + + return ( + { + props.onSuccess?.(); + }} + /> + ); +} diff --git a/examples/shadcn/src/components/twitter-sign-in-button.tsx b/examples/shadcn/src/components/twitter-sign-in-button.tsx new file mode 100644 index 000000000..7d4cc39ad --- /dev/null +++ b/examples/shadcn/src/components/twitter-sign-in-button.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { TwitterAuthProvider } from "firebase/auth"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type TwitterSignInButtonProps, TwitterLogo } from "@invertase/firebaseui-react"; + +import { OAuthButton } from "@/components/oauth-button"; + +export type { TwitterSignInButtonProps }; + +export function TwitterSignInButton({ provider, themed }: TwitterSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithTwitter")} + + ); +} diff --git a/examples/shadcn/src/components/ui/alert.tsx b/examples/shadcn/src/components/ui/alert.tsx new file mode 100644 index 000000000..c6f7846fd --- /dev/null +++ b/examples/shadcn/src/components/ui/alert.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps) { + return
; +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription }; diff --git a/examples/shadcn/src/components/ui/button.tsx b/examples/shadcn/src/components/ui/button.tsx new file mode 100644 index 000000000..1ee147901 --- /dev/null +++ b/examples/shadcn/src/components/ui/button.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : "button"; + + return ; +} + +export { Button, buttonVariants }; diff --git a/examples/shadcn/src/components/ui/card.tsx b/examples/shadcn/src/components/ui/card.tsx new file mode 100644 index 000000000..9939da87c --- /dev/null +++ b/examples/shadcn/src/components/ui/card.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; diff --git a/examples/shadcn/src/components/ui/form.tsx b/examples/shadcn/src/components/ui/form.tsx new file mode 100644 index 000000000..cbf278836 --- /dev/null +++ b/examples/shadcn/src/components/ui/form.tsx @@ -0,0 +1,136 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form"; + +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext({} as FormFieldContextValue); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState } = useFormContext(); + const formState = useFormState({ name: fieldContext.name }); + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext({} as FormItemContextValue); + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId(); + + return ( + +
+ + ); +} + +function FormLabel({ className, ...props }: React.ComponentProps) { + const { error, formItemId } = useFormField(); + + return ( +
; +} + +function InputOTPSlot({ + index, + className, + ...props +}: React.ComponentProps<"div"> & { + index: number; +}) { + const inputOTPContext = React.useContext(OTPInputContext); + const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}; + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ); +} + +function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { + return ( +
+ +
+ ); +} + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/examples/shadcn/src/components/ui/input.tsx b/examples/shadcn/src/components/ui/input.tsx new file mode 100644 index 000000000..868dec6cb --- /dev/null +++ b/examples/shadcn/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/examples/shadcn/src/components/ui/item.tsx b/examples/shadcn/src/components/ui/item.tsx new file mode 100644 index 000000000..822be1f3b --- /dev/null +++ b/examples/shadcn/src/components/ui/item.tsx @@ -0,0 +1,158 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; +import { Separator } from "@/components/ui/separator"; + +function ItemGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function ItemSeparator({ className, ...props }: React.ComponentProps) { + return ; +} + +const itemVariants = cva( + "group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + { + variants: { + variant: { + default: "bg-transparent", + outline: "border-border", + muted: "bg-muted/50", + }, + size: { + default: "p-4 gap-4 ", + sm: "py-3 px-4 gap-2.5", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +function Item({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"div"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "div"; + return ( + + ); +} + +const itemMediaVariants = cva( + "flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5", + { + variants: { + variant: { + default: "bg-transparent", + icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4", + image: "size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +function ItemMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function ItemContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function ItemTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function ItemDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +

a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", + className + )} + {...props} + /> + ); +} + +function ItemActions({ className, ...props }: React.ComponentProps<"div">) { + return

; +} + +function ItemHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function ItemFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Item, + ItemMedia, + ItemContent, + ItemActions, + ItemGroup, + ItemSeparator, + ItemTitle, + ItemDescription, + ItemHeader, + ItemFooter, +}; diff --git a/examples/shadcn/src/components/ui/label.tsx b/examples/shadcn/src/components/ui/label.tsx new file mode 100644 index 000000000..4f76cb35c --- /dev/null +++ b/examples/shadcn/src/components/ui/label.tsx @@ -0,0 +1,21 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; + +import { cn } from "@/lib/utils"; + +function Label({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Label }; diff --git a/examples/shadcn/src/components/ui/select.tsx b/examples/shadcn/src/components/ui/select.tsx new file mode 100644 index 000000000..35e252a5b --- /dev/null +++ b/examples/shadcn/src/components/ui/select.tsx @@ -0,0 +1,160 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Select({ ...props }: React.ComponentProps) { + return ; +} + +function SelectGroup({ ...props }: React.ComponentProps) { + return ; +} + +function SelectValue({ ...props }: React.ComponentProps) { + return ; +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default"; +}) { + return ( + + {children} + + + + + ); +} + +function SelectContent({ + className, + children, + position = "popper", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ className, children, ...props }: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function SelectSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectScrollUpButton({ className, ...props }: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/examples/shadcn/src/components/ui/separator.tsx b/examples/shadcn/src/components/ui/separator.tsx new file mode 100644 index 000000000..091415b67 --- /dev/null +++ b/examples/shadcn/src/components/ui/separator.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; + +import { cn } from "@/lib/utils"; + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/examples/shadcn/src/firebase/config.ts b/examples/shadcn/src/firebase/config.ts new file mode 100644 index 000000000..2d20a8abd --- /dev/null +++ b/examples/shadcn/src/firebase/config.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const firebaseConfig = { + apiKey: "AIzaSyA7xdkFMs7iUC6XWFYjjSxf_XbVV4F1mX4", + authDomain: "fir-ui-2025.firebaseapp.com", + projectId: "fir-ui-2025", + storageBucket: "fir-ui-2025.firebasestorage.app", + messagingSenderId: "616577669988", + appId: "1:616577669988:web:7e67401f952fa9288df871", +}; diff --git a/examples/shadcn/src/firebase/firebase.ts b/examples/shadcn/src/firebase/firebase.ts new file mode 100644 index 000000000..ddfc3b2cf --- /dev/null +++ b/examples/shadcn/src/firebase/firebase.ts @@ -0,0 +1,44 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { countryCodes, initializeUI, oneTapSignIn } from "@invertase/firebaseui-core"; +import { getApps, initializeApp } from "firebase/app"; +import { connectAuthEmulator, getAuth } from "firebase/auth"; +import { firebaseConfig } from "./config"; + +export const firebaseApp = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0]; + +export const auth = getAuth(firebaseApp); + +export const ui = initializeUI({ + app: firebaseApp, + behaviors: [ + // autoAnonymousLogin(), + oneTapSignIn({ + clientId: "616577669988-led6l3rqek9ckn9t1unj4l8l67070fhp.apps.googleusercontent.com", + }), + countryCodes({ + allowedCountries: ["US", "CA", "GB"], + defaultCountry: "GB", + }), + ], +}); + +if (import.meta.env.MODE === "development") { + connectAuthEmulator(auth, "http://localhost:9099"); +} diff --git a/examples/shadcn/src/firebase/hooks.ts b/examples/shadcn/src/firebase/hooks.ts new file mode 100644 index 000000000..3d89e07e5 --- /dev/null +++ b/examples/shadcn/src/firebase/hooks.ts @@ -0,0 +1,33 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState } from "react"; + +import { onAuthStateChanged } from "firebase/auth"; +import { type User } from "firebase/auth"; +import { useEffect } from "react"; +import { auth } from "./firebase"; + +export function useUser() { + const [user, setUser] = useState(auth.currentUser); + + useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, setUser); + return () => unsubscribe(); + }, []); + + return user; +} diff --git a/examples/shadcn/src/hooks/use-mobile.ts b/examples/shadcn/src/hooks/use-mobile.ts new file mode 100644 index 000000000..502fd3239 --- /dev/null +++ b/examples/shadcn/src/hooks/use-mobile.ts @@ -0,0 +1,19 @@ +import * as React from "react"; + +const MOBILE_BREAKPOINT = 768; + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined); + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return !!isMobile; +} diff --git a/examples/shadcn/src/index.css b/examples/shadcn/src/index.css new file mode 100644 index 000000000..ca3abb1f8 --- /dev/null +++ b/examples/shadcn/src/index.css @@ -0,0 +1,187 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +@layer components { + button[data-provider='apple.com'][data-themed='true'] { + --apple-primary: #000000; + --primary: var(--apple-primary); + --primary-foreground: var(--color-white); + } + button[data-provider='facebook.com'][data-themed='true'] { + --facebook-primary: #1877F2; + --primary: var(--facebook-primary); + --primary-foreground: var(--color-white); + } + button[data-provider='github.com'][data-themed='true'] { + --github-primary: #000000; + --primary: var(--github-primary); + --primary-foreground: var(--color-white); + } + button[data-provider='google.com'][data-themed='true'] { + --google-primary: #131314; + --primary: var(--google-primary); + --primary-foreground: var(--color-white); + } + button[data-provider='google.com'][data-themed='neutral'] { + --google-primary: #F2F2F2; + --primary: var(--google-primary); + --primary-foreground: var(--color-black); + } + button[data-provider='microsoft.com'][data-themed='true'] { + --microsoft-primary: #2F2F2F; + --primary: var(--microsoft-primary); + --primary-foreground: var(--color-white); + } + button[data-provider='twitter.com'][data-themed='true'] { + --twitter-primary: #1DA1F2; + --primary: var(--twitter-primary); + --primary-foreground: var(--color-white); + } + button[data-provider="oidc.line"][data-themed="true"] { + --line-primary: #07B53B; + --primary: var(--line-primary); + --primary-foreground: var(--color-white); + } + +} + +@variant dark { + button[data-provider='apple.com'][data-themed='true'] { + --apple-primary: var(--color-white); + --primary: var(--apple-primary); + --primary-foreground: var(--color-black); + } + button[data-provider='github.com'][data-themed='true'] { + --github-primary: var(--color-white); + --primary: var(--github-primary); + --primary-foreground: var(--color-black); + } + button[data-provider='google.com'][data-themed='true'] { + --google-primary: #FFFFFF; + --primary: var(--google-primary); + --primary-foreground: var(--color-black); + } + button[data-provider='microsoft.com'][data-themed='true'] { + --microsoft-primary: var(--color-white); + --primary: var(--microsoft-primary); + --primary-foreground: var(--color-black); + } +} \ No newline at end of file diff --git a/examples/shadcn/src/lib/utils.ts b/examples/shadcn/src/lib/utils.ts new file mode 100644 index 000000000..a5ef19350 --- /dev/null +++ b/examples/shadcn/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/examples/shadcn/src/main.tsx b/examples/shadcn/src/main.tsx new file mode 100644 index 000000000..954711da4 --- /dev/null +++ b/examples/shadcn/src/main.tsx @@ -0,0 +1,130 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BrowserRouter, Routes, Route, Outlet, Link } from "react-router"; + +import ReactDOM from "react-dom/client"; +import { FirebaseUIProvider, useUI } from "@invertase/firebaseui-react"; +import { ui, auth } from "./firebase/firebase"; +import App from "./App"; +import { Button } from "@/components/ui/button"; +import { hiddenRoutes, routes } from "./routes"; +import { enUs } from "@invertase/firebaseui-translations"; +import { pirate } from "./pirate"; + +const root = document.getElementById("root")!; + +const allRoutes = [...routes, ...hiddenRoutes]; + +// Hacky way to ensure we have an auth state before showing the app... +auth.authStateReady().then(() => { + ReactDOM.createRoot(root).render( + + + + + + } /> + }> + {allRoutes.map((route) => ( + } /> + ))} + + + + + ); +}); + +function ScreenRoute() { + return ( +
+ + + +
+ +
+
+ ); +} + +function ThemeToggle() { + return ( + + ); +} + +function PirateToggle() { + const ui = useUI(); + const isPirate = ui.locale.locale === "pirate"; + + return ( + + ); +} diff --git a/examples/shadcn/src/pirate.ts b/examples/shadcn/src/pirate.ts new file mode 100644 index 000000000..aa92433ce --- /dev/null +++ b/examples/shadcn/src/pirate.ts @@ -0,0 +1,95 @@ +import { registerLocale } from "@invertase/firebaseui-translations"; + +export const pirate = registerLocale("pirate", { + errors: { + userNotFound: "Arrr! No account found with this email address, matey", + wrongPassword: "Arrr! Incorrect password, ye scallywag", + invalidEmail: "Avast! Enter a valid email address, ye bilge rat", + userDisabled: "This account has been marooned, arrr!", + networkRequestFailed: "Can't connect to the server, ye land lubber! Check yer internet connection", + tooManyRequests: "Too many failed attempts, ye scurvy dog! Try again later", + missingVerificationCode: "Enter the verification code, ye scallywag", + emailAlreadyInUse: "An account already exists with this email, arrr!", + invalidCredential: "The credentials ye provided be invalid, matey", + weakPassword: "Ye password ain't long enough! It should be at least 8 characters", + unverifiedEmail: "Verify yer email address to continue, ye scallywag", + operationNotAllowed: "This operation ain't allowed, arrr! Contact support, matey", + invalidPhoneNumber: "The phone number be invalid, ye bilge rat", + missingPhoneNumber: "Provide a phone number, ye scallywag", + quotaExceeded: "SMS quota exceeded, arrr! Try again later, matey", + codeExpired: "The verification code has expired, ye scurvy dog", + captchaCheckFailed: "reCAPTCHA verification failed, arrr! Try again, matey", + missingVerificationId: "Complete the reCAPTCHA verification first, ye scallywag", + missingEmail: "Provide an email address, ye bilge rat", + invalidActionCode: "The password reset link be invalid or has expired, arrr!", + credentialAlreadyInUse: "An account already exists with this email, arrr! Sign in with that account, matey", + requiresRecentLogin: "This operation requires a recent login, ye scallywag! Sign in again", + providerAlreadyLinked: "This phone number be already linked to another account, arrr!", + invalidVerificationCode: "Invalid verification code, ye scurvy dog! Try again", + unknownError: "An unexpected error occurred, arrr!", + popupClosed: "The sign-in popup was closed, ye scallywag! Try again", + accountExistsWithDifferentCredential: + "An account already exists with this email, arrr! Sign in with the original provider, matey", + displayNameRequired: "Provide a display name, ye bilge rat", + secondFactorAlreadyInUse: "This phone number be already enrolled with this account, arrr!", + }, + messages: { + passwordResetEmailSent: "Password reset email sent successfully, arrr!", + signInLinkSent: "Sign-in link sent successfully, matey!", + verificationCodeFirst: "Request a verification code first, ye scallywag", + checkEmailForReset: "Check yer email for password reset instructions, ye bilge rat", + dividerOr: "or", + termsAndPrivacy: "By continuing, ye agree to our {tos} and {privacy}, arrr!", + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process, matey.", + }, + labels: { + emailAddress: "Email Address, ye bilge rat", + password: "Password, ye scallywag", + displayName: "Display Name, ye bilge rat", + forgotPassword: "Forgot Password, ye scallywag?", + signUp: "Sign Up, Matey", + signIn: "Sign In, Matey", + resetPassword: "Reset Password, ye scallywag", + createAccount: "Create Account, ye bilge rat", + backToSignIn: "Back to Sign In, ye scallywag", + signInWithPhone: "Sign in with Phone, ye scallywag", + phoneNumber: "Phone Number, ye bilge rat", + verificationCode: "Verification Code, ye scallywag", + sendCode: "Send Code, ye scallywag", + verifyCode: "Verify Code, ye scallywag", + signInWithGoogle: "Sign in with ye Google Account", + signInWithFacebook: "Sign in with ye Facebook Account", + signInWithApple: "Sign in with ye Apple Account", + signInWithMicrosoft: "Sign in with ye Microsoft Account", + signInWithGitHub: "Sign in with ye GitHub Account", + signInWithTwitter: "Sign in with ye X Account", + signInWithEmailLink: "Sign in with Email Link", + sendSignInLink: "Send Sign-in Link", + termsOfService: "Terms of Service", + privacyPolicy: "Privacy Policy", + resendCode: "Resend ye Code", + sending: "Firing...", + multiFactorEnrollment: "Multi-factor Enrrrrrrollment!", + multiFactorAssertion: "Multi-factor Authentication, arrr!", + mfaTotpVerification: "TOTP Verification, arrr!", + mfaSmsVerification: "SMS Verification, arrr!", + generateQrCode: "Generate ye QR Code", + }, + prompts: { + noAccount: "Don't have an account, ye scallywag?", + haveAccount: "Already have an account, matey?", + enterEmailToReset: "Enter yer email address to reset yer password, ye bilge rat", + signInToAccount: "Sign in to yer account, matey", + smsVerificationPrompt: "Enter the verification code sent to yer phone number, ye scallywag", + enterDetailsToCreate: "Enter yer details to create a new account, ye bilge rat", + enterPhoneNumber: "Enter yer phone number, matey", + enterVerificationCode: "Enter the verification code, ye scallywag", + enterEmailForLink: "Enter yer email to receive a sign-in link, ye bilge rat", + mfaEnrollmentPrompt: "Select a new multi-factor enrollment method, arrr!", + mfaAssertionPrompt: "Complete the multi-factor authentication process, ye scallywag", + mfaAssertionFactorPrompt: "Choose a multi-factor authentication method, matey", + mfaTotpQrCodePrompt: "Scan this QR code with yer authenticator app, ye bilge rat", + mfaTotpEnrollmentVerificationPrompt: "Add the code generated by yer authenticator app, arrr!", + }, +}); diff --git a/examples/shadcn/src/routes.ts b/examples/shadcn/src/routes.ts new file mode 100644 index 000000000..1f5d535a0 --- /dev/null +++ b/examples/shadcn/src/routes.ts @@ -0,0 +1,104 @@ +import SignInAuthScreenPage from "./screens/sign-in-auth-screen"; +import SignInAuthScreenWithHandlersPage from "./screens/sign-in-auth-screen-w-handlers"; +import SignInAuthScreenWithOAuthPage from "./screens/sign-in-auth-screen-w-oauth"; +import SignUpAuthScreenPage from "./screens/sign-up-auth-screen"; +import SignUpAuthScreenWithHandlersPage from "./screens/sign-up-auth-screen-w-handlers"; +import SignUpAuthScreenWithOAuthPage from "./screens/sign-up-auth-screen-w-oauth"; +import EmailLinkAuthScreenPage from "./screens/email-link-auth-screen"; +import EmailLinkAuthScreenWithOAuthPage from "./screens/email-link-auth-screen-w-oauth"; +import ForgotPasswordAuthScreenPage from "./screens/forgot-password-auth-screen"; +import OAuthScreenPage from "./screens/oauth-screen"; +import PhoneAuthScreenPage from "./screens/phone-auth-screen"; +import PhoneAuthScreenWithOAuthPage from "./screens/phone-auth-screen-w-oauth"; +import MultiFactorAuthEnrollmentScreenPage from "./screens/mfa-enrollment-screen"; +import ForgotPasswordAuthScreenWithHandlersPage from "./screens/forgot-password-auth-screen-w-handlers"; + +export const routes = [ + { + name: "Sign In Screen", + description: "A simple sign in screen with email and password", + path: "/screens/sign-in-auth-screen", + component: SignInAuthScreenPage, + }, + { + name: "Sign In Screen (with handlers)", + description: "A simple sign in screen with email and password, with forgot password and register handlers", + path: "/screens/sign-in-auth-screen-w-handlers", + component: SignInAuthScreenWithHandlersPage, + }, + { + name: "Sign In Screen (with OAuth)", + description: "A simple sign in screen with email and password, with oAuth buttons", + path: "/screens/sign-in-auth-screen-w-oauth", + component: SignInAuthScreenWithOAuthPage, + }, + { + name: "Sign Up Screen", + description: "A simple sign up screen with email and password", + path: "/screens/sign-up-auth-screen", + component: SignUpAuthScreenPage, + }, + { + name: "Sign Up Screen (with handlers)", + description: "A simple sign up screen with email and password, sign in handlers", + path: "/screens/sign-up-auth-screen-w-handlers", + component: SignUpAuthScreenWithHandlersPage, + }, + { + name: "Sign Up Screen (with OAuth)", + description: "A simple sign in screen with email and password, with oAuth buttons", + path: "/screens/sign-up-auth-screen-w-oauth", + component: SignUpAuthScreenWithOAuthPage, + }, + { + name: "Email Link Auth Screen", + description: "A screen allowing a user to send an email link for sign in", + path: "/screens/email-link-auth-screen", + component: EmailLinkAuthScreenPage, + }, + { + name: "Email Link Auth Screen (with OAuth)", + description: "A screen allowing a user to send an email link for sign in, with oAuth buttons", + path: "/screens/email-link-auth-screen-w-oauth", + component: EmailLinkAuthScreenWithOAuthPage, + }, + { + name: "Forgot Password Screen", + description: "A screen allowing a user to reset their password", + path: "/screens/forgot-password-screen", + component: ForgotPasswordAuthScreenPage, + }, + { + name: "Forgot Password Screen (with handlers)", + description: "A screen allowing a user to reset their password, with handlers", + path: "/screens/forgot-password-auth-screen-w-handlers", + component: ForgotPasswordAuthScreenWithHandlersPage, + }, + { + name: "OAuth Screen", + description: "A screen which allows a user to sign in with OAuth only", + path: "/screens/oauth-screen", + component: OAuthScreenPage, + }, + { + name: "Phone Auth Screen", + description: "A screen allowing a user to sign in with a phone number", + path: "/screens/phone-auth-screen", + component: PhoneAuthScreenPage, + }, + { + name: "Phone Auth Screen (with OAuth)", + description: "A screen allowing a user to sign in with a phone number, with oAuth buttons", + path: "/screens/phone-auth-screen-w-oauth", + component: PhoneAuthScreenWithOAuthPage, + }, +] as const; + +export const hiddenRoutes = [ + { + name: "MFA Enrollment Screen", + description: "A screen allowing a user to enroll in multi-factor authentication", + path: "/screens/mfa-enrollment-screen", + component: MultiFactorAuthEnrollmentScreenPage, + }, +] as const; 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 new file mode 100644 index 000000000..905b82c8d --- /dev/null +++ b/examples/shadcn/src/screens/email-link-auth-screen-w-oauth.tsx @@ -0,0 +1,44 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { GoogleSignInButton } from "@/components/google-sign-in-button"; +import { FacebookSignInButton } from "@/components/facebook-sign-in-button"; +import { AppleSignInButton } from "@/components/apple-sign-in-button"; +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 new file mode 100644 index 000000000..35d5a83e2 --- /dev/null +++ b/examples/shadcn/src/screens/email-link-auth-screen.tsx @@ -0,0 +1,34 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { EmailLinkAuthScreen } from "@/components/email-link-auth-screen"; +import { useNavigate } from "react-router"; + +export default function EmailLinkAuthScreenPage() { + const navigate = useNavigate(); + + return ( + { + alert("Email has been sent"); + }} + onSignIn={() => { + navigate("/"); + }} + /> + ); +} diff --git a/examples/shadcn/src/screens/forgot-password-auth-screen-w-handlers.tsx b/examples/shadcn/src/screens/forgot-password-auth-screen-w-handlers.tsx new file mode 100644 index 000000000..501efbc2d --- /dev/null +++ b/examples/shadcn/src/screens/forgot-password-auth-screen-w-handlers.tsx @@ -0,0 +1,25 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { ForgotPasswordAuthScreen } from "@/components/forgot-password-auth-screen"; +import { useNavigate } from "react-router"; + +export default function ForgotPasswordAuthScreenWithHandlersPage() { + const navigate = useNavigate(); + + return navigate("/screens/sign-in-auth-screen")} />; +} diff --git a/examples/nextjs/lib/examples/4/page.tsx b/examples/shadcn/src/screens/forgot-password-auth-screen.tsx similarity index 75% rename from examples/nextjs/lib/examples/4/page.tsx rename to examples/shadcn/src/screens/forgot-password-auth-screen.tsx index f8ba31146..24647d5c9 100644 --- a/examples/nextjs/lib/examples/4/page.tsx +++ b/examples/shadcn/src/screens/forgot-password-auth-screen.tsx @@ -4,7 +4,6 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software @@ -16,10 +15,8 @@ "use client"; -import { SignInAuthScreen } from "@firebase-ui/react"; +import { ForgotPasswordAuthScreen } from "@/components/forgot-password-auth-screen"; -export default function Example4() { - return ( - {}} onRegisterClick={() => {}} /> - ); +export default function ForgotPasswordAuthScreenPage() { + return ; } diff --git a/packages/firebaseui-core/src/translations.ts b/examples/shadcn/src/screens/mfa-enrollment-screen.tsx similarity index 61% rename from packages/firebaseui-core/src/translations.ts rename to examples/shadcn/src/screens/mfa-enrollment-screen.tsx index afccff2ee..b03a7a36b 100644 --- a/packages/firebaseui-core/src/translations.ts +++ b/examples/shadcn/src/screens/mfa-enrollment-screen.tsx @@ -4,7 +4,6 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software @@ -14,13 +13,19 @@ * limitations under the License. */ -import { getTranslation as _getTranslation, TranslationCategory, TranslationKey } from '@firebase-ui/translations'; -import { FirebaseUIConfiguration } from './config'; +"use client"; + +import { MultiFactorAuthEnrollmentScreen } from "@/components/multi-factor-auth-enrollment-screen"; +import { useNavigate } from "react-router"; + +export default function MultiFactorAuthEnrollmentScreenPage() { + const navigate = useNavigate(); -export function getTranslation( - ui: FirebaseUIConfiguration, - category: T, - key: TranslationKey -) { - return _getTranslation(category, key, ui.translations, ui.locale); + return ( + { + navigate("/"); + }} + /> + ); } diff --git a/examples/shadcn/src/screens/oauth-screen.tsx b/examples/shadcn/src/screens/oauth-screen.tsx new file mode 100644 index 000000000..bf65531ae --- /dev/null +++ b/examples/shadcn/src/screens/oauth-screen.tsx @@ -0,0 +1,71 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { useState } from "react"; +import { OAuthProvider } from "firebase/auth"; +import { OAuthButton } from "@/components/oauth-button"; +import { GoogleSignInButton } from "@/components/google-sign-in-button"; +import { FacebookSignInButton } from "@/components/facebook-sign-in-button"; +import { AppleSignInButton } from "@/components/apple-sign-in-button"; +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("/"); + }} + > + + + + + + + + +
+ setThemed(!themed)} /> + +
+ + ); +} + +function LineSignInButton({ themed }: { themed?: boolean }) { + const provider = new OAuthProvider("oidc.line"); + + return ( + + + + + Sign in with Line + + ); +} diff --git a/examples/shadcn/src/screens/phone-auth-screen-w-oauth.tsx b/examples/shadcn/src/screens/phone-auth-screen-w-oauth.tsx new file mode 100644 index 000000000..c36ffebe2 --- /dev/null +++ b/examples/shadcn/src/screens/phone-auth-screen-w-oauth.tsx @@ -0,0 +1,44 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { GoogleSignInButton } from "@/components/google-sign-in-button"; +import { FacebookSignInButton } from "@/components/facebook-sign-in-button"; +import { AppleSignInButton } from "@/components/apple-sign-in-button"; +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 { PhoneAuthScreen } from "@/components/phone-auth-screen"; +import { useNavigate } from "react-router"; + +export default function PhoneAuthScreenWithOAuthPage() { + const navigate = useNavigate(); + + return ( + { + navigate("/"); + }} + > + + + + + + + + ); +} diff --git a/examples/shadcn/src/screens/phone-auth-screen.tsx b/examples/shadcn/src/screens/phone-auth-screen.tsx new file mode 100644 index 000000000..c7d6e043b --- /dev/null +++ b/examples/shadcn/src/screens/phone-auth-screen.tsx @@ -0,0 +1,31 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { PhoneAuthScreen } from "@/components/phone-auth-screen"; +import { useNavigate } from "react-router"; + +export default function PhoneAuthScreenPage() { + const navigate = useNavigate(); + + return ( + { + navigate("/"); + }} + /> + ); +} diff --git a/examples/shadcn/src/screens/sign-in-auth-screen-w-handlers.tsx b/examples/shadcn/src/screens/sign-in-auth-screen-w-handlers.tsx new file mode 100644 index 000000000..e517339d9 --- /dev/null +++ b/examples/shadcn/src/screens/sign-in-auth-screen-w-handlers.tsx @@ -0,0 +1,36 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { SignInAuthScreen } from "@/components/sign-in-auth-screen"; +import { useNavigate } from "react-router"; + +export default function SignInAuthScreenWithHandlersPage() { + const navigate = useNavigate(); + return ( + { + navigate("/"); + }} + onForgotPasswordClick={() => { + navigate("/screens/forgot-password-screen"); + }} + onSignUpClick={() => { + navigate("/screens/sign-up-auth-screen"); + }} + /> + ); +} diff --git a/examples/shadcn/src/screens/sign-in-auth-screen-w-oauth.tsx b/examples/shadcn/src/screens/sign-in-auth-screen-w-oauth.tsx new file mode 100644 index 000000000..bb95f20ac --- /dev/null +++ b/examples/shadcn/src/screens/sign-in-auth-screen-w-oauth.tsx @@ -0,0 +1,46 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { AppleSignInButton } from "@/components/apple-sign-in-button"; +import { FacebookSignInButton } from "@/components/facebook-sign-in-button"; +import { GitHubSignInButton } from "@/components/github-sign-in-button"; +import { GoogleSignInButton } from "@/components/google-sign-in-button"; +import { MicrosoftSignInButton } from "@/components/microsoft-sign-in-button"; +import { SignInAuthScreen } from "@/components/sign-in-auth-screen"; +import { TwitterSignInButton } from "@/components/twitter-sign-in-button"; +import { useNavigate } from "react-router"; + +export default function SignInAuthScreenWithOAuthPage() { + const navigate = useNavigate(); + + return ( + { + navigate("/"); + }} + > +
+ + + + + + +
+
+ ); +} diff --git a/examples/shadcn/src/screens/sign-in-auth-screen.tsx b/examples/shadcn/src/screens/sign-in-auth-screen.tsx new file mode 100644 index 000000000..e4a9decc4 --- /dev/null +++ b/examples/shadcn/src/screens/sign-in-auth-screen.tsx @@ -0,0 +1,31 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { SignInAuthScreen } from "@/components/sign-in-auth-screen"; +import { useNavigate } from "react-router"; + +export default function SignInAuthScreenPage() { + const navigate = useNavigate(); + + return ( + { + 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 new file mode 100644 index 000000000..bfd9404c2 --- /dev/null +++ b/examples/shadcn/src/screens/sign-up-auth-screen-w-handlers.tsx @@ -0,0 +1,34 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { SignUpAuthScreen } from "@/components/sign-up-auth-screen"; +import { useNavigate } from "react-router"; + +export default function SignUpAuthScreenWithHandlersPage() { + const navigate = useNavigate(); + return ( + { + navigate("/screens/sign-in-auth-screen"); + }} + onSignUp={(credential) => { + console.log(credential); + navigate("/"); + }} + /> + ); +} 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 new file mode 100644 index 000000000..f91867f90 --- /dev/null +++ b/examples/shadcn/src/screens/sign-up-auth-screen-w-oauth.tsx @@ -0,0 +1,44 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { GoogleSignInButton } from "@/components/google-sign-in-button"; +import { FacebookSignInButton } from "@/components/facebook-sign-in-button"; +import { AppleSignInButton } from "@/components/apple-sign-in-button"; +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 { SignUpAuthScreen } from "@/components/sign-up-auth-screen"; +import { useNavigate } from "react-router"; + +export default function SignUpAuthScreenWithOAuthPage() { + const navigate = useNavigate(); + + return ( + { + navigate("/"); + }} + > + + + + + + + + ); +} diff --git a/examples/shadcn/src/screens/sign-up-auth-screen.tsx b/examples/shadcn/src/screens/sign-up-auth-screen.tsx new file mode 100644 index 000000000..aa4756258 --- /dev/null +++ b/examples/shadcn/src/screens/sign-up-auth-screen.tsx @@ -0,0 +1,30 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { SignUpAuthScreen } from "@/components/sign-up-auth-screen"; +import { useNavigate } from "react-router"; + +export default function SignUpAuthScreenPage() { + const navigate = useNavigate(); + return ( + { + navigate("/"); + }} + /> + ); +} diff --git a/packages/firebaseui-react/tsconfig.node.json b/examples/shadcn/tsconfig.json similarity index 67% rename from packages/firebaseui-react/tsconfig.node.json rename to examples/shadcn/tsconfig.json index fe3a37925..40f75883c 100644 --- a/packages/firebaseui-react/tsconfig.node.json +++ b/examples/shadcn/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2022", - "lib": ["ES2023"], + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, @@ -12,6 +12,9 @@ "isolatedModules": true, "moduleDetection": "force", "noEmit": true, + "jsx": "react-jsx", + + "types": ["vite/client"], /* Linting */ "strict": true, @@ -21,9 +24,8 @@ "noUncheckedSideEffectImports": true, "baseUrl": ".", "paths": { - "~/*": ["./src/*"], - "@firebase-ui/core": ["../firebaseui-core/src/*"] + "@/*": ["./src/*"] } }, - "include": ["vite.config.ts"] + "include": ["src", "vite.config.ts"] } diff --git a/examples/shadcn/vite.config.ts b/examples/shadcn/vite.config.ts new file mode 100644 index 000000000..bc96425be --- /dev/null +++ b/examples/shadcn/vite.config.ts @@ -0,0 +1,14 @@ +import path from "node:path"; +import { defineConfig } from "vite"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); diff --git a/firebase.json b/firebase.json index 2fb2a16b0..6d4a14706 100644 --- a/firebase.json +++ b/firebase.json @@ -7,5 +7,51 @@ "enabled": true }, "singleProjectMode": true - } + }, + "hosting": [ + { + "site": "fir-ui-shadcn-registry", + "public": "packages/shadcn/public" + }, + { + "site": "fir-ui-shadcn", + "public": "examples/shadcn/dist", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ] + }, + { + "site": "fir-ui-rework", + "public": "examples/react/dist", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ] + }, + { + "site": "fir-ui-rework-angular", + "source": "examples/angular", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"] + }, + { + "site": "fir-ui-rework-nextjs-ssg", + "public": "examples/nextjs/out", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"] + }, + { + "site": "fir-ui-rework-nextjs-ssr", + "source": "examples/nextjs-ssr", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], + "frameworksBackend": { + "region": "us-central1" + } + } + ] } diff --git a/package.json b/package.json index 61ad05637..50f6747f0 100644 --- a/package.json +++ b/package.json @@ -1,33 +1,32 @@ { "name": "@firebaseui/root", "private": true, + "type": "module", "scripts": { - "emulators": "firebase emulators:start --only auth", - "build": "pnpm run build:translations && pnpm run build:core && pnpm run build:react", - "build:core": "pnpm --filter=@firebase-ui/core run build", - "build:translations": "pnpm --filter=@firebase-ui/translations run build", - "build:react": "pnpm --filter=@firebase-ui/react run build", - "build:angular": "pnpm --filter=@firebase-ui/angular run build", - - "publish:tags:core": "pnpm --filter=@firebase-ui/core run publish:tags", - "publish:tags:translations": "pnpm --filter=@firebase-ui/translations run publish:tags", - "publish:tags:react": "pnpm --filter=@firebase-ui/react run publish:tags", - "publish:tags:angular": "pnpm --filter=@firebase-ui/angular run publish:tags", - "publish:tags:styles": "pnpm --filter=@firebase-ui/styles run publish:tags", - "publish:tags:all": "pnpm i && pnpm run publish:tags:core && pnpm run publish:tags:translations && pnpm run publish:tags:react && pnpm run publish:tags:styles && pnpm run publish:tags:angular", - - "release:core": "pnpm --filter=@firebase-ui/core run release", - "release:translations": "pnpm --filter=@firebase-ui/translations run release", - "release:react": "pnpm --filter=@firebase-ui/react run release", - "release:angular": "pnpm --filter=@firebase-ui/angular run release", - "release:styles": "pnpm --filter=@firebase-ui/styles run release", - "release:all": "pnpm i && pnpm run release:core && pnpm run release:translations && pnpm run release:react && pnpm run release:styles && pnpm run release:angular" + "emulators": "firebase emulators:start --only auth --project demo-test", + "build": "pnpm --filter=@invertase/firebaseui-translations run build && pnpm --filter=@invertase/firebaseui-styles run build && pnpm --filter=@invertase/firebaseui-core run build && pnpm --filter=@invertase/firebaseui-react run build && pnpm --filter=@invertase/firebaseui-angular run build && pnpm --filter=@invertase/firebaseui-shadcn run build && pnpm --filter react run build && pnpm --filter nextjs run build && pnpm --filter nextjs-ssr run build && pnpm --filter angular-example run build && pnpm --filter shadcn run build", + "build:packages": "pnpm --filter=@invertase/firebaseui-translations run build && pnpm --filter=@invertase/firebaseui-styles run build && pnpm --filter=@invertase/firebaseui-core run build && pnpm --filter=@invertase/firebaseui-react run build && pnpm --filter=@invertase/firebaseui-angular run build", + "deploy:hosting:all": "pnpm run build && pnpm --filter react run deploy && pnpm --filter nextjs run deploy && pnpm --filter nextjs-ssr run deploy && pnpm --filter angular-example run deploy && pnpm --filter shadcn run deploy", + "lint:check": "eslint", + "lint:fix": "eslint --fix", + "format:check": "prettier --check **/{src,tests}/**/*.{ts,tsx}", + "format:write": "prettier --write **/{src,tests}/**/*.{ts,tsx}", + "test": "pnpm --filter=@invertase/firebaseui-core run test && pnpm --filter=@invertase/firebaseui-translations run test && pnpm --filter=@invertase/firebaseui-styles run test && pnpm --filter=@invertase/firebaseui-react run test && pnpm --filter=@invertase/firebaseui-shadcn run test && pnpm --filter=@invertase/firebaseui-angular run test", + "test:watch": "pnpm --filter=@invertase/firebaseui-core run test:unit:watch & pnpm --filter=@invertase/firebaseui-react run test:unit:watch & pnpm --filter=@invertase/firebaseui-angular run test:watch", + "version:bump:all": "pnpm --filter=@invertase/firebaseui-core run version:bump && pnpm --filter=@invertase/firebaseui-translations run version:bump && pnpm --filter=@invertase/firebaseui-react run version:bump && pnpm --filter=@invertase/firebaseui-styles run version:bump && pnpm --filter=@invertase/firebaseui-angular run version:bump", + "publish:tags:all": "pnpm i && pnpm --filter=@invertase/firebaseui-core run publish:tags && pnpm --filter=@invertase/firebaseui-translations run publish:tags && pnpm --filter=@invertase/firebaseui-react run publish:tags && pnpm --filter=@invertase/firebaseui-styles run publish:tags && pnpm --filter=@invertase/firebaseui-angular run publish:tags", + "publish:npm:all": "pnpm run build:packages && pnpm run publish:npm:core && pnpm run publish:npm:translations && pnpm run publish:npm:react && pnpm run publish:npm:styles && pnpm run publish:npm:angular" }, "devDependencies": { - "rimraf": "^6.0.1", - "typescript": "^5.7.3", - "vite": "^6.0.11", - "vite-plugin-dts": "^4.2.3", - "vite-tsconfig-paths": "^5.0.1" + "@eslint/css": "^0.11.1", + "@eslint/js": "^9.35.0", + "angular-eslint": "^20.3.0", + "eslint": "catalog:", + "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", + "globals": "^16.4.0", + "prettier": "^3.1.1", + "typescript-eslint": "^8.45.0" } } diff --git a/packages/firebaseui-angular/README.md b/packages/angular/README.md similarity index 88% rename from packages/firebaseui-angular/README.md rename to packages/angular/README.md index 9af281fdb..2359a0db8 100644 --- a/packages/firebaseui-angular/README.md +++ b/packages/angular/README.md @@ -21,7 +21,7 @@ ng generate --help To build the library, run: ```bash -ng build firebaseui-angular +ng build angular ``` This command will compile your project, and the build artifacts will be placed in the `dist/` directory. @@ -31,8 +31,9 @@ This command will compile your project, and the build artifacts will be placed i Once the project is built, you can publish your library by following these steps: 1. Navigate to the `dist` directory: + ```bash - cd dist/firebaseui-angular + cd dist/angular ``` 2. Run the `npm publish` command to publish your library to the npm registry: @@ -42,10 +43,10 @@ Once the project is built, you can publish your library by following these steps ## Running unit tests -To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command: +To execute unit tests with [Vitest](https://vitest.dev), use the following command: ```bash -ng test +pnpm test ``` ## Running end-to-end tests diff --git a/packages/angular/angular.json b/packages/angular/angular.json new file mode 100644 index 000000000..d06d0a6c9 --- /dev/null +++ b/packages/angular/angular.json @@ -0,0 +1,23 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "projects": { + "firebase-ui-angular": { + "projectType": "library", + "root": "", + "sourceRoot": "src", + "prefix": "lib", + "architect": { + "test": { + "builder": "@analogjs/vitest-angular:test" + }, + "build": { + "builder": "@angular-devkit/build-angular:ng-packagr", + "options": { + "project": "ng-package.json" + } + } + } + } + } +} diff --git a/packages/angular/generate-logos.ts b/packages/angular/generate-logos.ts new file mode 100644 index 000000000..f3f98cc18 --- /dev/null +++ b/packages/angular/generate-logos.ts @@ -0,0 +1,171 @@ +#!/usr/bin/env node + +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { readdir, readFile, writeFile, mkdir } from "fs/promises"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { exec } from "child_process"; +import { promisify } from "util"; + +const execAsync = promisify(exec); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const CORE_BRANDS_DIR = join(__dirname, "../core/brands"); +const ANGULAR_LOGOS_DIR = join(__dirname, "src/lib/components/logos"); + +// Convert brand name to PascalCase for component names +function toPascalCase(str: string): string { + return str + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(""); +} + +// Convert brand name to kebab-case for file names +function toKebabCase(str: string): string { + return str.toLowerCase().replace(/\s+/g, "-"); +} + +// Format generated files with Prettier +async function formatWithPrettier(filePath: string): Promise { + try { + // Run prettier from the root directory to use the root prettier config + const rootDir = join(__dirname, "../../"); + await execAsync(`cd "${rootDir}" && pnpm prettier --write "${filePath}"`); + } catch (error) { + console.warn(`⚠️ Failed to format ${filePath}: ${error instanceof Error ? error.message : "Unknown error"}`); + } +} + +// Generate Angular component template from SVG content +function generateComponentTemplate(brandName: string, svgContent: string): string { + const componentName = toPascalCase(brandName); + const selector = `fui-${toKebabCase(brandName)}-logo`; + + // Clean up the SVG content - remove width/height attributes and add our class + // and add the fui-provider__icon class + const cleanedSvg = svgContent + .replace(/\s+width="[^"]*"/g, "") + .replace(/\s+height="[^"]*"/g, "") + .replace(/]*)>/, ''); + + return `/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// GENERATED BY generate-logos.ts + +import { Component, input } from "@angular/core"; + +@Component({ + selector: "${selector}", + standalone: true, + template: \` +${cleanedSvg} + \`, +}) +export class ${componentName}LogoComponent { + width = input("1em"); + height = input("1em"); + className = input(""); +} +`; +} + +// Main function to generate logo components +async function generateLogoComponents(): Promise { + try { + console.log("🎨 Generating Angular logo components from core SVG files..."); + + // Ensure the logos directory exists + await mkdir(ANGULAR_LOGOS_DIR, { recursive: true }); + + // Read all brand directories + const brandDirs = await readdir(CORE_BRANDS_DIR, { withFileTypes: true }); + const brandDirectories = brandDirs.filter((dirent) => dirent.isDirectory()); + + console.log(`📁 Found ${brandDirectories.length} brand directories`); + + for (const brandDir of brandDirectories) { + const brandName = brandDir.name; + const brandPath = join(CORE_BRANDS_DIR, brandName); + + try { + // Look for logo.svg in the brand directory + const logoPath = join(brandPath, "logo.svg"); + const svgContent = await readFile(logoPath, "utf-8"); + + // Generate the component + const componentContent = generateComponentTemplate(brandName, svgContent); + + // Write the component file + const componentFileName = `${toKebabCase(brandName)}.ts`; + const componentPath = join(ANGULAR_LOGOS_DIR, componentFileName); + + await writeFile(componentPath, componentContent, "utf-8"); + + // Format the generated file with Prettier + await formatWithPrettier(componentPath); + + console.log(`✅ Generated ${brandName} logo component: ${componentFileName}`); + } catch (error) { + console.warn(`⚠️ Skipping ${brandName}: ${error instanceof Error ? error.message : "Unknown error"}`); + } + } + + // Generate an index file to export all components + const indexContent = brandDirectories + .map((brandDir) => { + const brandName = brandDir.name; + const componentName = toPascalCase(brandName); + const fileName = toKebabCase(brandName); + return `export { ${componentName}LogoComponent } from './${fileName}';`; + }) + .join("\n"); + + const indexPath = join(ANGULAR_LOGOS_DIR, "index.ts"); + await writeFile(indexPath, indexContent, "utf-8"); + + // Format the index file with Prettier + await formatWithPrettier(indexPath); + + console.log("📄 Generated index.ts file"); + console.log("🎉 Logo component generation complete!"); + } catch (error) { + console.error("❌ Error generating logo components:", error); + process.exit(1); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + generateLogoComponents(); +} diff --git a/packages/angular/jest.config.ts b/packages/angular/jest.config.ts new file mode 100644 index 000000000..d6d1aff08 --- /dev/null +++ b/packages/angular/jest.config.ts @@ -0,0 +1,37 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Config } from "jest"; +import { createCjsPreset } from "jest-preset-angular/presets/index.js"; + +const config: Config = { + ...createCjsPreset(), + setupFilesAfterEnv: ["/setup-test.ts"], + coveragePathIgnorePatterns: ["/node_modules/", "/dist/"], + testEnvironment: "jsdom", + moduleNameMapper: { + "^@invertase/firebaseui-core$": "/src/lib/tests/test-helpers.ts", + "^@angular/fire/auth$": "/src/lib/tests/test-helpers.ts", + "^firebase/auth$": "/src/lib/tests/test-helpers.ts", + "^../provider$": "/src/lib/tests/test-helpers.ts", + "^../../provider$": "/src/lib/tests/test-helpers.ts", + "^../../../provider$": "/src/lib/tests/test-helpers.ts", + "^../../../../provider$": "/src/lib/tests/test-helpers.ts", + "^../../../../../provider$": "/src/lib/tests/test-helpers.ts", + }, +}; + +export default config; diff --git a/packages/firebaseui-angular/ng-package.json b/packages/angular/ng-package.json similarity index 68% rename from packages/firebaseui-angular/ng-package.json rename to packages/angular/ng-package.json index cb8a2e0b4..01c23e4ad 100644 --- a/packages/firebaseui-angular/ng-package.json +++ b/packages/angular/ng-package.json @@ -1,13 +1,14 @@ { "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", - "dest": "../../dist/firebaseui-angular", + "dest": "./dist/", "lib": { "entryFile": "src/public-api.ts" }, "allowedNonPeerDependencies": [ - "@firebase-ui/core", - "@firebase-ui/styles", + "@invertase/firebaseui-core", + "@invertase/firebaseui-styles", "@tanstack/angular-form", + "firebase", "nanostores", "tslib", "zod" diff --git a/packages/angular/package.json b/packages/angular/package.json new file mode 100644 index 000000000..98c5f8095 --- /dev/null +++ b/packages/angular/package.json @@ -0,0 +1,68 @@ +{ + "name": "@invertase/firebaseui-angular", + "version": "0.0.5", + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/fesm2022/invertase-firebaseui-angular.mjs", + "module": "./dist/fesm2022/invertase-firebaseui-angular.mjs", + "typings": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/fesm2022/invertase-firebaseui-angular.mjs" + } + }, + "scripts": { + "prepare": "pnpm run build", + "build": "pnpm run build:logos && ng-packagr -p ng-package.json", + "build:logos": "tsx generate-logos.ts", + "test": "jest --silent", + "version:bump": "pnpm version patch", + "publish:tags": "sh -c 'TAG=\"${npm_package_name}@${npm_package_version}\"; git tag --list \"$TAG\" | grep . || git tag \"$TAG\"; git push origin \"$TAG\"'", + "publish:npm": "pnpm publish --access public", + "release": "pnpm pack --pack-destination ../../releases/" + }, + "peerDependencies": { + "@angular/fire": "catalog:peerDependencies" + }, + "dependencies": { + "@invertase/firebaseui-core": "workspace:*", + "@invertase/firebaseui-styles": "workspace:*", + "@tanstack/angular-form": "^1.23.1", + "nanostores": "catalog:", + "tslib": "^2.8.1" + }, + "sideEffects": false, + "devDependencies": { + "@angular-devkit/build-angular": "catalog:", + "@angular/cli": "catalog:", + "@angular/common": "catalog:", + "@angular/compiler": "catalog:", + "@angular/compiler-cli": "catalog:", + "@angular/core": "catalog:", + "@angular/fire": "catalog:", + "@angular/forms": "catalog:", + "@angular/platform-browser": "catalog:", + "@angular/platform-browser-dynamic": "catalog:", + "@angular/router": "catalog:", + "@testing-library/angular": "^18.1.0", + "@testing-library/jest-dom": "catalog:", + "@types/jest": "^30.0.0", + "@types/node": "catalog:", + "firebase": "catalog:", + "jest": "^30.2.0", + "jest-environment-jsdom": "^30.2.0", + "jest-preset-angular": "^15.0.2", + "jsdom": "^25.0.0", + "ng-packagr": "^20.0.0", + "rxjs": "catalog:", + "ts-node": "^10.9.2", + "tsx": "^4.20.6", + "typescript": "catalog:", + "whatwg-fetch": "^3.6.20", + "zod": "catalog:", + "zone.js": "catalog:" + } +} diff --git a/packages/angular/setup-test.ts b/packages/angular/setup-test.ts new file mode 100644 index 000000000..48d4e7032 --- /dev/null +++ b/packages/angular/setup-test.ts @@ -0,0 +1,36 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; +import "@testing-library/jest-dom"; + +import "@angular/compiler"; + +// Add fetch polyfill for Firebase +import 'whatwg-fetch'; + +// import { BrowserTestingModule, platformBrowserTesting } from "@angular/platform-browser/testing"; +// import { NgModule, provideZonelessChangeDetection } from "@angular/core"; +// import { getTestBed } from "@angular/core/testing"; + +setupZoneTestEnv(); + +// @NgModule({ +// providers: [provideZonelessChangeDetection()], +// }) +// export class ZonelessTestModule {} + +// getTestBed().initTestEnvironment([BrowserTestingModule, ZonelessTestModule], platformBrowserTesting()); diff --git a/packages/angular/src/lib/auth/forms/email-link-auth-form.spec.ts b/packages/angular/src/lib/auth/forms/email-link-auth-form.spec.ts new file mode 100644 index 000000000..a1c9a5edc --- /dev/null +++ b/packages/angular/src/lib/auth/forms/email-link-auth-form.spec.ts @@ -0,0 +1,371 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, waitFor } from "@testing-library/angular"; +import { CommonModule } from "@angular/common"; +import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; +import { EmailLinkAuthFormComponent } from "./email-link-auth-form"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../components/form"; +import { PoliciesComponent } from "../../components/policies"; +import { UserCredential } from "@angular/fire/auth"; + +// Mock the @invertase/firebaseui-core module but preserve Angular providers +jest.mock("@invertase/firebaseui-core", () => { + const originalModule = jest.requireActual("@invertase/firebaseui-core"); + return { + ...originalModule, + sendSignInLinkToEmail: jest.fn(), + completeEmailLinkSignIn: jest.fn(), + FirebaseUIError: class FirebaseUIError extends Error { + constructor(message: string) { + super(message); + this.name = "FirebaseUIError"; + } + }, + }; +}); + +describe("", () => { + let mockSendSignInLinkToEmail: any; + let mockCompleteEmailLinkSignIn: any; + let mockFirebaseUIError: any; + + beforeEach(() => { + const { sendSignInLinkToEmail, completeEmailLinkSignIn, FirebaseUIError } = require("@invertase/firebaseui-core"); + mockSendSignInLinkToEmail = sendSignInLinkToEmail; + mockCompleteEmailLinkSignIn = completeEmailLinkSignIn; + mockFirebaseUIError = FirebaseUIError; + + mockCompleteEmailLinkSignIn.mockResolvedValue(null); + mockSendSignInLinkToEmail.mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should render the form initially", async () => { + await render(EmailLinkAuthFormComponent, { + imports: [ + CommonModule, + EmailLinkAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + + expect(screen.getByLabelText("Email Address")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send Sign In Link" })).toBeInTheDocument(); + expect(screen.getByText("By continuing, you agree to our")).toBeInTheDocument(); + }); + + it("should not show success message initially", async () => { + await render(EmailLinkAuthFormComponent, { + imports: [ + CommonModule, + EmailLinkAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + + expect(screen.queryByText("Check your email for a sign in link")).toBeNull(); + }); + + it("should have correct translation labels", async () => { + const { fixture } = await render(EmailLinkAuthFormComponent, { + imports: [ + CommonModule, + EmailLinkAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + const component = fixture.componentInstance; + + expect(component.emailLabel()).toBe("Email Address"); + expect(component.sendSignInLinkLabel()).toBe("Send Sign In Link"); + expect(component.emailSentMessage()).toBe("Check your email for a sign in link"); + expect(component.unknownErrorLabel()).toBe("An unknown error occurred"); + }); + + it("should initialize form with empty email", async () => { + const { fixture } = await render(EmailLinkAuthFormComponent, { + imports: [ + CommonModule, + EmailLinkAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + const component = fixture.componentInstance; + expect(component.form.getFieldValue("email")).toBe(""); + }); + + it("should prevent default and stop propagation on form submit", async () => { + // Mock the function to resolve immediately for this test + mockSendSignInLinkToEmail.mockResolvedValue(undefined); + + const { fixture } = await render(EmailLinkAuthFormComponent, { + imports: [ + CommonModule, + EmailLinkAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + const component = fixture.componentInstance; + component.form.setFieldValue("email", "test@example.com"); + fixture.detectChanges(); + + const submitEvent = new Event("submit") as SubmitEvent; + const preventDefaultSpy = jest.fn(); + const stopPropagationSpy = jest.fn(); + + Object.defineProperties(submitEvent, { + preventDefault: { value: preventDefaultSpy }, + stopPropagation: { value: stopPropagationSpy }, + }); + + // Wait for the async form submission to complete + await component.handleSubmit(submitEvent); + await waitFor(() => { + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(stopPropagationSpy).toHaveBeenCalled(); + }); + }); + + it("should handle form submission with valid email", async () => { + mockSendSignInLinkToEmail.mockResolvedValue(undefined); + + const { fixture } = await render(EmailLinkAuthFormComponent, { + imports: [ + CommonModule, + EmailLinkAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + const emailSentSpy = jest.spyOn(component.emailSent, "emit"); + + const mockUI = { app: {}, auth: {} }; + await mockSendSignInLinkToEmail(mockUI, "test@example.com"); + component.emailSentState.set(true); + component.emailSent?.emit(); + + expect(component.emailSentState()).toBe(true); + expect(emailSentSpy).toHaveBeenCalled(); + expect(mockSendSignInLinkToEmail).toHaveBeenCalledWith( + expect.objectContaining({ + app: expect.any(Object), + auth: expect.any(Object), + }), + "test@example.com" + ); + }); + + it("should show success message after email is sent", async () => { + mockSendSignInLinkToEmail.mockResolvedValue(undefined); + + const { fixture } = await render(EmailLinkAuthFormComponent, { + imports: [ + CommonModule, + EmailLinkAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.emailSentState.set(true); + fixture.detectChanges(); + + const successMessage = screen.getByText("Check your email for a sign in link"); + expect(successMessage).toBeInTheDocument(); + expect(successMessage).toHaveClass("fui-success"); + }); + + it("should handle FirebaseUIError and display error message", async () => { + const errorMessage = "User not found"; + mockSendSignInLinkToEmail.mockRejectedValue(new mockFirebaseUIError(errorMessage)); + + const { fixture } = await render(EmailLinkAuthFormComponent, { + imports: [ + CommonModule, + EmailLinkAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.form.setFieldValue("email", "nonexistent@example.com"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await waitFor(() => { + expect(component.emailSentState()).toBe(false); + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + }); + + it("should handle unknown errors and display generic error message", async () => { + mockSendSignInLinkToEmail.mockRejectedValue(new Error("Network error")); + + const { fixture } = await render(EmailLinkAuthFormComponent, { + imports: [ + CommonModule, + EmailLinkAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.form.setFieldValue("email", "test@example.com"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await waitFor(() => { + expect(component.emailSentState()).toBe(false); + expect(screen.getByText("An unknown error occurred")).toBeInTheDocument(); + }); + }); + + it("should use the same validation logic as the real createEmailLinkAuthFormSchema", async () => { + const { fixture } = await render(EmailLinkAuthFormComponent, { + imports: [ + CommonModule, + EmailLinkAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.form.setFieldValue("email", "invalid-email"); + fixture.detectChanges(); + + expect(component.form.state.errorMap).toBeDefined(); + + component.form.setFieldValue("email", "test@example.com"); + fixture.detectChanges(); + + expect(component.form.state.errors).toHaveLength(0); + }); + + it("should call completeSignIn on initialization", async () => { + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + mockCompleteEmailLinkSignIn.mockResolvedValue(mockCredential); + + await render(EmailLinkAuthFormComponent, { + imports: [ + CommonModule, + EmailLinkAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + + await waitFor(() => { + expect(mockCompleteEmailLinkSignIn).toHaveBeenCalledWith( + expect.objectContaining({ + app: expect.any(Object), + auth: expect.any(Object), + }), + "http://localhost/" + ); + }); + + expect(mockCompleteEmailLinkSignIn).toHaveBeenCalledTimes(1); + }); + + it("should not emit signIn if no credential is returned", async () => { + mockCompleteEmailLinkSignIn.mockResolvedValue(null); + + const { fixture } = await render(EmailLinkAuthFormComponent, { + imports: [ + CommonModule, + EmailLinkAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + await waitFor(() => { + expect(mockCompleteEmailLinkSignIn).toHaveBeenCalled(); + }); + + expect(signInSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/angular/src/lib/auth/forms/email-link-auth-form.ts b/packages/angular/src/lib/auth/forms/email-link-auth-form.ts new file mode 100644 index 000000000..223297376 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/email-link-auth-form.ts @@ -0,0 +1,137 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, effect, Output, EventEmitter, signal } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanstack/angular-form"; +import { UserCredential } from "@angular/fire/auth"; +import { FirebaseUIError, completeEmailLinkSignIn, sendSignInLinkToEmail } from "@invertase/firebaseui-core"; + +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../components/form"; +import { PoliciesComponent } from "../../components/policies"; +import { injectEmailLinkAuthFormSchema, injectTranslation, injectUI } from "../../provider"; + +@Component({ + selector: "fui-email-link-auth-form", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + TanStackField, + TanStackAppField, + PoliciesComponent, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + template: ` + @if (emailSentState()) { +
+ {{ emailSentMessage() }} +
+ } + + @if (!emailSentState()) { +
+
+ +
+ + + +
+ + {{ sendSignInLinkLabel() }} + + +
+ + } + `, +}) +/** + * A form component for email link authentication. + * + * Sends a sign-in link to the user's email address and automatically completes sign-in + * if the user arrives via an email link. + */ +export class EmailLinkAuthFormComponent { + private ui = injectUI(); + private formSchema = injectEmailLinkAuthFormSchema(); + + emailSentState = signal(false); + + emailLabel = injectTranslation("labels", "emailAddress"); + sendSignInLinkLabel = injectTranslation("labels", "sendSignInLink"); + emailSentMessage = injectTranslation("messages", "signInLinkSent"); + unknownErrorLabel = injectTranslation("errors", "unknownError"); + + /** Event emitter fired when sign-in link email is sent. */ + @Output() emailSent = new EventEmitter(); + /** Event emitter for successful sign-in. */ + @Output() signIn = new EventEmitter(); + + form = injectForm({ + defaultValues: { + email: "", + }, + }); + + state = injectStore(this.form, (state) => state); + + async handleSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.form.handleSubmit(); + } + + constructor() { + this.completeSignIn(); + + effect(() => { + this.form.update({ + validators: { + onBlur: this.formSchema(), + onSubmitAsync: async ({ value }) => { + try { + await sendSignInLinkToEmail(this.ui(), value.email); + this.emailSentState.set(true); + this.emailSent.emit(); + return; + } catch (error) { + if (error instanceof FirebaseUIError) { + return error.message; + } + + console.error(error); + return this.unknownErrorLabel(); + } + }, + }, + }); + }); + } + + private async completeSignIn() { + const credential = await completeEmailLinkSignIn(this.ui(), window.location.href); + + if (credential) { + this.signIn.emit(credential); + } + } +} diff --git a/packages/angular/src/lib/auth/forms/forgot-password-auth-form.spec.ts b/packages/angular/src/lib/auth/forms/forgot-password-auth-form.spec.ts new file mode 100644 index 000000000..71517c7b0 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/forgot-password-auth-form.spec.ts @@ -0,0 +1,377 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, fireEvent, waitFor } from "@testing-library/angular"; +import { CommonModule } from "@angular/common"; +import { EventEmitter } from "@angular/core"; +import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; +import { ForgotPasswordAuthFormComponent } from "./forgot-password-auth-form"; +import { + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, +} from "../../components/form"; +import { PoliciesComponent } from "../../components/policies"; + +// Mock the @invertase/firebaseui-core module but preserve Angular providers +jest.mock("@invertase/firebaseui-core", () => { + const originalModule = jest.requireActual("@invertase/firebaseui-core"); + return { + ...originalModule, + sendPasswordResetEmail: jest.fn(), + FirebaseUIError: class FirebaseUIError extends Error { + constructor(message: string) { + super(message); + this.name = "FirebaseUIError"; + } + }, + }; +}); + +describe("", () => { + let mockSendPasswordResetEmail: any; + let mockFirebaseUIError: any; + + beforeEach(() => { + const { sendPasswordResetEmail, FirebaseUIError } = require("@invertase/firebaseui-core"); + mockSendPasswordResetEmail = sendPasswordResetEmail; + mockFirebaseUIError = FirebaseUIError; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should create", async () => { + const { fixture } = await render(ForgotPasswordAuthFormComponent, { + imports: [ + CommonModule, + ForgotPasswordAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it("should render the form initially", async () => { + const backToSignInEmitter = new EventEmitter(); + backToSignInEmitter.subscribe(() => {}); + + const { fixture } = await render(ForgotPasswordAuthFormComponent, { + imports: [ + CommonModule, + ForgotPasswordAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + componentInputs: { + backToSignIn: backToSignInEmitter, + }, + }); + fixture.detectChanges(); + + expect(screen.getByLabelText("Email Address")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Reset Password" })).toBeInTheDocument(); + expect(screen.getByText("By continuing, you agree to our")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Back to Sign In →" })).toBeInTheDocument(); + }); + + it("should not show success message initially", async () => { + await render(ForgotPasswordAuthFormComponent, { + imports: [ + CommonModule, + ForgotPasswordAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + + expect(screen.queryByText("Check your email for a password reset link")).toBeNull(); + }); + + it("should have correct translation labels", async () => { + const { fixture } = await render(ForgotPasswordAuthFormComponent, { + imports: [ + CommonModule, + ForgotPasswordAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + const component = fixture.componentInstance; + + expect(component.emailLabel()).toBe("Email Address"); + expect(component.resetPasswordLabel()).toBe("Reset Password"); + expect(component.backToSignInLabel()).toBe("Back to Sign In"); + expect(component.checkEmailForResetMessage()).toBe("Check your email for a password reset link"); + expect(component.unknownErrorLabel()).toBe("An unknown error occurred"); + }); + + it("should initialize form with empty email", async () => { + const { fixture } = await render(ForgotPasswordAuthFormComponent, { + imports: [ + CommonModule, + ForgotPasswordAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + const component = fixture.componentInstance; + expect(component.form.getFieldValue("email")).toBe(""); + }); + + it("should emit backToSignIn when back button is clicked", async () => { + const backToSignInEmitter = new EventEmitter(); + backToSignInEmitter.subscribe(() => {}); + + const { fixture } = await render(ForgotPasswordAuthFormComponent, { + imports: [ + CommonModule, + ForgotPasswordAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + componentInputs: { + backToSignIn: backToSignInEmitter, + }, + }); + fixture.detectChanges(); + const backToSignInSpy = jest.spyOn(backToSignInEmitter, "emit"); + + const backButton = screen.getByRole("button", { name: "Back to Sign In →" }); + fireEvent.click(backButton); + expect(backToSignInSpy).toHaveBeenCalled(); + }); + + it("should prevent default and stop propagation on form submit", async () => { + const { fixture } = await render(ForgotPasswordAuthFormComponent, { + imports: [ + CommonModule, + ForgotPasswordAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + const component = fixture.componentInstance; + component.form.setFieldValue("email", "test@example.com"); + fixture.detectChanges(); + + const submitEvent = new Event("submit") as SubmitEvent; + const preventDefaultSpy = jest.fn(); + const stopPropagationSpy = jest.fn(); + + Object.defineProperties(submitEvent, { + preventDefault: { value: preventDefaultSpy }, + stopPropagation: { value: stopPropagationSpy }, + }); + + await component.handleSubmit(submitEvent); + await waitFor(() => { + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(stopPropagationSpy).toHaveBeenCalled(); + }); + }); + + it("should handle form submission with valid email", async () => { + mockSendPasswordResetEmail.mockResolvedValue(undefined); + + const { fixture } = await render(ForgotPasswordAuthFormComponent, { + imports: [ + CommonModule, + ForgotPasswordAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + // Access the getter to initialize EventEmitter (simulating template binding) + component.passwordSent.subscribe(() => {}); + fixture.detectChanges(); + const passwordSentSpy = jest.spyOn(component.passwordSent, "emit"); + + const mockUI = { app: {}, auth: {} }; + await mockSendPasswordResetEmail(mockUI, "test@example.com"); + component.emailSent.set(true); + component.passwordSent?.emit(); + + expect(component.emailSent()).toBe(true); + expect(passwordSentSpy).toHaveBeenCalled(); + expect(mockSendPasswordResetEmail).toHaveBeenCalledWith( + expect.objectContaining({ + app: expect.any(Object), + auth: expect.any(Object), + }), + "test@example.com" + ); + }); + + it("should show success message after email is sent", async () => { + mockSendPasswordResetEmail.mockResolvedValue(undefined); + + const { fixture } = await render(ForgotPasswordAuthFormComponent, { + imports: [ + CommonModule, + ForgotPasswordAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.emailSent.set(true); + fixture.detectChanges(); + + const successMessage = screen.getByText("Check your email for a password reset link"); + expect(successMessage).toBeInTheDocument(); + expect(successMessage).toHaveClass("fui-success"); + }); + + it("should handle FirebaseUIError and display error message", async () => { + const errorMessage = "User not found"; + mockSendPasswordResetEmail.mockRejectedValue(new mockFirebaseUIError(errorMessage)); + + const { fixture } = await render(ForgotPasswordAuthFormComponent, { + imports: [ + CommonModule, + ForgotPasswordAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.form.setFieldValue("email", "nonexistent@example.com"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await waitFor(() => { + expect(component.emailSent()).toBe(false); + expect(component.form.state.errors.length).toBeGreaterThan(0); + }); + }); + + it("should handle unknown errors and display generic error message", async () => { + mockSendPasswordResetEmail.mockRejectedValue(new Error("Network error")); + + const { fixture } = await render(ForgotPasswordAuthFormComponent, { + imports: [ + CommonModule, + ForgotPasswordAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.form.setFieldValue("email", "test@example.com"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await waitFor(() => { + expect(component.emailSent()).toBe(false); + expect(component.form.state.errors.length).toBeGreaterThan(0); + }); + }); + + it("should use the same validation logic as the real createForgotPasswordAuthFormSchema", async () => { + const { fixture } = await render(ForgotPasswordAuthFormComponent, { + imports: [ + CommonModule, + ForgotPasswordAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.form.setFieldValue("email", "invalid-email"); + fixture.detectChanges(); + + expect(component.form.state.errorMap).toBeDefined(); + + component.form.setFieldValue("email", "test@example.com"); + fixture.detectChanges(); + + expect(component.form.state.errors).toHaveLength(0); + }); +}); diff --git a/packages/angular/src/lib/auth/forms/forgot-password-auth-form.ts b/packages/angular/src/lib/auth/forms/forgot-password-auth-form.ts new file mode 100644 index 000000000..40a190f13 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/forgot-password-auth-form.ts @@ -0,0 +1,137 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, effect, Output, EventEmitter, input, signal } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanstack/angular-form"; +import { FirebaseUIError, sendPasswordResetEmail } from "@invertase/firebaseui-core"; + +import { + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, +} from "../../components/form"; +import { PoliciesComponent } from "../../components/policies"; +import { injectForgotPasswordAuthFormSchema, injectTranslation, injectUI } from "../../provider"; + +@Component({ + selector: "fui-forgot-password-auth-form", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + TanStackField, + TanStackAppField, + PoliciesComponent, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + ], + template: ` + @if (emailSent()) { +
+ {{ checkEmailForResetMessage() }} +
+ } + + @if (!emailSent()) { +
+
+ +
+ + + +
+ + {{ resetPasswordLabel() }} + + +
+ + @if (backToSignIn()?.observed) { + + } + + } + `, +}) +/** + * A form component for requesting a password reset email. + * + * Displays a success message after the email is sent. + */ +export class ForgotPasswordAuthFormComponent { + private ui = injectUI(); + private formSchema = injectForgotPasswordAuthFormSchema(); + + emailSent = signal(false); + + emailLabel = injectTranslation("labels", "emailAddress"); + resetPasswordLabel = injectTranslation("labels", "resetPassword"); + backToSignInLabel = injectTranslation("labels", "backToSignIn"); + checkEmailForResetMessage = injectTranslation("messages", "checkEmailForReset"); + unknownErrorLabel = injectTranslation("errors", "unknownError"); + + /** Event emitter for back to sign in action. */ + backToSignIn = input>(); + + /** Event emitter fired when password reset email is sent. */ + @Output() passwordSent = new EventEmitter(); + + form = injectForm({ + defaultValues: { + email: "", + }, + }); + + state = injectStore(this.form, (state) => state); + + async handleSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.form.handleSubmit(); + } + + constructor() { + effect(() => { + this.form.update({ + validators: { + onBlur: this.formSchema(), + onSubmitAsync: async ({ value }) => { + try { + await sendPasswordResetEmail(this.ui(), value.email); + this.emailSent.set(true); + this.passwordSent.emit(); + return; + } catch (error) { + if (error instanceof FirebaseUIError) { + return error.message; + } + + console.error(error); + return this.unknownErrorLabel(); + } + }, + }, + }); + }); + } +} diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts new file mode 100644 index 000000000..4221d1f73 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.spec.ts @@ -0,0 +1,422 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, fireEvent, waitFor } from "@testing-library/angular"; + +import { + SmsMultiFactorAssertionFormComponent, + SmsMultiFactorAssertionPhoneFormComponent, + SmsMultiFactorAssertionVerifyFormComponent, +} from "./sms-multi-factor-assertion-form"; + +import { + verifyPhoneNumber, + signInWithMultiFactorAssertion, + PhoneMultiFactorGenerator, +} from "../../../tests/test-helpers"; + +describe("", () => { + beforeEach(() => { + const { + injectTranslation, + injectUI, + injectMultiFactorPhoneAuthAssertionFormSchema, + injectMultiFactorPhoneAuthVerifyFormSchema, + injectRecaptchaVerifier, + } = require("../../../tests/test-helpers"); + + const { getTranslation } = require("@invertase/firebaseui-core"); + getTranslation.mockImplementation((ui: any, category: string, key: string, params?: any) => { + if (category === "messages" && key === "mfaSmsAssertionPrompt" && params) { + return `A verification code will be sent to ${params.phoneNumber} to complete the authentication process.`; + } + const mockTranslations: Record> = { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, + prompts: { + smsVerificationPrompt: "Enter the verification code sent to your phone number", + }, + errors: { + unknownError: "An unknown error occurred", + }, + }; + return mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, + prompts: { + smsVerificationPrompt: "Enter the verification code sent to your phone number", + }, + errors: { + unknownError: "An unknown error occurred", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => { + return () => ({ + auth: {}, + }); + }); + + injectMultiFactorPhoneAuthAssertionFormSchema.mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + phoneNumber: z.string().min(1, "Phone number is required"), + }); + }); + + injectMultiFactorPhoneAuthVerifyFormSchema.mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + verificationCode: z.string().min(1, "Verification code is required"), + }); + }); + + verifyPhoneNumber.mockResolvedValue("test-verification-id"); + signInWithMultiFactorAssertion.mockResolvedValue({}); + + injectRecaptchaVerifier.mockImplementation(() => { + return () => ({ + clear: jest.fn(), + render: jest.fn(), + verify: jest.fn(), + }); + }); + + const { PhoneAuthProvider, PhoneMultiFactorGenerator } = require("firebase/auth"); + PhoneAuthProvider.credential = jest.fn().mockReturnValue({}); + PhoneMultiFactorGenerator.assertion = jest.fn().mockReturnValue({}); + }); + + it("renders phone form initially", async () => { + const mockHint = { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Phone", + phoneNumber: "+1234567890", + }; + + await render(SmsMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [SmsMultiFactorAssertionFormComponent], + }); + + expect(screen.getByText(/A verification code will be sent to \+1234567890/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send Code" })).toBeInTheDocument(); + }); + + it("switches to verify form after phone submission", async () => { + const mockHint = { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Phone", + phoneNumber: "+1234567890", + }; + + await render(SmsMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [SmsMultiFactorAssertionFormComponent], + }); + + expect(screen.getByText(/A verification code will be sent to \+1234567890/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Send Code" })); + + await waitFor(() => { + expect(screen.getByRole("textbox", { name: /Verification Code/i })).toBeInTheDocument(); + }); + + expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); + expect(screen.queryByText(/A verification code will be sent to/)).not.toBeInTheDocument(); + }); + + it("emits onSuccess when verification is successful", async () => { + const mockHint = { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Phone", + phoneNumber: "+1234567890", + }; + + const { fixture } = await render(SmsMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [SmsMultiFactorAssertionFormComponent], + }); + + const onSuccessSpy = jest.fn(); + fixture.componentInstance.onSuccess.subscribe(onSuccessSpy); + + const phoneFormComponent = fixture.debugElement.query( + (el) => el.componentInstance?.constructor?.name === "SmsMultiFactorAssertionPhoneFormComponent" + )?.componentInstance; + + if (phoneFormComponent) { + await phoneFormComponent.form.handleSubmit(); + } + + await waitFor(() => { + expect(screen.getByRole("textbox", { name: /Verification Code/i })).toBeInTheDocument(); + }); + + const verifyFormComponent = fixture.debugElement.query( + (el) => el.componentInstance?.constructor?.name === "SmsMultiFactorAssertionVerifyFormComponent" + )?.componentInstance; + + if (verifyFormComponent) { + verifyFormComponent.form.setFieldValue("verificationCode", "123456"); + verifyFormComponent.form.setFieldValue("verificationId", "test-verification-id"); + await verifyFormComponent.form.handleSubmit(); + } else { + fail("Verify form component not found"); + } + + await waitFor(() => { + expect(onSuccessSpy).toHaveBeenCalled(); + }); + }); +}); + +describe("", () => { + beforeEach(() => { + const { + injectTranslation, + injectUI, + injectMultiFactorPhoneAuthAssertionFormSchema, + } = require("../../../tests/test-helpers"); + + const { getTranslation } = require("@invertase/firebaseui-core"); + getTranslation.mockImplementation((ui: any, category: string, key: string, params?: any) => { + if (category === "messages" && key === "mfaSmsAssertionPrompt" && params) { + return `A verification code will be sent to ${params.phoneNumber} to complete the authentication process.`; + } + const mockTranslations: Record> = { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + }, + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, + errors: { + unknownError: "An unknown error occurred", + }, + }; + return mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + }, + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, + errors: { + unknownError: "An unknown error occurred", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => { + return () => ({ + auth: {}, + }); + }); + + injectMultiFactorPhoneAuthAssertionFormSchema.mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + phoneNumber: z.string().min(1, "Phone number is required"), + }); + }); + + verifyPhoneNumber.mockResolvedValue("test-verification-id"); + }); + + it("renders phone form with message showing phone number from hint", async () => { + const mockHint = { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Phone", + phoneNumber: "+1234567890", + }; + + await render(SmsMultiFactorAssertionPhoneFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [SmsMultiFactorAssertionPhoneFormComponent], + }); + + expect(screen.getByText(/A verification code will be sent to \+1234567890/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send Code" })).toBeInTheDocument(); + }); + + it("emits onSubmit when form is submitted", async () => { + const mockHint = { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Phone", + phoneNumber: "+1234567890", + }; + + const { fixture } = await render(SmsMultiFactorAssertionPhoneFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [SmsMultiFactorAssertionPhoneFormComponent], + }); + + const onSubmitSpy = jest.fn(); + fixture.componentInstance.onSubmit.subscribe(onSubmitSpy); + + fireEvent.click(screen.getByRole("button", { name: "Send Code" })); + + await waitFor(() => { + expect(onSubmitSpy).toHaveBeenCalledWith("test-verification-id"); + }); + }); +}); + +describe("", () => { + beforeEach(() => { + const { + injectTranslation, + injectUI, + injectMultiFactorPhoneAuthVerifyFormSchema, + } = require("../../../tests/test-helpers"); + + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + prompts: { + smsVerificationPrompt: "Enter the verification code sent to your phone number", + }, + errors: { + unknownError: "An unknown error occurred", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => { + return () => ({ + auth: {}, + }); + }); + + injectMultiFactorPhoneAuthVerifyFormSchema.mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + verificationCode: z.string().min(1, "Verification code is required"), + }); + }); + + signInWithMultiFactorAssertion.mockResolvedValue({}); + + const { PhoneAuthProvider, PhoneMultiFactorGenerator } = require("firebase/auth"); + PhoneAuthProvider.credential = jest.fn().mockReturnValue({}); + PhoneMultiFactorGenerator.assertion = jest.fn().mockReturnValue({}); + }); + + it("renders verification form", async () => { + await render(SmsMultiFactorAssertionVerifyFormComponent, { + componentInputs: { + verificationId: "test-verification-id", + }, + imports: [SmsMultiFactorAssertionVerifyFormComponent], + }); + + await waitFor(() => { + expect(screen.getByRole("textbox", { name: /Verification Code/i })).toBeInTheDocument(); + }); + expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); + }); + + it("emits onSuccess when verification is successful", async () => { + const { fixture } = await render(SmsMultiFactorAssertionVerifyFormComponent, { + componentInputs: { + verificationId: "test-verification-id", + }, + imports: [SmsMultiFactorAssertionVerifyFormComponent], + }); + + const onSuccessSpy = jest.fn(); + fixture.componentInstance.onSuccess.subscribe(onSuccessSpy); + + const component = fixture.componentInstance; + component.form.setFieldValue("verificationCode", "123456"); + component.form.setFieldValue("verificationId", "test-verification-id"); + await component.form.handleSubmit(); + + await waitFor(() => { + expect(onSuccessSpy).toHaveBeenCalled(); + }); + }); + + it("emits onSuccess with credential after successful verification", async () => { + const mockCredential = { user: { uid: "sms-verify-user" } }; + signInWithMultiFactorAssertion.mockResolvedValue(mockCredential); + + const { fixture } = await render(SmsMultiFactorAssertionVerifyFormComponent, { + componentInputs: { + verificationId: "test-verification-id", + }, + imports: [SmsMultiFactorAssertionVerifyFormComponent], + }); + + const onSuccessSpy = jest.fn(); + fixture.componentInstance.onSuccess.subscribe(onSuccessSpy); + + const component = fixture.componentInstance; + component.form.setFieldValue("verificationCode", "123456"); + component.form.setFieldValue("verificationId", "test-verification-id"); + await component.form.handleSubmit(); + + await waitFor(() => { + expect(onSuccessSpy).toHaveBeenCalledWith(mockCredential); + }); + }); +}); diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts new file mode 100644 index 000000000..b45742577 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts @@ -0,0 +1,267 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, ElementRef, effect, input, signal, Output, EventEmitter, computed, viewChild } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanstack/angular-form"; +import { + injectMultiFactorPhoneAuthVerifyFormSchema, + injectRecaptchaVerifier, + injectTranslation, + injectUI, +} from "../../../provider"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; +import { + FirebaseUIError, + verifyPhoneNumber, + signInWithMultiFactorAssertion, + getTranslation, +} from "@invertase/firebaseui-core"; +import { PhoneAuthProvider, PhoneMultiFactorGenerator, type MultiFactorInfo, type UserCredential } from "firebase/auth"; + +type PhoneMultiFactorInfo = MultiFactorInfo & { + phoneNumber?: string; +}; + +@Component({ + selector: "fui-sms-multi-factor-assertion-phone-form", + standalone: true, + imports: [CommonModule, FormSubmitComponent, FormErrorMessageComponent], + host: { + style: "display: block;", + }, + template: ` +
+
+ +
+
+
+
+
+ + {{ sendCodeLabel() }} + + +
+
+ `, +}) +/** + * A form component for requesting SMS verification code during MFA assertion. + */ +export class SmsMultiFactorAssertionPhoneFormComponent { + private ui = injectUI(); + + /** The multi-factor info hint containing phone number details. */ + hint = input.required(); + /** Event emitter fired when verification ID is received. */ + @Output() onSubmit = new EventEmitter(); + + sendCodeLabel = injectTranslation("labels", "sendCode"); + + recaptchaContainer = viewChild.required>("recaptchaContainer"); + + phoneNumber = computed(() => { + const hint = this.hint() as PhoneMultiFactorInfo; + return hint.phoneNumber || ""; + }); + + mfaSmsAssertionPrompt = computed(() => { + return getTranslation(this.ui(), "messages", "mfaSmsAssertionPrompt", { phoneNumber: this.phoneNumber() }); + }); + + recaptchaVerifier = injectRecaptchaVerifier(() => this.recaptchaContainer()); + + form = injectForm({ + defaultValues: {}, + }); + + state = injectStore(this.form, (state) => state); + + constructor() { + effect(() => { + this.form.update({ + validators: { + onSubmitAsync: async () => { + try { + const verifier = this.recaptchaVerifier(); + if (!verifier) { + return "Recaptcha verifier not available"; + } + + const verificationId = await verifyPhoneNumber(this.ui(), "", verifier, undefined, this.hint()); + this.onSubmit.emit(verificationId); + return; + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); + }); + + effect((onCleanup) => { + const verifier = this.recaptchaVerifier(); + onCleanup(() => { + if (verifier) { + verifier.clear(); + } + }); + }); + } + + async handleSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.form.handleSubmit(); + } +} + +@Component({ + selector: "fui-sms-multi-factor-assertion-verify-form", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + template: ` +
+
+ +
+
+ + {{ verifyCodeLabel() }} + + +
+
+ `, +}) +/** + * A form component for verifying SMS code during MFA assertion. + */ +export class SmsMultiFactorAssertionVerifyFormComponent { + private ui = injectUI(); + private formSchema = injectMultiFactorPhoneAuthVerifyFormSchema(); + + /** The verification ID received from the phone form. */ + verificationId = input.required(); + /** Event emitter for successful MFA assertion. */ + @Output() onSuccess = new EventEmitter(); + + verificationCodeLabel = injectTranslation("labels", "verificationCode"); + verifyCodeLabel = injectTranslation("labels", "verifyCode"); + smsVerificationPrompt = injectTranslation("prompts", "smsVerificationPrompt"); + unknownErrorLabel = injectTranslation("errors", "unknownError"); + + form = injectForm({ + defaultValues: { + verificationId: "", + verificationCode: "", + }, + }); + + state = injectStore(this.form, (state) => state); + + constructor() { + effect(() => { + this.form.setFieldValue("verificationId", this.verificationId()); + }); + + effect(() => { + this.form.update({ + validators: { + onBlur: this.formSchema(), + onSubmit: this.formSchema(), + onSubmitAsync: async ({ value }) => { + try { + const credential = PhoneAuthProvider.credential(value.verificationId, value.verificationCode); + const assertion = PhoneMultiFactorGenerator.assertion(credential); + const result = await signInWithMultiFactorAssertion(this.ui(), assertion); + this.onSuccess.emit(result); + return; + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); + }); + } + + async handleSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.form.handleSubmit(); + } +} + +@Component({ + selector: "fui-sms-multi-factor-assertion-form", + standalone: true, + imports: [CommonModule, SmsMultiFactorAssertionPhoneFormComponent, SmsMultiFactorAssertionVerifyFormComponent], + host: { + style: "display: block;", + }, + template: ` +
+ @if (verification()) { + + } @else { + + } +
+ `, +}) +/** + * A form component for SMS multi-factor authentication assertion. + * + * Manages the flow between requesting and verifying SMS codes for MFA. + */ +export class SmsMultiFactorAssertionFormComponent { + /** The multi-factor info hint containing phone number details. */ + hint = input.required(); + /** Event emitter for successful MFA assertion. */ + @Output() onSuccess = new EventEmitter(); + + verification = signal<{ verificationId: string } | null>(null); + + handlePhoneSubmit(verificationId: string) { + this.verification.set({ verificationId }); + } +} diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts new file mode 100644 index 000000000..5819cbd47 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.spec.ts @@ -0,0 +1,408 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, waitFor } from "@testing-library/angular"; +import { CommonModule } from "@angular/common"; +import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; +import { SmsMultiFactorEnrollmentFormComponent } from "./sms-multi-factor-enrollment-form"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; +import { CountrySelectorComponent } from "../../../components/country-selector"; + +jest.mock("@invertase/firebaseui-core", () => { + const originalModule = jest.requireActual("@invertase/firebaseui-core"); + return { + ...originalModule, + verifyPhoneNumber: jest.fn(), + enrollWithMultiFactorAssertion: jest.fn(), + formatPhoneNumber: jest.fn(), + FirebaseUIError: class FirebaseUIError extends Error { + constructor(message: string) { + super(message); + this.name = "FirebaseUIError"; + } + }, + }; +}); + +jest.mock("firebase/auth", () => { + const originalModule = jest.requireActual("firebase/auth"); + return { + ...originalModule, + multiFactor: jest.fn(() => ({ + enroll: jest.fn(), + unenroll: jest.fn(), + getEnrolledFactors: jest.fn(), + })), + }; +}); + +describe("", () => { + let mockVerifyPhoneNumber: any; + let mockEnrollWithMultiFactorAssertion: any; + let mockFormatPhoneNumber: any; + let mockFirebaseUIError: any; + let mockMultiFactor: any; + let mockPhoneAuthProvider: any; + let mockPhoneMultiFactorGenerator: any; + + beforeEach(() => { + const { + injectTranslation, + injectUI, + injectMultiFactorPhoneAuthNumberFormSchema, + injectMultiFactorPhoneAuthVerifyFormSchema, + injectDefaultCountry, + injectRecaptchaVerifier, + } = require("../../../tests/test-helpers"); + const { PhoneAuthProvider, PhoneMultiFactorGenerator } = require("../../../tests/test-helpers"); + const { + verifyPhoneNumber, + enrollWithMultiFactorAssertion, + formatPhoneNumber, + FirebaseUIError, + } = require("@invertase/firebaseui-core"); + const { multiFactor } = require("firebase/auth"); + + mockVerifyPhoneNumber = verifyPhoneNumber; + mockEnrollWithMultiFactorAssertion = enrollWithMultiFactorAssertion; + mockFormatPhoneNumber = formatPhoneNumber; + mockFirebaseUIError = FirebaseUIError; + mockMultiFactor = multiFactor; + mockPhoneAuthProvider = PhoneAuthProvider; + mockPhoneMultiFactorGenerator = PhoneMultiFactorGenerator; + + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + displayName: "Display Name", + phoneNumber: "Phone Number", + sendCode: "Send Verification Code", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + prompts: { + smsVerificationPrompt: "Enter the verification code sent to your phone number", + }, + errors: { + unknownError: "An unknown error occurred", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => { + return () => ({ + auth: { + currentUser: { uid: "test-user" }, + }, + }); + }); + + injectMultiFactorPhoneAuthNumberFormSchema.mockImplementation(() => { + return () => jest.fn(); + }); + + injectMultiFactorPhoneAuthVerifyFormSchema.mockImplementation(() => { + return () => jest.fn(); + }); + + injectDefaultCountry.mockImplementation(() => { + return () => ({ code: "US" }); + }); + + injectRecaptchaVerifier.mockImplementation(() => { + return () => ({ + clear: jest.fn(), + render: jest.fn(), + verify: jest.fn(), + }); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should create", async () => { + const { fixture } = await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + ], + }); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it("should render phone number form initially", async () => { + const { container } = await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + ], + }); + + expect(screen.getByLabelText("Display Name")).toBeInTheDocument(); + expect(container.querySelector('input[name="phoneNumber"]')).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send Verification Code" })).toBeInTheDocument(); + }); + + it("should render verification form after phone number is submitted", async () => { + const mockVerificationId = "test-verification-id"; + mockVerifyPhoneNumber.mockResolvedValue(mockVerificationId); + + const { fixture } = await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + ], + }); + + const component = fixture.componentInstance; + component.verificationId.set(mockVerificationId); + fixture.detectChanges(); + + await waitFor(() => { + expect(screen.getByRole("textbox", { name: /Verification Code/i })).toBeInTheDocument(); + }); + expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); + }); + + it("should handle phone number submission", async () => { + const mockVerificationId = "test-verification-id"; + mockVerifyPhoneNumber.mockResolvedValue(mockVerificationId); + + const { fixture } = await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + ], + }); + + const component = fixture.componentInstance; + + component.phoneForm.setFieldValue("displayName", "Test User"); + component.phoneForm.setFieldValue("phoneNumber", "1234567890"); + component.country.set("US" as any); + fixture.detectChanges(); + + await component.phoneForm.handleSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.verificationId()).toBe(mockVerificationId); + expect(component.displayName()).toBe("Test User"); + }); + + it("should handle verification code submission", async () => { + mockEnrollWithMultiFactorAssertion.mockResolvedValue(undefined); + + const { fixture } = await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + ], + }); + + const component = fixture.componentInstance; + component.verificationId.set("test-verification-id"); + component.displayName.set("Test User"); + fixture.detectChanges(); + + const enrollmentSpy = jest.spyOn(component.onEnrollment, "emit"); + + component.verificationForm.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await component.verificationForm.handleSubmit(); + await waitFor(() => { + expect(enrollmentSpy).toHaveBeenCalled(); + }); + }); + + it("should handle FirebaseUIError in phone verification", async () => { + const errorMessage = "Invalid phone number"; + mockVerifyPhoneNumber.mockRejectedValue(new mockFirebaseUIError(errorMessage)); + + const { fixture } = await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + ], + }); + + const component = fixture.componentInstance; + + component.phoneForm.setFieldValue("displayName", "Test User"); + component.phoneForm.setFieldValue("phoneNumber", "1234567890"); + fixture.detectChanges(); + + await component.phoneForm.handleSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + expect(component.verificationId()).toBeNull(); + }); + + it("should handle FirebaseUIError in code verification", async () => { + const errorMessage = "Invalid verification code"; + mockEnrollWithMultiFactorAssertion.mockRejectedValue(new mockFirebaseUIError(errorMessage)); + + const { fixture } = await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + ], + }); + + const component = fixture.componentInstance; + component.verificationId.set("test-verification-id"); + component.displayName.set("Test User"); + fixture.detectChanges(); + + component.verificationForm.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await component.verificationForm.handleSubmit(); + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + }); + + it("should format phone number correctly", async () => { + const formattedNumber = "+1 (234) 567-8900"; + mockFormatPhoneNumber.mockReturnValue(formattedNumber); + mockVerifyPhoneNumber.mockResolvedValue("test-verification-id"); + + const { fixture } = await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + ], + }); + + const component = fixture.componentInstance; + + component.phoneForm.setFieldValue("displayName", "Test User"); + component.phoneForm.setFieldValue("phoneNumber", "1234567890"); + component.country.set("US" as any); + fixture.detectChanges(); + + await component.phoneForm.handleSubmit(); + await fixture.whenStable(); + + expect(mockFormatPhoneNumber).toHaveBeenCalledWith("1234567890", expect.objectContaining({ code: "US" })); + expect(mockVerifyPhoneNumber).toHaveBeenCalledWith( + expect.any(Object), + formattedNumber, + expect.any(Object), + expect.any(Object) + ); + }); + + it("should throw error if user is not authenticated", async () => { + const { injectUI } = require("../../../tests/test-helpers"); + injectUI.mockImplementation(() => { + return () => ({ + auth: { + currentUser: null, + }, + }); + }); + + await expect( + render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + ], + }) + ).rejects.toThrow("User must be authenticated to enroll with multi-factor authentication"); + }); + + it("should have correct CSS classes", async () => { + const { container } = await render(SmsMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + ], + }); + + expect(container.querySelector(".fui-form-container")).toBeInTheDocument(); + expect(container.querySelector(".fui-form")).toBeInTheDocument(); + expect(container.querySelector(".fui-recaptcha-container")).toBeInTheDocument(); + }); +}); diff --git a/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts new file mode 100644 index 000000000..6a949b6d0 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.ts @@ -0,0 +1,224 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, signal, effect, viewChild, computed, Output, EventEmitter } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { TanStackField, TanStackAppField, injectForm, injectStore } from "@tanstack/angular-form"; +import { ElementRef } from "@angular/core"; +import { RecaptchaVerifier } from "firebase/auth"; +import { PhoneAuthProvider, PhoneMultiFactorGenerator } from "firebase/auth"; +import { + verifyPhoneNumber, + enrollWithMultiFactorAssertion, + formatPhoneNumber, + FirebaseUIError, +} from "@invertase/firebaseui-core"; +import { multiFactor } from "firebase/auth"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; +import { CountrySelectorComponent } from "../../../components/country-selector"; +import { PoliciesComponent } from "../../../components/policies"; +import { + injectUI, + injectTranslation, + injectMultiFactorPhoneAuthNumberFormSchema, + injectMultiFactorPhoneAuthVerifyFormSchema, + injectDefaultCountry, + injectRecaptchaVerifier, +} from "../../../provider"; + +@Component({ + selector: "fui-sms-multi-factor-enrollment-form", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + ], + template: ` +
+ @if (!verificationId()) { +
+
+ +
+
+ + + +
+
+
+
+
+ + {{ sendCodeLabel() }} + + +
+
+ } @else { +
+
+ +
+
+ + {{ verifyCodeLabel() }} + + +
+
+ } +
+ `, +}) +/** + * A form component for SMS multi-factor authentication enrollment. + * + * Manages the flow between phone number entry and verification code entry for MFA enrollment. + */ +export class SmsMultiFactorEnrollmentFormComponent { + private ui = injectUI(); + private phoneFormSchema = injectMultiFactorPhoneAuthNumberFormSchema(); + private verificationFormSchema = injectMultiFactorPhoneAuthVerifyFormSchema(); + private defaultCountry = injectDefaultCountry(); + + verificationId = signal(null); + country = signal(this.defaultCountry().code); + displayName = signal(""); + + displayNameLabel = injectTranslation("labels", "displayName"); + phoneNumberLabel = injectTranslation("labels", "phoneNumber"); + sendCodeLabel = injectTranslation("labels", "sendCode"); + verificationCodeLabel = injectTranslation("labels", "verificationCode"); + verifyCodeLabel = injectTranslation("labels", "verifyCode"); + smsVerificationPrompt = injectTranslation("prompts", "smsVerificationPrompt"); + + /** Event emitter fired when MFA enrollment is completed. */ + @Output() onEnrollment = new EventEmitter(); + + recaptchaContainer = viewChild.required>("recaptchaContainer"); + + recaptchaVerifier = injectRecaptchaVerifier(() => this.recaptchaContainer()); + + phoneForm = injectForm({ + defaultValues: { + displayName: "", + phoneNumber: "", + }, + }); + + verificationForm = injectForm({ + defaultValues: { + verificationCode: "", + }, + }); + + phoneState = injectStore(this.phoneForm, (state) => state); + verificationState = injectStore(this.verificationForm, (state) => state); + + constructor() { + if (!this.ui().auth.currentUser) { + throw new Error("User must be authenticated to enroll with multi-factor authentication"); + } + + effect(() => { + this.phoneForm.update({ + validators: { + onBlur: this.phoneFormSchema(), + onSubmit: this.phoneFormSchema(), + onSubmitAsync: async ({ value }) => { + try { + const verifier = this.recaptchaVerifier(); + if (!verifier) { + return "Recaptcha verifier not available"; + } + + const currentUser = this.ui().auth.currentUser!; + const mfaUser = multiFactor(currentUser); + const formattedPhoneNumber = formatPhoneNumber(value.phoneNumber, this.defaultCountry()); + const verificationId = await verifyPhoneNumber(this.ui(), formattedPhoneNumber, verifier, mfaUser); + + this.displayName.set(value.displayName); + this.verificationId.set(verificationId); + return; + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); + }); + + effect(() => { + this.verificationForm.update({ + validators: { + onBlur: this.verificationFormSchema(), + onSubmit: this.verificationFormSchema(), + onSubmitAsync: async ({ value }) => { + try { + const credential = PhoneAuthProvider.credential(this.verificationId()!, value.verificationCode); + const assertion = PhoneMultiFactorGenerator.assertion(credential); + await enrollWithMultiFactorAssertion(this.ui(), assertion, this.displayName()); + this.onEnrollment.emit(); + return; + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); + }); + } + + async handlePhoneSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.phoneForm.handleSubmit(); + } + + async handleVerificationSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.verificationForm.handleSubmit(); + } +} diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts new file mode 100644 index 000000000..a1df6dc81 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.spec.ts @@ -0,0 +1,296 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, waitFor } from "@testing-library/angular"; + +import { TotpMultiFactorAssertionFormComponent } from "./totp-multi-factor-assertion-form"; +import { signInWithMultiFactorAssertion, FirebaseUIError } from "../../../tests/test-helpers"; + +jest.mock("@invertase/firebaseui-core", () => { + const originalModule = jest.requireActual("@invertase/firebaseui-core"); + return { + ...originalModule, + signInWithMultiFactorAssertion: jest.fn(), + FirebaseUIError: class FirebaseUIError extends Error { + constructor(message: string) { + super(message); + this.name = "FirebaseUIError"; + } + }, + }; +}); + +describe("", () => { + let TotpMultiFactorGenerator: any; + + beforeEach(() => { + const { + injectTranslation, + injectUI, + injectMultiFactorTotpAuthVerifyFormSchema, + } = require("../../../tests/test-helpers"); + + const { signInWithMultiFactorAssertion } = require("@invertase/firebaseui-core"); + + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + prompts: { + enterVerificationCode: "Enter the verification code", + }, + errors: { + unknownError: "An unknown error occurred", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => { + return () => ({ + auth: {}, + }); + }); + + injectMultiFactorTotpAuthVerifyFormSchema.mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + verificationCode: z.string().refine((val: string) => val.length === 6, { + message: "Verification code must be 6 digits", + }), + }); + }); + + signInWithMultiFactorAssertion.mockResolvedValue({}); + + TotpMultiFactorGenerator = require("firebase/auth").TotpMultiFactorGenerator; + TotpMultiFactorGenerator.assertionForSignIn = jest.fn().mockReturnValue({}); + + jest.clearAllMocks(); + }); + + it("renders TOTP verification form", async () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + await waitFor(() => { + expect(screen.getByRole("textbox", { name: /Verification Code/i })).toBeInTheDocument(); + }); + expect(screen.getByPlaceholderText("123456")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); + }); + + it("renders form with placeholder text", async () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + // Verify the verification code input field starts empty (no pre-filled value) + const formInput = screen.getByDisplayValue(""); + expect(formInput).toBeInTheDocument(); + }); + + it("emits onSuccess when verification is successful", async () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + const { fixture } = await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + const component = fixture.componentInstance; + const onSuccessSpy = jest.fn(); + component.onSuccess.subscribe(onSuccessSpy); + + component.form.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await waitFor(() => { + expect(onSuccessSpy).toHaveBeenCalled(); + }); + }); + + it("emits onSuccess with credential after successful verification", async () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + const mockCredential = { user: { uid: "totp-verify-user" } }; + signInWithMultiFactorAssertion.mockResolvedValue(mockCredential); + + const { fixture } = await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + const component = fixture.componentInstance; + const onSuccessSpy = jest.fn(); + component.onSuccess.subscribe(onSuccessSpy); + + component.form.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await waitFor(() => { + expect(onSuccessSpy).toHaveBeenCalledWith(mockCredential); + }); + }); + + it("calls TotpMultiFactorGenerator.assertionForSignIn with correct parameters", async () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + const assertionForSignInSpy = TotpMultiFactorGenerator.assertionForSignIn; + + const { fixture } = await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + const component = fixture.componentInstance; + + component.form.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await waitFor(() => { + expect(assertionForSignInSpy).toHaveBeenCalledWith("test-uid", "123456"); + }); + }); + + it("calls signInWithMultiFactorAssertion with the assertion", async () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + const mockAssertion = { type: "totp" }; + TotpMultiFactorGenerator.assertionForSignIn.mockReturnValue(mockAssertion); + + const { fixture } = await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + const component = fixture.componentInstance; + + component.form.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await waitFor(() => { + expect(signInWithMultiFactorAssertion).toHaveBeenCalledWith( + expect.any(Object), // UI instance + mockAssertion + ); + }); + }); + + it("handles FirebaseUIError correctly", async () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + const errorMessage = "Invalid verification code"; + signInWithMultiFactorAssertion.mockRejectedValue(new FirebaseUIError(errorMessage)); + + const { fixture } = await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + const component = fixture.componentInstance; + + component.form.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + }); + + it("handles unknown errors correctly", async () => { + const mockHint = { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + uid: "test-uid", + }; + + const errorMessage = "Network error"; + signInWithMultiFactorAssertion.mockRejectedValue(new Error(errorMessage)); + + const { fixture } = await render(TotpMultiFactorAssertionFormComponent, { + componentInputs: { + hint: mockHint, + }, + imports: [TotpMultiFactorAssertionFormComponent], + }); + + const component = fixture.componentInstance; + + component.form.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await waitFor(() => { + expect(screen.getByText(new RegExp(errorMessage))).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts new file mode 100644 index 000000000..ecf9190c6 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts @@ -0,0 +1,112 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, effect, input, Output, EventEmitter } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanstack/angular-form"; +import { injectMultiFactorTotpAuthVerifyFormSchema, injectTranslation, injectUI } from "../../../provider"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; +import { FirebaseUIError, signInWithMultiFactorAssertion } from "@invertase/firebaseui-core"; +import { TotpMultiFactorGenerator, type MultiFactorInfo, type UserCredential } from "firebase/auth"; + +@Component({ + selector: "fui-totp-multi-factor-assertion-form", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + template: ` +
+
+ +
+
+ + {{ verifyCodeLabel() }} + + +
+
+ `, +}) +/** + * A form component for TOTP multi-factor authentication assertion. + * + * Allows users to enter a TOTP code from their authenticator app. + */ +export class TotpMultiFactorAssertionFormComponent { + private ui = injectUI(); + private formSchema = injectMultiFactorTotpAuthVerifyFormSchema(); + + /** The multi-factor info hint containing TOTP details. */ + hint = input.required(); + /** Event emitter for successful MFA assertion. */ + @Output() onSuccess = new EventEmitter(); + + verificationCodeLabel = injectTranslation("labels", "verificationCode"); + verifyCodeLabel = injectTranslation("labels", "verifyCode"); + enterVerificationCodePrompt = injectTranslation("prompts", "enterVerificationCode"); + + form = injectForm({ + defaultValues: { + verificationCode: "", + }, + }); + + state = injectStore(this.form, (state) => state); + + constructor() { + effect(() => { + this.form.update({ + validators: { + onBlur: this.formSchema(), + onSubmitAsync: async ({ value }) => { + try { + const assertion = TotpMultiFactorGenerator.assertionForSignIn(this.hint().uid, value.verificationCode); + const result = await signInWithMultiFactorAssertion(this.ui(), assertion); + this.onSuccess.emit(result); + return; + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); + }); + } + + async handleSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.form.handleSubmit(); + } +} diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts new file mode 100644 index 000000000..4145d4add --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.spec.ts @@ -0,0 +1,360 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, waitFor } from "@testing-library/angular"; +import { CommonModule } from "@angular/common"; +import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; +import { TotpMultiFactorEnrollmentFormComponent } from "./totp-multi-factor-enrollment-form"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; + +describe("", () => { + let mockGenerateTotpSecret: any; + let mockEnrollWithMultiFactorAssertion: any; + let mockGenerateTotpQrCode: any; + let mockFirebaseUIError: any; + let mockTotpMultiFactorGenerator: any; + + beforeEach(() => { + const { + generateTotpSecret, + enrollWithMultiFactorAssertion, + generateTotpQrCode, + FirebaseUIError, + TotpMultiFactorGenerator, + injectTranslation, + injectUI, + injectMultiFactorTotpAuthEnrollmentFormSchema, + injectMultiFactorTotpAuthVerifyFormSchema, + } = require("../../../tests/test-helpers"); + + mockGenerateTotpSecret = generateTotpSecret; + mockEnrollWithMultiFactorAssertion = enrollWithMultiFactorAssertion; + mockGenerateTotpQrCode = generateTotpQrCode; + mockFirebaseUIError = FirebaseUIError; + mockTotpMultiFactorGenerator = TotpMultiFactorGenerator; + + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + displayName: "Display Name", + generateQrCode: "Generate QR Code", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + prompts: { + mfaTotpQrCodePrompt: "Scan this QR code with your authenticator app", + mfaTotpEnrollmentVerificationPrompt: "Add the code generated by your authenticator app", + }, + errors: { + unknownError: "An unknown error occurred", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => { + return () => ({ + auth: { + currentUser: { uid: "test-user" }, + }, + }); + }); + + injectMultiFactorTotpAuthEnrollmentFormSchema.mockImplementation(() => { + return () => jest.fn(); + }); + + injectMultiFactorTotpAuthVerifyFormSchema.mockImplementation(() => { + return () => jest.fn(); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should create", async () => { + const { fixture } = await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + }); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it("should render display name form initially", async () => { + await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + }); + + expect(screen.getByLabelText("Display Name")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Generate QR Code" })).toBeInTheDocument(); + }); + + it("should render QR code and verification form after display name is submitted", async () => { + const mockSecret = { + generateQrCodeUrl: jest.fn(), + sessionInfo: {}, + auth: {}, + secretKey: new Uint8Array(), + hashingAlgorithm: "SHA1", + codeLength: 6, + timeStepSize: 30, + } as any; + mockGenerateTotpSecret.mockResolvedValue(mockSecret); + mockGenerateTotpQrCode.mockReturnValue("data:image/png;base64,test-qr-code"); + + const { fixture } = await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + }); + + const component = fixture.componentInstance; + component.enrollment.set({ secret: mockSecret, displayName: "Test User" }); + fixture.detectChanges(); + + expect(screen.getByAltText("TOTP QR Code")).toBeInTheDocument(); + expect(screen.getByText("Scan this QR code with your authenticator app")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole("textbox", { name: /Verification Code/i })).toBeInTheDocument(); + }); + expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); + }); + + it("should handle display name submission", async () => { + const mockSecret = { + generateQrCodeUrl: jest.fn(), + sessionInfo: {}, + auth: {}, + secretKey: new Uint8Array(), + hashingAlgorithm: "SHA1", + codeLength: 6, + timeStepSize: 30, + } as any; + mockGenerateTotpSecret.mockResolvedValue(mockSecret); + + const { fixture } = await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + }); + + const component = fixture.componentInstance; + + // Simulate the secret generation form submission + component.handleSecretGeneration({ secret: mockSecret, displayName: "Test User" }); + fixture.detectChanges(); + + expect(component.enrollment()).toEqual({ secret: mockSecret, displayName: "Test User" }); + }); + + it("should handle verification code submission", async () => { + const mockSecret = { + generateQrCodeUrl: jest.fn(), + sessionInfo: {}, + auth: {}, + secretKey: new Uint8Array(), + hashingAlgorithm: "SHA1", + codeLength: 6, + timeStepSize: 30, + } as any; + mockEnrollWithMultiFactorAssertion.mockResolvedValue(undefined); + + const { fixture } = await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + }); + + const component = fixture.componentInstance; + component.enrollment.set({ secret: mockSecret, displayName: "Test User" }); + fixture.detectChanges(); + + const enrollmentSpy = jest.spyOn(component.onEnrollment, "emit"); + + // Simulate the verification form enrollment event + component.onEnrollment.emit(); + + expect(enrollmentSpy).toHaveBeenCalled(); + }); + + it("should handle FirebaseUIError in secret generation", async () => { + const errorMessage = "Failed to generate TOTP secret"; + mockGenerateTotpSecret.mockRejectedValue(new mockFirebaseUIError(errorMessage)); + + const { fixture } = await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + }); + + const component = fixture.componentInstance; + + // Since the parent component doesn't have direct access to the child form, + // we test that the enrollment state remains null when there's an error + expect(component.enrollment()).toBeNull(); + }); + + it("should handle FirebaseUIError in verification", async () => { + const errorMessage = "Invalid verification code"; + mockEnrollWithMultiFactorAssertion.mockRejectedValue(new mockFirebaseUIError(errorMessage)); + + const { fixture } = await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + }); + + const component = fixture.componentInstance; + const mockSecret = { + generateQrCodeUrl: jest.fn(), + sessionInfo: {}, + auth: {}, + secretKey: new Uint8Array(), + hashingAlgorithm: "SHA1", + codeLength: 6, + timeStepSize: 30, + } as any; + component.enrollment.set({ secret: mockSecret, displayName: "Test User" }); + fixture.detectChanges(); + + // Since the parent component doesn't have direct access to the child form, + // we test that the enrollment state is maintained when there's an error + expect(component.enrollment()).toEqual({ secret: mockSecret, displayName: "Test User" }); + }); + + it("should throw error if user is not authenticated", async () => { + const { injectUI } = require("../../../tests/test-helpers"); + injectUI.mockImplementation(() => { + return () => ({ + auth: { + currentUser: null, + }, + }); + }); + + await expect( + render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + }) + ).rejects.toThrow("User must be authenticated to enroll with multi-factor authentication"); + }); + + it("should generate QR code with correct parameters", async () => { + const mockSecret = { + generateQrCodeUrl: jest.fn(), + sessionInfo: {}, + auth: {}, + secretKey: new Uint8Array(), + hashingAlgorithm: "SHA1", + codeLength: 6, + timeStepSize: 30, + } as any; + const mockQrCodeDataUrl = "data:image/png;base64,test-qr-code"; + mockGenerateTotpSecret.mockResolvedValue(mockSecret); + mockGenerateTotpQrCode.mockReturnValue(mockQrCodeDataUrl); + + const { fixture } = await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + }); + + const component = fixture.componentInstance; + component.enrollment.set({ secret: mockSecret, displayName: "Test User" }); + fixture.detectChanges(); + + // Test that the enrollment state is set correctly + expect(component.enrollment()).toEqual({ secret: mockSecret, displayName: "Test User" }); + expect(mockGenerateTotpQrCode).toHaveBeenCalledWith(expect.any(Object), mockSecret, "Test User"); + }); + + it("should have correct CSS classes", async () => { + const { container } = await render(TotpMultiFactorEnrollmentFormComponent, { + imports: [ + CommonModule, + TotpMultiFactorEnrollmentFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + }); + + expect(container.querySelector(".fui-form-container")).toBeInTheDocument(); + expect(container.querySelector(".fui-form")).toBeInTheDocument(); + }); +}); diff --git a/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.ts b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.ts new file mode 100644 index 000000000..157dde79f --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.ts @@ -0,0 +1,260 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, signal, effect, Output, EventEmitter, computed, input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { TanStackField, TanStackAppField, injectForm, injectStore } from "@tanstack/angular-form"; +import { TotpMultiFactorGenerator, type TotpSecret } from "firebase/auth"; +import { + enrollWithMultiFactorAssertion, + generateTotpSecret, + generateTotpQrCode, + FirebaseUIError, +} from "@invertase/firebaseui-core"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../../components/form"; +import { + injectUI, + injectTranslation, + injectMultiFactorTotpAuthNumberFormSchema, + injectMultiFactorTotpAuthVerifyFormSchema, +} from "../../../provider"; + +@Component({ + selector: "fui-totp-multi-factor-secret-generation-form", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + template: ` +
+
+ +
+
+ + {{ generateQrCodeLabel() }} + + +
+
+ `, +}) +/** + * A form component for generating a TOTP secret and display name during MFA enrollment. + */ +export class TotpMultiFactorSecretGenerationFormComponent { + private ui = injectUI(); + private formSchema = injectMultiFactorTotpAuthNumberFormSchema(); + + /** Event emitter fired when TOTP secret is generated. */ + @Output() onSubmit = new EventEmitter<{ secret: TotpSecret; displayName: string }>(); + + displayNameLabel = injectTranslation("labels", "displayName"); + generateQrCodeLabel = injectTranslation("labels", "generateQrCode"); + + form = injectForm({ + defaultValues: { + displayName: "", + }, + }); + + state = injectStore(this.form, (state) => state); + + constructor() { + effect(() => { + this.form.update({ + validators: { + onBlur: this.formSchema(), + onSubmit: this.formSchema(), + onSubmitAsync: async ({ value }) => { + try { + const secret = await generateTotpSecret(this.ui()); + this.onSubmit.emit({ secret, displayName: value.displayName }); + return; + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); + }); + } + + async handleSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.form.handleSubmit(); + } +} + +@Component({ + selector: "fui-totp-multi-factor-verification-form", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + template: ` +
+ TOTP QR Code + {{ secret().secretKey.toString() }} +

{{ mfaTotpQrCodePrompt() }}

+
+
+
+ +
+
+ + {{ verifyCodeLabel() }} + + +
+
+ `, +}) +/** + * A form component for verifying TOTP code during MFA enrollment. + * + * Displays a QR code and allows users to verify their authenticator app setup. + */ +export class TotpMultiFactorVerificationFormComponent { + private ui = injectUI(); + private formSchema = injectMultiFactorTotpAuthVerifyFormSchema(); + + /** The TOTP secret generated in the previous step. */ + secret = input.required(); + /** The display name for the TOTP factor. */ + displayName = input.required(); + /** Event emitter fired when MFA enrollment is completed. */ + @Output() onEnrollment = new EventEmitter(); + + verificationCodeLabel = injectTranslation("labels", "verificationCode"); + verifyCodeLabel = injectTranslation("labels", "verifyCode"); + mfaTotpQrCodePrompt = injectTranslation("prompts", "mfaTotpQrCodePrompt"); + mfaTotpEnrollmentVerificationPrompt = injectTranslation("prompts", "mfaTotpEnrollmentVerificationPrompt"); + + form = injectForm({ + defaultValues: { + verificationCode: "", + }, + }); + + state = injectStore(this.form, (state) => state); + + qrCodeDataUrl = computed(() => { + return generateTotpQrCode(this.ui(), this.secret(), this.displayName()); + }); + + constructor() { + effect(() => { + this.form.update({ + validators: { + onBlur: this.formSchema(), + onSubmit: this.formSchema(), + onSubmitAsync: async ({ value }) => { + try { + const assertion = TotpMultiFactorGenerator.assertionForEnrollment(this.secret(), value.verificationCode); + await enrollWithMultiFactorAssertion(this.ui(), assertion, this.displayName()); + this.onEnrollment.emit(); + return; + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); + }); + } + + async handleSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.form.handleSubmit(); + } +} + +@Component({ + selector: "fui-totp-multi-factor-enrollment-form", + standalone: true, + imports: [CommonModule, TotpMultiFactorSecretGenerationFormComponent, TotpMultiFactorVerificationFormComponent], + host: { + style: "display: block;", + }, + template: ` +
+ @if (!enrollment()) { + + } @else { + + } +
+ `, +}) +/** + * A form component for TOTP multi-factor authentication enrollment. + * + * Manages the flow between secret generation and verification for TOTP MFA enrollment. + */ +export class TotpMultiFactorEnrollmentFormComponent { + private ui = injectUI(); + + enrollment = signal<{ secret: TotpSecret; displayName: string } | null>(null); + /** Event emitter fired when MFA enrollment is completed. */ + @Output() onEnrollment = new EventEmitter(); + + constructor() { + if (!this.ui().auth.currentUser) { + throw new Error("User must be authenticated to enroll with multi-factor authentication"); + } + } + + handleSecretGeneration(data: { secret: TotpSecret; displayName: string }) { + this.enrollment.set(data); + } +} diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts new file mode 100644 index 000000000..246e0170d --- /dev/null +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.spec.ts @@ -0,0 +1,235 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, fireEvent } from "@testing-library/angular"; +import { TestBed } from "@angular/core/testing"; +import { PhoneMultiFactorGenerator, TotpMultiFactorGenerator } from "firebase/auth"; + +import { MultiFactorAuthAssertionFormComponent } from "./multi-factor-auth-assertion-form"; +import { SmsMultiFactorAssertionFormComponent } from "./mfa/sms-multi-factor-assertion-form"; +import { TotpMultiFactorAssertionFormComponent } from "./mfa/totp-multi-factor-assertion-form"; + +describe("", () => { + beforeEach(() => { + const { injectTranslation, injectUI } = require("../../../provider"); + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + mfaSmsVerification: "SMS Verification", + mfaTotpVerification: "TOTP Verification", + }, + prompts: { + mfaAssertionFactorPrompt: "Please choose a multi-factor authentication method", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Phone", + }, + { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "TOTP", + }, + ], + }, + setMultiFactorResolver: jest.fn(), + }); + }); + }); + + it("renders selection UI when multiple hints are available", async () => { + TestBed.overrideComponent(SmsMultiFactorAssertionFormComponent, { + set: { + template: '
SMS Assertion Form
', + }, + }); + TestBed.overrideComponent(TotpMultiFactorAssertionFormComponent, { + set: { + template: '
TOTP Assertion Form
', + }, + }); + + await render(MultiFactorAuthAssertionFormComponent, { + imports: [MultiFactorAuthAssertionFormComponent], + }); + + expect(screen.getByText("Please choose a multi-factor authentication method")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "SMS Verification" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "TOTP Verification" })).toBeInTheDocument(); + + expect(screen.queryByTestId("sms-assertion-form")).not.toBeInTheDocument(); + expect(screen.queryByTestId("totp-assertion-form")).not.toBeInTheDocument(); + }); + + it("auto-selects single hint when only one is available", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Phone", + }, + ], + }, + setMultiFactorResolver: jest.fn(), + }); + }); + + TestBed.overrideComponent(SmsMultiFactorAssertionFormComponent, { + set: { + template: '
SMS Assertion Form
', + }, + }); + TestBed.overrideComponent(TotpMultiFactorAssertionFormComponent, { + set: { + template: '
TOTP Assertion Form
', + }, + }); + + await render(MultiFactorAuthAssertionFormComponent, { + imports: [MultiFactorAuthAssertionFormComponent], + }); + + expect(screen.getByTestId("sms-assertion-form")).toBeInTheDocument(); + + expect(screen.queryByRole("button", { name: "SMS Verification" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "TOTP Verification" })).not.toBeInTheDocument(); + }); + + it("switches to assertion form when selection button is clicked", async () => { + TestBed.overrideComponent(SmsMultiFactorAssertionFormComponent, { + set: { + template: '
SMS Assertion Form
', + }, + }); + TestBed.overrideComponent(TotpMultiFactorAssertionFormComponent, { + set: { + template: '
TOTP Assertion Form
', + }, + }); + + await render(MultiFactorAuthAssertionFormComponent, { + imports: [MultiFactorAuthAssertionFormComponent], + }); + + expect(screen.getByRole("button", { name: "SMS Verification" })).toBeInTheDocument(); + expect(screen.queryByTestId("sms-assertion-form")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "SMS Verification" })); + + expect(screen.getByTestId("sms-assertion-form")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "SMS Verification" })).not.toBeInTheDocument(); + }); + + it("throws error when no resolver is provided", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: null, + setMultiFactorResolver: jest.fn(), + }); + }); + + await expect( + render(MultiFactorAuthAssertionFormComponent, { + imports: [MultiFactorAuthAssertionFormComponent], + }) + ).rejects.toThrow("MultiFactorAuthAssertionForm requires a multi-factor resolver"); + }); + + it("calls setMultiFactorResolver on component destruction", async () => { + const { injectUI } = require("../../../provider"); + const setMultiFactorResolverSpy = jest.fn(); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Phone", + }, + ], + }, + setMultiFactorResolver: setMultiFactorResolverSpy, + }); + }); + + TestBed.overrideComponent(SmsMultiFactorAssertionFormComponent, { + set: { + template: '
SMS Assertion Form
', + }, + }); + + const { fixture } = await render(MultiFactorAuthAssertionFormComponent, { + imports: [MultiFactorAuthAssertionFormComponent], + }); + + expect(setMultiFactorResolverSpy).not.toHaveBeenCalled(); + + fixture.destroy(); + + expect(setMultiFactorResolverSpy).toHaveBeenCalledTimes(1); + }); + + it("clears multiFactorResolver when component is destroyed", async () => { + const { injectUI } = require("../../../provider"); + const mockResolver = { + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Phone", + }, + ], + }; + let currentResolver: any = mockResolver; + const setMultiFactorResolverSpy = jest.fn((value?: any) => { + currentResolver = value; + }); + const uiMock = () => ({ + get multiFactorResolver() { + return currentResolver; + }, + setMultiFactorResolver: setMultiFactorResolverSpy, + }); + + injectUI.mockImplementation(() => uiMock); + + TestBed.overrideComponent(SmsMultiFactorAssertionFormComponent, { + set: { + template: '
SMS Assertion Form
', + }, + }); + + const { fixture } = await render(MultiFactorAuthAssertionFormComponent, { + imports: [MultiFactorAuthAssertionFormComponent], + }); + + expect(uiMock().multiFactorResolver).toBe(mockResolver); + + fixture.destroy(); + + expect(setMultiFactorResolverSpy).toHaveBeenCalledTimes(1); + expect(uiMock().multiFactorResolver).toBeUndefined(); + }); +}); diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts new file mode 100644 index 000000000..b078d2a8e --- /dev/null +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts @@ -0,0 +1,104 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, computed, effect, Output, EventEmitter, signal } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { injectUI, injectTranslation } from "../../provider"; +import { + PhoneMultiFactorGenerator, + TotpMultiFactorGenerator, + type UserCredential, + type MultiFactorInfo, +} from "firebase/auth"; +import { SmsMultiFactorAssertionFormComponent } from "./mfa/sms-multi-factor-assertion-form"; +import { TotpMultiFactorAssertionFormComponent } from "./mfa/totp-multi-factor-assertion-form"; +import { ButtonComponent } from "../../components/button"; + +@Component({ + selector: "fui-multi-factor-auth-assertion-form", + standalone: true, + imports: [CommonModule, SmsMultiFactorAssertionFormComponent, TotpMultiFactorAssertionFormComponent, ButtonComponent], + host: { + style: "display: block;", + }, + template: ` +
+ @if (selectedHint()) { + @if (selectedHint()!.factorId === phoneFactorId) { + + } @else if (selectedHint()!.factorId === totpFactorId) { + + } + } @else { +

{{ mfaAssertionFactorPrompt() }}

+ @for (hint of resolver().hints; track hint.factorId) { + @if (hint.factorId === totpFactorId) { + + } @else if (hint.factorId === phoneFactorId) { + + } + } + } +
+ `, +}) +/** + * A form component for multi-factor authentication assertion. + * + * Allows users to select and complete MFA verification using SMS or TOTP. + */ +export class MultiFactorAuthAssertionFormComponent { + private ui = injectUI(); + + constructor() { + effect((onCleanup) => { + // Cleanup the multi-factor resolver when the component unmounts. + onCleanup(() => { + this.ui().setMultiFactorResolver(); + }); + }); + } + + /** Event emitter for successful MFA assertion. */ + @Output() onSuccess = new EventEmitter(); + + resolver = computed(() => { + const resolver = this.ui().multiFactorResolver; + if (!resolver) { + throw new Error("MultiFactorAuthAssertionForm requires a multi-factor resolver"); + } + return resolver; + }); + + selectedHint = signal( + this.resolver().hints.length === 1 ? this.resolver().hints[0] : undefined + ); + + phoneFactorId = PhoneMultiFactorGenerator.FACTOR_ID; + totpFactorId = TotpMultiFactorGenerator.FACTOR_ID; + + smsVerificationLabel = injectTranslation("labels", "mfaSmsVerification"); + totpVerificationLabel = injectTranslation("labels", "mfaTotpVerification"); + mfaAssertionFactorPrompt = injectTranslation("prompts", "mfaAssertionFactorPrompt"); + + selectHint(hint: MultiFactorInfo) { + this.selectedHint.set(hint); + } +} diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts new file mode 100644 index 000000000..6b2f32292 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.spec.ts @@ -0,0 +1,240 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, fireEvent } from "@testing-library/angular"; +import { CommonModule } from "@angular/common"; +import { MultiFactorAuthEnrollmentFormComponent } from "./multi-factor-auth-enrollment-form"; +import { SmsMultiFactorEnrollmentFormComponent } from "./mfa/sms-multi-factor-enrollment-form"; +import { TotpMultiFactorEnrollmentFormComponent } from "./mfa/totp-multi-factor-enrollment-form"; +import { ButtonComponent } from "../../components/button"; +import { FactorId } from "firebase/auth"; + +describe("", () => { + beforeEach(() => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + auth: { + currentUser: { uid: "test-user" }, + }, + }); + }); + }); + + it("should create", async () => { + const { fixture } = await render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + }); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it("should render selection buttons when multiple hints are provided", async () => { + await render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + componentInputs: { + hints: [FactorId.TOTP, FactorId.PHONE], + }, + }); + + expect(screen.getByRole("button", { name: "SMS Verification" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "TOTP Verification" })).toBeInTheDocument(); + }); + + it("should auto-select single hint when only one is provided", async () => { + const { container } = await render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + componentInputs: { + hints: [FactorId.PHONE], + }, + }); + + expect(screen.queryByRole("button", { name: "SMS Verification" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "TOTP Verification" })).not.toBeInTheDocument(); + + expect(screen.getByLabelText("Display Name")).toBeInTheDocument(); + expect(container.querySelector('input[name="phoneNumber"]')).toBeInTheDocument(); + }); + + it("should show SMS form when SMS hint is selected", async () => { + const { fixture, container } = await render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + componentInputs: { + hints: [FactorId.TOTP, FactorId.PHONE], + }, + }); + + const smsButton = screen.getByRole("button", { name: "SMS Verification" }); + fireEvent.click(smsButton); + fixture.detectChanges(); + + expect(screen.getByLabelText("Display Name")).toBeInTheDocument(); + expect(container.querySelector('input[name="phoneNumber"]')).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send Verification Code" })).toBeInTheDocument(); + }); + + it("should show TOTP form when TOTP hint is selected", async () => { + const { fixture } = await render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + componentInputs: { + hints: [FactorId.TOTP, FactorId.PHONE], + }, + }); + + const totpButton = screen.getByRole("button", { name: "TOTP Verification" }); + fireEvent.click(totpButton); + fixture.detectChanges(); + + expect(screen.getByLabelText("Display Name")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Generate QR Code" })).toBeInTheDocument(); + }); + + it("should emit onEnrollment when SMS form completes enrollment", async () => { + const { fixture } = await render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + componentInputs: { + hints: [FactorId.PHONE], + }, + }); + + const component = fixture.componentInstance; + const enrollmentSpy = jest.spyOn(component.onEnrollment, "emit"); + + const smsFormComponent = fixture.debugElement.query( + (el) => el.componentInstance instanceof SmsMultiFactorEnrollmentFormComponent + )?.componentInstance as SmsMultiFactorEnrollmentFormComponent; + + expect(smsFormComponent).toBeTruthy(); + smsFormComponent.onEnrollment.emit(); + + expect(enrollmentSpy).toHaveBeenCalled(); + }); + + it("should emit onEnrollment when TOTP form completes enrollment", async () => { + const { fixture } = await render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + componentInputs: { + hints: [FactorId.TOTP], + }, + }); + + const component = fixture.componentInstance; + const enrollmentSpy = jest.spyOn(component.onEnrollment, "emit"); + + const totpFormComponent = fixture.debugElement.query( + (el) => el.componentInstance instanceof TotpMultiFactorEnrollmentFormComponent + )?.componentInstance as TotpMultiFactorEnrollmentFormComponent; + + expect(totpFormComponent).toBeTruthy(); + totpFormComponent.onEnrollment.emit(); + + expect(enrollmentSpy).toHaveBeenCalled(); + }); + + it("should have correct CSS classes", async () => { + const { container } = await render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + componentInputs: { + hints: [FactorId.TOTP, FactorId.PHONE], + }, + }); + + expect(container.querySelector(".fui-content")).toBeInTheDocument(); + }); + + it("should throw error when hints array is empty", async () => { + await expect( + render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + componentInputs: { + hints: [], + }, + }) + ).rejects.toThrow("MultiFactorAuthEnrollmentForm must have at least one hint"); + }); + + it("should throw error for unknown hint type", async () => { + const unknownHint = "unknown" as any; + + await expect( + render(MultiFactorAuthEnrollmentFormComponent, { + imports: [ + CommonModule, + MultiFactorAuthEnrollmentFormComponent, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + componentInputs: { + hints: [unknownHint], + }, + }) + ).rejects.toThrow("Unknown multi-factor enrollment type: unknown"); + }); +}); diff --git a/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.ts b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.ts new file mode 100644 index 000000000..559fae251 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.ts @@ -0,0 +1,104 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, signal, input, Output, EventEmitter, OnInit, computed } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { FactorId } from "firebase/auth"; +import { injectTranslation } from "../../provider"; +import { SmsMultiFactorEnrollmentFormComponent } from "./mfa/sms-multi-factor-enrollment-form"; +import { TotpMultiFactorEnrollmentFormComponent } from "./mfa/totp-multi-factor-enrollment-form"; +import { ButtonComponent } from "../../components/button"; + +type Hint = (typeof FactorId)[keyof typeof FactorId]; + +@Component({ + selector: "fui-multi-factor-auth-enrollment-form", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + SmsMultiFactorEnrollmentFormComponent, + TotpMultiFactorEnrollmentFormComponent, + ButtonComponent, + ], + template: ` +
+ @if (validatedHint()) { + @if (validatedHint() === phoneFactorId) { + + } @else if (validatedHint() === totpFactorId) { + + } + } @else { + @for (hint of hints(); track hint) { + @if (hint === totpFactorId) { + + } @else if (hint === phoneFactorId) { + + } + } + } +
+ `, +}) +/** + * A form component for multi-factor authentication enrollment. + * + * Allows users to enroll in MFA using SMS or TOTP methods. + */ +export class MultiFactorAuthEnrollmentFormComponent implements OnInit { + /** The available MFA factor types for enrollment. */ + hints = input([FactorId.TOTP, FactorId.PHONE]); + /** Event emitter fired when MFA enrollment is completed. */ + @Output() onEnrollment = new EventEmitter(); + + selectedHint = signal(undefined); + + phoneFactorId = FactorId.PHONE; + totpFactorId = FactorId.TOTP; + + smsVerificationLabel = injectTranslation("labels", "mfaSmsVerification"); + totpVerificationLabel = injectTranslation("labels", "mfaTotpVerification"); + + validatedHint = computed(() => { + const hint = this.selectedHint(); + if (hint && hint !== this.phoneFactorId && hint !== this.totpFactorId) { + throw new Error(`Unknown multi-factor enrollment type: ${hint}`); + } + return hint; + }); + + ngOnInit() { + const hints = this.hints(); + if (hints.length === 0) { + throw new Error("MultiFactorAuthEnrollmentForm must have at least one hint"); + } + // Auto-select single hint after component initialization + if (hints.length === 1) { + this.selectedHint.set(hints[0]); + } + } + + selectHint(hint: Hint) { + this.selectedHint.set(hint); + } +} diff --git a/packages/angular/src/lib/auth/forms/phone-auth-form.spec.ts b/packages/angular/src/lib/auth/forms/phone-auth-form.spec.ts new file mode 100644 index 000000000..0f0d15c1e --- /dev/null +++ b/packages/angular/src/lib/auth/forms/phone-auth-form.spec.ts @@ -0,0 +1,327 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, waitFor } from "@testing-library/angular"; +import { CommonModule } from "@angular/common"; +import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; +import { PhoneAuthFormComponent, PhoneNumberFormComponent, VerificationFormComponent } from "./phone-auth-form"; +import { + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, +} from "../../components/form"; +import { UserCredential } from "@angular/fire/auth"; + +// Mock the @invertase/firebaseui-core module but preserve Angular providers +jest.mock("@invertase/firebaseui-core", () => { + const originalModule = jest.requireActual("@invertase/firebaseui-core"); + return { + ...originalModule, + verifyPhoneNumber: jest.fn(), + confirmPhoneNumber: jest.fn(), + FirebaseUIError: class FirebaseUIError extends Error { + constructor(message: string) { + super(message); + this.name = "FirebaseUIError"; + } + }, + }; +}); + +describe("", () => { + let mockVerifyPhoneNumber: any; + let mockConfirmPhoneNumber: any; + let mockFormatPhoneNumber: any; + let mockFirebaseUIError: any; + + beforeEach(() => { + const { + verifyPhoneNumber, + confirmPhoneNumber, + formatPhoneNumber, + FirebaseUIError, + } = require("@invertase/firebaseui-core"); + const { injectRecaptchaVerifier } = require("../../tests/test-helpers"); + mockVerifyPhoneNumber = verifyPhoneNumber; + mockConfirmPhoneNumber = confirmPhoneNumber; + mockFormatPhoneNumber = formatPhoneNumber; + mockFirebaseUIError = FirebaseUIError; + + injectRecaptchaVerifier.mockImplementation(() => { + return () => ({ + clear: jest.fn(), + render: jest.fn(), + verify: jest.fn(), + }); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should create", async () => { + const { fixture } = await render(PhoneAuthFormComponent, { + imports: [ + CommonModule, + PhoneAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + ], + }); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it("should render phone number form initially", async () => { + const { container } = await render(PhoneAuthFormComponent, { + imports: [ + CommonModule, + PhoneAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + ], + }); + + expect(container.querySelector('input[name="phoneNumber"]')).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send Verification Code" })).toBeInTheDocument(); + }); + + it("should render verification form after phone number is submitted", async () => { + const { fixture } = await render(PhoneAuthFormComponent, { + imports: [ + CommonModule, + PhoneAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + ], + }); + + const component = fixture.componentInstance; + component.verificationId.set("test-verification-id"); + fixture.detectChanges(); + + await waitFor(() => { + expect(screen.getByRole("textbox", { name: /Verification Code/i })).toBeInTheDocument(); + }); + expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); + }); + + it("should handle phone number submission", async () => { + const mockVerificationId = "test-verification-id"; + mockVerifyPhoneNumber.mockResolvedValue(mockVerificationId); + + const { fixture } = await render(PhoneAuthFormComponent, { + imports: [ + CommonModule, + PhoneAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + ], + }); + + const component = fixture.componentInstance; + + // Simulate the phone number form submission + component.handlePhoneSubmit({ verificationId: mockVerificationId, phoneNumber: "+1234567890" }); + fixture.detectChanges(); + + expect(component.verificationId()).toBe(mockVerificationId); + }); + + it("should handle verification code submission", async () => { + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + mockConfirmPhoneNumber.mockResolvedValue(mockCredential); + + const { fixture } = await render(PhoneAuthFormComponent, { + imports: [ + CommonModule, + PhoneAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + ], + }); + + const component = fixture.componentInstance; + component.verificationId.set("test-verification-id"); + fixture.detectChanges(); + + const signInSpy = jest.spyOn(component.signIn, "emit"); + + // Simulate the verification form emitting the signIn event + component.signIn.emit(mockCredential); + + expect(signInSpy).toHaveBeenCalledWith(mockCredential); + }); + + it("should handle FirebaseUIError in phone verification", async () => { + const errorMessage = "Invalid phone number"; + mockVerifyPhoneNumber.mockRejectedValue(new mockFirebaseUIError(errorMessage)); + + const { fixture } = await render(PhoneAuthFormComponent, { + imports: [ + CommonModule, + PhoneAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + ], + }); + + const component = fixture.componentInstance; + + // Get the phone number form component and trigger form submission + const phoneFormComponent = fixture.debugElement.query( + (el) => el.componentInstance instanceof PhoneNumberFormComponent + )?.componentInstance as PhoneNumberFormComponent; + + expect(phoneFormComponent).toBeTruthy(); + + phoneFormComponent.form.setFieldValue("phoneNumber", "1234567890"); + fixture.detectChanges(); + + await phoneFormComponent.form.handleSubmit(); + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + expect(component.verificationId()).toBeNull(); + }); + }); + + it("should handle FirebaseUIError in code verification", async () => { + const errorMessage = "Invalid verification code"; + mockConfirmPhoneNumber.mockRejectedValue(new mockFirebaseUIError(errorMessage)); + + const { fixture } = await render(PhoneAuthFormComponent, { + imports: [ + CommonModule, + PhoneAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + ], + }); + + const component = fixture.componentInstance; + component.verificationId.set("test-verification-id"); + fixture.detectChanges(); + + // Get the verification form component and trigger form submission + const verificationFormComponent = fixture.debugElement.query( + (el) => el.componentInstance instanceof VerificationFormComponent + )?.componentInstance as VerificationFormComponent; + + expect(verificationFormComponent).toBeTruthy(); + + verificationFormComponent.form.setFieldValue("verificationCode", "123456"); + fixture.detectChanges(); + + await verificationFormComponent.form.handleSubmit(); + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + expect(component.verificationId()).toBe("test-verification-id"); + }); + }); + + it("should format phone number correctly", async () => { + const formattedNumber = "+1 (234) 567-8900"; + mockFormatPhoneNumber.mockReturnValue(formattedNumber); + mockVerifyPhoneNumber.mockResolvedValue("test-verification-id"); + + const { fixture } = await render(PhoneAuthFormComponent, { + imports: [ + CommonModule, + PhoneAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + ], + }); + + const component = fixture.componentInstance; + + // Get the phone number form component and trigger form submission + const phoneFormComponent = fixture.debugElement.query( + (el) => el.componentInstance instanceof PhoneNumberFormComponent + )?.componentInstance as PhoneNumberFormComponent; + + expect(phoneFormComponent).toBeTruthy(); + + phoneFormComponent.form.setFieldValue("phoneNumber", "1234567890"); + phoneFormComponent.country.set("US" as any); + fixture.detectChanges(); + + await phoneFormComponent.form.handleSubmit(); + await waitFor(() => { + expect(mockFormatPhoneNumber).toHaveBeenCalledWith("1234567890", expect.objectContaining({ code: "US" })); + expect(mockVerifyPhoneNumber).toHaveBeenCalledWith(expect.any(Object), formattedNumber, expect.any(Object)); + expect(component.verificationId()).toBe("test-verification-id"); + }); + }); + + it("should reset form when going back to phone number step", async () => { + const { fixture, container } = await render(PhoneAuthFormComponent, { + imports: [ + CommonModule, + PhoneAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + ], + }); + + const component = fixture.componentInstance; + component.verificationId.set("test-verification-id"); + fixture.detectChanges(); + + component.verificationId.set(null); + fixture.detectChanges(); + + expect(container.querySelector('input[name="phoneNumber"]')).toBeInTheDocument(); + expect(screen.queryByLabelText("Verification Code")).toBeNull(); + }); +}); diff --git a/packages/angular/src/lib/auth/forms/phone-auth-form.ts b/packages/angular/src/lib/auth/forms/phone-auth-form.ts new file mode 100644 index 000000000..39c6a77b8 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/phone-auth-form.ts @@ -0,0 +1,285 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, ElementRef, effect, input, signal, Output, EventEmitter, computed, viewChild } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanstack/angular-form"; +import { + injectPhoneAuthFormSchema, + injectPhoneAuthVerifyFormSchema, + injectRecaptchaVerifier, + injectTranslation, + injectUI, +} from "../../provider"; +import { RecaptchaVerifier, UserCredential } from "@angular/fire/auth"; +import { PoliciesComponent } from "../../components/policies"; +import { CountrySelectorComponent } from "../../components/country-selector"; +import { FormInputComponent, FormSubmitComponent, FormErrorMessageComponent } from "../../components/form"; +import { + countryData, + FirebaseUIError, + formatPhoneNumber, + confirmPhoneNumber, + verifyPhoneNumber, + CountryCode, +} from "@invertase/firebaseui-core"; + +@Component({ + selector: "fui-phone-number-form", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + TanStackField, + TanStackAppField, + PoliciesComponent, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + CountrySelectorComponent, + ], + template: ` +
+
+ + + +
+
+
+
+ +
+ + {{ sendCodeLabel() }} + + +
+ + `, +}) +/** + * A form component for entering a phone number and requesting a verification code. + */ +export class PhoneNumberFormComponent { + private ui = injectUI(); + private formSchema = injectPhoneAuthFormSchema(); + + /** Event emitter fired when phone number is verified and verification ID is received. */ + @Output() onSubmit = new EventEmitter<{ verificationId: string; phoneNumber: string }>(); + /** The selected country code for phone number formatting. */ + country = signal(countryData[0].code); + + phoneNumberLabel = injectTranslation("labels", "phoneNumber"); + sendCodeLabel = injectTranslation("labels", "sendCode"); + unknownErrorLabel = injectTranslation("errors", "unknownError"); + + recaptchaContainer = viewChild.required>("recaptchaContainer"); + recaptchaVerifier = injectRecaptchaVerifier(() => this.recaptchaContainer()); + + form = injectForm({ + defaultValues: { + phoneNumber: "", + }, + }); + + state = injectStore(this.form, (state) => state); + + constructor() { + effect(() => { + this.form.update({ + validators: { + onBlur: this.formSchema(), + onSubmitAsync: async ({ value }) => { + const selectedCountry = countryData.find((c) => c.code === this.country()); + const formattedNumber = formatPhoneNumber(value.phoneNumber, selectedCountry!); + + try { + const verifier = this.recaptchaVerifier(); + if (!verifier) { + return this.unknownErrorLabel(); + } + const verificationId = await verifyPhoneNumber(this.ui(), formattedNumber, verifier); + this.onSubmit.emit({ verificationId, phoneNumber: formattedNumber }); + return; + } catch (error) { + if (error instanceof FirebaseUIError) { + return error.message; + } + + return this.unknownErrorLabel(); + } + }, + }, + }); + }); + + effect((onCleanup) => { + const verifier = this.recaptchaVerifier(); + + onCleanup(() => { + if (verifier) { + verifier.clear(); + } + }); + }); + } + + async handleSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.form.handleSubmit(); + } +} + +@Component({ + selector: "fui-verification-form", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + TanStackField, + TanStackAppField, + PoliciesComponent, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + ], + template: ` +
+
+ +
+ + + +
+ + {{ verifyCodeLabel() }} + + +
+ + `, +}) +/** + * A form component for entering and verifying the SMS verification code. + */ +export class VerificationFormComponent { + private ui = injectUI(); + private formSchema = injectPhoneAuthVerifyFormSchema(); + + /** The verification ID received from the phone number form. */ + verificationId = input.required(); + /** Event emitter for successful sign-in. */ + @Output() signIn = new EventEmitter(); + + verificationCodeLabel = injectTranslation("labels", "verificationCode"); + verifyCodeLabel = injectTranslation("labels", "verifyCode"); + smsVerificationPrompt = injectTranslation("prompts", "smsVerificationPrompt"); + unknownErrorLabel = injectTranslation("errors", "unknownError"); + + form = injectForm({ + defaultValues: { + verificationId: "", + verificationCode: "", + }, + }); + + state = injectStore(this.form, (state) => state); + + constructor() { + effect(() => { + this.form.setFieldValue("verificationId", this.verificationId()); + }); + + effect(() => { + this.form.update({ + validators: { + onBlur: this.formSchema(), + onSubmitAsync: async ({ value }) => { + try { + const credential = await confirmPhoneNumber(this.ui(), this.verificationId(), value.verificationCode); + this.signIn.emit(credential); + return; + } catch (error) { + if (error instanceof FirebaseUIError) { + return error.message; + } + + return this.unknownErrorLabel(); + } + }, + }, + }); + }); + } + + async handleSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.form.handleSubmit(); + } +} + +@Component({ + selector: "fui-phone-auth-form", + standalone: true, + imports: [CommonModule, PhoneNumberFormComponent, VerificationFormComponent], + host: { + style: "display: block;", + }, + template: ` +
+ @if (verificationId()) { + + } @else { + + } +
+ `, +}) +/** + * A form component for phone number authentication. + * + * Manages the flow between phone number entry and verification code entry. + */ +export class PhoneAuthFormComponent { + verificationId = signal(null); + /** Event emitter for successful sign-in. */ + @Output() signIn = new EventEmitter(); + + handlePhoneSubmit(data: { verificationId: string; phoneNumber: string }) { + this.verificationId.set(data.verificationId); + } +} diff --git a/packages/angular/src/lib/auth/forms/sign-in-auth-form.spec.ts b/packages/angular/src/lib/auth/forms/sign-in-auth-form.spec.ts new file mode 100644 index 000000000..616bb4cc1 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/sign-in-auth-form.spec.ts @@ -0,0 +1,429 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, fireEvent } from "@testing-library/angular"; +import { CommonModule } from "@angular/common"; +import { EventEmitter } from "@angular/core"; +import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; +import { SignInAuthFormComponent } from "./sign-in-auth-form"; +import { + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, +} from "../../components/form"; +import { PoliciesComponent } from "../../components/policies"; +import { UserCredential } from "@angular/fire/auth"; + +describe("", () => { + let mockSignInWithEmailAndPassword: any; + let mockFirebaseUIError: any; + + beforeEach(() => { + const { signInWithEmailAndPassword, FirebaseUIError } = require("@invertase/firebaseui-core"); + mockSignInWithEmailAndPassword = signInWithEmailAndPassword; + mockFirebaseUIError = FirebaseUIError; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should create", async () => { + const { fixture } = await render(SignInAuthFormComponent, { + imports: [ + CommonModule, + SignInAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it("should render the form initially", async () => { + const forgotPasswordEmitter = new EventEmitter(); + const signUpEmitter = new EventEmitter(); + forgotPasswordEmitter.subscribe(() => {}); + signUpEmitter.subscribe(() => {}); + + const { fixture } = await render(SignInAuthFormComponent, { + imports: [ + CommonModule, + SignInAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + componentInputs: { + forgotPassword: forgotPasswordEmitter, + signUp: signUpEmitter, + }, + }); + fixture.detectChanges(); + + expect(screen.getByLabelText("Email Address")).toBeInTheDocument(); + expect(screen.getByLabelText("Password", { selector: "input" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Sign In" })).toBeInTheDocument(); + expect(screen.getByText("By continuing, you agree to our")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Forgot Password" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Don't have an account? Sign Up" })).toBeInTheDocument(); + }); + + it("should not render forgot password button when output is not bound", async () => { + const { fixture } = await render(SignInAuthFormComponent, { + imports: [ + CommonModule, + SignInAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + fixture.detectChanges(); + + expect(screen.getByLabelText("Email Address")).toBeInTheDocument(); + expect(screen.getByLabelText("Password", { selector: "input" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Sign In" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Forgot Password" })).not.toBeInTheDocument(); + }); + + it("should not render sign up button when output is not bound", async () => { + const { fixture } = await render(SignInAuthFormComponent, { + imports: [ + CommonModule, + SignInAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + fixture.detectChanges(); + + expect(screen.getByLabelText("Email Address")).toBeInTheDocument(); + expect(screen.getByLabelText("Password", { selector: "input" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Sign In" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Don't have an account? Sign Up" })).not.toBeInTheDocument(); + }); + + it("should conditionally render buttons based on which outputs are bound", async () => { + const forgotPasswordEmitter = new EventEmitter(); + forgotPasswordEmitter.subscribe(() => {}); + + const { fixture } = await render(SignInAuthFormComponent, { + imports: [ + CommonModule, + SignInAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + componentInputs: { + forgotPassword: forgotPasswordEmitter, + }, + }); + fixture.detectChanges(); + + expect(screen.getByRole("button", { name: "Forgot Password" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Don't have an account? Sign Up" })).not.toBeInTheDocument(); + }); + + it("should have correct translation labels", async () => { + const { fixture } = await render(SignInAuthFormComponent, { + imports: [ + CommonModule, + SignInAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + const component = fixture.componentInstance; + + expect(component.emailLabel()).toBe("Email Address"); + expect(component.passwordLabel()).toBe("Password"); + expect(component.forgotPasswordLabel()).toBe("Forgot Password"); + expect(component.signInLabel()).toBe("Sign In"); + expect(component.noAccountLabel()).toBe("Don't have an account?"); + expect(component.signUpLabel()).toBe("Sign Up"); + expect(component.unknownErrorLabel()).toBe("An unknown error occurred"); + }); + + it("should initialize form with empty values", async () => { + const { fixture } = await render(SignInAuthFormComponent, { + imports: [ + CommonModule, + SignInAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + const component = fixture.componentInstance; + expect(component.form.getFieldValue("email")).toBe(""); + expect(component.form.getFieldValue("password")).toBe(""); + }); + + it("should emit forgotPassword when forgot password button is clicked", async () => { + const forgotPasswordEmitter = new EventEmitter(); + forgotPasswordEmitter.subscribe(() => {}); + + const { fixture } = await render(SignInAuthFormComponent, { + imports: [ + CommonModule, + SignInAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + componentInputs: { + forgotPassword: forgotPasswordEmitter, + }, + }); + fixture.detectChanges(); + const forgotPasswordSpy = jest.spyOn(forgotPasswordEmitter, "emit"); + + const forgotPasswordButton = screen.getByRole("button", { name: "Forgot Password" }); + fireEvent.click(forgotPasswordButton); + expect(forgotPasswordSpy).toHaveBeenCalled(); + }); + + it("should emit signUp when sign up button is clicked", async () => { + const signUpEmitter = new EventEmitter(); + signUpEmitter.subscribe(() => {}); + + const { fixture } = await render(SignInAuthFormComponent, { + imports: [ + CommonModule, + SignInAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + componentInputs: { + signUp: signUpEmitter, + }, + }); + fixture.detectChanges(); + const signUpSpy = jest.spyOn(signUpEmitter, "emit"); + + const signUpButton = screen.getByRole("button", { name: "Don't have an account? Sign Up" }); + fireEvent.click(signUpButton); + expect(signUpSpy).toHaveBeenCalled(); + }); + + it("should prevent default and stop propagation on form submit", async () => { + const { fixture } = await render(SignInAuthFormComponent, { + imports: [ + CommonModule, + SignInAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + const component = fixture.componentInstance; + component.form.setFieldValue("email", "test@example.com"); + component.form.setFieldValue("password", "password123"); + fixture.detectChanges(); + + const submitEvent = new Event("submit") as SubmitEvent; + const preventDefaultSpy = jest.fn(); + const stopPropagationSpy = jest.fn(); + + Object.defineProperties(submitEvent, { + preventDefault: { value: preventDefaultSpy }, + stopPropagation: { value: stopPropagationSpy }, + }); + + component.handleSubmit(submitEvent); + await fixture.whenStable(); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(stopPropagationSpy).toHaveBeenCalled(); + }); + + it("should handle form submission with valid credentials", async () => { + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + mockSignInWithEmailAndPassword.mockResolvedValue(mockCredential); + + const { fixture } = await render(SignInAuthFormComponent, { + imports: [ + CommonModule, + SignInAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + const signInSpy = jest.spyOn(component.signIn, "emit"); + + component.form.setFieldValue("email", "test@example.com"); + component.form.setFieldValue("password", "password123"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await fixture.whenStable(); + + expect(signInSpy).toHaveBeenCalledWith(mockCredential); + expect(mockSignInWithEmailAndPassword).toHaveBeenCalledWith( + expect.objectContaining({ + app: expect.any(Object), + auth: expect.any(Object), + }), + "test@example.com", + "password123" + ); + }); + + it("should handle FirebaseUIError and display error message", async () => { + const errorMessage = "Invalid credentials"; + mockSignInWithEmailAndPassword.mockRejectedValue(new mockFirebaseUIError(errorMessage)); + + const { fixture } = await render(SignInAuthFormComponent, { + imports: [ + CommonModule, + SignInAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.form.setFieldValue("email", "test@example.com"); + component.form.setFieldValue("password", "wrongpassword"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + + it("should handle unknown errors and display generic error message", async () => { + mockSignInWithEmailAndPassword.mockRejectedValue(new Error("Network error")); + + const { fixture } = await render(SignInAuthFormComponent, { + imports: [ + CommonModule, + SignInAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.form.setFieldValue("email", "test@example.com"); + component.form.setFieldValue("password", "password123"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(screen.getByText("An unknown error occurred")).toBeInTheDocument(); + }); + + it("should use the same validation logic as the real createSignInAuthFormSchema", async () => { + const { fixture } = await render(SignInAuthFormComponent, { + imports: [ + CommonModule, + SignInAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.form.setFieldValue("email", "invalid-email"); + component.form.setFieldValue("password", ""); + fixture.detectChanges(); + + expect(component.form.state.errorMap).toBeDefined(); + + component.form.setFieldValue("email", "test@example.com"); + component.form.setFieldValue("password", "password123"); + fixture.detectChanges(); + + expect(component.form.state.errors).toHaveLength(0); + }); +}); diff --git a/packages/angular/src/lib/auth/forms/sign-in-auth-form.ts b/packages/angular/src/lib/auth/forms/sign-in-auth-form.ts new file mode 100644 index 000000000..a88cb3379 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/sign-in-auth-form.ts @@ -0,0 +1,151 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, Output, EventEmitter, input, effect } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { UserCredential } from "@angular/fire/auth"; +import { injectForm, TanStackField, TanStackAppField, injectStore } from "@tanstack/angular-form"; +import { FirebaseUIError, signInWithEmailAndPassword } from "@invertase/firebaseui-core"; + +import { injectSignInAuthFormSchema, injectTranslation, injectUI } from "../../provider"; +import { PoliciesComponent } from "../../components/policies"; +import { + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, +} from "../../components/form"; + +@Component({ + selector: "fui-sign-in-auth-form", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + TanStackField, + TanStackAppField, + PoliciesComponent, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + ], + template: ` +
+
+ +
+
+ + @if (forgotPassword()?.observed) { + + } + +
+ + + +
+ + {{ signInLabel() }} + + +
+ + @if (signUp()?.observed) { + + } + + `, +}) +/** + * A form component for signing in with email and password. + */ +export class SignInAuthFormComponent { + private ui = injectUI(); + private formSchema = injectSignInAuthFormSchema(); + + emailLabel = injectTranslation("labels", "emailAddress"); + passwordLabel = injectTranslation("labels", "password"); + forgotPasswordLabel = injectTranslation("labels", "forgotPassword"); + signInLabel = injectTranslation("labels", "signIn"); + noAccountLabel = injectTranslation("prompts", "noAccount"); + signUpLabel = injectTranslation("labels", "signUp"); + unknownErrorLabel = injectTranslation("errors", "unknownError"); + + /** Event emitter for forgot password action. */ + forgotPassword = input>(); + /** Event emitter for sign up action. */ + signUp = input>(); + + /** Event emitter for successful sign-in. */ + @Output() signIn = new EventEmitter(); + + form = injectForm({ + defaultValues: { + email: "", + password: "", + }, + }); + + state = injectStore(this.form, (state) => state); + + handleSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.form.handleSubmit(); + } + + constructor() { + effect(() => { + this.form.update({ + validators: { + onBlur: this.formSchema(), + onSubmitAsync: async ({ value }) => { + try { + const credential = await signInWithEmailAndPassword(this.ui(), value.email, value.password); + this.signIn.emit(credential); + return; + } catch (error) { + if (error instanceof FirebaseUIError) { + return error.message; + } + + console.error(error); + return this.unknownErrorLabel(); + } + }, + }, + }); + }); + } +} diff --git a/packages/angular/src/lib/auth/forms/sign-up-auth-form.spec.ts b/packages/angular/src/lib/auth/forms/sign-up-auth-form.spec.ts new file mode 100644 index 000000000..57161b17c --- /dev/null +++ b/packages/angular/src/lib/auth/forms/sign-up-auth-form.spec.ts @@ -0,0 +1,400 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, fireEvent } from "@testing-library/angular"; +import { CommonModule } from "@angular/common"; +import { EventEmitter } from "@angular/core"; +import { TanStackField, TanStackAppField } from "@tanstack/angular-form"; +import { SignUpAuthFormComponent } from "./sign-up-auth-form"; +import { + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, +} from "../../components/form"; +import { PoliciesComponent } from "../../components/policies"; +import { UserCredential } from "@angular/fire/auth"; + +describe("", () => { + let mockCreateUserWithEmailAndPassword: any; + let mockHasBehavior: any; + let mockFirebaseUIError: any; + + beforeEach(() => { + const { createUserWithEmailAndPassword, hasBehavior, FirebaseUIError } = require("@invertase/firebaseui-core"); + mockCreateUserWithEmailAndPassword = createUserWithEmailAndPassword; + mockHasBehavior = hasBehavior; + mockFirebaseUIError = FirebaseUIError; + + // no display name required by default + mockHasBehavior.mockReturnValue(false); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should create", async () => { + const { fixture } = await render(SignUpAuthFormComponent, { + imports: [ + CommonModule, + SignUpAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it("should render the form initially without display name field", async () => { + const signInEmitter = new EventEmitter(); + signInEmitter.subscribe(() => {}); + + const { fixture } = await render(SignUpAuthFormComponent, { + imports: [ + CommonModule, + SignUpAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + componentInputs: { + signIn: signInEmitter, + }, + }); + fixture.detectChanges(); + + expect(screen.getByLabelText("Email Address")).toBeInTheDocument(); + expect(screen.getByLabelText("Password")).toBeInTheDocument(); + expect(screen.queryByLabelText("Display Name")).toBeNull(); + expect(screen.getByRole("button", { name: "Create Account" })).toBeInTheDocument(); + expect(screen.getByText("By continuing, you agree to our")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Already have an account? Sign In" })).toBeInTheDocument(); + }); + + it("should render display name field when hasBehavior returns true", async () => { + mockHasBehavior.mockReturnValue(true); + + await render(SignUpAuthFormComponent, { + imports: [ + CommonModule, + SignUpAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + + expect(screen.getByLabelText("Email Address")).toBeInTheDocument(); + expect(screen.getByLabelText("Password")).toBeInTheDocument(); + expect(screen.getByLabelText("Display Name")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create Account" })).toBeInTheDocument(); + }); + + it("should have correct translation labels", async () => { + const { fixture } = await render(SignUpAuthFormComponent, { + imports: [ + CommonModule, + SignUpAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + const component = fixture.componentInstance; + + expect(component.emailLabel()).toBe("Email Address"); + expect(component.passwordLabel()).toBe("Password"); + expect(component.displayNameLabel()).toBe("Display Name"); + expect(component.createAccountLabel()).toBe("Create Account"); + expect(component.haveAccountLabel()).toBe("Already have an account?"); + expect(component.signInLabel()).toBe("Sign In"); + expect(component.unknownErrorLabel()).toBe("An unknown error occurred"); + }); + + it("should initialize form with empty values", async () => { + const { fixture } = await render(SignUpAuthFormComponent, { + imports: [ + CommonModule, + SignUpAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + const component = fixture.componentInstance; + expect(component.form.getFieldValue("email")).toBe(""); + expect(component.form.getFieldValue("password")).toBe(""); + expect(component.form.getFieldValue("displayName")).toBeUndefined(); + }); + + it("should emit signIn when sign in button is clicked", async () => { + const signInEmitter = new EventEmitter(); + signInEmitter.subscribe(() => {}); + + const { fixture } = await render(SignUpAuthFormComponent, { + imports: [ + CommonModule, + SignUpAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + componentInputs: { + signIn: signInEmitter, + }, + }); + fixture.detectChanges(); + const signInSpy = jest.spyOn(signInEmitter, "emit"); + + const signInButton = screen.getByRole("button", { name: "Already have an account? Sign In" }); + fireEvent.click(signInButton); + expect(signInSpy).toHaveBeenCalled(); + }); + + it("should prevent default and stop propagation on form submit", async () => { + const { fixture } = await render(SignUpAuthFormComponent, { + imports: [ + CommonModule, + SignUpAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + const component = fixture.componentInstance; + component.form.setFieldValue("email", "test@example.com"); + component.form.setFieldValue("password", "password123"); + fixture.detectChanges(); + + const submitEvent = new Event("submit") as SubmitEvent; + const preventDefaultSpy = jest.fn(); + const stopPropagationSpy = jest.fn(); + + Object.defineProperties(submitEvent, { + preventDefault: { value: preventDefaultSpy }, + stopPropagation: { value: stopPropagationSpy }, + }); + + component.handleSubmit(submitEvent); + await fixture.whenStable(); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(stopPropagationSpy).toHaveBeenCalled(); + }); + + it("should handle form submission with valid credentials", async () => { + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + mockCreateUserWithEmailAndPassword.mockResolvedValue(mockCredential); + + const { fixture } = await render(SignUpAuthFormComponent, { + imports: [ + CommonModule, + SignUpAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + const signUpSpy = jest.spyOn(component.signUp, "emit"); + + component.form.setFieldValue("email", "test@example.com"); + component.form.setFieldValue("password", "password123"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await fixture.whenStable(); + + expect(signUpSpy).toHaveBeenCalledWith(mockCredential); + expect(mockCreateUserWithEmailAndPassword).toHaveBeenCalledWith( + expect.objectContaining({ + app: expect.any(Object), + auth: expect.any(Object), + }), + "test@example.com", + "password123", + undefined // displayName is undefined when hasBehavior returns false + ); + }); + + it("should handle form submission with display name when hasBehavior is true", async () => { + mockHasBehavior.mockReturnValue(true); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + mockCreateUserWithEmailAndPassword.mockResolvedValue(mockCredential); + + const { fixture } = await render(SignUpAuthFormComponent, { + imports: [ + CommonModule, + SignUpAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + const signUpSpy = jest.spyOn(component.signUp, "emit"); + + component.form.setFieldValue("email", "test@example.com"); + component.form.setFieldValue("password", "password123"); + component.form.setFieldValue("displayName", "John Doe"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await fixture.whenStable(); + + expect(signUpSpy).toHaveBeenCalledWith(mockCredential); + expect(mockCreateUserWithEmailAndPassword).toHaveBeenCalledWith( + expect.objectContaining({ + app: expect.any(Object), + auth: expect.any(Object), + }), + "test@example.com", + "password123", + "John Doe" // displayName is passed when hasBehavior returns true + ); + }); + + it("should handle FirebaseUIError and display error message", async () => { + const errorMessage = "Email already in use"; + mockCreateUserWithEmailAndPassword.mockRejectedValue(new mockFirebaseUIError(errorMessage)); + + const { fixture } = await render(SignUpAuthFormComponent, { + imports: [ + CommonModule, + SignUpAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.form.setFieldValue("email", "existing@example.com"); + component.form.setFieldValue("password", "password123"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + + it("should handle unknown errors and display generic error message", async () => { + mockCreateUserWithEmailAndPassword.mockRejectedValue(new Error("Network error")); + + const { fixture } = await render(SignUpAuthFormComponent, { + imports: [ + CommonModule, + SignUpAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.form.setFieldValue("email", "test@example.com"); + component.form.setFieldValue("password", "password123"); + fixture.detectChanges(); + + await component.form.handleSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(screen.getByText("An unknown error occurred")).toBeInTheDocument(); + }); + + it("should use the same validation logic as the real createSignUpAuthFormSchema", async () => { + const { fixture } = await render(SignUpAuthFormComponent, { + imports: [ + CommonModule, + SignUpAuthFormComponent, + TanStackField, + TanStackAppField, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + PoliciesComponent, + ], + }); + + const component = fixture.componentInstance; + + component.form.setFieldValue("email", "invalid-email"); + component.form.setFieldValue("password", "123"); + fixture.detectChanges(); + + expect(component.form.state.errorMap).toBeDefined(); + + component.form.setFieldValue("email", "test@example.com"); + component.form.setFieldValue("password", "password123"); + fixture.detectChanges(); + + expect(component.form.state.errors).toHaveLength(0); + }); +}); diff --git a/packages/angular/src/lib/auth/forms/sign-up-auth-form.ts b/packages/angular/src/lib/auth/forms/sign-up-auth-form.ts new file mode 100644 index 000000000..266d2904d --- /dev/null +++ b/packages/angular/src/lib/auth/forms/sign-up-auth-form.ts @@ -0,0 +1,152 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, Output, EventEmitter, input, effect, computed } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { injectForm, injectStore, TanStackAppField, TanStackField } from "@tanstack/angular-form"; +import { FirebaseUIError, createUserWithEmailAndPassword, hasBehavior } from "@invertase/firebaseui-core"; +import { UserCredential } from "@angular/fire/auth"; + +import { PoliciesComponent } from "../../components/policies"; +import { injectSignUpAuthFormSchema, injectTranslation, injectUI } from "../../provider"; +import { + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, +} from "../../components/form"; + +@Component({ + selector: "fui-sign-up-auth-form", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + TanStackField, + TanStackAppField, + PoliciesComponent, + FormInputComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormActionComponent, + ], + template: ` +
+ @if (requireDisplayNameField()) { +
+ +
+ } +
+ +
+
+ +
+ +
+ + {{ createAccountLabel() }} + + +
+ + @if (signIn()?.observed) { + + } + + `, +}) +/** + * A form component for signing up with email and password. + * + * Optionally includes a display name field if the requireDisplayName behavior is enabled. + */ +export class SignUpAuthFormComponent { + private ui = injectUI(); + private formSchema = injectSignUpAuthFormSchema(); + + requireDisplayNameField = computed(() => { + return hasBehavior(this.ui(), "requireDisplayName"); + }); + + emailLabel = injectTranslation("labels", "emailAddress"); + displayNameLabel = injectTranslation("labels", "displayName"); + passwordLabel = injectTranslation("labels", "password"); + createAccountLabel = injectTranslation("labels", "createAccount"); + haveAccountLabel = injectTranslation("prompts", "haveAccount"); + signInLabel = injectTranslation("labels", "signIn"); + unknownErrorLabel = injectTranslation("errors", "unknownError"); + + /** Event emitter for sign in action. */ + signIn = input>(); + + /** Event emitter for successful sign-up. */ + @Output() signUp = new EventEmitter(); + + form = injectForm({ + defaultValues: { + email: "", + password: "", + displayName: this.requireDisplayNameField() ? "" : undefined, + }, + }); + + state = injectStore(this.form, (state) => state); + + handleSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + this.form.handleSubmit(); + } + + constructor() { + effect(() => { + this.form.update({ + validators: { + onBlur: this.formSchema(), + onSubmitAsync: async ({ value }) => { + try { + const credential = await createUserWithEmailAndPassword( + this.ui(), + value.email, + value.password, + value.displayName + ); + this.signUp.emit(credential); + return; + } catch (error) { + if (error instanceof FirebaseUIError) { + return error.message; + } + + console.error(error); + return this.unknownErrorLabel(); + } + }, + }, + }); + }); + } +} diff --git a/packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts new file mode 100644 index 000000000..36731ddf1 --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts @@ -0,0 +1,108 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; +import { Component } from "@angular/core"; + +import { AppleSignInButtonComponent } from "./apple-sign-in-button"; + +@Component({ + template: ``, + standalone: true, + imports: [AppleSignInButtonComponent], +}) +class TestAppleSignInButtonHostComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [AppleSignInButtonComponent], +}) +class TestAppleSignInButtonWithCustomProviderHostComponent { + customProvider = { providerId: "custom.apple.com" }; +} + +describe("", () => { + beforeEach(() => { + const { injectUI, injectTranslation } = require("../../tests/test-helpers"); + + injectUI.mockReturnValue(() => ({})); + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + signInWithApple: "Sign in with Apple", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + }); + + it("renders with the correct provider", async () => { + await render(TestAppleSignInButtonHostComponent); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute("data-provider", "apple.com"); + }); + + it("renders with custom provider when provided", async () => { + await render(TestAppleSignInButtonWithCustomProviderHostComponent); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute("data-provider", "custom.apple.com"); + }); + + it("renders with the Apple icon", async () => { + await render(TestAppleSignInButtonHostComponent); + + const svg = document.querySelector(".fui-provider__icon"); + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute("viewBox", "0 0 50 50"); + }); + + it("renders with the correct translated text", async () => { + await render(TestAppleSignInButtonHostComponent); + + expect(screen.getByText("Sign in with Apple")).toBeInTheDocument(); + }); + + it("renders as a button with correct classes", async () => { + await render(TestAppleSignInButtonHostComponent); + + const button = screen.getByRole("button"); + expect(button).toHaveClass("fui-provider__button"); + }); + + it("uses default provider when no provider is provided", async () => { + await render(TestAppleSignInButtonHostComponent); + + const button = screen.getByRole("button"); + expect(button).toHaveAttribute("data-provider", "apple.com"); + }); + + it("has signIn output", async () => { + const { fixture } = await render(TestAppleSignInButtonHostComponent); + + const component = fixture.componentInstance; + expect(component).toBeTruthy(); + // Verify the component has the signIn output + const buttonComponent = fixture.debugElement.query( + (el) => el.name === "fui-apple-sign-in-button" + )?.componentInstance; + expect(buttonComponent?.signIn).toBeDefined(); + }); +}); diff --git a/packages/angular/src/lib/auth/oauth/apple-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.ts new file mode 100644 index 000000000..bdc9e81f0 --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.ts @@ -0,0 +1,57 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, input, output } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { OAuthButtonComponent } from "./oauth-button"; +import { injectTranslation, injectUI } from "../../provider"; +import { OAuthProvider, UserCredential } from "@angular/fire/auth"; +import { AppleLogoComponent } from "../../components/logos/apple"; + +@Component({ + selector: "fui-apple-sign-in-button", + standalone: true, + imports: [CommonModule, OAuthButtonComponent, AppleLogoComponent], + host: { + style: "display: block;", + }, + template: ` + + + {{ signInWithAppleLabel() }} + + `, +}) +/** + * A button component for signing in with Apple. + */ +export class AppleSignInButtonComponent { + ui = injectUI(); + signInWithAppleLabel = injectTranslation("labels", "signInWithApple"); + /** Whether to use themed styling. */ + themed = input(false); + /** Event emitter for successful sign-in. */ + signIn = output(); + + private defaultProvider = new OAuthProvider("apple.com"); + + /** Optional custom OAuth provider configuration. */ + provider = input(); + + get appleProvider() { + return this.provider() || this.defaultProvider; + } +} diff --git a/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts new file mode 100644 index 000000000..46a1ca8dc --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts @@ -0,0 +1,108 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; +import { Component } from "@angular/core"; + +import { FacebookSignInButtonComponent } from "./facebook-sign-in-button"; + +@Component({ + template: ``, + standalone: true, + imports: [FacebookSignInButtonComponent], +}) +class TestFacebookSignInButtonHostComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [FacebookSignInButtonComponent], +}) +class TestFacebookSignInButtonWithCustomProviderHostComponent { + customProvider = { providerId: "custom.facebook.com" }; +} + +describe("", () => { + beforeEach(() => { + const { injectUI, injectTranslation } = require("../../tests/test-helpers"); + + injectUI.mockReturnValue(() => ({})); + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + }); + + it("renders with the correct provider", async () => { + await render(TestFacebookSignInButtonHostComponent); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute("data-provider", "facebook.com"); + }); + + it("renders with custom provider when provided", async () => { + await render(TestFacebookSignInButtonWithCustomProviderHostComponent); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute("data-provider", "custom.facebook.com"); + }); + + it("renders with the Facebook icon", async () => { + await render(TestFacebookSignInButtonHostComponent); + + const svg = document.querySelector(".fui-provider__icon"); + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute("viewBox", "0 0 50 50"); + }); + + it("renders with the correct translated text", async () => { + await render(TestFacebookSignInButtonHostComponent); + + expect(screen.getByText("Sign in with Facebook")).toBeInTheDocument(); + }); + + it("renders as a button with correct classes", async () => { + await render(TestFacebookSignInButtonHostComponent); + + const button = screen.getByRole("button"); + expect(button).toHaveClass("fui-provider__button"); + }); + + it("uses default provider when no provider is provided", async () => { + await render(TestFacebookSignInButtonHostComponent); + + const button = screen.getByRole("button"); + expect(button).toHaveAttribute("data-provider", "facebook.com"); + }); + + it("has signIn output", async () => { + const { fixture } = await render(TestFacebookSignInButtonHostComponent); + + const component = fixture.componentInstance; + expect(component).toBeTruthy(); + // Verify the component has the signIn output + const buttonComponent = fixture.debugElement.query( + (el) => el.name === "fui-facebook-sign-in-button" + )?.componentInstance; + expect(buttonComponent?.signIn).toBeDefined(); + }); +}); diff --git a/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts new file mode 100644 index 000000000..6c2ed949e --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts @@ -0,0 +1,57 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, input, output } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { FacebookAuthProvider, UserCredential } from "@angular/fire/auth"; +import { OAuthButtonComponent } from "./oauth-button"; +import { injectTranslation, injectUI } from "../../provider"; +import { FacebookLogoComponent } from "../../components/logos/facebook"; + +@Component({ + selector: "fui-facebook-sign-in-button", + standalone: true, + imports: [CommonModule, OAuthButtonComponent, FacebookLogoComponent], + host: { + style: "display: block;", + }, + template: ` + + + {{ signInWithFacebookLabel() }} + + `, +}) +/** + * A button component for signing in with Facebook. + */ +export class FacebookSignInButtonComponent { + ui = injectUI(); + signInWithFacebookLabel = injectTranslation("labels", "signInWithFacebook"); + /** Whether to use themed styling. */ + themed = input(false); + /** Event emitter for successful sign-in. */ + signIn = output(); + + private defaultProvider = new FacebookAuthProvider(); + + /** Optional custom OAuth provider configuration. */ + provider = input(); + + get facebookProvider() { + return this.provider() || this.defaultProvider; + } +} diff --git a/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts new file mode 100644 index 000000000..d1a5c3a54 --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts @@ -0,0 +1,108 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; +import { Component } from "@angular/core"; + +import { GitHubSignInButtonComponent } from "./github-sign-in-button"; + +@Component({ + template: ``, + standalone: true, + imports: [GitHubSignInButtonComponent], +}) +class TestGithubSignInButtonHostComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [GitHubSignInButtonComponent], +}) +class TestGithubSignInButtonWithCustomProviderHostComponent { + customProvider = { providerId: "custom.github.com" }; +} + +describe("", () => { + beforeEach(() => { + const { injectUI, injectTranslation } = require("../../tests/test-helpers"); + + injectUI.mockReturnValue(() => ({})); + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + }); + + it("renders with the correct provider", async () => { + await render(TestGithubSignInButtonHostComponent); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute("data-provider", "github.com"); + }); + + it("renders with custom provider when provided", async () => { + await render(TestGithubSignInButtonWithCustomProviderHostComponent); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute("data-provider", "custom.github.com"); + }); + + it("renders with the GitHub icon", async () => { + await render(TestGithubSignInButtonHostComponent); + + const svg = document.querySelector(".fui-provider__icon"); + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute("viewBox", "0 0 30 30"); + }); + + it("renders with the correct translated text", async () => { + await render(TestGithubSignInButtonHostComponent); + + expect(screen.getByText("Sign in with GitHub")).toBeInTheDocument(); + }); + + it("renders as a button with correct classes", async () => { + await render(TestGithubSignInButtonHostComponent); + + const button = screen.getByRole("button"); + expect(button).toHaveClass("fui-provider__button"); + }); + + it("uses default provider when no provider is provided", async () => { + await render(TestGithubSignInButtonHostComponent); + + const button = screen.getByRole("button"); + expect(button).toHaveAttribute("data-provider", "github.com"); + }); + + it("has signIn output", async () => { + const { fixture } = await render(TestGithubSignInButtonHostComponent); + + const component = fixture.componentInstance; + expect(component).toBeTruthy(); + // Verify the component has the signIn output + const buttonComponent = fixture.debugElement.query( + (el) => el.name === "fui-github-sign-in-button" + )?.componentInstance; + expect(buttonComponent?.signIn).toBeDefined(); + }); +}); diff --git a/packages/angular/src/lib/auth/oauth/github-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/github-sign-in-button.ts new file mode 100644 index 000000000..3e41e82ed --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/github-sign-in-button.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, input, output } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { OAuthButtonComponent } from "./oauth-button"; +import { injectTranslation } from "../../provider"; +import { GithubAuthProvider, UserCredential } from "@angular/fire/auth"; +import { GithubLogoComponent } from "../../components/logos/github"; + +@Component({ + selector: "fui-github-sign-in-button", + standalone: true, + imports: [CommonModule, OAuthButtonComponent, GithubLogoComponent], + host: { + style: "display: block;", + }, + template: ` + + + {{ signInWithGitHubLabel() }} + + `, +}) +/** + * A button component for signing in with GitHub. + */ +export class GitHubSignInButtonComponent { + signInWithGitHubLabel = injectTranslation("labels", "signInWithGitHub"); + /** Whether to use themed styling. */ + themed = input(false); + /** Event emitter for successful sign-in. */ + signIn = output(); + + private defaultProvider = new GithubAuthProvider(); + + /** Optional custom OAuth provider configuration. */ + provider = input(); + + get githubProvider() { + return this.provider() || this.defaultProvider; + } +} diff --git a/packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts new file mode 100644 index 000000000..25ec50e3e --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts @@ -0,0 +1,108 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; +import { Component, signal } from "@angular/core"; + +import { GoogleSignInButtonComponent } from "./google-sign-in-button"; + +@Component({ + template: ``, + standalone: true, + imports: [GoogleSignInButtonComponent], +}) +class TestGoogleSignInButtonHostComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [GoogleSignInButtonComponent], +}) +class TestGoogleSignInButtonWithCustomProviderHostComponent { + customProvider = { providerId: "custom.google.com" }; +} + +describe("", () => { + beforeEach(() => { + const { injectUI, injectTranslation } = require("../../tests/test-helpers"); + + injectUI.mockReturnValue(() => ({})); + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + }); + + it("renders with the correct provider", async () => { + await render(TestGoogleSignInButtonHostComponent); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute("data-provider", "google.com"); + }); + + it("renders with custom provider when provided", async () => { + await render(TestGoogleSignInButtonWithCustomProviderHostComponent); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute("data-provider", "custom.google.com"); + }); + + it("renders with the Google icon", async () => { + await render(TestGoogleSignInButtonHostComponent); + + const svg = document.querySelector(".fui-provider__icon"); + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute("viewBox", "0 0 48 48"); + }); + + it("renders with the correct translated text", async () => { + await render(TestGoogleSignInButtonHostComponent); + + expect(screen.getByText("Sign in with Google")).toBeInTheDocument(); + }); + + it("renders as a button with correct classes", async () => { + await render(TestGoogleSignInButtonHostComponent); + + const button = screen.getByRole("button"); + expect(button).toHaveClass("fui-provider__button"); + }); + + it("uses default provider when no provider is provided", async () => { + await render(TestGoogleSignInButtonHostComponent); + + const button = screen.getByRole("button"); + expect(button).toHaveAttribute("data-provider", "google.com"); + }); + + it("has signIn output", async () => { + const { fixture } = await render(TestGoogleSignInButtonHostComponent); + + const component = fixture.componentInstance; + expect(component).toBeTruthy(); + // Verify the component has the signIn output + const buttonComponent = fixture.debugElement.query( + (el) => el.name === "fui-google-sign-in-button" + )?.componentInstance; + expect(buttonComponent?.signIn).toBeDefined(); + }); +}); diff --git a/packages/angular/src/lib/auth/oauth/google-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/google-sign-in-button.ts new file mode 100644 index 000000000..11119857e --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/google-sign-in-button.ts @@ -0,0 +1,57 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, input, output } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { GoogleAuthProvider, UserCredential } from "@angular/fire/auth"; +import { injectTranslation, injectUI } from "../../provider"; +import { OAuthButtonComponent } from "./oauth-button"; +import { GoogleLogoComponent } from "../../components/logos/google"; + +@Component({ + selector: "fui-google-sign-in-button", + standalone: true, + imports: [CommonModule, OAuthButtonComponent, GoogleLogoComponent], + host: { + style: "display: block;", + }, + template: ` + + + {{ signInWithGoogleLabel() }} + + `, +}) +/** + * A button component for signing in with Google. + */ +export class GoogleSignInButtonComponent { + ui = injectUI(); + signInWithGoogleLabel = injectTranslation("labels", "signInWithGoogle"); + /** Whether to use themed styling. */ + themed = input(false); + /** Event emitter for successful sign-in. */ + signIn = output(); + + private defaultProvider = new GoogleAuthProvider(); + + /** Optional custom OAuth provider configuration. */ + provider = input(); + + get googleProvider() { + return this.provider() || this.defaultProvider; + } +} diff --git a/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts new file mode 100644 index 000000000..765be9910 --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts @@ -0,0 +1,108 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; +import { Component } from "@angular/core"; + +import { MicrosoftSignInButtonComponent } from "./microsoft-sign-in-button"; + +@Component({ + template: ``, + standalone: true, + imports: [MicrosoftSignInButtonComponent], +}) +class TestMicrosoftSignInButtonHostComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [MicrosoftSignInButtonComponent], +}) +class TestMicrosoftSignInButtonWithCustomProviderHostComponent { + customProvider = { providerId: "custom.microsoft.com" }; +} + +describe("", () => { + beforeEach(() => { + const { injectUI, injectTranslation } = require("../../tests/test-helpers"); + + injectUI.mockReturnValue(() => ({})); + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + }); + + it("renders with the correct provider", async () => { + await render(TestMicrosoftSignInButtonHostComponent); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute("data-provider", "microsoft.com"); + }); + + it("renders with custom provider when provided", async () => { + await render(TestMicrosoftSignInButtonWithCustomProviderHostComponent); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute("data-provider", "custom.microsoft.com"); + }); + + it("renders with the Microsoft icon", async () => { + await render(TestMicrosoftSignInButtonHostComponent); + + const svg = document.querySelector(".fui-provider__icon"); + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute("viewBox", "0 0 48 48"); + }); + + it("renders with the correct translated text", async () => { + await render(TestMicrosoftSignInButtonHostComponent); + + expect(screen.getByText("Sign in with Microsoft")).toBeInTheDocument(); + }); + + it("renders as a button with correct classes", async () => { + await render(TestMicrosoftSignInButtonHostComponent); + + const button = screen.getByRole("button"); + expect(button).toHaveClass("fui-provider__button"); + }); + + it("uses default provider when no provider is provided", async () => { + await render(TestMicrosoftSignInButtonHostComponent); + + const button = screen.getByRole("button"); + expect(button).toHaveAttribute("data-provider", "microsoft.com"); + }); + + it("has signIn output", async () => { + const { fixture } = await render(TestMicrosoftSignInButtonHostComponent); + + const component = fixture.componentInstance; + expect(component).toBeTruthy(); + // Verify the component has the signIn output + const buttonComponent = fixture.debugElement.query( + (el) => el.name === "fui-microsoft-sign-in-button" + )?.componentInstance; + expect(buttonComponent?.signIn).toBeDefined(); + }); +}); diff --git a/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.ts new file mode 100644 index 000000000..d292fcfc9 --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, input, output } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { OAuthButtonComponent } from "./oauth-button"; +import { injectTranslation } from "../../provider"; +import { OAuthProvider, UserCredential } from "@angular/fire/auth"; +import { MicrosoftLogoComponent } from "../../components/logos/microsoft"; + +@Component({ + selector: "fui-microsoft-sign-in-button", + standalone: true, + imports: [CommonModule, OAuthButtonComponent, MicrosoftLogoComponent], + host: { + style: "display: block;", + }, + template: ` + + + {{ signInWithMicrosoftLabel() }} + + `, +}) +/** + * A button component for signing in with Microsoft. + */ +export class MicrosoftSignInButtonComponent { + signInWithMicrosoftLabel = injectTranslation("labels", "signInWithMicrosoft"); + /** Whether to use themed styling. */ + themed = input(false); + /** Event emitter for successful sign-in. */ + signIn = output(); + + private defaultProvider = new OAuthProvider("microsoft.com"); + + /** Optional custom OAuth provider configuration. */ + provider = input(); + + get microsoftProvider() { + return this.provider() || this.defaultProvider; + } +} diff --git a/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts b/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts new file mode 100644 index 000000000..67e2bac80 --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts @@ -0,0 +1,232 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, fireEvent, waitFor } from "@testing-library/angular"; +import { Component } from "@angular/core"; +import { OAuthButtonComponent } from "./oauth-button"; +import { AuthProvider, UserCredential } from "@angular/fire/auth"; + +@Component({ + template: ` Sign in with Google `, + standalone: true, + imports: [OAuthButtonComponent], +}) +class TestOAuthButtonHostComponent { + provider: AuthProvider = { providerId: "google.com" } as AuthProvider; +} + +@Component({ + template: ` Sign in with Facebook `, + standalone: true, + imports: [OAuthButtonComponent], +}) +class TestOAuthButtonWithCustomProviderHostComponent { + provider: AuthProvider = { providerId: "facebook.com" } as AuthProvider; +} + +@Component({ + template: ` + Sign in with Google + `, + standalone: true, + imports: [OAuthButtonComponent], +}) +class TestOAuthButtonWithSignInHostComponent { + provider: AuthProvider = { providerId: "google.com" } as AuthProvider; + signInCallback = jest.fn(); + handleSignIn(credential: UserCredential) { + this.signInCallback(credential); + } +} + +describe("", () => { + let mockSignInWithProvider: any; + let mockFirebaseUIError: any; + let mockGetTranslation: any; + + beforeEach(() => { + const { signInWithProvider, FirebaseUIError, getTranslation } = require("@invertase/firebaseui-core"); + mockSignInWithProvider = signInWithProvider; + mockFirebaseUIError = FirebaseUIError; + mockGetTranslation = getTranslation; + + mockSignInWithProvider.mockClear(); + mockGetTranslation.mockImplementation((ui: any, category: string, key: string) => { + if (category === "errors" && key === "unknownError") { + return "An unknown error occurred"; + } + return `${category}.${key}`; + }); + }); + + it("should create", async () => { + const { fixture } = await render(TestOAuthButtonHostComponent, { + imports: [OAuthButtonComponent], + }); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it("should render with correct provider", async () => { + await render(TestOAuthButtonHostComponent, { + imports: [OAuthButtonComponent], + }); + + expect(screen.getByText("Sign in with Google")).toBeInTheDocument(); + expect(screen.getByRole("button")).toHaveAttribute("data-provider", "google.com"); + }); + + it("should render with custom provider when provided", async () => { + await render(TestOAuthButtonWithCustomProviderHostComponent, { + imports: [OAuthButtonComponent], + }); + + expect(screen.getByText("Sign in with Facebook")).toBeInTheDocument(); + expect(screen.getByRole("button")).toHaveAttribute("data-provider", "facebook.com"); + }); + + it("should call signInWithProvider when button is clicked", async () => { + mockSignInWithProvider.mockResolvedValue(undefined); + + const { fixture } = await render(TestOAuthButtonHostComponent, { + imports: [OAuthButtonComponent], + }); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(mockSignInWithProvider).toHaveBeenCalledWith( + expect.objectContaining({ + app: expect.any(Object), + auth: expect.any(Object), + }), + expect.objectContaining({ + providerId: "google.com", + }) + ); + }); + }); + + it("should display error message when FirebaseUIError occurs", async () => { + const errorMessage = "The popup was closed by the user"; + mockSignInWithProvider.mockRejectedValue(new mockFirebaseUIError(errorMessage)); + + await render(TestOAuthButtonHostComponent, { + imports: [OAuthButtonComponent], + }); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + }); + + it("should display generic error message when non-Firebase error occurs", async () => { + mockSignInWithProvider.mockRejectedValue(new Error("Network error")); + + await render(TestOAuthButtonHostComponent, { + imports: [OAuthButtonComponent], + }); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText("An unknown error occurred")).toBeInTheDocument(); + }); + }); + + it("should have correct CSS classes", async () => { + const { container } = await render(TestOAuthButtonHostComponent, { + imports: [OAuthButtonComponent], + }); + + const button = container.querySelector(".fui-provider__button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveClass("fui-provider__button"); + }); + + it("should have correct button attributes", async () => { + await render(TestOAuthButtonHostComponent, { + imports: [OAuthButtonComponent], + }); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute("type", "button"); + expect(button).toHaveAttribute("data-provider", "google.com"); + }); + + it("should clear error when sign-in is attempted again", async () => { + // Throw an error to start + mockSignInWithProvider.mockRejectedValueOnce(new mockFirebaseUIError("First error")); + + await render(TestOAuthButtonHostComponent, { + imports: [OAuthButtonComponent], + }); + + const button = screen.getByRole("button"); + + fireEvent.click(button); + await waitFor(() => { + expect(screen.getByText("First error")).toBeInTheDocument(); + }); + + // Remove the error + mockSignInWithProvider.mockResolvedValueOnce(undefined); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.queryByText("First error")).not.toBeInTheDocument(); + }); + }); + + it("should emit signIn when sign-in is successful", async () => { + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const { fixture } = await render(TestOAuthButtonWithSignInHostComponent, { + imports: [OAuthButtonComponent], + }); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(fixture.componentInstance.signInCallback).toHaveBeenCalledTimes(1); + expect(fixture.componentInstance.signInCallback).toHaveBeenCalledWith(mockCredential); + }); + }); + + it("should not emit signIn when sign-in fails", async () => { + mockSignInWithProvider.mockRejectedValue(new mockFirebaseUIError("Sign-in failed")); + + const { fixture } = await render(TestOAuthButtonWithSignInHostComponent, { + imports: [OAuthButtonComponent], + }); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText("Sign-in failed")).toBeInTheDocument(); + }); + + expect(fixture.componentInstance.signInCallback).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/angular/src/lib/auth/oauth/oauth-button.ts b/packages/angular/src/lib/auth/oauth/oauth-button.ts new file mode 100644 index 000000000..1556808e7 --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/oauth-button.ts @@ -0,0 +1,83 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, input, signal, computed, output } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { ButtonComponent } from "../../components/button"; +import { injectUI } from "../../provider"; +import { AuthProvider, UserCredential } from "@angular/fire/auth"; +import { FirebaseUIError, signInWithProvider, getTranslation } from "@invertase/firebaseui-core"; + +@Component({ + selector: "fui-oauth-button", + standalone: true, + imports: [CommonModule, ButtonComponent], + host: { + style: "display: block;", + }, + template: ` +
+ + + @if (error()) { +
{{ error() }}
+ } +
+ `, +}) +/** + * A generic OAuth button component for signing in with any OAuth provider. + */ +export class OAuthButtonComponent { + ui = injectUI(); + /** The OAuth provider to use for sign-in. */ + provider = input.required(); + /** Whether to use themed styling. */ + themed = input(); + error = signal(null); + /** Event emitter for successful sign-in. */ + signIn = output(); + + buttonVariant = computed(() => { + return this.themed() ? "primary" : "secondary"; + }); + + async handleOAuthSignIn() { + this.error.set(null); + try { + const credential = await signInWithProvider(this.ui(), this.provider()); + this.signIn.emit(credential); + } catch (error) { + if (error instanceof FirebaseUIError) { + this.error.set(error.message); + return; + } + console.error(error); + this.error.set(getTranslation(this.ui(), "errors", "unknownError")); + } + } +} diff --git a/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts new file mode 100644 index 000000000..070c75f8b --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts @@ -0,0 +1,108 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; +import { Component } from "@angular/core"; + +import { TwitterSignInButtonComponent } from "./twitter-sign-in-button"; + +@Component({ + template: ``, + standalone: true, + imports: [TwitterSignInButtonComponent], +}) +class TestTwitterSignInButtonHostComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [TwitterSignInButtonComponent], +}) +class TestTwitterSignInButtonWithCustomProviderHostComponent { + customProvider = { providerId: "custom.twitter.com" }; +} + +describe("", () => { + beforeEach(() => { + const { injectUI, injectTranslation } = require("../../tests/test-helpers"); + + injectUI.mockReturnValue(() => ({})); + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + }); + + it("renders with the correct provider", async () => { + await render(TestTwitterSignInButtonHostComponent); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute("data-provider", "twitter.com"); + }); + + it("renders with custom provider when provided", async () => { + await render(TestTwitterSignInButtonWithCustomProviderHostComponent); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute("data-provider", "custom.twitter.com"); + }); + + it("renders with the Twitter icon", async () => { + await render(TestTwitterSignInButtonHostComponent); + + const svg = document.querySelector(".fui-provider__icon"); + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute("viewBox", "0 0 30 30"); + }); + + it("renders with the correct translated text", async () => { + await render(TestTwitterSignInButtonHostComponent); + + expect(screen.getByText("Sign in with Twitter")).toBeInTheDocument(); + }); + + it("renders as a button with correct classes", async () => { + await render(TestTwitterSignInButtonHostComponent); + + const button = screen.getByRole("button"); + expect(button).toHaveClass("fui-provider__button"); + }); + + it("uses default provider when no provider is provided", async () => { + await render(TestTwitterSignInButtonHostComponent); + + const button = screen.getByRole("button"); + expect(button).toHaveAttribute("data-provider", "twitter.com"); + }); + + it("has signIn output", async () => { + const { fixture } = await render(TestTwitterSignInButtonHostComponent); + + const component = fixture.componentInstance; + expect(component).toBeTruthy(); + // Verify the component has the signIn output + const buttonComponent = fixture.debugElement.query( + (el) => el.name === "fui-twitter-sign-in-button" + )?.componentInstance; + expect(buttonComponent?.signIn).toBeDefined(); + }); +}); diff --git a/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.ts b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.ts new file mode 100644 index 000000000..cba57a801 --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, input, output } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { OAuthButtonComponent } from "./oauth-button"; +import { injectTranslation } from "../../provider"; +import { TwitterAuthProvider, UserCredential } from "@angular/fire/auth"; +import { TwitterLogoComponent } from "../../components/logos/twitter"; + +@Component({ + selector: "fui-twitter-sign-in-button", + standalone: true, + imports: [CommonModule, OAuthButtonComponent, TwitterLogoComponent], + host: { + style: "display: block;", + }, + template: ` + + + {{ signInWithTwitterLabel() }} + + `, +}) +/** + * A button component for signing in with Twitter/X. + */ +export class TwitterSignInButtonComponent { + signInWithTwitterLabel = injectTranslation("labels", "signInWithTwitter"); + /** Whether to use themed styling. */ + themed = input(false); + /** Event emitter for successful sign-in. */ + signIn = output(); + + private defaultProvider = new TwitterAuthProvider(); + + /** Optional custom OAuth provider configuration. */ + provider = input(); + + get twitterProvider() { + return this.provider() || this.defaultProvider; + } +} 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 new file mode 100644 index 000000000..512282042 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts @@ -0,0 +1,399 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, fireEvent } from "@testing-library/angular"; +import { Component, EventEmitter } from "@angular/core"; +import { Subject } from "rxjs"; +import { User } from "@angular/fire/auth"; + +import { EmailLinkAuthScreenComponent } from "./email-link-auth-screen"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; + +@Component({ + selector: "fui-email-link-auth-form", + template: '', + standalone: true, +}) +class MockEmailLinkAuthFormComponent {} + +@Component({ + selector: "fui-redirect-error", + template: '
Redirect Error
', + standalone: true, +}) +class MockRedirectErrorComponent {} + +@Component({ + selector: "fui-multi-factor-auth-assertion-screen", + template: ` +
MFA Assertion Screen
+ + `, + standalone: true, + outputs: ["onSuccess"], +}) +class MockMultiFactorAuthAssertionScreenComponent { + onSuccess = new EventEmitter(); +} + +@Component({ + template: ` + +
Test Content
+
+ `, + standalone: true, + imports: [EmailLinkAuthScreenComponent], +}) +class TestHostWithContentComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [EmailLinkAuthScreenComponent], +}) +class TestHostWithoutContentComponent {} + +describe("", () => { + let authStateSubject: Subject; + let userAuthenticatedCallback: ((user: User) => void) | null = null; + + beforeEach(() => { + 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: { + signIn: "Sign In", + }, + prompts: { + signInToAccount: "Sign in to your account", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => () => ({ + multiFactorResolver: null, + setMultiFactorResolver: jest.fn(), + })); + }); + + afterEach(() => { + userAuthenticatedCallback = null; + authStateSubject.complete(); + authStateSubject = new Subject(); + jest.clearAllMocks(); + }); + + it("renders with correct title and subtitle", async () => { + await render(TestHostWithoutContentComponent, { + imports: [ + EmailLinkAuthScreenComponent, + MockEmailLinkAuthFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.getByText("Sign In")).toBeInTheDocument(); + expect(screen.getByText("Sign in to your account")).toBeInTheDocument(); + }); + + it("includes the EmailLinkAuthForm component", async () => { + await render(TestHostWithoutContentComponent, { + imports: [ + EmailLinkAuthScreenComponent, + MockEmailLinkAuthFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const form = screen.getByRole("button", { name: "labels.sendSignInLink" }); + expect(form).toBeInTheDocument(); + expect(form).toHaveClass("fui-form__action", "fui-button"); + }); + + it("renders projected content when provided", async () => { + await render(TestHostWithContentComponent, { + imports: [ + EmailLinkAuthScreenComponent, + MockEmailLinkAuthFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const projectedContent = screen.getByTestId("projected-content"); + expect(projectedContent).toBeInTheDocument(); + expect(projectedContent).toHaveTextContent("Test Content"); + }); + + it("renders RedirectError component in children section", async () => { + const { container } = await render(TestHostWithContentComponent, { + imports: [ + EmailLinkAuthScreenComponent, + MockEmailLinkAuthFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const redirectErrorElement = container.querySelector("fui-redirect-error"); + expect(redirectErrorElement).toBeInTheDocument(); + }); + + it("has correct CSS classes", async () => { + const { container } = await render(TestHostWithoutContentComponent, { + imports: [ + EmailLinkAuthScreenComponent, + MockEmailLinkAuthFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(container.querySelector(".fui-screen")).toBeInTheDocument(); + expect(container.querySelector(".fui-card")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__header")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__title")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__subtitle")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__content")).toBeInTheDocument(); + }); + + it("calls injectTranslation with correct parameters", async () => { + const { injectTranslation } = require("../../../provider"); + await render(TestHostWithoutContentComponent, { + imports: [ + EmailLinkAuthScreenComponent, + MockEmailLinkAuthFormComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(injectTranslation).toHaveBeenCalledWith("labels", "signIn"); + expect(injectTranslation).toHaveBeenCalledWith("prompts", "signInToAccount"); + }); + + it("renders MFA assertion form when MFA resolver is present", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => () => ({ + multiFactorResolver: { auth: {}, session: null, hints: [] }, + setMultiFactorResolver: jest.fn(), + })); + + const { container } = await render(TestHostWithoutContentComponent, { + imports: [ + EmailLinkAuthScreenComponent, + MockEmailLinkAuthFormComponent, + MockMultiFactorAuthAssertionScreenComponent, + MockRedirectErrorComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + // Check for the MFA screen element by its selector + expect(container.querySelector("fui-multi-factor-auth-assertion-screen")).toBeInTheDocument(); + }); + + it("emits signIn when MFA flow succeeds and user authenticates", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => () => ({ + multiFactorResolver: { auth: {}, session: null, hints: [] }, + setMultiFactorResolver: jest.fn(), + })); + + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + EmailLinkAuthScreenComponent, + MockEmailLinkAuthFormComponent, + MockMultiFactorAuthAssertionScreenComponent, + 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 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(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 new file mode 100644 index 000000000..eaf33575f --- /dev/null +++ b/packages/angular/src/lib/auth/screens/email-link-auth-screen.ts @@ -0,0 +1,92 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, Output, EventEmitter, computed } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; +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 { User } from "@angular/fire/auth"; + +@Component({ + selector: "fui-email-link-auth-screen", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + EmailLinkAuthFormComponent, + MultiFactorAuthAssertionScreenComponent, + RedirectErrorComponent, + ], + template: ` + @if (mfaResolver()) { + + } @else { +
+ + + {{ titleText() }} + {{ subtitleText() }} + + + + + + + +
+ } + `, +}) +/** + * A screen component for email link authentication. + * + * Automatically displays the MFA assertion screen if a multi-factor resolver is present. + */ +export class EmailLinkAuthScreenComponent { + private ui = injectUI(); + + mfaResolver = computed(() => this.ui().multiFactorResolver); + + titleText = injectTranslation("labels", "signIn"); + subtitleText = injectTranslation("prompts", "signInToAccount"); + + constructor() { + injectUserAuthenticated((user) => { + this.signIn.emit(user); + }); + } + + /** Event emitter fired when sign-in link email is sent. */ + @Output() emailSent = new EventEmitter(); + /** Event emitter for successful sign-in. */ + @Output() signIn = new EventEmitter(); +} diff --git a/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.spec.ts new file mode 100644 index 000000000..fbff9cdab --- /dev/null +++ b/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.spec.ts @@ -0,0 +1,124 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; +import { Component } from "@angular/core"; + +import { ForgotPasswordAuthScreenComponent } from "./forgot-password-auth-screen"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; + +@Component({ + selector: "fui-forgot-password-auth-form", + template: '
Forgot Password Form
', + standalone: true, +}) +class MockForgotPasswordAuthFormComponent {} + +describe("", () => { + beforeEach(() => { + const { injectTranslation } = require("../../../provider"); + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + resetPassword: "Reset Password", + }, + prompts: { + enterEmailToReset: "Enter your email to reset your password", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + }); + + it("renders with correct title and subtitle", async () => { + await render(ForgotPasswordAuthScreenComponent, { + imports: [ + ForgotPasswordAuthScreenComponent, + MockForgotPasswordAuthFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.getByRole("heading", { name: "Reset Password" })).toBeInTheDocument(); + expect(screen.getByText("Enter your email to reset your password")).toBeInTheDocument(); + }); + + it("includes the ForgotPasswordAuthForm component", async () => { + const { container } = await render(ForgotPasswordAuthScreenComponent, { + imports: [ + ForgotPasswordAuthScreenComponent, + MockForgotPasswordAuthFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const form = container.querySelector(".fui-form"); + expect(form).toBeInTheDocument(); + }); + + it("has correct CSS classes", async () => { + const { container } = await render(ForgotPasswordAuthScreenComponent, { + imports: [ + ForgotPasswordAuthScreenComponent, + MockForgotPasswordAuthFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(container.querySelector(".fui-screen")).toBeInTheDocument(); + expect(container.querySelector(".fui-card")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__header")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__title")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__subtitle")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__content")).toBeInTheDocument(); + }); + + it("calls injectTranslation with correct parameters", async () => { + const { injectTranslation } = require("../../../provider"); + await render(ForgotPasswordAuthScreenComponent, { + imports: [ + ForgotPasswordAuthScreenComponent, + MockForgotPasswordAuthFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(injectTranslation).toHaveBeenCalledWith("labels", "resetPassword"); + expect(injectTranslation).toHaveBeenCalledWith("prompts", "enterEmailToReset"); + }); +}); diff --git a/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.ts b/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.ts new file mode 100644 index 000000000..ed17b083e --- /dev/null +++ b/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.ts @@ -0,0 +1,69 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, Output, EventEmitter } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; +import { injectTranslation } from "../../provider"; +import { ForgotPasswordAuthFormComponent } from "../forms/forgot-password-auth-form"; + +@Component({ + selector: "fui-forgot-password-auth-screen", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ForgotPasswordAuthFormComponent, + ], + template: ` +
+ + + {{ titleText() }} + {{ subtitleText() }} + + + + + +
+ `, +}) +/** + * A screen component for requesting a password reset. + */ +export class ForgotPasswordAuthScreenComponent { + titleText = injectTranslation("labels", "resetPassword"); + subtitleText = injectTranslation("prompts", "enterEmailToReset"); + + /** Event emitter fired when password reset email is sent. */ + @Output() passwordSent = new EventEmitter(); + /** Event emitter for back to sign in action. */ + @Output() backToSignIn = new EventEmitter(); +} diff --git a/packages/angular/src/lib/auth/screens/multi-factor-auth-assertion-screen.spec.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-assertion-screen.spec.ts new file mode 100644 index 000000000..2efac64f7 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-assertion-screen.spec.ts @@ -0,0 +1,171 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { MultiFactorAuthAssertionScreenComponent } from "./multi-factor-auth-assertion-screen"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; + +@Component({ + template: ``, + standalone: true, + imports: [MultiFactorAuthAssertionScreenComponent], +}) +class TestHostWithoutContentComponent {} + +describe("", () => { + beforeEach(() => { + const { injectTranslation, injectUI } = require("../../../provider"); + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + multiFactorAssertion: "Multi-Factor Assertion", + }, + prompts: { + mfaAssertionPrompt: "Verify your multi-factor authentication", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => () => ({ + multiFactorResolver: { + auth: {}, + session: null, + hints: [], + }, + setMultiFactorResolver: jest.fn(), + })); + }); + + it("renders with correct title and subtitle", async () => { + await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.getByRole("heading", { name: "Multi-Factor Assertion" })).toBeInTheDocument(); + expect(screen.getByText("Verify your multi-factor authentication")).toBeInTheDocument(); + }); + + it("includes the MultiFactorAuthAssertionForm component", async () => { + TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + set: { + template: '
MFA Assertion Form
', + }, + }); + + await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const form = screen.getByTestId("mfa-assertion-form"); + expect(form).toBeInTheDocument(); + expect(form).toHaveTextContent("MFA Assertion Form"); + }); + + it("has correct CSS classes", async () => { + const { container } = await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(container.querySelector(".fui-screen")).toBeInTheDocument(); + expect(container.querySelector(".fui-card")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__header")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__title")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__subtitle")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__content")).toBeInTheDocument(); + }); + + it("calls injectTranslation with correct parameters", async () => { + const { injectTranslation } = require("../../../provider"); + await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(injectTranslation).toHaveBeenCalledWith("labels", "multiFactorAssertion"); + expect(injectTranslation).toHaveBeenCalledWith("prompts", "mfaAssertionPrompt"); + }); + + it("emits onSuccess event when form emits onSuccess", async () => { + TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + set: { + template: '
MFA Assertion Form
', + }, + }); + + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query( + (el) => el.name === "fui-multi-factor-auth-assertion-screen" + ).componentInstance; + const onSuccessSpy = jest.spyOn(component.onSuccess, "emit"); + + const formComponent = fixture.debugElement.query( + (el) => el.name === "fui-multi-factor-auth-assertion-form" + ).componentInstance; + formComponent.onSuccess.emit({ user: { uid: "mfa-user" } }); + + expect(onSuccessSpy).toHaveBeenCalledTimes(1); + expect(onSuccessSpy).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) }) + ); + }); +}); diff --git a/packages/angular/src/lib/auth/screens/multi-factor-auth-assertion-screen.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-assertion-screen.ts new file mode 100644 index 000000000..2a66aed1e --- /dev/null +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-assertion-screen.ts @@ -0,0 +1,70 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, Output, EventEmitter } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { UserCredential } from "@angular/fire/auth"; +import { injectTranslation } from "../../provider"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; + +@Component({ + selector: "fui-multi-factor-auth-assertion-screen", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + MultiFactorAuthAssertionFormComponent, + ], + template: ` +
+ + + {{ titleText() }} + {{ subtitleText() }} + + + + + +
+ `, +}) +/** + * A screen component for multi-factor authentication assertion. + * + * Displays the MFA assertion form for completing multi-factor verification. + */ +export class MultiFactorAuthAssertionScreenComponent { + /** Event emitter for successful MFA assertion. */ + @Output() onSuccess = new EventEmitter(); + + titleText = injectTranslation("labels", "multiFactorAssertion"); + subtitleText = injectTranslation("prompts", "mfaAssertionPrompt"); +} diff --git a/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.spec.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.spec.ts new file mode 100644 index 000000000..abe68f671 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.spec.ts @@ -0,0 +1,172 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; +import { Component } from "@angular/core"; +import { MultiFactorAuthEnrollmentScreenComponent } from "./multi-factor-auth-enrollment-screen"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; +import { FactorId } from "firebase/auth"; + +@Component({ + selector: "fui-multi-factor-auth-enrollment-form", + template: '
MFA Enrollment Form
', + standalone: true, +}) +class MockMultiFactorAuthEnrollmentFormComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [MultiFactorAuthEnrollmentScreenComponent], +}) +class TestHostWithoutContentComponent {} + +describe("", () => { + beforeEach(() => { + const { injectTranslation } = require("../../../provider"); + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + multiFactorEnrollment: "Multi-Factor Enrollment", + }, + prompts: { + mfaEnrollmentPrompt: "Set up multi-factor authentication for your account", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + }); + + it("renders with correct title and subtitle", async () => { + await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthEnrollmentScreenComponent, + MockMultiFactorAuthEnrollmentFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.getByRole("heading", { name: "Multi-Factor Enrollment" })).toBeInTheDocument(); + expect(screen.getByText("Set up multi-factor authentication for your account")).toBeInTheDocument(); + }); + + it("includes the MultiFactorAuthEnrollmentForm component", async () => { + await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthEnrollmentScreenComponent, + MockMultiFactorAuthEnrollmentFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const form = screen.getByRole("button", { name: "labels.mfaTotpVerification" }); + expect(form).toBeInTheDocument(); + expect(form.parentElement).toHaveTextContent("labels.mfaTotpVerification labels.mfaSmsVerification"); + }); + + it("has correct CSS classes", async () => { + const { container } = await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthEnrollmentScreenComponent, + MockMultiFactorAuthEnrollmentFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(container.querySelector(".fui-screen")).toBeInTheDocument(); + expect(container.querySelector(".fui-card")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__header")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__title")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__subtitle")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__content")).toBeInTheDocument(); + }); + + it("calls injectTranslation with correct parameters", async () => { + const { injectTranslation } = require("../../../provider"); + await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthEnrollmentScreenComponent, + MockMultiFactorAuthEnrollmentFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(injectTranslation).toHaveBeenCalledWith("labels", "multiFactorEnrollment"); + expect(injectTranslation).toHaveBeenCalledWith("prompts", "mfaEnrollmentPrompt"); + }); + + it("passes hints to the form component", async () => { + const { fixture } = await render(MultiFactorAuthEnrollmentScreenComponent, { + imports: [ + MultiFactorAuthEnrollmentScreenComponent, + MockMultiFactorAuthEnrollmentFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + componentInputs: { + hints: [FactorId.TOTP, FactorId.PHONE], + }, + }); + + const component = fixture.componentInstance; + expect(component.hints()).toEqual([FactorId.TOTP, FactorId.PHONE]); + }); + + it("emits onEnrollment event", async () => { + const { fixture } = await render(MultiFactorAuthEnrollmentScreenComponent, { + imports: [ + MultiFactorAuthEnrollmentScreenComponent, + MockMultiFactorAuthEnrollmentFormComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.componentInstance; + const enrollmentSpy = jest.spyOn(component.onEnrollment, "emit"); + + component.onEnrollment.emit(); + expect(enrollmentSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.ts new file mode 100644 index 000000000..f759d66b5 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.ts @@ -0,0 +1,74 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, Output, EventEmitter, input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { FactorId } from "firebase/auth"; +import { injectTranslation } from "../../provider"; +import { MultiFactorAuthEnrollmentFormComponent } from "../forms/multi-factor-auth-enrollment-form"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; + +type Hint = (typeof FactorId)[keyof typeof FactorId]; + +@Component({ + selector: "fui-multi-factor-auth-enrollment-screen", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + MultiFactorAuthEnrollmentFormComponent, + ], + template: ` +
+ + + {{ titleText() }} + {{ subtitleText() }} + + + + + +
+ `, +}) +/** + * A screen component for multi-factor authentication enrollment. + * + * Displays the MFA enrollment form for setting up multi-factor authentication. + */ +export class MultiFactorAuthEnrollmentScreenComponent { + /** The available MFA factor types for enrollment. */ + hints = input([FactorId.TOTP, FactorId.PHONE]); + /** Event emitter fired when MFA enrollment is completed. */ + @Output() onEnrollment = new EventEmitter(); + + titleText = injectTranslation("labels", "multiFactorEnrollment"); + subtitleText = injectTranslation("prompts", "mfaEnrollmentPrompt"); +} diff --git a/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts b/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts new file mode 100644 index 000000000..9a9578775 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts @@ -0,0 +1,525 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; +import { Component, 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 { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; +import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; +import { ContentComponent } from "../../components/content"; + +jest.mock("../../../provider", () => ({ + injectTranslation: jest.fn(), + injectPolicies: jest.fn(), + injectRedirectError: jest.fn(), + injectUI: jest.fn(), + injectUserAuthenticated: jest.fn(), +})); + +@Component({ + selector: "fui-policies", + template: '
Policies
', + standalone: true, +}) +class MockPoliciesComponent {} + +@Component({ + selector: "fui-redirect-error", + template: '
Redirect Error
', + standalone: true, +}) +class MockRedirectErrorComponent {} + +@Component({ + template: ` + +
OAuth Provider
+
+ `, + standalone: true, + imports: [OAuthScreenComponent], +}) +class TestHostWithContentComponent {} + +@Component({ + template: ` + +
Provider 1
+
Provider 2
+
+ `, + standalone: true, + imports: [OAuthScreenComponent], +}) +class TestHostWithMultipleProvidersComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [OAuthScreenComponent], +}) +class TestHostWithoutContentComponent {} + +@Component({ + selector: "fui-multi-factor-auth-assertion-screen", + template: '
MFA Assertion Screen
', + standalone: true, + outputs: ["onSuccess"], +}) +class MockMultiFactorAuthAssertionScreenComponent { + onSuccess = new EventEmitter(); +} + +describe("", () => { + let authStateSubject: Subject; + let userAuthenticatedCallback: ((user: User) => void) | null = null; + + beforeEach(() => { + 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: { + signIn: "Sign In", + }, + prompts: { + signInToAccount: "Sign in to your account", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectPolicies.mockReturnValue({ + termsOfServiceUrl: "https://example.com/terms", + privacyPolicyUrl: "https://example.com/privacy", + }); + + injectRedirectError.mockImplementation(() => { + return () => undefined; + }); + + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: null, + }); + }); + }); + + afterEach(() => { + userAuthenticatedCallback = null; + authStateSubject.complete(); + authStateSubject = new Subject(); + jest.clearAllMocks(); + }); + + it("renders with correct title and subtitle", async () => { + await render(TestHostWithoutContentComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + expect(screen.getByText("Sign In")).toBeInTheDocument(); + expect(screen.getByText("Sign in to your account")).toBeInTheDocument(); + }); + + it("includes the Policies component", async () => { + const { container } = await render(TestHostWithoutContentComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + const policies = container.querySelector(".fui-policies"); + expect(policies).toBeInTheDocument(); + }); + + it("renders projected content wrapped in fui-content", async () => { + await render(TestHostWithContentComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + const provider = screen.getByTestId("oauth-provider"); + expect(provider).toBeInTheDocument(); + expect(provider).toHaveTextContent("OAuth Provider"); + }); + + it("renders multiple providers wrapped in fui-content", async () => { + await render(TestHostWithMultipleProvidersComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + const provider1 = screen.getByTestId("provider-1"); + const provider2 = screen.getByTestId("provider-2"); + + expect(provider1).toBeInTheDocument(); + expect(provider1).toHaveTextContent("Provider 1"); + expect(provider2).toBeInTheDocument(); + expect(provider2).toHaveTextContent("Provider 2"); + }); + + it("renders RedirectError component with children when no MFA resolver", async () => { + const { container } = await render(TestHostWithContentComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + const redirectErrorElement = container.querySelector("fui-redirect-error"); + expect(redirectErrorElement).toBeInTheDocument(); + }); + + it("has correct CSS classes", async () => { + const { container } = await render(TestHostWithoutContentComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + expect(container.querySelector(".fui-screen")).toBeInTheDocument(); + expect(container.querySelector(".fui-card")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__header")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__title")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__subtitle")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__content")).toBeInTheDocument(); + }); + + it("calls injectTranslation with correct parameters", async () => { + const { injectTranslation } = require("../../../provider"); + await render(TestHostWithoutContentComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + expect(injectTranslation).toHaveBeenCalledWith("labels", "signIn"); + expect(injectTranslation).toHaveBeenCalledWith("prompts", "signInToAccount"); + }); + + it("renders MFA assertion screen when multiFactorResolver is present", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { auth: {}, session: null, hints: [] }, + }); + }); + + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { + set: { + template: '
MFA Assertion Screen
', + }, + }); + + await render(TestHostWithoutContentComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("policies")).not.toBeInTheDocument(); + }); + + it("does not render Policies component when MFA resolver exists", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { auth: {}, session: null, hints: [] }, + }); + }); + + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { + set: { + template: '
MFA Assertion Screen
', + }, + }); + + await render(TestHostWithContentComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + expect(screen.queryByTestId("policies")).not.toBeInTheDocument(); + expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); + }); + + it("emits onSignIn when MFA flow succeeds and user authenticates", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { auth: {}, session: null, hints: [{ factorId: "totp", uid: "test" }] }, + }); + }); + + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { + set: { + template: '
MFA Assertion Screen
', + }, + }); + + 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 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(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 new file mode 100644 index 000000000..d12851600 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/oauth-screen.ts @@ -0,0 +1,93 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, computed, Output, EventEmitter } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; +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 User } from "@angular/fire/auth"; + +@Component({ + selector: "fui-oauth-screen", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + PoliciesComponent, + MultiFactorAuthAssertionScreenComponent, + RedirectErrorComponent, + ], + template: ` + @if (mfaResolver()) { + + } @else { +
+ + + {{ titleText() }} + {{ subtitleText() }} + + +
+ + + +
+
+
+
+ } + `, +}) +/** + * A screen component for OAuth authentication. + * + * Automatically displays the MFA assertion screen if a multi-factor resolver is present. + * Use this screen to display OAuth sign-in buttons. + */ +export class OAuthScreenComponent { + private ui = injectUI(); + + mfaResolver = computed(() => this.ui().multiFactorResolver); + + titleText = injectTranslation("labels", "signIn"); + subtitleText = injectTranslation("prompts", "signInToAccount"); + + constructor() { + injectUserAuthenticated((user) => { + this.onSignIn.emit(user); + }); + } + + /** Event emitter for successful sign-in. */ + @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 new file mode 100644 index 000000000..ab5e1d8df --- /dev/null +++ b/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts @@ -0,0 +1,449 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { Subject } from "rxjs"; +import { User } from "@angular/fire/auth"; + +import { PhoneAuthScreenComponent } from "./phone-auth-screen"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; +import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; +import { TotpMultiFactorAssertionFormComponent } from "../forms/mfa/totp-multi-factor-assertion-form"; +import { TotpMultiFactorGenerator } from "firebase/auth"; + +@Component({ + selector: "fui-phone-auth-form", + template: '
Phone Auth Form
', + standalone: true, +}) +class MockPhoneAuthFormComponent {} + +@Component({ + selector: "fui-redirect-error", + template: '
Redirect Error
', + standalone: true, +}) +class MockRedirectErrorComponent {} + +@Component({ + template: ` + +
Test Content
+
+ `, + standalone: true, + imports: [PhoneAuthScreenComponent], +}) +class TestHostWithContentComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [PhoneAuthScreenComponent], +}) +class TestHostWithoutContentComponent {} + +describe("", () => { + let authStateSubject: Subject; + let userAuthenticatedCallback: ((user: User) => void) | null = null; + + beforeEach(() => { + 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: { + signIn: "Sign In", + }, + prompts: { + signInToAccount: "Sign in to your account", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: null, + }); + }); + }); + + afterEach(() => { + userAuthenticatedCallback = null; + authStateSubject.complete(); + authStateSubject = new Subject(); + jest.clearAllMocks(); + }); + + it("renders with correct title and subtitle", async () => { + await render(TestHostWithoutContentComponent, { + imports: [ + PhoneAuthScreenComponent, + MockPhoneAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.getByText("Sign In")).toBeInTheDocument(); + expect(screen.getByText("Sign in to your account")).toBeInTheDocument(); + }); + + it("includes the PhoneAuthForm component", async () => { + await render(TestHostWithoutContentComponent, { + imports: [ + PhoneAuthScreenComponent, + MockPhoneAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const form = document.querySelector(".fui-form"); + expect(form).toBeInTheDocument(); + expect(form).toHaveClass("fui-form"); + }); + + it("renders projected content when provided", async () => { + await render(TestHostWithContentComponent, { + imports: [ + PhoneAuthScreenComponent, + MockPhoneAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const projectedContent = screen.getByTestId("projected-content"); + expect(projectedContent).toBeInTheDocument(); + expect(projectedContent).toHaveTextContent("Test Content"); + }); + + it("renders RedirectError component in children section when no MFA resolver", async () => { + const { container } = await render(TestHostWithContentComponent, { + imports: [ + PhoneAuthScreenComponent, + MockPhoneAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const redirectErrorElement = container.querySelector("fui-redirect-error"); + expect(redirectErrorElement).toBeInTheDocument(); + }); + + it("has correct CSS classes", async () => { + const { container } = await render(TestHostWithoutContentComponent, { + imports: [ + PhoneAuthScreenComponent, + MockPhoneAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(container.querySelector(".fui-screen")).toBeInTheDocument(); + expect(container.querySelector(".fui-card")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__header")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__title")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__subtitle")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__content")).toBeInTheDocument(); + }); + + it("calls injectTranslation with correct parameters", async () => { + const { injectTranslation } = require("../../../provider"); + await render(TestHostWithoutContentComponent, { + imports: [ + PhoneAuthScreenComponent, + MockPhoneAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(injectTranslation).toHaveBeenCalledWith("labels", "signIn"); + expect(injectTranslation).toHaveBeenCalledWith("prompts", "signInToAccount"); + }); + + it("renders MFA assertion screen when multiFactorResolver is present", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { auth: {}, session: null, hints: [] }, + }); + }); + + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { + set: { + template: '
MFA Assertion Screen
', + }, + }); + + await render(TestHostWithoutContentComponent, { + imports: [ + PhoneAuthScreenComponent, + MockPhoneAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); + expect(screen.queryByText("Phone Auth Form")).not.toBeInTheDocument(); + }); + + it("does not render PhoneAuthForm when MFA resolver exists", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { auth: {}, session: null, hints: [] }, + }); + }); + + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { + set: { + template: '
MFA Assertion Screen
', + }, + }); + + await render(TestHostWithContentComponent, { + imports: [ + PhoneAuthScreenComponent, + MockPhoneAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.queryByText("Phone Auth Form")).not.toBeInTheDocument(); + expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); + }); + + it("emits signIn when MFA flow succeeds and user authenticates", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { + auth: {}, + session: null, + hints: [{ factorId: TotpMultiFactorGenerator.FACTOR_ID, uid: "test" }], + }, + }); + }); + + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { + set: { + template: '
MFA Assertion Screen
', + }, + }); + + 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 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(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 new file mode 100644 index 000000000..392666aac --- /dev/null +++ b/packages/angular/src/lib/auth/screens/phone-auth-screen.ts @@ -0,0 +1,90 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, input, Output, EventEmitter, computed } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; +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 { User } from "@angular/fire/auth"; + +@Component({ + selector: "fui-phone-auth-screen", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + PhoneAuthFormComponent, + MultiFactorAuthAssertionScreenComponent, + RedirectErrorComponent, + ], + template: ` + @if (mfaResolver()) { + + } @else { +
+ + + {{ titleText() }} + {{ subtitleText() }} + + + + + + + +
+ } + `, +}) +/** + * A screen component for phone number authentication. + * + * Automatically displays the MFA assertion screen if a multi-factor resolver is present. + */ +export class PhoneAuthScreenComponent { + private ui = injectUI(); + + mfaResolver = computed(() => this.ui().multiFactorResolver); + + titleText = injectTranslation("labels", "signIn"); + subtitleText = injectTranslation("prompts", "signInToAccount"); + + constructor() { + injectUserAuthenticated((user) => { + this.signIn.emit(user); + }); + } + + /** Event emitter for successful sign-in. */ + @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 new file mode 100644 index 000000000..bed61fbfe --- /dev/null +++ b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts @@ -0,0 +1,453 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { Subject } from "rxjs"; +import { User } from "@angular/fire/auth"; + +import { SignInAuthScreenComponent } from "./sign-in-auth-screen"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; +import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; +import { TotpMultiFactorAssertionFormComponent } from "../forms/mfa/totp-multi-factor-assertion-form"; +import { TotpMultiFactorGenerator } from "firebase/auth"; + +@Component({ + selector: "fui-sign-in-auth-form", + template: '', + standalone: true, +}) +class MockSignInAuthFormComponent {} + +@Component({ + selector: "fui-redirect-error", + template: '
Redirect Error
', + standalone: true, +}) +class MockRedirectErrorComponent {} + +@Component({ + template: ` + +
Test Content
+
+ `, + standalone: true, + imports: [SignInAuthScreenComponent], +}) +class TestHostWithContentComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [SignInAuthScreenComponent], +}) +class TestHostWithoutContentComponent {} + +describe("", () => { + let authStateSubject: Subject; + let userAuthenticatedCallback: ((user: User) => void) | null = null; + + beforeEach(() => { + 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: { + signIn: "Sign in", + }, + prompts: { + signInToAccount: "Sign in to your account", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: null, + }); + }); + }); + + afterEach(() => { + userAuthenticatedCallback = null; + authStateSubject.complete(); + authStateSubject = new Subject(); + jest.clearAllMocks(); + }); + + it("renders with correct title and subtitle", async () => { + await render(TestHostWithoutContentComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.getByRole("heading", { name: "Sign in" })).toBeInTheDocument(); + expect(screen.getByText("Sign in to your account")).toBeInTheDocument(); + }); + + it("includes the SignInAuthForm component", async () => { + await render(TestHostWithoutContentComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const form = screen.getByRole("button", { name: "Sign in" }); + expect(form).toBeInTheDocument(); + expect(form).toHaveClass("fui-form__action", "fui-button"); + }); + + it("renders projected content when provided", async () => { + await render(TestHostWithContentComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const projectedContent = screen.getByTestId("projected-content"); + expect(projectedContent).toBeInTheDocument(); + expect(projectedContent).toHaveTextContent("Test Content"); + }); + + it("renders RedirectError component in children section when no MFA resolver", async () => { + const { container } = await render(TestHostWithContentComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const redirectErrorElement = container.querySelector("fui-redirect-error"); + expect(redirectErrorElement).toBeInTheDocument(); + }); + + it("has correct CSS classes", async () => { + const { container } = await render(TestHostWithoutContentComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(container.querySelector(".fui-screen")).toBeInTheDocument(); + expect(container.querySelector(".fui-card")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__header")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__title")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__subtitle")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__content")).toBeInTheDocument(); + }); + + it("calls injectTranslation with correct parameters", async () => { + const { injectTranslation } = require("../../../provider"); + await render(TestHostWithoutContentComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(injectTranslation).toHaveBeenCalledWith("labels", "signIn"); + expect(injectTranslation).toHaveBeenCalledWith("prompts", "signInToAccount"); + }); + + it("renders MFA assertion screen when multiFactorResolver is present", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { auth: {}, session: null, hints: [] }, + }); + }); + + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { + set: { + template: '
MFA Assertion Screen
', + }, + }); + + await render(TestHostWithoutContentComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Sign in" })).not.toBeInTheDocument(); + }); + + it("does not render SignInAuthForm when MFA resolver exists", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { auth: {}, session: null, hints: [] }, + }); + }); + + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { + set: { + template: '
MFA Assertion Screen
', + }, + }); + + await render(TestHostWithContentComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.queryByRole("button", { name: "Sign in" })).not.toBeInTheDocument(); + expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); + }); + + it("emits signIn when MFA flow succeeds and user authenticates", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { + auth: {}, + session: null, + hints: [{ factorId: TotpMultiFactorGenerator.FACTOR_ID, uid: "test" }], + }, + }); + }); + + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { + set: { + template: '
MFA Assertion Screen
', + }, + }); + + 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 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(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 new file mode 100644 index 000000000..86cba026b --- /dev/null +++ b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts @@ -0,0 +1,93 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, Output, EventEmitter, computed, inject, effect } from "@angular/core"; +import { CommonModule } from "@angular/common"; + +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"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; +import { Auth, authState, User, UserCredential } from "@angular/fire/auth"; +@Component({ + selector: "fui-sign-in-auth-screen", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + SignInAuthFormComponent, + MultiFactorAuthAssertionScreenComponent, + RedirectErrorComponent, + ], + template: ` + @if (mfaResolver()) { + + } @else { +
+ + + {{ titleText() }} + {{ subtitleText() }} + + + + + + + +
+ } + `, +}) +/** + * A screen component for email/password sign-in. + * + * Automatically displays the MFA assertion screen if a multi-factor resolver is present. + */ +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); + }); + } + + /** Event emitter for forgot password action. */ + @Output() forgotPassword = new EventEmitter(); + /** Event emitter for sign up action. */ + @Output() signUp = new EventEmitter(); + /** Event emitter for successful sign-in. */ + @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 new file mode 100644 index 000000000..82b394e86 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts @@ -0,0 +1,448 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { Subject } from "rxjs"; +import { User } from "@angular/fire/auth"; + +import { SignUpAuthScreenComponent } from "./sign-up-auth-screen"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; +import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; +import { TotpMultiFactorAssertionFormComponent } from "../forms/mfa/totp-multi-factor-assertion-form"; +import { TotpMultiFactorGenerator } from "firebase/auth"; + +@Component({ + selector: "fui-sign-up-auth-form", + template: '
Sign Up Form
', + standalone: true, +}) +class MockSignUpAuthFormComponent {} + +@Component({ + selector: "fui-redirect-error", + template: '
Redirect Error
', + standalone: true, +}) +class MockRedirectErrorComponent {} + +@Component({ + template: ` + +
Test Content
+
+ `, + standalone: true, + imports: [SignUpAuthScreenComponent], +}) +class TestHostWithContentComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [SignUpAuthScreenComponent], +}) +class TestHostWithoutContentComponent {} + +describe("", () => { + let authStateSubject: Subject; + let userAuthenticatedCallback: ((user: User) => void) | null = null; + + beforeEach(() => { + 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: { + signUp: "Create Account", + }, + prompts: { + enterDetailsToCreate: "Enter your details to create an account", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: null, + }); + }); + }); + + afterEach(() => { + userAuthenticatedCallback = null; + authStateSubject.complete(); + authStateSubject = new Subject(); + jest.clearAllMocks(); + }); + + it("renders with correct title and subtitle", async () => { + await render(TestHostWithoutContentComponent, { + imports: [ + SignUpAuthScreenComponent, + MockSignUpAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.getByText("Create Account")).toBeInTheDocument(); + expect(screen.getByText("Enter your details to create an account")).toBeInTheDocument(); + }); + + it("includes the SignUpAuthForm component", async () => { + const { container } = await render(TestHostWithoutContentComponent, { + imports: [ + SignUpAuthScreenComponent, + MockSignUpAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const form = container.querySelector(".fui-form"); + expect(form).toBeInTheDocument(); + }); + + it("renders projected content when provided", async () => { + await render(TestHostWithContentComponent, { + imports: [ + SignUpAuthScreenComponent, + MockSignUpAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const projectedContent = screen.getByTestId("projected-content"); + expect(projectedContent).toBeInTheDocument(); + expect(projectedContent).toHaveTextContent("Test Content"); + }); + + it("renders RedirectError component in children section when no MFA resolver", async () => { + const { container } = await render(TestHostWithContentComponent, { + imports: [ + SignUpAuthScreenComponent, + MockSignUpAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const redirectErrorElement = container.querySelector("fui-redirect-error"); + expect(redirectErrorElement).toBeInTheDocument(); + }); + + it("has correct CSS classes", async () => { + const { container } = await render(TestHostWithoutContentComponent, { + imports: [ + SignUpAuthScreenComponent, + MockSignUpAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(container.querySelector(".fui-screen")).toBeInTheDocument(); + expect(container.querySelector(".fui-card")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__header")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__title")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__subtitle")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__content")).toBeInTheDocument(); + }); + + it("calls injectTranslation with correct parameters", async () => { + const { injectTranslation } = require("../../../provider"); + await render(TestHostWithoutContentComponent, { + imports: [ + SignUpAuthScreenComponent, + MockSignUpAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(injectTranslation).toHaveBeenCalledWith("labels", "signUp"); + expect(injectTranslation).toHaveBeenCalledWith("prompts", "enterDetailsToCreate"); + }); + + it("renders MFA assertion screen when multiFactorResolver is present", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { auth: {}, session: null, hints: [] }, + }); + }); + + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { + set: { + template: '
MFA Assertion Screen
', + }, + }); + + await render(TestHostWithoutContentComponent, { + imports: [ + SignUpAuthScreenComponent, + MockSignUpAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); + expect(screen.queryByText("Sign Up Form")).not.toBeInTheDocument(); + }); + + it("does not render SignUpAuthForm when MFA resolver exists", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { auth: {}, session: null, hints: [] }, + }); + }); + + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { + set: { + template: '
MFA Assertion Screen
', + }, + }); + + await render(TestHostWithContentComponent, { + imports: [ + SignUpAuthScreenComponent, + MockSignUpAuthFormComponent, + MockRedirectErrorComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.queryByText("Sign Up Form")).not.toBeInTheDocument(); + expect(screen.getByTestId("mfa-assertion-screen")).toBeInTheDocument(); + }); + + it("emits signUp when MFA flow succeeds and user authenticates", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => { + return () => ({ + multiFactorResolver: { + auth: {}, + session: null, + hints: [{ factorId: TotpMultiFactorGenerator.FACTOR_ID, uid: "test" }], + }, + }); + }); + + TestBed.overrideComponent(MultiFactorAuthAssertionScreenComponent, { + set: { + template: '
MFA Assertion Screen
', + }, + }); + + 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 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(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 new file mode 100644 index 000000000..9ceaaab22 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts @@ -0,0 +1,93 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, Output, EventEmitter, computed } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { User } from "@angular/fire/auth"; + +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"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; + +@Component({ + selector: "fui-sign-up-auth-screen", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + SignUpAuthFormComponent, + MultiFactorAuthAssertionScreenComponent, + RedirectErrorComponent, + ], + template: ` + @if (mfaResolver()) { + + } @else { +
+ + + {{ titleText() }} + {{ subtitleText() }} + + + + + + + +
+ } + `, +}) +/** + * A screen component for email/password sign-up. + * + * Automatically displays the MFA assertion screen if a multi-factor resolver is present. + */ +export class SignUpAuthScreenComponent { + private ui = injectUI(); + + mfaResolver = computed(() => this.ui().multiFactorResolver); + + titleText = injectTranslation("labels", "signUp"); + subtitleText = injectTranslation("prompts", "enterDetailsToCreate"); + + constructor() { + injectUserAuthenticated((user) => { + this.signUp.emit(user); + }); + } + + /** Event emitter for successful sign-up. */ + @Output() signUp = new EventEmitter(); + /** Event emitter for sign in action. */ + @Output() signIn = new EventEmitter(); +} diff --git a/packages/angular/src/lib/components/button.spec.ts b/packages/angular/src/lib/components/button.spec.ts new file mode 100644 index 000000000..6b4925b6f --- /dev/null +++ b/packages/angular/src/lib/components/button.spec.ts @@ -0,0 +1,65 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, fireEvent } from "@testing-library/angular"; + +import { ButtonComponent } from "./button"; + +describe("`, { imports: [ButtonComponent] }); + const button = screen.getByRole("button", { name: /click me/i }); + expect(button).toBeDefined(); + expect(button).toHaveClass("fui-button"); + expect(button).not.toHaveClass("fui-button--secondary"); + }); + + it("renders with secondary variant", async () => { + await render(``, { imports: [ButtonComponent] }); + const button = screen.getByRole("button", { name: /click me/i }); + expect(button).toHaveClass("fui-button"); + expect(button).toHaveClass("fui-button--secondary"); + }); + + it("applies custom class", async () => { + await render(``, { imports: [ButtonComponent] }); + const button = screen.getByRole("button", { name: /click me/i }); + expect(button).toHaveClass("fui-button"); + expect(button).toHaveClass("custom-class"); + }); + + it("handles click events", async () => { + const handleClick = jest.fn(); + await render(``, { + imports: [ButtonComponent], + componentProperties: { handleClick }, + }); + const button = screen.getByRole("button", { name: /click me/i }); + + fireEvent.click(button); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("passes other props to the button element", async () => { + await render(``, { + imports: [ButtonComponent], + }); + const button = screen.getByTestId("test-button"); + + expect(button).toHaveAttribute("disabled"); + }); +}); diff --git a/packages/firebaseui-angular/src/lib/components/button/button.component.ts b/packages/angular/src/lib/components/button.ts similarity index 57% rename from packages/firebaseui-angular/src/lib/components/button/button.component.ts rename to packages/angular/src/lib/components/button.ts index 9e23bd2b3..70654bcfd 100644 --- a/packages/firebaseui-angular/src/lib/components/button/button.component.ts +++ b/packages/angular/src/lib/components/button.ts @@ -14,23 +14,23 @@ * limitations under the License. */ -import { Component, Directive, ElementRef, Input } from '@angular/core'; +import { Component, HostBinding, input } from "@angular/core"; +import { buttonVariant, type ButtonVariant } from "@invertase/firebaseui-styles"; @Component({ - selector: 'fui-button', - template: ` - - `, + selector: "button[fui-button]", + template: ``, standalone: true, }) +/** + * A customizable button component with multiple variants. + */ export class ButtonComponent { - @Input() type: 'button' | 'submit' | 'reset' = 'button'; - @Input() disabled: boolean = false; - @Input() variant: 'primary' | 'secondary' = 'primary'; -} \ No newline at end of file + /** The visual variant of the button. */ + variant = input(); + + @HostBinding("class") + get getButtonClasses(): string { + return buttonVariant({ variant: this.variant() }); + } +} diff --git a/packages/angular/src/lib/components/card.spec.ts b/packages/angular/src/lib/components/card.spec.ts new file mode 100644 index 000000000..022b230f5 --- /dev/null +++ b/packages/angular/src/lib/components/card.spec.ts @@ -0,0 +1,189 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; + +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "./card"; + +describe("", () => { + it("renders a card with children", async () => { + await render(`Card content`, { + imports: [CardComponent, CardContentComponent], + }); + const card = screen.getByTestId("test-card"); + + expect(card).toHaveClass("fui-card"); + expect(card).toHaveTextContent("Card content"); + }); + + it("applies custom class", async () => { + await render( + `Card content`, + { imports: [CardComponent, CardContentComponent] } + ); + const card = screen.getByTestId("test-card"); + + expect(card).toHaveClass("fui-card"); + expect(card).toHaveClass("custom-class"); + }); + + it("passes other props to the div element", async () => { + await render( + `Card content`, + { imports: [CardComponent, CardContentComponent] } + ); + const card = screen.getByTestId("test-card"); + + expect(card).toHaveClass("fui-card"); + expect(card).toHaveAttribute("aria-label", "card"); + }); + + it("renders a complete card with all subcomponents", async () => { + await render( + ` + + + Card Title + Card Subtitle + + +
Card Body Content
+
+
+ `, + { + imports: [CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent, CardContentComponent], + } + ); + + const card = screen.getByTestId("complete-card"); + const header = screen.getByTestId("complete-header"); + const titleHost = screen.getByTestId("complete-title"); + const subtitleHost = screen.getByTestId("complete-subtitle"); + const title = screen.getByRole("heading", { name: "Card Title" }); + const subtitle = screen.getByText("Card Subtitle"); + const content = screen.getByText("Card Body Content"); + + expect(card).toHaveClass("fui-card"); + expect(titleHost).toHaveClass("fui-card__title"); + expect(subtitleHost).toHaveClass("fui-card__subtitle"); + expect(header).toHaveClass("fui-card__header"); + expect(content).toBeTruthy(); + + expect(header).toContainElement(title); + expect(header).toContainElement(subtitle); + expect(card).toContainElement(header); + expect(card).toContainElement(content); + }); + + describe("", () => { + it("renders a card header with children", async () => { + await render( + `Header content`, + { imports: [CardHeaderComponent, CardTitleComponent] } + ); + const header = screen.getByTestId("test-header"); + + expect(header).toHaveClass("fui-card__header"); + expect(header).toHaveTextContent("Header content"); + }); + + it("applies custom className", async () => { + await render( + `Header content`, + { imports: [CardHeaderComponent, CardTitleComponent] } + ); + const header = screen.getByTestId("test-header"); + + expect(header).toHaveClass("fui-card__header"); + expect(header).toHaveClass("custom-header"); + }); + }); + + describe("", () => { + it("renders a card title with children", async () => { + await render(`Title content`, { + imports: [CardTitleComponent], + }); + const titleHost = screen.getByTestId("title-host"); + const title = screen.getByRole("heading", { name: "Title content" }); + + expect(titleHost).toHaveClass("fui-card__title"); + expect(title.tagName).toBe("H2"); + }); + + it("applies custom className", async () => { + await render(`Title content`, { + imports: [CardTitleComponent], + }); + const titleHost = screen.getByTestId("title-host"); + + expect(titleHost).toHaveClass("fui-card__title"); + expect(titleHost).toHaveClass("custom-title"); + }); + }); + + describe("", () => { + it("renders a card subtitle with children", async () => { + await render(`Subtitle content`, { + imports: [CardSubtitleComponent], + }); + const subtitleHost = screen.getByTestId("subtitle-host"); + const subtitle = screen.getByText("Subtitle content"); + + expect(subtitleHost).toHaveClass("fui-card__subtitle"); + expect(subtitle.tagName).toBe("P"); + }); + + it("applies custom className", async () => { + await render( + `Subtitle content`, + { imports: [CardSubtitleComponent] } + ); + const subtitleHost = screen.getByTestId("subtitle-host"); + + expect(subtitleHost).toHaveClass("fui-card__subtitle"); + expect(subtitleHost).toHaveClass("custom-subtitle"); + }); + }); + + describe("", () => { + it("renders a card content with children", async () => { + await render(`Content content`, { + imports: [CardContentComponent], + }); + const content = screen.getByTestId("test-content"); + + expect(content).toHaveClass("fui-card__content"); + }); + + it("applies custom className", async () => { + await render(`Content`, { + imports: [CardContentComponent], + }); + const contentHost = screen.getByTestId("content-host"); + + expect(contentHost).toHaveClass("fui-card__content"); + expect(contentHost).toHaveClass("custom-content"); + }); + }); +}); diff --git a/packages/angular/src/lib/components/card.ts b/packages/angular/src/lib/components/card.ts new file mode 100644 index 000000000..02aa8bba9 --- /dev/null +++ b/packages/angular/src/lib/components/card.ts @@ -0,0 +1,107 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component } from "@angular/core"; +import { CommonModule } from "@angular/common"; + +@Component({ + selector: "fui-card", + standalone: true, + imports: [], + host: { + class: "fui-card", + style: "display: block;", + }, + template: ` + + + `, +}) +/** + * A card container component for grouping related content. + */ +export class CardComponent {} + +@Component({ + selector: "fui-card-header", + standalone: true, + imports: [CommonModule], + host: { + class: "fui-card__header", + style: "display: block;", + }, + template: ` + + + `, +}) +/** + * The header section of a card. + */ +export class CardHeaderComponent {} + +@Component({ + selector: "fui-card-title", + standalone: true, + imports: [CommonModule], + host: { + class: "fui-card__title", + style: "display: block;", + }, + template: ` +

+ +

+ `, +}) +/** + * The title of a card. + */ +export class CardTitleComponent {} + +@Component({ + selector: "fui-card-subtitle", + standalone: true, + imports: [CommonModule], + host: { + class: "fui-card__subtitle", + style: "display: block;", + }, + template: ` +

+ +

+ `, +}) +/** + * The subtitle of a card. + */ +export class CardSubtitleComponent {} + +@Component({ + selector: "fui-card-content", + standalone: true, + imports: [CommonModule], + host: { + class: "fui-card__content", + style: "display: block;", + }, + template: ` `, +}) +/** + * The content section of a card. + */ +export class CardContentComponent {} diff --git a/packages/angular/src/lib/components/content.spec.ts b/packages/angular/src/lib/components/content.spec.ts new file mode 100644 index 000000000..aa68aa633 --- /dev/null +++ b/packages/angular/src/lib/components/content.spec.ts @@ -0,0 +1,119 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; +import { Component } from "@angular/core"; + +import { ContentComponent } from "./content"; + +jest.mock("../../provider", () => ({ + injectTranslation: jest.fn(), +})); + +@Component({ + template: ` + +
foo
+
+ `, + standalone: true, + imports: [ContentComponent], +}) +class TestContentHostComponent {} + +@Component({ + template: ` + + + +

baz

+
+ `, + standalone: true, + imports: [ContentComponent], +}) +class TestContentWithMultipleElementsHostComponent {} + +describe("", () => { + beforeEach(() => { + const { injectTranslation } = require("../../provider"); + injectTranslation.mockReturnValue(() => "OR"); + }); + + it("renders content with default divider label", async () => { + const { container } = await render(TestContentHostComponent); + + const contentWrapper = container.querySelector(".fui-screen__children"); + expect(contentWrapper).toBeTruthy(); + + const projectedContent = screen.getByTestId("projected-content"); + expect(projectedContent).toBeInTheDocument(); + expect(projectedContent).toHaveTextContent("foo"); + + const divider = container.querySelector(".fui-divider"); + expect(divider).toBeTruthy(); + expect(divider?.querySelector(".fui-divider__text")).toHaveTextContent("OR"); + }); + + it("renders multiple projected elements", async () => { + const { container } = await render(TestContentWithMultipleElementsHostComponent); + + const contentWrapper = container.querySelector(".fui-screen__children"); + expect(contentWrapper).toBeTruthy(); + + const button1 = screen.getByTestId("button-1"); + const button2 = screen.getByTestId("button-2"); + const description = screen.getByTestId("description"); + + expect(button1).toBeInTheDocument(); + expect(button1).toHaveTextContent("foo"); + expect(button2).toBeInTheDocument(); + expect(button2).toHaveTextContent("bar"); + expect(description).toBeInTheDocument(); + expect(description).toHaveTextContent("baz"); + + const divider = container.querySelector(".fui-divider"); + expect(divider).toBeTruthy(); + expect(divider?.querySelector(".fui-divider__text")).toHaveTextContent("OR"); + }); + + it("has correct classes", async () => { + const { container } = await render(TestContentHostComponent); + + const contentWrapper = container.querySelector(".fui-screen__children"); + expect(contentWrapper).toHaveClass("fui-screen__children"); + + const divider = container.querySelector(".fui-divider"); + expect(divider).toHaveClass("fui-divider"); + }); + + it("renders both divider and content wrapper", async () => { + const { container } = await render(TestContentHostComponent); + + const divider = container.querySelector(".fui-divider"); + const contentWrapper = container.querySelector(".fui-screen__children"); + + expect(divider).toBeTruthy(); + expect(contentWrapper).toBeTruthy(); + }); + + it("calls injectTranslation with correct parameters", async () => { + const { injectTranslation } = require("../../provider"); + await render(TestContentHostComponent); + + expect(injectTranslation).toHaveBeenCalledWith("messages", "dividerOr"); + }); +}); diff --git a/packages/angular/src/lib/components/content.ts b/packages/angular/src/lib/components/content.ts new file mode 100644 index 000000000..b426dea79 --- /dev/null +++ b/packages/angular/src/lib/components/content.ts @@ -0,0 +1,40 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component } from "@angular/core"; +import { DividerComponent } from "./divider"; +import { injectTranslation } from "../provider"; + +@Component({ + selector: "fui-content", + standalone: true, + imports: [DividerComponent], + host: { + style: "display: block;", + }, + template: ` + +
+ +
+ `, +}) +/** + * A content wrapper component that displays a divider and children content. + */ +export class ContentComponent { + dividerOrLabel = injectTranslation("messages", "dividerOr"); +} diff --git a/packages/angular/src/lib/components/country-selector.spec.ts b/packages/angular/src/lib/components/country-selector.spec.ts new file mode 100644 index 000000000..998b58e9c --- /dev/null +++ b/packages/angular/src/lib/components/country-selector.spec.ts @@ -0,0 +1,143 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, fireEvent } from "@testing-library/angular"; +import { Component, signal } from "@angular/core"; +import { FormsModule } from "@angular/forms"; + +import { CountrySelectorComponent } from "./country-selector"; + +jest.mock("../../provider", () => ({ + injectCountries: jest.fn(), + injectDefaultCountry: jest.fn(), +})); + +const mockCountryData = [ + { name: "United States", dialCode: "+1", code: "US", emoji: "🇺🇸" }, + { name: "United Kingdom", dialCode: "+44", code: "GB", emoji: "🇬🇧" }, + { name: "Canada", dialCode: "+1", code: "CA", emoji: "🇨🇦" }, + { name: "Germany", dialCode: "+49", code: "DE", emoji: "🇩🇪" }, + { name: "France", dialCode: "+33", code: "FR", emoji: "🇫🇷" }, +] as const; + +@Component({ + template: ``, + standalone: true, + imports: [CountrySelectorComponent, FormsModule], +}) +class TestCountrySelectorHostComponent { + value = signal("US"); + onValueChange = jest.fn(); +} + +@Component({ + template: ``, + standalone: true, + imports: [CountrySelectorComponent, FormsModule], +}) +class TestCountrySelectorWithCustomClassHostComponent { + value = signal("US"); +} + +describe("", () => { + const defaultCountry = mockCountryData.find((country) => country.code === "US")!; + + beforeEach(() => { + const { injectCountries, injectDefaultCountry } = require("../../provider"); + + injectCountries.mockReturnValue(signal(mockCountryData)); + injectDefaultCountry.mockReturnValue(signal(defaultCountry)); + }); + + it("renders with the default country", async () => { + await render(TestCountrySelectorHostComponent); + + expect(screen.getByText(defaultCountry.emoji)).toBeInTheDocument(); + expect(screen.getByText(defaultCountry.dialCode)).toBeInTheDocument(); + + const select = screen.getByRole("combobox"); + expect(select).toHaveValue(defaultCountry.code); + }); + + it("applies custom className", async () => { + const { container } = await render(TestCountrySelectorWithCustomClassHostComponent); + + const hostElement = container.querySelector("fui-country-selector"); + expect(hostElement).toHaveClass("custom-class"); + }); + + it("calls valueChange when a different country is selected", async () => { + const { fixture } = await render(TestCountrySelectorHostComponent); + const hostComponent = fixture.componentInstance; + + const newCountry = mockCountryData.find((country) => country.code === "GB")!; + + const select = screen.getByRole("combobox"); + fireEvent.change(select, { target: { value: newCountry.code } }); + expect(hostComponent.onValueChange).toHaveBeenCalledWith(newCountry.code); + }); + + it("renders all countries in the dropdown", async () => { + await render(TestCountrySelectorHostComponent); + + const select = screen.getByRole("combobox"); + const options = select.querySelectorAll("option"); + + expect(options).toHaveLength(mockCountryData.length); + + const usCountry = mockCountryData.find((country) => country.code === "US"); + expect(usCountry).toBeTruthy(); + + if (usCountry) { + const optionsArray = Array.from(options) as HTMLOptionElement[]; + const usOption = optionsArray.find((option: HTMLOptionElement) => option.value === usCountry.code); + expect(usOption).toBeTruthy(); + if (usOption) { + expect(usOption.textContent?.trim()).toBe(`${usCountry.dialCode} (${usCountry.name})`); + } + } else { + fail("US country not found in mockCountryData"); + } + }); + + it("displays country information correctly", async () => { + await render(TestCountrySelectorHostComponent); + + const options = screen.getAllByRole("option"); + options.forEach((option) => { + const text = option.textContent; + expect(text).toMatch(/^\+\d+ \([^)]+\)$/); + }); + }); + + it("updates display when value changes", async () => { + const { fixture } = await render(TestCountrySelectorHostComponent); + const hostComponent = fixture.componentInstance; + + const newCountry = mockCountryData.find((country) => country.code === "GB")!; + + hostComponent.value.set(newCountry.code); + fixture.detectChanges(); + + await fixture.whenStable(); + + expect(screen.getByText(newCountry.emoji)).toBeInTheDocument(); + expect(screen.getByText(newCountry.dialCode)).toBeInTheDocument(); + + const select = screen.getByRole("combobox"); + expect(select).toHaveValue(newCountry.code); + }); +}); diff --git a/packages/angular/src/lib/components/country-selector.ts b/packages/angular/src/lib/components/country-selector.ts new file mode 100644 index 000000000..72c8969c2 --- /dev/null +++ b/packages/angular/src/lib/components/country-selector.ts @@ -0,0 +1,76 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, computed, model } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { type CountryCode } from "@invertase/firebaseui-core"; +import { FormsModule } from "@angular/forms"; +import { injectCountries, injectDefaultCountry } from "../provider"; + +@Component({ + selector: "fui-country-selector", + standalone: true, + imports: [CommonModule, FormsModule], + host: { + style: "display: block;", + }, + template: ` +
+
+ {{ selected().emoji }} +
+ {{ selected().dialCode }} + +
+
+
+ `, +}) +/** + * A country selector component for phone number input. + * + * Displays a dropdown with country flags, dial codes, and names for selecting a country. + */ +export class CountrySelectorComponent { + countries = injectCountries(); + defaultCountry = injectDefaultCountry(); + /** The selected country code (two-way binding). */ + value = model(); + + selected = computed(() => { + if (!this.value()) { + return this.defaultCountry(); + } + + return this.countries().find((c) => c.code === this.value()) || this.defaultCountry(); + }); + + handleCountryChange(code: string) { + const country = this.countries().find((c) => c.code === code); + + if (country) { + this.value.update(() => country.code as CountryCode); + } + } +} diff --git a/packages/angular/src/lib/components/divider.spec.ts b/packages/angular/src/lib/components/divider.spec.ts new file mode 100644 index 000000000..1ad16b2fb --- /dev/null +++ b/packages/angular/src/lib/components/divider.spec.ts @@ -0,0 +1,53 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; + +import { DividerComponent } from "./divider"; + +describe("", () => { + it("renders a divider with no text", async () => { + const { container } = await render(DividerComponent, { + inputs: { + label: undefined, + }, + }); + + const divider = container.querySelector(".fui-divider"); + expect(divider).toBeTruthy(); + expect(divider).toHaveClass("fui-divider"); + expect(divider?.querySelector(".fui-divider__line")).toBeTruthy(); + expect(divider?.querySelector(".fui-divider__text")).toBeFalsy(); + }); + + it("renders a divider with text", async () => { + const dividerText = "OR"; + const { container } = await render(DividerComponent, { + inputs: { + label: dividerText, + }, + }); + + const divider = container.querySelector(".fui-divider"); + const textElement = screen.getByText(dividerText); + + expect(divider).toBeTruthy(); + expect(divider).toHaveClass("fui-divider"); + expect(divider?.querySelectorAll(".fui-divider__line")).toHaveLength(2); + expect(textElement).toBeTruthy(); + expect(textElement.closest(".fui-divider__text")).toBeTruthy(); + }); +}); diff --git a/packages/angular/src/lib/components/divider.ts b/packages/angular/src/lib/components/divider.ts new file mode 100644 index 000000000..63c0ba8d3 --- /dev/null +++ b/packages/angular/src/lib/components/divider.ts @@ -0,0 +1,43 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, input } from "@angular/core"; +import { CommonModule } from "@angular/common"; + +@Component({ + selector: "fui-divider", + standalone: true, + imports: [CommonModule], + host: { + style: "display: block;", + }, + template: ` +
+
+ @if (label()) { +
{{ label() }}
+
+ } +
+ `, +}) +/** + * A divider component that can display a line or a line with text in the middle. + */ +export class DividerComponent { + /** Optional label text to display in the center of the divider. */ + label = input(); +} diff --git a/packages/angular/src/lib/components/form.spec.ts b/packages/angular/src/lib/components/form.spec.ts new file mode 100644 index 000000000..34372808e --- /dev/null +++ b/packages/angular/src/lib/components/form.spec.ts @@ -0,0 +1,298 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; +import { Component, signal } from "@angular/core"; +import { injectForm, TanStackAppField } from "@tanstack/angular-form"; + +import { + FormMetadataComponent, + FormActionComponent, + FormSubmitComponent, + FormErrorMessageComponent, + FormInputComponent, +} from "./form"; +import { ButtonComponent } from "./button"; + +@Component({ + template: ``, + standalone: true, + imports: [FormMetadataComponent], +}) +class TestFormMetadataHostComponent { + field = signal({ + state: { + meta: { + isTouched: true, + errors: [{ message: "Test error" }], + }, + }, + } as any); +} + +@Component({ + template: ``, + standalone: true, + imports: [FormActionComponent], +}) +class TestFormActionHostComponent {} + +@Component({ + template: `Submit`, + standalone: true, + imports: [FormSubmitComponent, ButtonComponent], +}) +class TestFormSubmitHostComponent { + state = signal({ + isSubmitting: false, + } as any); + customClass = signal("custom-submit-class"); +} + +@Component({ + template: ``, + standalone: true, + imports: [FormErrorMessageComponent], +}) +class TestFormErrorMessageHostComponent { + state = signal({ + errorMap: { + onSubmit: "Test error message", + }, + } as any); +} + +describe("Form Components", () => { + describe("", () => { + it("renders error message when field has errors and is touched", async () => { + await render(TestFormMetadataHostComponent); + + const errorElement = screen.getByRole("alert"); + + expect(errorElement).toBeTruthy(); + expect(errorElement).toHaveClass("fui-error"); + expect(errorElement).toHaveTextContent("Test error"); + }); + + it("does not render error message when field has no errors", async () => { + const component = await render(TestFormMetadataHostComponent); + + component.fixture.componentInstance.field.set({ + state: { + meta: { + isTouched: true, + errors: [], + }, + }, + } as any); + component.fixture.detectChanges(); + + const errorElement = screen.queryByRole("alert"); + expect(errorElement).toBeFalsy(); + }); + + it("does not render error message when field is not touched", async () => { + const component = await render(TestFormMetadataHostComponent); + + component.fixture.componentInstance.field.set({ + state: { + meta: { + isTouched: false, + errors: [{ message: "Test error" }], + }, + }, + } as any); + component.fixture.detectChanges(); + + const errorElement = screen.queryByRole("alert"); + expect(errorElement).toBeFalsy(); + }); + }); + + describe(" + + `, + standalone: true, + imports: [FormInputComponent, TanStackAppField, FormActionComponent], + }) + class TestFormInputHostComponent { + form = injectForm({ + defaultValues: { + test: "", + }, + }); + } + + @Component({ + template: ` + + `, + standalone: true, + imports: [FormInputComponent, TanStackAppField], + }) + class TestFormInputWithDescriptionHostComponent { + form = injectForm({ + defaultValues: { + test: "", + }, + }); + description = signal(undefined); + } + + it("renders action content when provided", async () => { + await render(TestFormInputHostComponent, { + imports: [TestFormInputHostComponent], + }); + + const actionButton = screen.getByTestId("test-action"); + expect(actionButton).toBeTruthy(); + expect(actionButton).toHaveTextContent("Action"); + }); + + it("renders description when provided", async () => { + const component = await render(TestFormInputWithDescriptionHostComponent, { + imports: [TestFormInputWithDescriptionHostComponent], + }); + + component.fixture.componentInstance.description.set("Test description text"); + component.fixture.detectChanges(); + + const descriptionElement = screen.getByText("Test description text"); + expect(descriptionElement).toBeTruthy(); + expect(descriptionElement).toHaveAttribute("data-input-description"); + }); + + it("does not render description when not provided", async () => { + const { container } = await render(TestFormInputWithDescriptionHostComponent, { + imports: [TestFormInputWithDescriptionHostComponent], + }); + + const descriptionElement = container.querySelector("[data-input-description]"); + expect(descriptionElement).toBeFalsy(); + }); + + it("updates description when input changes", async () => { + const component = await render(TestFormInputWithDescriptionHostComponent, { + imports: [TestFormInputWithDescriptionHostComponent], + }); + + component.fixture.componentInstance.description.set("Initial description"); + component.fixture.detectChanges(); + + expect(screen.getByText("Initial description")).toBeTruthy(); + + component.fixture.componentInstance.description.set("Updated description"); + component.fixture.detectChanges(); + + expect(screen.queryByText("Initial description")).toBeFalsy(); + expect(screen.getByText("Updated description")).toBeTruthy(); + }); + }); + + describe("", () => { + it("renders error message when onSubmit error exists", async () => { + await render(TestFormErrorMessageHostComponent); + + const errorElement = screen.getByText("Test error message"); + + expect(errorElement).toBeTruthy(); + expect(errorElement).toHaveClass("fui-error"); + }); + + it("does not render error message when no onSubmit error", async () => { + const component = await render(TestFormErrorMessageHostComponent); + + component.fixture.componentInstance.state.set({ + errorMap: {}, + } as any); + component.fixture.detectChanges(); + + const errorElement = screen.queryByText("Test error message"); + expect(errorElement).toBeFalsy(); + }); + + it("does not render error message when errorMap is null", async () => { + const component = await render(TestFormErrorMessageHostComponent); + + component.fixture.componentInstance.state.set({ + errorMap: null, + } as any); + component.fixture.detectChanges(); + + const errorElement = screen.queryByText("Test error message"); + expect(errorElement).toBeFalsy(); + }); + }); +}); diff --git a/packages/angular/src/lib/components/form.ts b/packages/angular/src/lib/components/form.ts new file mode 100644 index 000000000..856a96fa7 --- /dev/null +++ b/packages/angular/src/lib/components/form.ts @@ -0,0 +1,171 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, computed, input } from "@angular/core"; +import { AnyFieldApi, AnyFormState, injectField } from "@tanstack/angular-form"; +import { ButtonComponent } from "./button"; + +@Component({ + selector: "fui-form-metadata", + standalone: true, + host: { + style: "display: block;", + }, + template: ` + @if (field().state.meta.isTouched && errors().length > 0) { +
+ +
+ } + `, +}) +/** + * A component that displays form field metadata, such as validation errors. + */ +export class FormMetadataComponent { + /** The form field API instance. */ + field = input.required(); + errors = computed(() => + this.field() + .state.meta.errors.map((error) => error.message) + .join(", ") + ); +} + +@Component({ + selector: "fui-form-input", + standalone: true, + imports: [FormMetadataComponent], + host: { + style: "display: block;", + }, + template: ` + + `, +}) +/** + * A form input component with label, description, and validation support. + */ +export class FormInputComponent { + field = injectField(); + /** The label text for the input field. */ + label = input.required(); + /** The input type (e.g., "text", "email", "password"). */ + type = input("text"); + /** Optional description text displayed below the label. */ + description = input(); +} + +@Component({ + selector: "button[fui-form-action]", + standalone: true, + host: { + class: "fui-form__action", + type: "button", + }, + template: ` `, +}) +/** + * A button component for form actions (e.g., "Forgot Password?" link). + */ +export class FormActionComponent {} + +@Component({ + selector: "fui-form-submit", + standalone: true, + imports: [ButtonComponent], + host: { + type: "submit", + style: "display: block;", + }, + template: ` + + `, +}) +/** + * A submit button component for forms. + * + * Automatically disables when the form is submitting. + */ +export class FormSubmitComponent { + /** Optional additional CSS classes. */ + class = input(); + /** The form state for tracking submission status. */ + state = input.required(); + + isSubmitting = computed(() => this.state().isSubmitting); +} + +@Component({ + selector: "fui-form-error-message", + standalone: true, + host: { + style: "display: block;", + }, + template: ` + @if (errorMessage()) { +
+ {{ errorMessage() }} +
+ } + `, +}) +/** + * A component that displays form-level error messages. + * + * Shows errors from form submission, not validation errors. + */ +export class FormErrorMessageComponent { + /** The form state containing error information. */ + state = input.required(); + + errorMessage = computed(() => { + const error = this.state().errorMap?.onSubmit; + + // We only care about errors thrown from the form submission, rather than validation errors + if (error && typeof error === "string") { + return error; + } + + return undefined; + }); +} diff --git a/packages/angular/src/lib/components/logos/README.md b/packages/angular/src/lib/components/logos/README.md new file mode 100644 index 000000000..d379b14d8 --- /dev/null +++ b/packages/angular/src/lib/components/logos/README.md @@ -0,0 +1,3 @@ +This directory is generated, please do not edit. + +Run `pnpm run build:logos` to regenerate any files. \ No newline at end of file diff --git a/packages/angular/src/lib/components/logos/apple.ts b/packages/angular/src/lib/components/logos/apple.ts new file mode 100644 index 000000000..5506447e7 --- /dev/null +++ b/packages/angular/src/lib/components/logos/apple.ts @@ -0,0 +1,43 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// GENERATED BY generate-logos.ts + +import { Component, input } from "@angular/core"; + +@Component({ + selector: "fui-apple-logo", + standalone: true, + template: ` + + + + `, +}) +/** + * The Apple logo SVG component. + */ +export class AppleLogoComponent { + /** The width of the logo. */ + width = input("1em"); + /** The height of the logo. */ + height = input("1em"); + /** Optional additional CSS class names. */ + className = input(""); +} diff --git a/packages/angular/src/lib/components/logos/facebook.ts b/packages/angular/src/lib/components/logos/facebook.ts new file mode 100644 index 000000000..c022c5db1 --- /dev/null +++ b/packages/angular/src/lib/components/logos/facebook.ts @@ -0,0 +1,43 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// GENERATED BY generate-logos.ts + +import { Component, input } from "@angular/core"; + +@Component({ + selector: "fui-facebook-logo", + standalone: true, + template: ` + + + + `, +}) +/** + * The Facebook logo SVG component. + */ +export class FacebookLogoComponent { + /** The width of the logo. */ + width = input("1em"); + /** The height of the logo. */ + height = input("1em"); + /** Optional additional CSS class names. */ + className = input(""); +} diff --git a/packages/angular/src/lib/components/logos/github.ts b/packages/angular/src/lib/components/logos/github.ts new file mode 100644 index 000000000..bd417c8fd --- /dev/null +++ b/packages/angular/src/lib/components/logos/github.ts @@ -0,0 +1,42 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// GENERATED BY generate-logos.ts + +import { Component, input } from "@angular/core"; + +@Component({ + selector: "fui-github-logo", + standalone: true, + template: ` + + + + `, +}) +/** + * The GitHub logo SVG component. + */ +export class GithubLogoComponent { + /** The width of the logo. */ + width = input("1em"); + /** The height of the logo. */ + height = input("1em"); + /** Optional additional CSS class names. */ + className = input(""); +} diff --git a/packages/angular/src/lib/components/logos/google.ts b/packages/angular/src/lib/components/logos/google.ts new file mode 100644 index 000000000..805114852 --- /dev/null +++ b/packages/angular/src/lib/components/logos/google.ts @@ -0,0 +1,55 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// GENERATED BY generate-logos.ts + +import { Component, input } from "@angular/core"; + +@Component({ + selector: "fui-google-logo", + standalone: true, + template: ` + + + + + + + `, +}) +/** + * The Google logo SVG component. + */ +export class GoogleLogoComponent { + /** The width of the logo. */ + width = input("1em"); + /** The height of the logo. */ + height = input("1em"); + /** Optional additional CSS class names. */ + className = input(""); +} diff --git a/examples/angular/src/app/auth/password-reset/index.ts b/packages/angular/src/lib/components/logos/index.ts similarity index 60% rename from examples/angular/src/app/auth/password-reset/index.ts rename to packages/angular/src/lib/components/logos/index.ts index f969fada9..9385b0a33 100644 --- a/examples/angular/src/app/auth/password-reset/index.ts +++ b/packages/angular/src/lib/components/logos/index.ts @@ -14,4 +14,11 @@ * limitations under the License. */ -export * from './password-reset.component'; +export { AppleLogoComponent } from "./apple"; +export { FacebookLogoComponent } from "./facebook"; +export { GithubLogoComponent } from "./github"; +export { GoogleLogoComponent } from "./google"; +export { LineLogoComponent } from "./line"; +export { MicrosoftLogoComponent } from "./microsoft"; +export { SnapchatLogoComponent } from "./snapchat"; +export { TwitterLogoComponent } from "./twitter"; diff --git a/packages/angular/src/lib/components/logos/line.ts b/packages/angular/src/lib/components/logos/line.ts new file mode 100644 index 000000000..ce7c69923 --- /dev/null +++ b/packages/angular/src/lib/components/logos/line.ts @@ -0,0 +1,47 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// GENERATED BY generate-logos.ts + +import { Component, input } from "@angular/core"; + +@Component({ + selector: "fui-line-logo", + standalone: true, + template: ` + + + + + `, +}) +/** + * The Line logo SVG component. + */ +export class LineLogoComponent { + /** The width of the logo. */ + width = input("1em"); + /** The height of the logo. */ + height = input("1em"); + /** Optional additional CSS class names. */ + className = input(""); +} diff --git a/packages/angular/src/lib/components/logos/microsoft.ts b/packages/angular/src/lib/components/logos/microsoft.ts new file mode 100644 index 000000000..c4563b0d8 --- /dev/null +++ b/packages/angular/src/lib/components/logos/microsoft.ts @@ -0,0 +1,43 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// GENERATED BY generate-logos.ts + +import { Component, input } from "@angular/core"; + +@Component({ + selector: "fui-microsoft-logo", + standalone: true, + template: ` + + + + + + + `, +}) +/** + * The Microsoft logo SVG component. + */ +export class MicrosoftLogoComponent { + /** The width of the logo. */ + width = input("1em"); + /** The height of the logo. */ + height = input("1em"); + /** Optional additional CSS class names. */ + className = input(""); +} diff --git a/packages/angular/src/lib/components/logos/snapchat.ts b/packages/angular/src/lib/components/logos/snapchat.ts new file mode 100644 index 000000000..0b129fc39 --- /dev/null +++ b/packages/angular/src/lib/components/logos/snapchat.ts @@ -0,0 +1,43 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// GENERATED BY generate-logos.ts + +import { Component, input } from "@angular/core"; + +@Component({ + selector: "fui-snapchat-logo", + standalone: true, + template: ` + + + + `, +}) +/** + * The Snapchat logo SVG component. + */ +export class SnapchatLogoComponent { + /** The width of the logo. */ + width = input("1em"); + /** The height of the logo. */ + height = input("1em"); + /** Optional additional CSS class names. */ + className = input(""); +} diff --git a/packages/angular/src/lib/components/logos/twitter.ts b/packages/angular/src/lib/components/logos/twitter.ts new file mode 100644 index 000000000..9630ea8e6 --- /dev/null +++ b/packages/angular/src/lib/components/logos/twitter.ts @@ -0,0 +1,42 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// GENERATED BY generate-logos.ts + +import { Component, input } from "@angular/core"; + +@Component({ + selector: "fui-twitter-logo", + standalone: true, + template: ` + + + + `, +}) +/** + * The Twitter/X logo SVG component. + */ +export class TwitterLogoComponent { + /** The width of the logo. */ + width = input("1em"); + /** The height of the logo. */ + height = input("1em"); + /** Optional additional CSS class names. */ + className = input(""); +} diff --git a/packages/angular/src/lib/components/policies.spec.ts b/packages/angular/src/lib/components/policies.spec.ts new file mode 100644 index 000000000..bcdfb48ef --- /dev/null +++ b/packages/angular/src/lib/components/policies.spec.ts @@ -0,0 +1,271 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render } from "@testing-library/angular"; +import { Component } from "@angular/core"; + +import { PoliciesComponent } from "./policies"; + +jest.mock("../../provider", () => ({ + injectUI: jest.fn(), + injectPolicies: jest.fn(), + injectTranslation: jest.fn(), +})); + +@Component({ + template: ``, + standalone: true, + imports: [PoliciesComponent], + providers: [ + { + provide: "FIREBASE_UI_STORE", + useValue: { + get: () => ({}), + subscribe: (callback: any) => callback({}), + }, + }, + { + provide: "FIREBASE_UI_POLICIES", + useValue: { + termsOfServiceUrl: "https://example.com/terms", + privacyPolicyUrl: "https://example.com/privacy", + }, + }, + ], +}) +class TestPoliciesWithBothUrlsHostComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [PoliciesComponent], + providers: [ + { + provide: "FIREBASE_UI_STORE", + useValue: { + get: () => ({}), + subscribe: (callback: any) => callback({}), + }, + }, + { + provide: "FIREBASE_UI_POLICIES", + useValue: null, + }, + ], +}) +class TestPoliciesWithNoUrlsHostComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [PoliciesComponent], + providers: [ + { + provide: "FIREBASE_UI_STORE", + useValue: { + get: () => ({}), + subscribe: (callback: any) => callback({}), + }, + }, + { + provide: "FIREBASE_UI_POLICIES", + useValue: { + termsOfServiceUrl: "https://example.com/terms", + privacyPolicyUrl: null, + }, + }, + ], +}) +class TestPoliciesWithTosOnlyHostComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [PoliciesComponent], + providers: [ + { + provide: "FIREBASE_UI_STORE", + useValue: { + get: () => ({}), + subscribe: (callback: any) => callback({}), + }, + }, + { + provide: "FIREBASE_UI_POLICIES", + useValue: { + termsOfServiceUrl: null, + privacyPolicyUrl: "https://example.com/privacy", + }, + }, + ], +}) +class TestPoliciesWithPrivacyOnlyHostComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [PoliciesComponent], + providers: [ + { + provide: "FIREBASE_UI_STORE", + useValue: { + get: () => ({}), + subscribe: (callback: any) => callback({}), + }, + }, + { + provide: "FIREBASE_UI_POLICIES", + useValue: { + termsOfServiceUrl: "https://example.com/terms", + privacyPolicyUrl: "https://example.com/privacy", + }, + }, + ], +}) +class TestPoliciesWithCustomTemplateHostComponent {} + +describe("", () => { + beforeEach(() => { + const { injectUI, injectPolicies, injectTranslation } = require("../../provider"); + + injectUI.mockReturnValue(() => ({})); + injectPolicies.mockReturnValue({ + termsOfServiceUrl: "https://example.com/terms", + privacyPolicyUrl: "https://example.com/privacy", + }); + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + termsOfService: "Terms of Service", + privacyPolicy: "Privacy Policy", + }, + messages: { + termsAndPrivacy: "By continuing, you agree to our {tos} and {privacy}", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + }); + + it("renders component with terms and privacy links", async () => { + const { container } = await render(TestPoliciesWithBothUrlsHostComponent); + + const policiesContainer = container.querySelector(".fui-policies"); + expect(policiesContainer).toBeTruthy(); + expect(policiesContainer).toHaveClass("fui-policies"); + + const tosLink = container.querySelector('a[href="https://example.com/terms"]'); + expect(tosLink).toBeTruthy(); + expect(tosLink?.tagName).toBe("A"); + expect(tosLink).toHaveAttribute("target", "_blank"); + expect(tosLink).toHaveAttribute("rel", "noopener noreferrer"); + expect(tosLink).toHaveTextContent("Terms of Service"); + + const privacyLink = container.querySelector('a[href="https://example.com/privacy"]'); + expect(privacyLink).toBeTruthy(); + expect(privacyLink?.tagName).toBe("A"); + expect(privacyLink).toHaveAttribute("target", "_blank"); + expect(privacyLink).toHaveAttribute("rel", "noopener noreferrer"); + expect(privacyLink).toHaveTextContent("Privacy Policy"); + + const textContent = policiesContainer?.textContent; + expect(textContent).toContain("By continuing, you agree to our"); + }); + + it("does not render when both tosUrl and privacyPolicyUrl are not provided", async () => { + const { injectPolicies } = require("../../provider"); + injectPolicies.mockReturnValue(null); + + const { container } = await render(TestPoliciesWithNoUrlsHostComponent); + + const policiesContainer = container.querySelector(".fui-policies"); + // Host element is always rendered, but should be hidden and have no content + expect(policiesContainer).toBeTruthy(); + expect(policiesContainer).toHaveClass("fui-policies"); + expect(policiesContainer).toHaveStyle({ display: "none" }); + expect(policiesContainer?.textContent?.trim()).toBe(""); + expect(policiesContainer?.querySelectorAll("a").length).toBe(0); + }); + + it("renders with tosUrl when privacyPolicyUrl is not provided", async () => { + const { injectPolicies } = require("../../provider"); + injectPolicies.mockReturnValue({ + termsOfServiceUrl: "https://example.com/terms", + privacyPolicyUrl: null, + }); + + const { container } = await render(TestPoliciesWithTosOnlyHostComponent); + + const policiesContainer = container.querySelector(".fui-policies"); + expect(policiesContainer).toBeTruthy(); + expect(policiesContainer).toHaveClass("fui-policies"); + + const tosLink = container.querySelector('a[href="https://example.com/terms"]'); + expect(tosLink).toBeTruthy(); + expect(tosLink).toHaveTextContent("Terms of Service"); + + const privacyLink = container.querySelector('a[href="https://example.com/privacy"]'); + expect(privacyLink).toBeFalsy(); + }); + + it("renders with privacyPolicyUrl when tosUrl is not provided", async () => { + const { injectPolicies } = require("../../provider"); + injectPolicies.mockReturnValue({ + termsOfServiceUrl: null, + privacyPolicyUrl: "https://example.com/privacy", + }); + + const { container } = await render(TestPoliciesWithPrivacyOnlyHostComponent); + + const policiesContainer = container.querySelector(".fui-policies"); + expect(policiesContainer).toBeTruthy(); + expect(policiesContainer).toHaveClass("fui-policies"); + + const tosLink = container.querySelector('a[href="https://example.com/terms"]'); + expect(tosLink).toBeFalsy(); + + const privacyLink = container.querySelector('a[href="https://example.com/privacy"]'); + expect(privacyLink).toBeTruthy(); + expect(privacyLink).toHaveTextContent("Privacy Policy"); + }); + + it("uses custom template text when provided", async () => { + const { injectTranslation } = require("../../provider"); + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + termsOfService: "Terms of Service", + privacyPolicy: "Privacy Policy", + }, + messages: { + termsAndPrivacy: "Custom template with {tos} and {privacy}", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + const { container } = await render(TestPoliciesWithCustomTemplateHostComponent); + + const policiesContainer = container.querySelector(".fui-policies"); + expect(policiesContainer).toBeTruthy(); + expect(policiesContainer).toHaveClass("fui-policies"); + + const textContent = policiesContainer?.textContent; + expect(textContent).toContain("Custom template with"); + expect(textContent).toContain("Terms of Service"); + expect(textContent).toContain("Privacy Policy"); + }); +}); diff --git a/packages/angular/src/lib/components/policies.ts b/packages/angular/src/lib/components/policies.ts new file mode 100644 index 000000000..efe22bc16 --- /dev/null +++ b/packages/angular/src/lib/components/policies.ts @@ -0,0 +1,104 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, computed, HostBinding, Signal } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { injectPolicies, injectTranslation } from "../provider"; + +type PolicyPart = + | { type: "tos"; url: string; text: string } + | { type: "privacy"; url: string; text: string } + | { type: "text"; content: string }; + +@Component({ + selector: "fui-policies", + host: { + class: "fui-policies", + }, + standalone: true, + imports: [CommonModule], + template: ` + @if (shouldShow()) { + @for (part of policyParts(); track $index) { + @if (part.type === "tos") { + + {{ part.text }} + + } @else if (part.type === "privacy") { + + {{ part.text }} + + } @else { + {{ part.content }} + } + } + } + `, +}) +/** + * A component that displays terms of service and privacy policy links. + * + * Parses the terms and privacy policy template and renders clickable links. + */ +export class PoliciesComponent { + private readonly policies = injectPolicies(); + + private readonly termsText = injectTranslation("labels", "termsOfService"); + private readonly privacyText = injectTranslation("labels", "privacyPolicy"); + private readonly templateText = injectTranslation("messages", "termsAndPrivacy"); + + private readonly tosUrl = this.policies?.termsOfServiceUrl; + private readonly privacyPolicyUrl = this.policies?.privacyPolicyUrl; + + readonly shouldShow = computed(() => this.policies !== null); + + @HostBinding("style.display") + get displayStyle(): string { + return this.shouldShow() ? "block" : "none"; + } + + readonly policyParts: Signal = computed(() => { + if (!this.shouldShow()) { + return []; + } + + const template = this.templateText(); + const parts = template.split(/({tos}|{privacy})/); + + return parts + .filter((part) => part.length > 0) + .map((part) => { + if (part === "{tos}" && this.tosUrl) { + return { + type: "tos" as const, + url: this.tosUrl, + text: this.termsText(), + }; + } + if (part === "{privacy}" && this.privacyPolicyUrl) { + return { + type: "privacy" as const, + url: this.privacyPolicyUrl, + text: this.privacyText(), + }; + } + return { + type: "text" as const, + content: part, + }; + }); + }); +} diff --git a/packages/angular/src/lib/components/redirect-error.spec.ts b/packages/angular/src/lib/components/redirect-error.spec.ts new file mode 100644 index 000000000..4bc66a82f --- /dev/null +++ b/packages/angular/src/lib/components/redirect-error.spec.ts @@ -0,0 +1,92 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; +import { Component } from "@angular/core"; +import { RedirectErrorComponent } from "./redirect-error"; + +@Component({ + template: ``, + standalone: true, + imports: [RedirectErrorComponent], +}) +class TestHostComponent {} + +describe("", () => { + it("renders error message when redirectError is present in UI state", async () => { + const { injectRedirectError } = require("../../provider"); + const errorMessage = "Authentication failed"; + injectRedirectError.mockReturnValue(() => errorMessage); + + await render(TestHostComponent); + + const errorElement = screen.getByText(errorMessage); + expect(errorElement).toBeDefined(); + expect(errorElement).toHaveClass("fui-error"); + }); + + it("returns null when no redirectError exists", async () => { + const { injectRedirectError } = require("../../provider"); + injectRedirectError.mockReturnValue(() => undefined); + + const { container } = await render(TestHostComponent); + + expect(container.querySelector(".fui-error")).toBeNull(); + }); + + it("properly formats error messages for Error objects", async () => { + const { injectRedirectError } = require("../../provider"); + const errorMessage = "Network error occurred"; + injectRedirectError.mockReturnValue(() => errorMessage); + + const { container } = await render(TestHostComponent); + + const errorElement = screen.getByText(errorMessage); + expect(errorElement).toBeDefined(); + expect(errorElement).toHaveClass("fui-error"); + }); + + it("properly formats error messages for string values", async () => { + const { injectRedirectError } = require("../../provider"); + const errorMessage = "Custom error string"; + injectRedirectError.mockReturnValue(() => errorMessage); + + await render(TestHostComponent); + + const errorElement = screen.getByText(errorMessage); + expect(errorElement).toBeDefined(); + expect(errorElement).toHaveClass("fui-error"); + }); + + it("displays error with correct CSS class", async () => { + const { injectRedirectError } = require("../../provider"); + const errorMessage = "Test error"; + injectRedirectError.mockReturnValue(() => errorMessage); + + await render(TestHostComponent); + + const errorElement = screen.getByText(errorMessage); + expect(errorElement).toHaveClass("fui-error"); + }); + + it("handles undefined redirectError", async () => { + const { injectRedirectError } = require("../../provider"); + injectRedirectError.mockReturnValue(() => undefined); + + const { container } = await render(TestHostComponent); + + expect(container.querySelector(".fui-error")).toBeNull(); + }); +}); diff --git a/packages/angular/src/lib/components/redirect-error.ts b/packages/angular/src/lib/components/redirect-error.ts new file mode 100644 index 000000000..9ef2a9bc7 --- /dev/null +++ b/packages/angular/src/lib/components/redirect-error.ts @@ -0,0 +1,40 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { injectRedirectError } from "../provider"; + +@Component({ + selector: "fui-redirect-error", + standalone: true, + imports: [CommonModule], + host: { + style: "display: block;", + }, + template: ` + @if (error()) { +
{{ error() }}
+ } + `, +}) +/** + * A component that displays redirect error messages. + * + * Shows errors that occurred during OAuth redirect flows. + */ +export class RedirectErrorComponent { + error = injectRedirectError(); +} diff --git a/packages/angular/src/lib/provider.ts b/packages/angular/src/lib/provider.ts new file mode 100644 index 000000000..41e1f75c9 --- /dev/null +++ b/packages/angular/src/lib/provider.ts @@ -0,0 +1,358 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Provider, + EnvironmentProviders, + makeEnvironmentProviders, + InjectionToken, + inject, + signal, + computed, + effect, + Signal, + ElementRef, + Optional, + PLATFORM_ID, +} from "@angular/core"; +import { isPlatformBrowser } from "@angular/common"; +import { FirebaseApps } from "@angular/fire/app"; +import { Auth, authState, User } from "@angular/fire/auth"; +import { + createEmailLinkAuthFormSchema, + createForgotPasswordAuthFormSchema, + createPhoneAuthNumberFormSchema, + createPhoneAuthVerifyFormSchema, + createSignInAuthFormSchema, + createSignUpAuthFormSchema, + createMultiFactorPhoneAuthNumberFormSchema, + createMultiFactorPhoneAuthAssertionFormSchema, + createMultiFactorPhoneAuthVerifyFormSchema, + createMultiFactorTotpAuthNumberFormSchema, + createMultiFactorTotpAuthVerifyFormSchema, + FirebaseUIStore, + type FirebaseUI as FirebaseUIType, + getTranslation, + getBehavior, + type CountryData, +} from "@invertase/firebaseui-core"; + +const FIREBASE_UI_STORE = new InjectionToken("firebaseui.store"); +const FIREBASE_UI_POLICIES = new InjectionToken("firebaseui.policies"); + +/** Configuration for terms of service and privacy policy links. */ +type PolicyConfig = { + /** The URL to the terms of service page. */ + termsOfServiceUrl: string; + /** The URL to the privacy policy page. */ + privacyPolicyUrl: string; +}; + +/** + * Provides FirebaseUI configuration for the Angular application. + * + * This function must be called in your application's providers array to enable FirebaseUI functionality. + * + * @param uiFactory - Factory function that creates a FirebaseUIStore from Firebase apps. + * @returns Environment providers for FirebaseUI. + */ +export function provideFirebaseUI(uiFactory: (apps: FirebaseApps) => FirebaseUIStore): EnvironmentProviders { + const providers: Provider[] = [ + // TODO: This should depend on the FirebaseAuth provider via deps, + // see https://github.com/angular/angularfire/blob/35e0a9859299010488852b1826e4083abe56528f/src/firestore/firestore.module.ts#L76 + { + provide: FIREBASE_UI_STORE, + deps: [FirebaseApps, [new Optional(), Auth]], + useFactory: () => { + const apps = inject(FirebaseApps); + if (!apps || apps.length === 0) { + throw new Error("No Firebase apps found"); + } + return uiFactory(apps); + }, + }, + ]; + + return makeEnvironmentProviders(providers); +} + +/** + * Provides policy configuration (terms of service and privacy policy URLs) for FirebaseUI components. + * + * @param factory - Factory function that returns the policy configuration. + * @returns Environment providers for FirebaseUI policies. + */ +export function provideFirebaseUIPolicies(factory: () => PolicyConfig) { + const providers: Provider[] = [{ provide: FIREBASE_UI_POLICIES, useFactory: factory }]; + + return makeEnvironmentProviders(providers); +} + +/** + * Injects the FirebaseUI store as a reactive signal. + * + * Returns a readonly signal that updates when the UI state changes. + * + * @returns A readonly signal containing the current FirebaseUI state. + */ +export function injectUI() { + const store = inject(FIREBASE_UI_STORE); + const ui = signal(store.get()); + + effect(() => { + return store.subscribe(ui.set); + }); + + return ui.asReadonly(); +} + +/** + * Injects a callback that is called when a user is authenticated. + * + * The callback is only triggered for non-anonymous users. + * + * @param onAuthenticated - Callback function called when a user is authenticated. + */ +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(); + }); + }); +} + +/** + * Injects a reCAPTCHA verifier for phone authentication. + * + * Automatically renders the reCAPTCHA widget in the provided element when available. + * + * @param element - Function that returns the element reference where reCAPTCHA should be rendered. + * @returns A computed signal containing the reCAPTCHA verifier instance, or null if not available. + */ +export function injectRecaptchaVerifier(element: () => ElementRef) { + const ui = injectUI(); + const platformId = inject(PLATFORM_ID); + + const verifier = computed(() => { + if (!isPlatformBrowser(platformId)) { + return null; + } + const elementRef = element(); + if (!elementRef) { + return null; + } + return getBehavior(ui(), "recaptchaVerification")(ui(), elementRef.nativeElement); + }); + + effect(() => { + const verifierInstance = verifier(); + if (verifierInstance) { + verifierInstance.render(); + } + }); + + return verifier; +} + +/** + * Injects a translation string as a reactive signal. + * + * The signal updates when the UI locale changes. + * + * @param category - The translation category (e.g., "labels", "errors", "prompts"). + * @param key - The translation key within the category. + * @returns A computed signal containing the translated string. + */ +export function injectTranslation(category: string, key: string) { + const ui = injectUI(); + return computed(() => getTranslation(ui(), category as any, key as any)); +} + +/** + * Injects the sign-in authentication form schema as a reactive signal. + * + * @returns A computed signal containing the sign-in form schema. + */ +export function injectSignInAuthFormSchema(): Signal> { + const ui = injectUI(); + return computed(() => createSignInAuthFormSchema(ui())); +} + +/** + * Injects the sign-up authentication form schema as a reactive signal. + * + * @returns A computed signal containing the sign-up form schema. + */ +export function injectSignUpAuthFormSchema(): Signal> { + const ui = injectUI(); + return computed(() => createSignUpAuthFormSchema(ui())); +} + +/** + * Injects the forgot password authentication form schema as a reactive signal. + * + * @returns A computed signal containing the forgot password form schema. + */ +export function injectForgotPasswordAuthFormSchema(): Signal> { + const ui = injectUI(); + return computed(() => createForgotPasswordAuthFormSchema(ui())); +} + +/** + * Injects the email link authentication form schema as a reactive signal. + * + * @returns A computed signal containing the email link auth form schema. + */ +export function injectEmailLinkAuthFormSchema(): Signal> { + const ui = injectUI(); + return computed(() => createEmailLinkAuthFormSchema(ui())); +} + +/** + * Injects the phone authentication number form schema as a reactive signal. + * + * @returns A computed signal containing the phone auth number form schema. + */ +export function injectPhoneAuthFormSchema(): Signal> { + const ui = injectUI(); + return computed(() => createPhoneAuthNumberFormSchema(ui())); +} + +/** + * Injects the phone authentication verification form schema as a reactive signal. + * + * @returns A computed signal containing the phone auth verification form schema. + */ +export function injectPhoneAuthVerifyFormSchema(): Signal> { + const ui = injectUI(); + return computed(() => createPhoneAuthVerifyFormSchema(ui())); +} + +/** + * Injects the multi-factor phone authentication number form schema as a reactive signal. + * + * @returns A computed signal containing the MFA phone auth number form schema. + */ +export function injectMultiFactorPhoneAuthNumberFormSchema(): Signal< + ReturnType +> { + const ui = injectUI(); + return computed(() => createMultiFactorPhoneAuthNumberFormSchema(ui())); +} + +/** + * Injects the multi-factor phone authentication assertion form schema as a reactive signal. + * + * @returns A computed signal containing the MFA phone auth assertion form schema. + */ +export function injectMultiFactorPhoneAuthAssertionFormSchema(): Signal< + ReturnType +> { + const ui = injectUI(); + return computed(() => createMultiFactorPhoneAuthAssertionFormSchema(ui())); +} + +/** + * Injects the multi-factor phone authentication verification form schema as a reactive signal. + * + * @returns A computed signal containing the MFA phone auth verification form schema. + */ +export function injectMultiFactorPhoneAuthVerifyFormSchema(): Signal< + ReturnType +> { + const ui = injectUI(); + return computed(() => createMultiFactorPhoneAuthVerifyFormSchema(ui())); +} + +/** + * Injects the multi-factor TOTP authentication number form schema as a reactive signal. + * + * @returns A computed signal containing the MFA TOTP auth number form schema. + */ +export function injectMultiFactorTotpAuthNumberFormSchema(): Signal< + ReturnType +> { + const ui = injectUI(); + return computed(() => createMultiFactorTotpAuthNumberFormSchema(ui())); +} + +/** + * Injects the multi-factor TOTP authentication verification form schema as a reactive signal. + * + * @returns A computed signal containing the MFA TOTP auth verification form schema. + */ +export function injectMultiFactorTotpAuthVerifyFormSchema(): Signal< + ReturnType +> { + const ui = injectUI(); + return computed(() => createMultiFactorTotpAuthVerifyFormSchema(ui())); +} + +/** + * Injects the policy configuration (terms of service and privacy policy URLs). + * + * @returns The policy configuration, or null if not provided. + */ +export function injectPolicies(): PolicyConfig | null { + return inject(FIREBASE_UI_POLICIES, { optional: true }); +} + +/** + * Injects the list of allowed countries for phone authentication as a reactive signal. + * + * @returns A computed signal containing the array of allowed country data. + */ +export function injectCountries(): Signal { + const ui = injectUI(); + return computed(() => getBehavior(ui(), "countryCodes")().allowedCountries); +} + +/** + * Injects the default country for phone authentication as a reactive signal. + * + * @returns A computed signal containing the default country data. + */ +export function injectDefaultCountry(): Signal { + const ui = injectUI(); + return computed(() => getBehavior(ui(), "countryCodes")().defaultCountry); +} + +/** + * Injects the redirect error message as a reactive signal. + * + * Returns the error message if a redirect error occurred, undefined otherwise. + * + * @returns A computed signal containing the redirect error message, or undefined if no error. + */ +export function injectRedirectError(): Signal { + const ui = injectUI(); + return computed(() => { + const redirectError = ui().redirectError; + if (!redirectError) { + return undefined; + } + return redirectError instanceof Error ? redirectError.message : String(redirectError); + }); +} diff --git a/packages/angular/src/lib/tests/test-helpers.ts b/packages/angular/src/lib/tests/test-helpers.ts new file mode 100644 index 000000000..8b8dd83cb --- /dev/null +++ b/packages/angular/src/lib/tests/test-helpers.ts @@ -0,0 +1,343 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Mock implementations for @invertase/firebaseui-core to avoid ESM issues in tests +export const sendPasswordResetEmail = jest.fn(); +export const sendSignInLinkToEmail = jest.fn(); +export const completeEmailLinkSignIn = jest.fn(); +export const signInWithEmailAndPassword = jest.fn(); +export const createUserWithEmailAndPassword = jest.fn(); + +export class FirebaseUIError extends Error { + constructor(message: string) { + super(message); + this.name = "FirebaseUIError"; + } +} + +export const getTranslation = jest.fn(); +export const hasBehavior = jest.fn(); +export const signInWithProvider = jest.fn(); +export const verifyPhoneNumber = jest.fn(); +export const confirmPhoneNumber = jest.fn(); +export const formatPhoneNumber = jest.fn(); +export const generateTotpSecret = jest.fn(); +export const enrollWithMultiFactorAssertion = jest.fn(); +export const generateTotpQrCode = jest.fn(); + +// Mock Firebase Auth classes +export const TotpMultiFactorGenerator = { + FACTOR_ID: "totp", + assertionForSignIn: jest.fn(), + assertionForEnrollment: jest.fn(), +}; + +export const PhoneMultiFactorGenerator = { + FACTOR_ID: "phone", + assertionForSignIn: jest.fn(), + assertionForEnrollment: jest.fn(), + assertion: jest.fn(), +}; + +export const PhoneAuthProvider = { + credential: jest.fn(), +}; + +export const multiFactor = jest.fn(() => ({ + enroll: jest.fn(), + unenroll: jest.fn(), + getEnrolledFactors: jest.fn(), +})); + +export const signInWithMultiFactorAssertion = jest.fn(); + +// Mock FactorId enum +export const FactorId = { + TOTP: "totp", + PHONE: "phone", +}; + +export const countryData = [ + { name: "United States", dialCode: "+1", code: "US", emoji: "🇺🇸" }, + { name: "Canada", dialCode: "+1", code: "CA", emoji: "🇨🇦" }, + { name: "United Kingdom", dialCode: "+44", code: "GB", emoji: "🇬🇧" }, +]; + +export const injectUI = jest.fn().mockReturnValue(() => ({ + app: {}, + auth: {}, + locale: { + locale: "en-US", + translations: { + labels: { + emailAddress: "Email Address", + password: "Password", + signIn: "Sign In", + signUp: "Sign Up", + forgotPassword: "Forgot Password", + sendSignInLink: "Send Sign In Link", + resetPassword: "Reset Password", + backToSignIn: "Back to Sign In", + termsOfService: "Terms of Service", + privacyPolicy: "Privacy Policy", + }, + messages: { + signInLinkSent: "Check your email for a sign in link", + checkEmailForReset: "Check your email for a password reset link", + termsAndPrivacy: "By continuing, you agree to our {tos} and {privacy}", + }, + prompts: { + noAccount: "Don't have an account?", + signInToAccount: "Sign in to your account", + }, + errors: { + unknownError: "An unknown error occurred", + invalidEmail: "Please enter a valid email address", + invalidPassword: "Please enter a valid password", + }, + }, + fallback: undefined, + }, +})); + +export const injectTranslation = jest.fn().mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + emailAddress: "Email Address", + password: "Password", + signIn: "Sign In", + signUp: "Sign Up", + forgotPassword: "Forgot Password", + sendSignInLink: "Send Sign In Link", + resetPassword: "Reset Password", + backToSignIn: "Back to Sign In", + termsOfService: "Terms of Service", + privacyPolicy: "Privacy Policy", + phoneNumber: "Phone Number", + sendCode: "Send Verification Code", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + displayName: "Display Name", + createAccount: "Create Account", + generateQrCode: "Generate QR Code", + mfaSmsVerification: "SMS Verification", + mfaTotpVerification: "TOTP Verification", + }, + messages: { + signInLinkSent: "Check your email for a sign in link", + checkEmailForReset: "Check your email for a password reset link", + termsAndPrivacy: "By continuing, you agree to our {tos} and {privacy}", + }, + prompts: { + noAccount: "Don't have an account?", + signInToAccount: "Sign in to your account", + haveAccount: "Already have an account?", + smsVerificationPrompt: "Enter the verification code sent to your phone number", + }, + errors: { + unknownError: "An unknown error occurred", + invalidEmail: "Please enter a valid email address", + invalidPassword: "Please enter a valid password", + userNotAuthenticated: "User must be authenticated to enroll with multi-factor authentication", + invalidPhoneNumber: "Invalid phone number", + invalidVerificationCode: "Invalid verification code", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; +}); + +export const injectPolicies = jest.fn().mockReturnValue({ + termsOfServiceUrl: "https://example.com/terms", + privacyPolicyUrl: "https://example.com/privacy", +}); + +export const injectRedirectError = jest.fn().mockImplementation(() => { + return () => undefined; +}); + +// TODO(ehesp): Unfortunately, we cannot use the real schemas here because of the ESM-only dependency on nanostores in @invertase/firebaseui-core - this is a little +// risky as schema updates and tests need aligning, but this is a workaround for now. + +export const createForgotPasswordAuthFormSchema = jest.fn(() => { + const { z } = require("zod"); + return z.object({ + email: z.string().email("Please enter a valid email address"), + }); +}); + +export const createEmailLinkAuthFormSchema = jest.fn(() => { + const { z } = require("zod"); + return z.object({ + email: z.string().email("Please enter a valid email address"), + }); +}); + +export const createSignInAuthFormSchema = jest.fn(() => { + const { z } = require("zod"); + return z.object({ + email: z.string().email("Please enter a valid email address"), + password: z.string().min(1, "Password is required"), + }); +}); + +export const createSignUpAuthFormSchema = jest.fn(() => { + const { z } = require("zod"); + return z.object({ + email: z.string().email("Please enter a valid email address"), + password: z.string().min(6, "Password must be at least 6 characters"), + displayName: z.string().optional(), + }); +}); + +export const injectForgotPasswordAuthFormSchema = jest.fn().mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + email: z.string().email("Please enter a valid email address"), + }); +}); + +export const injectEmailLinkAuthFormSchema = jest.fn().mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + email: z.string().email("Please enter a valid email address"), + }); +}); + +export const injectSignInAuthFormSchema = jest.fn().mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + email: z.string().email("Please enter a valid email address"), + password: z.string().min(1, "Password is required"), + }); +}); + +export const injectSignUpAuthFormSchema = jest.fn().mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + email: z.string().email("Please enter a valid email address"), + password: z.string().min(6, "Password must be at least 6 characters"), + displayName: z.string().optional(), + }); +}); + +export const injectPhoneAuthFormSchema = jest.fn().mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + phoneNumber: z.string().min(1, "Phone number is required"), + }); +}); + +export const injectPhoneAuthVerifyFormSchema = jest.fn().mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + verificationCode: z.string().min(1, "Verification code is required"), + }); +}); + +export const injectMultiFactorPhoneAuthNumberFormSchema = jest.fn().mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + displayName: z.string().min(1, "Display name is required"), + phoneNumber: z.string().min(1, "Phone number is required"), + }); +}); + +export const injectMultiFactorPhoneAuthAssertionFormSchema = jest.fn().mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + phoneNumber: z.string().min(1, "Phone number is required"), + }); +}); + +export const injectMultiFactorPhoneAuthVerifyFormSchema = jest.fn().mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + verificationCode: z.string().min(1, "Verification code is required"), + }); +}); + +export const injectMultiFactorTotpAuthNumberFormSchema = jest.fn().mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + displayName: z.string().min(1, "Display name is required"), + }); +}); + +export const injectMultiFactorTotpAuthVerifyFormSchema = jest.fn().mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + verificationCode: z.string().refine((val: string) => val.length === 6, { + message: "Verification code must be 6 digits", + }), + }); +}); + +export const injectMultiFactorTotpAuthEnrollmentFormSchema = jest.fn().mockReturnValue(() => { + const { z } = require("zod"); + return z.object({ + displayName: z.string().min(1, "Display name is required"), + }); +}); + +export const injectCountries = jest.fn().mockReturnValue(() => countryData); +export const injectDefaultCountry = jest.fn().mockReturnValue(() => "US"); + +export const injectRecaptchaVerifier = jest.fn().mockImplementation(() => { + return () => ({ + clear: jest.fn(), + render: jest.fn(), + verify: jest.fn(), + }); +}); + +export const injectUserAuthenticated = jest.fn(); + +export const RecaptchaVerifier = jest.fn().mockImplementation(() => ({ + clear: jest.fn(), + render: jest.fn(), + verify: jest.fn(), +})); + +export const UserCredential = jest.fn(); + +// TODO(ehesp): We can't use the real providers here because of the ESM-only dependency with angular-fire. + +export const FacebookAuthProvider = class FacebookAuthProvider { + providerId = "facebook.com"; +}; + +export const GoogleAuthProvider = class GoogleAuthProvider { + providerId = "google.com"; +}; + +export const TwitterAuthProvider = class TwitterAuthProvider { + providerId = "twitter.com"; +}; + +export const GithubAuthProvider = class GithubAuthProvider { + providerId = "github.com"; +}; + +export const MicrosoftAuthProvider = class MicrosoftAuthProvider { + providerId = "microsoft.com"; +}; + +export const OAuthProvider = class OAuthProvider { + providerId: string; + constructor(providerId: string) { + this.providerId = providerId; + } +}; diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts new file mode 100644 index 000000000..1836ee38b --- /dev/null +++ b/packages/angular/src/public-api.ts @@ -0,0 +1,74 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { isDevMode } from "@angular/core"; +import { registerFramework } from "@invertase/firebaseui-core"; + +export { EmailLinkAuthFormComponent } from "./lib/auth/forms/email-link-auth-form"; +export { ForgotPasswordAuthFormComponent } from "./lib/auth/forms/forgot-password-auth-form"; +export { MultiFactorAuthAssertionFormComponent } from "./lib/auth/forms/multi-factor-auth-assertion-form"; +export { MultiFactorAuthEnrollmentFormComponent } from "./lib/auth/forms/multi-factor-auth-enrollment-form"; +export { PhoneAuthFormComponent } from "./lib/auth/forms/phone-auth-form"; +export { SignInAuthFormComponent } from "./lib/auth/forms/sign-in-auth-form"; +export { SignUpAuthFormComponent } from "./lib/auth/forms/sign-up-auth-form"; + +export { + SmsMultiFactorAssertionFormComponent, + SmsMultiFactorAssertionPhoneFormComponent, + SmsMultiFactorAssertionVerifyFormComponent, +} from "./lib/auth/forms/mfa/sms-multi-factor-assertion-form"; +export { SmsMultiFactorEnrollmentFormComponent } from "./lib/auth/forms/mfa/sms-multi-factor-enrollment-form"; +export { TotpMultiFactorAssertionFormComponent } from "./lib/auth/forms/mfa/totp-multi-factor-assertion-form"; +export { TotpMultiFactorEnrollmentFormComponent } from "./lib/auth/forms/mfa/totp-multi-factor-enrollment-form"; + +export { GoogleSignInButtonComponent } from "./lib/auth/oauth/google-sign-in-button"; +export { FacebookSignInButtonComponent } from "./lib/auth/oauth/facebook-sign-in-button"; +export { AppleSignInButtonComponent } from "./lib/auth/oauth/apple-sign-in-button"; +export { MicrosoftSignInButtonComponent } from "./lib/auth/oauth/microsoft-sign-in-button"; +export { TwitterSignInButtonComponent } from "./lib/auth/oauth/twitter-sign-in-button"; +export { GitHubSignInButtonComponent } from "./lib/auth/oauth/github-sign-in-button"; +export { OAuthButtonComponent } from "./lib/auth/oauth/oauth-button"; + +export { EmailLinkAuthScreenComponent } from "./lib/auth/screens/email-link-auth-screen"; +export { ForgotPasswordAuthScreenComponent } from "./lib/auth/screens/forgot-password-auth-screen"; +export { MultiFactorAuthAssertionScreenComponent } from "./lib/auth/screens/multi-factor-auth-assertion-screen"; +export { MultiFactorAuthEnrollmentScreenComponent } from "./lib/auth/screens/multi-factor-auth-enrollment-screen"; +export { OAuthScreenComponent } from "./lib/auth/screens/oauth-screen"; +export { PhoneAuthScreenComponent } from "./lib/auth/screens/phone-auth-screen"; +export { SignInAuthScreenComponent } from "./lib/auth/screens/sign-in-auth-screen"; +export { SignUpAuthScreenComponent } from "./lib/auth/screens/sign-up-auth-screen"; + +export { ButtonComponent } from "./lib/components/button"; +export { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "./lib/components/card"; +export { CountrySelectorComponent } from "./lib/components/country-selector"; +export { DividerComponent } from "./lib/components/divider"; +export { PoliciesComponent } from "./lib/components/policies"; +export { ContentComponent } from "./lib/components/content"; +export { RedirectErrorComponent } from "./lib/components/redirect-error"; + +// Provider +export * from "./lib/provider"; + +if (!isDevMode()) { + const pkgJson = require("../package.json"); + registerFramework("angular", pkgJson.version); +} diff --git a/packages/angular/tsconfig.build.json b/packages/angular/tsconfig.build.json new file mode 100644 index 000000000..39da4c374 --- /dev/null +++ b/packages/angular/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "./node_modules/ng-packagr/src/lib/ts/conf/tsconfig.ngc.json", + "compilerOptions": { + "allowJs": true, + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "Bundler" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true + }, + "include": ["src"] +} \ No newline at end of file diff --git a/packages/angular/tsconfig.json b/packages/angular/tsconfig.json new file mode 100644 index 000000000..62d27df3e --- /dev/null +++ b/packages/angular/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "moduleResolution": "Bundler", + "useDefineForClassFields": false, + "paths": { + "~/*": ["./src/*"], + "~/tests/*": ["./tests/*"], + "@invertase/firebaseui-core": ["../core/src/index.ts"], + "@invertase/firebaseui-translations": ["../translations/src/index.ts"], + "@invertase/firebaseui-styles": ["../styles/src/index.ts"] + } + }, + "include": ["src", "tests", "jest.config.ts", "setup-test.ts"] +} diff --git a/packages/angular/tsconfig.spec.json b/packages/angular/tsconfig.spec.json new file mode 100644 index 000000000..1dadd203e --- /dev/null +++ b/packages/angular/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "target": "es2016", + "types": ["vitest/globals", "node"], + "paths": { + "~/tests/*": ["./tests/*"], + "@invertase/firebaseui-core": ["../core/src/index.ts"], + "@invertase/firebaseui-styles": ["../styles/src/index.ts"] + } + }, + "files": ["setup-test.ts"], + "include": ["tests/**/*.spec.ts", "tests/**/*.d.ts", "setup-test.ts"] +} diff --git a/packages/firebaseui-core/.gitignore b/packages/core/.gitignore similarity index 100% rename from packages/firebaseui-core/.gitignore rename to packages/core/.gitignore diff --git a/packages/firebaseui-core/.npmignore b/packages/core/.npmignore similarity index 100% rename from packages/firebaseui-core/.npmignore rename to packages/core/.npmignore diff --git a/packages/core/GEMINI.md b/packages/core/GEMINI.md new file mode 100644 index 000000000..f7d545f4f --- /dev/null +++ b/packages/core/GEMINI.md @@ -0,0 +1,81 @@ +# Firebase UI Core + +This document provides context for the `@invertase/firebaseui-core` package. + +## Overview + +The `@invertase/firebaseui-core` package is the framework-agnostic core of the Firebase UI for Web library. It provides a set of functions and utilities for building UIs with Firebase Authentication. The core package is designed to be used by framework-specific packages like `@invertase/firebaseui-react` and `@invertase/firebaseui-angular`, but it can also be used directly to build custom UIs. + +## Usage + +The main entry point to the core package is the `initializeUI` function. This function takes a configuration object and returns a `FirebaseUI` instance, which is a `nanostores` store that holds the configuration and state of the UI. + +```typescript +import { initializeUI } from "@invertase/firebaseui-core"; +import { enUs } from "@invertase/firebaseui-translations"; +import { firebaseApp } from "./firebase"; + +const ui = initializeUI({ + app: firebaseApp, + locale: enUs, + behaviors: [ + // ... + ], +}); +``` + +The `FirebaseUI` instance can then be used to call the various authentication functions, such as `signInWithEmailAndPassword`, `createUserWithEmailAndPassword`, etc. + +```typescript +import { initializeUI, signInWithEmailAndPassword } from "@invertase/firebaseui-core"; + +const ui = initializeUI({ + // ... your config +}); + +async function signIn(email, password) { + await signInWithEmailAndPassword(ui, email, password); +} +``` + +## Behaviors + +Behaviors are a way to customize the functionality of the Firebase UI. They are functions that are executed at different points in the authentication process. For example, the `requireDisplayName` behavior can be used to require the user to enter a display name when signing up. + +Behaviors are passed to the `initializeUI` function in the `behaviors` array. + +```typescript +import { initializeUI, requireDisplayName } from "@invertase/firebaseui-core"; + +const ui = initializeUI({ + // ... + behaviors: [ + requireDisplayName(), + ], +}); +``` + +## State Management + +The core package uses `nanostores` for state management. The `FirebaseUI` instance is a `nanostores` store that holds the configuration and state of the UI. The state can be one of the following: + +* `idle`: The UI is idle. +* `pending`: The UI is waiting for an asynchronous operation to complete. +* `loading`: The UI is loading. + +The state can be accessed from the `state` property of the `FirebaseUI` instance. + +```typescript +import { useStore } from "@nanostores/react"; +import { ui } from "./firebase"; // assuming ui is exported from a firebase config file + +function MyComponent() { + const { state } = useStore(ui); + + if (state === "pending") { + return

Loading...

; + } + + return

Idle

; +} +``` diff --git a/packages/core/brands/apple/logo.svg b/packages/core/brands/apple/logo.svg new file mode 100644 index 000000000..f08dbc705 --- /dev/null +++ b/packages/core/brands/apple/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/brands/facebook/logo.svg b/packages/core/brands/facebook/logo.svg new file mode 100644 index 000000000..b7d91c533 --- /dev/null +++ b/packages/core/brands/facebook/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/brands/github/logo.svg b/packages/core/brands/github/logo.svg new file mode 100644 index 000000000..6d487e64b --- /dev/null +++ b/packages/core/brands/github/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/brands/google/logo.svg b/packages/core/brands/google/logo.svg new file mode 100644 index 000000000..c0669b38f --- /dev/null +++ b/packages/core/brands/google/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/brands/line/logo.svg b/packages/core/brands/line/logo.svg new file mode 100644 index 000000000..cc69bb5fb --- /dev/null +++ b/packages/core/brands/line/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/brands/microsoft/logo.svg b/packages/core/brands/microsoft/logo.svg new file mode 100644 index 000000000..23a77fb51 --- /dev/null +++ b/packages/core/brands/microsoft/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/brands/snapchat/logo.svg b/packages/core/brands/snapchat/logo.svg new file mode 100644 index 000000000..04cd82e2a --- /dev/null +++ b/packages/core/brands/snapchat/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/brands/twitter/logo.svg b/packages/core/brands/twitter/logo.svg new file mode 100644 index 000000000..a21afdb47 --- /dev/null +++ b/packages/core/brands/twitter/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/firebaseui-core/firebase.json b/packages/core/firebase.json similarity index 100% rename from packages/firebaseui-core/firebase.json rename to packages/core/firebase.json diff --git a/packages/firebaseui-core/package.json b/packages/core/package.json similarity index 51% rename from packages/firebaseui-core/package.json rename to packages/core/package.json index 7f0a2fde2..2c44a22b4 100644 --- a/packages/firebaseui-core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { - "name": "@firebase-ui/core", - "version": "0.0.1", + "name": "@invertase/firebaseui-core", + "version": "0.0.11", "description": "Core authentication service for Firebase UI", "type": "module", "main": "./dist/index.cjs", @@ -11,26 +11,32 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" - } + }, + "./brands/*": "./brands/*" }, "files": [ - "dist" + "dist", + "brands" ], "scripts": { "prepare": "pnpm run build", "emulators:start": "firebase emulators:start -P demo-firebaseui", - "build": "tsup", + "build": "tsup --env.PROD=true", "build:local": "pnpm run build && pnpm pack", "dev": "tsup --watch", - "lint": "tsc --noEmit", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"", + "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"", "clean": "rimraf dist", - "test:unit": "vitest run tests/unit", - "test:unit:watch": "vitest tests/unit", - "test:integration": "vitest run tests/integration", - "test:integration:watch": "vitest tests/integration", + "test:unit": "vitest run src", + "test:unit:watch": "vitest tests", + "test:integration": "vitest run tests", + "test:integration:watch": "vitest integration", "test": "vitest run", + "version:bump": "pnpm version patch", "publish:tags": "sh -c 'TAG=\"${npm_package_name}@${npm_package_version}\"; git tag --list \"$TAG\" | grep . || git tag \"$TAG\"; git push origin \"$TAG\"'", + "publish:npm": "pnpm publish --access public", "release": "pnpm run build && pnpm pack --pack-destination --pack-destination ../../releases/" }, "keywords": [ @@ -42,22 +48,24 @@ "author": "TODO", "license": "MIT", "peerDependencies": { - "firebase": "^11" + "firebase": "catalog:peerDependencies" }, "dependencies": { - "@firebase-ui/translations": "workspace:*", - "nanostores": "^0.11.3", - "zod": "^3.24.1" + "@invertase/firebaseui-translations": "workspace:*", + "libphonenumber-js": "^1.12.23", + "nanostores": "catalog:", + "qrcode-generator": "^2.0.4", + "zod": "catalog:" }, "devDependencies": { - "@types/jsdom": "^21.1.7", - "firebase": "^11.0.0", - "jsdom": "^26.0.0", - "prettier": "^3.1.1", - "rimraf": "^6.0.1", - "tsup": "^8.0.1", - "typescript": "^5.7.3", - "vite": "^6.2.0", - "vitest": "^3.0.7" + "@types/google-one-tap": "^1.2.6", + "@types/jsdom": "catalog:", + "firebase": "catalog:", + "jsdom": "catalog:", + "rimraf": "catalog:", + "tsup": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/core/src/auth.test.ts b/packages/core/src/auth.test.ts new file mode 100644 index 000000000..692757355 --- /dev/null +++ b/packages/core/src/auth.test.ts @@ -0,0 +1,1472 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + signInWithEmailAndPassword, + createUserWithEmailAndPassword, + verifyPhoneNumber, + confirmPhoneNumber, + sendPasswordResetEmail, + sendSignInLinkToEmail, + signInWithEmailLink, + signInWithCredential, + signInAnonymously, + signInWithProvider, + signInWithCustomToken, + generateTotpQrCode, + completeEmailLinkSignIn, + signInWithMultiFactorAssertion, +} from "./auth"; + +vi.mock("firebase/auth", () => ({ + signInWithCredential: vi.fn(), + createUserWithEmailAndPassword: vi.fn(), + sendPasswordResetEmail: vi.fn(), + sendSignInLinkToEmail: vi.fn(), + signInAnonymously: vi.fn(), + signInWithCustomToken: vi.fn(), + signInWithRedirect: vi.fn(), + isSignInWithEmailLink: vi.fn(), + EmailAuthProvider: { + credential: vi.fn(), + credentialWithLink: vi.fn(), + }, + PhoneAuthProvider: Object.assign(vi.fn(), { + credential: vi.fn(), + }), + linkWithCredential: vi.fn(), +})); + +vi.mock("./behaviors", () => ({ + hasBehavior: vi.fn(), + getBehavior: vi.fn(), +})); + +vi.mock("./errors", () => ({ + handleFirebaseError: vi.fn(), +})); + +import { + signInWithCredential as _signInWithCredential, + EmailAuthProvider, + PhoneAuthProvider, + createUserWithEmailAndPassword as _createUserWithEmailAndPassword, + sendPasswordResetEmail as _sendPasswordResetEmail, + sendSignInLinkToEmail as _sendSignInLinkToEmail, + signInAnonymously as _signInAnonymously, + signInWithCustomToken as _signInWithCustomToken, + isSignInWithEmailLink as _isSignInWithEmailLink, + UserCredential, + Auth, + AuthProvider, + TotpSecret, +} from "firebase/auth"; +import { hasBehavior, getBehavior } from "./behaviors"; +import { handleFirebaseError } from "./errors"; +import { FirebaseError } from "firebase/app"; + +import { createMockUI } from "~/tests/utils"; + +// TODO(ehesp): Add tests for handlePendingCredential. + +describe("signInWithEmailAndPassword", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should update state and call _signInWithCredential with no behavior", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + + const credential = EmailAuthProvider.credential(email, password); + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(EmailAuthProvider.credential).mockReturnValue(credential); + vi.mocked(_signInWithCredential).mockResolvedValue({ providerId: "password" } as UserCredential); + + const result = await signInWithEmailAndPassword(mockUI, email, password); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(_signInWithCredential).toHaveBeenCalledTimes(1); + + expect(result.providerId).toBe("password"); + }); + + it("should call the autoUpgradeAnonymousCredential behavior if enabled and return a value", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + + const credential = EmailAuthProvider.credential(email, password); + vi.mocked(hasBehavior).mockReturnValue(true); + const mockBehavior = vi.fn().mockResolvedValue({ providerId: "password" } as UserCredential); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + const result = await signInWithEmailAndPassword(mockUI, email, password); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + expect(result.providerId).toBe("password"); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should call the autoUpgradeAnonymousCredential behavior if enabled and handle no result from the behavior", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + + const credential = EmailAuthProvider.credential(email, password); + vi.mocked(hasBehavior).mockReturnValue(true); + const mockBehavior = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + await signInWithEmailAndPassword(mockUI, email, password); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + + expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(_signInWithCredential).toHaveBeenCalledTimes(1); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + + vi.mocked(hasBehavior).mockReturnValue(false); + + const error = new FirebaseError("foo/bar", "Foo bar"); + + vi.mocked(_signInWithCredential).mockRejectedValue(error); + + await signInWithEmailAndPassword(mockUI, email, password); + + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); +}); + +describe("createUserWithEmailAndPassword", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should update state and call createUserWithEmailAndPassword with no behavior", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + + const credential = EmailAuthProvider.credential(email, password); + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(EmailAuthProvider.credential).mockReturnValue(credential); + vi.mocked(_createUserWithEmailAndPassword).mockResolvedValue({ providerId: "password" } as UserCredential); + + const result = await createUserWithEmailAndPassword(mockUI, email, password); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + expect(_createUserWithEmailAndPassword).toHaveBeenCalledWith(mockUI.auth, email, password); + expect(_createUserWithEmailAndPassword).toHaveBeenCalledTimes(1); + + expect(result.providerId).toBe("password"); + }); + + it("should call the autoUpgradeAnonymousCredential behavior if enabled and return a value", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + + const credential = EmailAuthProvider.credential(email, password); + vi.mocked(hasBehavior).mockImplementation((_, behavior) => { + if (behavior === "autoUpgradeAnonymousCredential") return true; + if (behavior === "requireDisplayName") return false; + return false; + }); + const mockBehavior = vi.fn().mockResolvedValue({ providerId: "password" } as UserCredential); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + const result = await createUserWithEmailAndPassword(mockUI, email, password); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + expect(result.providerId).toBe("password"); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should call the autoUpgradeAnonymousCredential behavior if enabled and handle no result from the behavior", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + + const credential = EmailAuthProvider.credential(email, password); + vi.mocked(hasBehavior).mockImplementation((_, behavior) => { + if (behavior === "autoUpgradeAnonymousCredential") return true; + if (behavior === "requireDisplayName") return false; + return false; + }); + const mockBehavior = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + await createUserWithEmailAndPassword(mockUI, email, password); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + + expect(_createUserWithEmailAndPassword).toHaveBeenCalledWith(mockUI.auth, email, password); + expect(_createUserWithEmailAndPassword).toHaveBeenCalledTimes(1); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + + vi.mocked(hasBehavior).mockReturnValue(false); + + const error = new FirebaseError("foo/bar", "Foo bar"); + + vi.mocked(_createUserWithEmailAndPassword).mockRejectedValue(error); + + await createUserWithEmailAndPassword(mockUI, email, password); + + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should call handleFirebaseError when requireDisplayName behavior is enabled but no displayName provided", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + + vi.mocked(hasBehavior).mockImplementation((_, behavior) => { + if (behavior === "requireDisplayName") return true; + if (behavior === "autoUpgradeAnonymousCredential") return false; + return false; + }); + + await createUserWithEmailAndPassword(mockUI, email, password); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "requireDisplayName"); + expect(_createUserWithEmailAndPassword).not.toHaveBeenCalled(); + expect(handleFirebaseError).toHaveBeenCalled(); + }); + + it("should call requireDisplayName behavior when enabled and displayName provided", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + const displayName = "John Doe"; + + const mockRequireDisplayNameBehavior = vi.fn().mockResolvedValue(undefined); + const mockResult = { providerId: "password", user: { uid: "user123" } } as UserCredential; + + vi.mocked(hasBehavior).mockImplementation((_, behavior) => { + if (behavior === "requireDisplayName") return true; + if (behavior === "autoUpgradeAnonymousCredential") return false; + return false; + }); + vi.mocked(getBehavior).mockReturnValue(mockRequireDisplayNameBehavior); + vi.mocked(_createUserWithEmailAndPassword).mockResolvedValue(mockResult); + + const result = await createUserWithEmailAndPassword(mockUI, email, password, displayName); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "requireDisplayName"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "requireDisplayName"); + expect(mockRequireDisplayNameBehavior).toHaveBeenCalledWith(mockUI, mockResult.user, displayName); + expect(result).toBe(mockResult); + }); + + it("should call requireDisplayName behavior after autoUpgradeAnonymousCredential when both enabled", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + const displayName = "John Doe"; + + const mockAutoUpgradeBehavior = vi + .fn() + .mockResolvedValue({ providerId: "upgraded", user: { uid: "upgraded-user" } } as UserCredential); + const mockRequireDisplayNameBehavior = vi.fn().mockResolvedValue(undefined); + const credential = EmailAuthProvider.credential(email, password); + + vi.mocked(hasBehavior).mockImplementation((_, behavior) => { + if (behavior === "requireDisplayName") return true; + if (behavior === "autoUpgradeAnonymousCredential") return true; + return false; + }); + + vi.mocked(getBehavior).mockImplementation((_, behavior) => { + if (behavior === "autoUpgradeAnonymousCredential") return mockAutoUpgradeBehavior; + if (behavior === "requireDisplayName") return mockRequireDisplayNameBehavior; + return vi.fn(); + }); + + vi.mocked(EmailAuthProvider.credential).mockReturnValue(credential); + + const result = await createUserWithEmailAndPassword(mockUI, email, password, displayName); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "requireDisplayName"); + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(mockAutoUpgradeBehavior).toHaveBeenCalledWith(mockUI, credential); + expect(mockRequireDisplayNameBehavior).toHaveBeenCalledWith(mockUI, { uid: "upgraded-user" }, displayName); + expect(result).toEqual({ providerId: "upgraded", user: { uid: "upgraded-user" } }); + }); + + it("should not call requireDisplayName behavior when not enabled", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + const displayName = "John Doe"; + + const mockResult = { providerId: "password", user: { uid: "user123" } } as UserCredential; + + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(_createUserWithEmailAndPassword).mockResolvedValue(mockResult); + + const result = await createUserWithEmailAndPassword(mockUI, email, password, displayName); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "requireDisplayName"); + expect(getBehavior).not.toHaveBeenCalledWith(mockUI, "requireDisplayName"); + expect(result).toBe(mockResult); + }); + + it("should handle requireDisplayName behavior errors", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + const displayName = "John Doe"; + + const mockRequireDisplayNameBehavior = vi.fn().mockRejectedValue(new Error("Display name update failed")); + const mockResult = { providerId: "password", user: { uid: "user123" } } as UserCredential; + + vi.mocked(hasBehavior).mockImplementation((_, behavior) => { + if (behavior === "requireDisplayName") return true; + if (behavior === "autoUpgradeAnonymousCredential") return false; + return false; + }); + vi.mocked(getBehavior).mockReturnValue(mockRequireDisplayNameBehavior); + vi.mocked(_createUserWithEmailAndPassword).mockResolvedValue(mockResult); + + await createUserWithEmailAndPassword(mockUI, email, password, displayName); + + expect(mockRequireDisplayNameBehavior).toHaveBeenCalledWith(mockUI, mockResult.user, displayName); + expect(handleFirebaseError).toHaveBeenCalled(); + }); +}); + +describe("verifyPhoneNumber", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should update state and call PhoneAuthProvider.verifyPhoneNumber successfully", async () => { + const mockUI = createMockUI(); + const phoneNumber = "+1234567890"; + const mockAppVerifier = {} as any; + const mockVerificationId = "test-verification-id"; + + const mockVerifyPhoneNumber = vi.fn().mockResolvedValue(mockVerificationId); + vi.mocked(PhoneAuthProvider).mockImplementation( + () => + ({ + verifyPhoneNumber: mockVerifyPhoneNumber, + }) as any + ); + + const result = await verifyPhoneNumber(mockUI, phoneNumber, mockAppVerifier); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + expect(PhoneAuthProvider).toHaveBeenCalledWith(mockUI.auth); + expect(mockVerifyPhoneNumber).toHaveBeenCalledWith(phoneNumber, mockAppVerifier); + expect(mockVerifyPhoneNumber).toHaveBeenCalledTimes(1); + + expect(result).toEqual(mockVerificationId); + }); + + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const phoneNumber = "+1234567890"; + const mockAppVerifier = {} as any; + const error = new FirebaseError("auth/invalid-phone-number", "Invalid phone number"); + + const mockVerifyPhoneNumber = vi.fn().mockRejectedValue(error); + vi.mocked(PhoneAuthProvider).mockImplementation( + () => + ({ + verifyPhoneNumber: mockVerifyPhoneNumber, + }) as any + ); + + await verifyPhoneNumber(mockUI, phoneNumber, mockAppVerifier); + + // Verify error handling + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + // Verify state management still happens + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should handle recaptcha verification errors", async () => { + const mockUI = createMockUI(); + const phoneNumber = "+1234567890"; + const mockAppVerifier = {} as any; + const error = new Error("reCAPTCHA verification failed"); + + const mockVerifyPhoneNumber = vi.fn().mockRejectedValue(error); + vi.mocked(PhoneAuthProvider).mockImplementation( + () => + ({ + verifyPhoneNumber: mockVerifyPhoneNumber, + }) as any + ); + + await verifyPhoneNumber(mockUI, phoneNumber, mockAppVerifier); + + // Verify error handling + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + // Verify state management + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); +}); + +describe("confirmPhoneNumber", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should update state and call _signInWithCredential with no behavior", async () => { + const mockUI = createMockUI({ + auth: { currentUser: null } as Auth, + }); + const verificationId = "test-verification-id"; + const verificationCode = "123456"; + + const credential = PhoneAuthProvider.credential(verificationId, verificationCode); + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(PhoneAuthProvider.credential).mockReturnValue(credential); + vi.mocked(_signInWithCredential).mockResolvedValue({ providerId: "phone" } as UserCredential); + + const result = await confirmPhoneNumber(mockUI, verificationId, verificationCode); + + // Since currentUser is null, the behavior should not called. + expect(hasBehavior).toHaveBeenCalledTimes(0); + + // Calls pending pre-_signInWithCredential call, then idle after. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(_signInWithCredential).toHaveBeenCalledTimes(1); + + // Assert that the result is a valid UserCredential. + expect(result.providerId).toBe("phone"); + }); + + it("should call autoUpgradeAnonymousCredential behavior when user is anonymous", async () => { + const mockUI = createMockUI({ + auth: { currentUser: { isAnonymous: true } } as Auth, + }); + const verificationId = "test-verification-id"; + const verificationCode = "123456"; + + const credential = PhoneAuthProvider.credential(verificationId, verificationCode); + vi.mocked(hasBehavior).mockReturnValue(true); + vi.mocked(PhoneAuthProvider.credential).mockReturnValue(credential); + const mockBehavior = vi.fn().mockResolvedValue({ providerId: "phone" } as UserCredential); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + const result = await confirmPhoneNumber(mockUI, verificationId, verificationCode); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + expect(result.providerId).toBe("phone"); + + // Auth method sets pending at start, then idle in finally block. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should not call behavior when user is not anonymous", async () => { + const mockUI = createMockUI({ + auth: { currentUser: { isAnonymous: false } } as Auth, + }); + const verificationId = "test-verification-id"; + const verificationCode = "123456"; + + const credential = PhoneAuthProvider.credential(verificationId, verificationCode); + vi.mocked(PhoneAuthProvider.credential).mockReturnValue(credential); + vi.mocked(_signInWithCredential).mockResolvedValue({ providerId: "phone" } as UserCredential); + + const result = await confirmPhoneNumber(mockUI, verificationId, verificationCode); + + // Behavior should not be called when user is not anonymous + expect(hasBehavior).not.toHaveBeenCalled(); + + // Should proceed with normal sign-in flow + expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + expect(result.providerId).toBe("phone"); + }); + + it("should not call behavior when user is null", async () => { + const mockUI = createMockUI({ + auth: { currentUser: null } as Auth, + }); + const verificationId = "test-verification-id"; + const verificationCode = "123456"; + + const credential = PhoneAuthProvider.credential(verificationId, verificationCode); + vi.mocked(PhoneAuthProvider.credential).mockReturnValue(credential); + vi.mocked(_signInWithCredential).mockResolvedValue({ providerId: "phone" } as UserCredential); + + const result = await confirmPhoneNumber(mockUI, verificationId, verificationCode); + + // Behavior should not be called when user is null + expect(hasBehavior).not.toHaveBeenCalled(); + + // Should proceed with normal sign-in flow + expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + expect(result.providerId).toBe("phone"); + }); + + it("should fall back to normal sign-in when behavior returns undefined", async () => { + const mockUI = createMockUI({ + auth: { currentUser: { isAnonymous: true } } as Auth, + }); + const verificationId = "test-verification-id"; + const verificationCode = "123456"; + + const credential = PhoneAuthProvider.credential(verificationId, verificationCode); + vi.mocked(hasBehavior).mockReturnValue(true); + vi.mocked(PhoneAuthProvider.credential).mockReturnValue(credential); + const mockBehavior = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + await confirmPhoneNumber(mockUI, verificationId, verificationCode); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + + expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(_signInWithCredential).toHaveBeenCalledTimes(1); + + // Calls pending pre-_signInWithCredential call, then idle after. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI({ + auth: { currentUser: null } as Auth, + }); + const verificationId = "test-verification-id"; + const verificationCode = "123456"; + + const error = new FirebaseError("auth/invalid-verification-code", "Invalid verification code"); + + vi.mocked(_signInWithCredential).mockRejectedValue(error); + + await confirmPhoneNumber(mockUI, verificationId, verificationCode); + + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); +}); + +describe("signInWithMultiFactorAssertion", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("resolves sign-in via resolver, clears resolver, and returns credential", async () => { + const mockUI = createMockUI(); + + const mockCredential = { providerId: "mfa", user: { uid: "mfa-user" } } as UserCredential; + const resolveSignIn = vi.fn().mockResolvedValue(mockCredential); + + const mockResolver = { resolveSignIn } as any; + (mockUI as any).multiFactorResolver = mockResolver; + + const mockAssertion = { assertion: true } as any; // type MultiFactorAssertion + + const result = await signInWithMultiFactorAssertion(mockUI, mockAssertion); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + expect(resolveSignIn).toHaveBeenCalledWith(mockAssertion); + expect(resolveSignIn).toHaveBeenCalledTimes(1); + + expect(mockUI.setMultiFactorResolver).toHaveBeenCalledWith(undefined); + + expect(result).toBe(mockCredential); + }); + + it("handles errors via handleFirebaseError and maintains state transitions", async () => { + const mockUI = createMockUI(); + + const error = new FirebaseError("auth/mfa-error", "MFA resolution failed"); + const resolveSignIn = vi.fn().mockRejectedValue(error); + const mockResolver = { resolveSignIn } as any; + (mockUI as any).multiFactorResolver = mockResolver; + + const mockAssertion = { assertion: true } as any; // type MultiFactorAssertion + + await signInWithMultiFactorAssertion(mockUI, mockAssertion); + + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); +}); + +describe("sendPasswordResetEmail", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should update state and call sendPasswordResetEmail successfully", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + + vi.mocked(_sendPasswordResetEmail).mockResolvedValue(undefined); + + await sendPasswordResetEmail(mockUI, email); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + expect(_sendPasswordResetEmail).toHaveBeenCalledWith(mockUI.auth, email); + expect(_sendPasswordResetEmail).toHaveBeenCalledTimes(1); + }); + + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const error = new FirebaseError("auth/user-not-found", "User not found"); + + vi.mocked(_sendPasswordResetEmail).mockRejectedValue(error); + + await sendPasswordResetEmail(mockUI, email); + + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); +}); + +describe("sendSignInLinkToEmail", () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(window, "location", { + value: { href: "https://example.com" }, + writable: true, + }); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + it("should update state and call sendSignInLinkToEmail successfully", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + + vi.mocked(_sendSignInLinkToEmail).mockResolvedValue(undefined); + + await sendSignInLinkToEmail(mockUI, email); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + const expectedActionCodeSettings = { + url: "https://example.com", + handleCodeInApp: true, + }; + expect(_sendSignInLinkToEmail).toHaveBeenCalledWith(mockUI.auth, email, expectedActionCodeSettings); + expect(_sendSignInLinkToEmail).toHaveBeenCalledTimes(1); + + expect(window.localStorage.getItem("emailForSignIn")).toBe(email); + }); + + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const error = new FirebaseError("auth/invalid-email", "Invalid email address"); + + vi.mocked(_sendSignInLinkToEmail).mockRejectedValue(error); + + await sendSignInLinkToEmail(mockUI, email); + + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + expect(window.localStorage.getItem("emailForSignIn")).toBeNull(); + }); + + it("should use current window location for action code settings", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + + Object.defineProperty(window, "location", { + value: { href: "https://myapp.com/auth" }, + writable: true, + }); + + vi.mocked(_sendSignInLinkToEmail).mockResolvedValue(undefined); + + await sendSignInLinkToEmail(mockUI, email); + + const expectedActionCodeSettings = { + url: "https://myapp.com/auth", + handleCodeInApp: true, + }; + expect(_sendSignInLinkToEmail).toHaveBeenCalledWith(mockUI.auth, email, expectedActionCodeSettings); + }); + + it("should overwrite existing email in localStorage", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const existingEmail = "old@example.com"; + + window.localStorage.setItem("emailForSignIn", existingEmail); + + vi.mocked(_sendSignInLinkToEmail).mockResolvedValue(undefined); + + await sendSignInLinkToEmail(mockUI, email); + + expect(window.localStorage.getItem("emailForSignIn")).toBe(email); + }); +}); + +describe("signInWithEmailLink", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should create credential and call signInWithCredential with no behavior", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const link = "https://example.com/auth?oobCode=abc123"; + + const credential = EmailAuthProvider.credentialWithLink(email, link); + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(credential); + vi.mocked(_signInWithCredential).mockResolvedValue({ providerId: "emailLink" } as UserCredential); + + const result = await signInWithEmailLink(mockUI, email, link); + + expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, link); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(_signInWithCredential).toHaveBeenCalledTimes(1); + + expect(result.providerId).toBe("emailLink"); + }); + + it("should call the autoUpgradeAnonymousCredential behavior if enabled and return a value", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const link = "https://example.com/auth?oobCode=abc123"; + + const credential = EmailAuthProvider.credentialWithLink(email, link); + vi.mocked(hasBehavior).mockReturnValue(true); + vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(credential); + const mockBehavior = vi.fn().mockResolvedValue({ providerId: "emailLink" } as UserCredential); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + const result = await signInWithEmailLink(mockUI, email, link); + + expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, link); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + expect(result.providerId).toBe("emailLink"); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should call the autoUpgradeAnonymousCredential behavior if enabled and handle no result from the behavior", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const link = "https://example.com/auth?oobCode=abc123"; + + const credential = EmailAuthProvider.credentialWithLink(email, link); + vi.mocked(hasBehavior).mockReturnValue(true); + vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(credential); + const mockBehavior = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + await signInWithEmailLink(mockUI, email, link); + + expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, link); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + + expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(_signInWithCredential).toHaveBeenCalledTimes(1); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const link = "https://example.com/auth?oobCode=abc123"; + + vi.mocked(hasBehavior).mockReturnValue(false); + + const error = new FirebaseError("auth/invalid-action-code", "Invalid action code"); + + vi.mocked(_signInWithCredential).mockRejectedValue(error); + + await signInWithEmailLink(mockUI, email, link); + + expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, link); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); +}); + +describe("signInWithCredential", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should update state and call _signInWithCredential with no behavior", async () => { + const mockUI = createMockUI(); + const credential = { providerId: "password" } as any; + + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(_signInWithCredential).mockResolvedValue({ providerId: "password" } as UserCredential); + + const result = await signInWithCredential(mockUI, credential); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(_signInWithCredential).toHaveBeenCalledTimes(1); + + expect(result.providerId).toBe("password"); + }); + + it("should call the autoUpgradeAnonymousCredential behavior if enabled and return a value", async () => { + const mockUI = createMockUI(); + const credential = { providerId: "password" } as any; + + vi.mocked(hasBehavior).mockReturnValue(true); + const mockBehavior = vi.fn().mockResolvedValue({ providerId: "password" } as UserCredential); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + const result = await signInWithCredential(mockUI, credential); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + expect(result.providerId).toBe("password"); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should call the autoUpgradeAnonymousCredential behavior if enabled and handle no result from the behavior", async () => { + const mockUI = createMockUI(); + const credential = { providerId: "password" } as any; + + vi.mocked(hasBehavior).mockReturnValue(true); + const mockBehavior = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + await signInWithCredential(mockUI, credential); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + + expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(_signInWithCredential).toHaveBeenCalledTimes(1); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const credential = { providerId: "password" } as any; + + vi.mocked(hasBehavior).mockReturnValue(false); + + const error = new FirebaseError("auth/invalid-credential", "Invalid credential"); + + vi.mocked(_signInWithCredential).mockRejectedValue(error); + + await signInWithCredential(mockUI, credential); + + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should handle behavior errors", async () => { + const mockUI = createMockUI(); + const credential = { providerId: "password" } as any; + const error = new Error("Behavior error"); + + vi.mocked(hasBehavior).mockReturnValue(true); + const mockBehavior = vi.fn().mockRejectedValue(error); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + await signInWithCredential(mockUI, credential); + + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + expect(_signInWithCredential).not.toHaveBeenCalled(); + }); +}); + +describe("signInAnonymously", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should update state and call signInAnonymously successfully", async () => { + const mockUI = createMockUI(); + const mockUserCredential = { + user: { uid: "anonymous-uid", isAnonymous: true }, + providerId: "anonymous", + operationType: "signIn", + } as UserCredential; + + vi.mocked(_signInAnonymously).mockResolvedValue(mockUserCredential); + + const result = await signInAnonymously(mockUI); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + expect(_signInAnonymously).toHaveBeenCalledWith(mockUI.auth); + expect(_signInAnonymously).toHaveBeenCalledTimes(1); + + expect(result).toEqual(mockUserCredential); + }); + + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const error = new FirebaseError("auth/operation-not-allowed", "Anonymous sign-in is not enabled"); + + vi.mocked(_signInAnonymously).mockRejectedValue(error); + + await signInAnonymously(mockUI); + + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); +}); + +describe("signInWithCustomToken", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should update state and call signInWithCustomToken successfully", async () => { + const mockUI = createMockUI(); + const customToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."; + const mockUserCredential = { + user: { uid: "custom-user-uid", email: "user@example.com" }, + providerId: "custom", + operationType: "signIn", + } as UserCredential; + + vi.mocked(_signInWithCustomToken).mockResolvedValue(mockUserCredential); + + const result = await signInWithCustomToken(mockUI, customToken); + + expect(mockUI.setRedirectError).toHaveBeenCalledWith(undefined); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + expect(_signInWithCustomToken).toHaveBeenCalledWith(mockUI.auth, customToken); + expect(_signInWithCustomToken).toHaveBeenCalledTimes(1); + + expect(result).toEqual(mockUserCredential); + }); + + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const customToken = "invalid-token"; + const error = new FirebaseError("auth/invalid-custom-token", "Invalid custom token"); + + vi.mocked(_signInWithCustomToken).mockRejectedValue(error); + + await signInWithCustomToken(mockUI, customToken); + + expect(mockUI.setRedirectError).toHaveBeenCalledWith(undefined); + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should handle network errors", async () => { + const mockUI = createMockUI(); + const customToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."; + const error = new Error("Network error"); + + vi.mocked(_signInWithCustomToken).mockRejectedValue(error); + + await signInWithCustomToken(mockUI, customToken); + + expect(mockUI.setRedirectError).toHaveBeenCalledWith(undefined); + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should handle expired custom token", async () => { + const mockUI = createMockUI(); + const customToken = "expired-token"; + const error = new FirebaseError("auth/custom-token-mismatch", "Custom token expired"); + + vi.mocked(_signInWithCustomToken).mockRejectedValue(error); + + await signInWithCustomToken(mockUI, customToken); + + expect(mockUI.setRedirectError).toHaveBeenCalledWith(undefined); + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); +}); + +describe("signInWithProvider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should call providerSignInStrategy behavior when no autoUpgradeAnonymousProvider", async () => { + const mockUI = createMockUI(); + const provider = { providerId: "google.com" } as AuthProvider; + const mockResult = { user: { uid: "test-user" } } as UserCredential; + + vi.mocked(hasBehavior).mockReturnValue(false); + + const mockProviderStrategy = vi.fn().mockResolvedValue(mockResult); + vi.mocked(getBehavior).mockReturnValue(mockProviderStrategy); + + const result = await signInWithProvider(mockUI, provider); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousProvider"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "providerSignInStrategy"); + expect(mockProviderStrategy).toHaveBeenCalledWith(mockUI, provider); + expect(result).toBe(mockResult); + }); + + it("should call autoUpgradeAnonymousProvider behavior if enabled and return result", async () => { + const mockUI = createMockUI(); + const provider = { providerId: "google.com" } as AuthProvider; + const mockCredential = { user: { uid: "upgraded-user" } } as UserCredential; + + vi.mocked(hasBehavior).mockReturnValue(true); + const mockUpgradeBehavior = vi.fn().mockResolvedValue(mockCredential); + vi.mocked(getBehavior).mockReturnValue(mockUpgradeBehavior); + + const result = await signInWithProvider(mockUI, provider); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousProvider"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousProvider"); + expect(mockUpgradeBehavior).toHaveBeenCalledWith(mockUI, provider); + expect(result).toBe(mockCredential); + }); + + it("should call providerSignInStrategy when autoUpgradeAnonymousProvider returns undefined", async () => { + const mockUI = createMockUI(); + const provider = { providerId: "google.com" } as AuthProvider; + const mockResult = { user: { uid: "test-user" } } as UserCredential; + + vi.mocked(hasBehavior).mockReturnValue(true); + + const mockUpgradeBehavior = vi.fn().mockResolvedValue(undefined); + const mockProviderStrategy = vi.fn().mockResolvedValue(mockResult); + vi.mocked(getBehavior).mockImplementation((_ui, behavior) => { + if (behavior === "autoUpgradeAnonymousProvider") return mockUpgradeBehavior; + if (behavior === "providerSignInStrategy") return mockProviderStrategy; + return vi.fn(); + }); + + const result = await signInWithProvider(mockUI, provider); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousProvider"); + expect(mockUpgradeBehavior).toHaveBeenCalledWith(mockUI, provider); + expect(mockProviderStrategy).toHaveBeenCalledWith(mockUI, provider); + expect(result).toBe(mockResult); + }); + + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const provider = { providerId: "google.com" } as AuthProvider; + const error = new FirebaseError("auth/operation-not-allowed", "Google sign-in is not enabled"); + + vi.mocked(hasBehavior).mockReturnValue(false); + const mockProviderStrategy = vi.fn().mockRejectedValue(error); + vi.mocked(getBehavior).mockReturnValue(mockProviderStrategy); + + await signInWithProvider(mockUI, provider); + + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); +}); + +describe("generateTotpQrCode", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should generate QR code successfully with authenticated user", () => { + const mockUI = createMockUI({ + auth: { currentUser: { email: "test@example.com" } } as Auth, + }); + const mockSecret = { + generateQrCodeUrl: vi.fn().mockReturnValue("otpauth://totp/test@example.com?secret=ABC123&issuer=TestApp"), + } as unknown as TotpSecret; + + const result = generateTotpQrCode(mockUI, mockSecret); + + expect(mockSecret.generateQrCodeUrl).toHaveBeenCalledWith("test@example.com", undefined); + expect(result).toMatch(/^data:image\/gif;base64,/); + }); + + it("should generate QR code with custom account name and issuer", () => { + const mockUI = createMockUI({ + auth: { currentUser: { email: "test@example.com" } } as Auth, + }); + const mockSecret = { + generateQrCodeUrl: vi.fn().mockReturnValue("otpauth://totp/CustomAccount?secret=ABC123&issuer=CustomIssuer"), + } as unknown as TotpSecret; + + const result = generateTotpQrCode(mockUI, mockSecret, "CustomAccount", "CustomIssuer"); + + expect(mockSecret.generateQrCodeUrl).toHaveBeenCalledWith("CustomAccount", "CustomIssuer"); + expect(result).toMatch(/^data:image\/gif;base64,/); + }); + + it("should use user email as account name when no custom account name provided", () => { + const mockUI = createMockUI({ + auth: { currentUser: { email: "user@example.com" } } as Auth, + }); + const mockSecret = { + generateQrCodeUrl: vi.fn().mockReturnValue("otpauth://totp/user@example.com?secret=ABC123"), + } as unknown as TotpSecret; + + generateTotpQrCode(mockUI, mockSecret); + + expect(mockSecret.generateQrCodeUrl).toHaveBeenCalledWith("user@example.com", undefined); + }); + + it("should use empty string as account name when user has no email", () => { + const mockUI = createMockUI({ + auth: { currentUser: { email: null } } as Auth, + }); + const mockSecret = { + generateQrCodeUrl: vi.fn().mockReturnValue("otpauth://totp/?secret=ABC123"), + } as unknown as TotpSecret; + + generateTotpQrCode(mockUI, mockSecret); + + expect(mockSecret.generateQrCodeUrl).toHaveBeenCalledWith("", undefined); + }); + + it("should throw error when user is not authenticated", () => { + const mockUI = createMockUI({ + auth: { currentUser: null } as Auth, + }); + const mockSecret = { + generateQrCodeUrl: vi.fn(), + } as unknown as TotpSecret; + + expect(() => generateTotpQrCode(mockUI, mockSecret)).toThrow( + "User must be authenticated to generate a TOTP QR code" + ); + expect(mockSecret.generateQrCodeUrl).not.toHaveBeenCalled(); + }); + + it("should throw error when currentUser is undefined", () => { + const mockUI = createMockUI({ + auth: { currentUser: undefined } as unknown as Auth, + }); + const mockSecret = { + generateQrCodeUrl: vi.fn(), + } as unknown as TotpSecret; + + expect(() => generateTotpQrCode(mockUI, mockSecret)).toThrow( + "User must be authenticated to generate a TOTP QR code" + ); + expect(mockSecret.generateQrCodeUrl).not.toHaveBeenCalled(); + }); +}); + +describe("completeEmailLinkSignIn", () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(window, "location", { + value: { href: "https://example.com/auth?oobCode=abc123" }, + writable: true, + }); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + it("should return null when URL is not an email link", async () => { + const mockUI = createMockUI(); + const currentUrl = "https://example.com/not-email-link"; + + vi.mocked(_isSignInWithEmailLink).mockReturnValue(false); + + const result = await completeEmailLinkSignIn(mockUI, currentUrl); + + expect(_isSignInWithEmailLink).toHaveBeenCalledWith(mockUI.auth, currentUrl); + expect(result).toBeNull(); + }); + + it("should return null when no email is stored in localStorage", async () => { + const mockUI = createMockUI(); + const currentUrl = "https://example.com/auth?oobCode=abc123"; + + vi.mocked(_isSignInWithEmailLink).mockReturnValue(true); + + const result = await completeEmailLinkSignIn(mockUI, currentUrl); + + expect(_isSignInWithEmailLink).toHaveBeenCalledWith(mockUI.auth, currentUrl); + expect(result).toBeNull(); + }); + + it("should complete email link sign-in with no behavior", async () => { + const mockUI = createMockUI(); + const currentUrl = "https://example.com/auth?oobCode=abc123"; + const email = "test@example.com"; + const mockCredential = { providerId: "emailLink" } as UserCredential; + const emailLinkCredential = { providerId: "emailLink" } as any; + + vi.mocked(_isSignInWithEmailLink).mockReturnValue(true); + window.localStorage.setItem("emailForSignIn", email); + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(emailLinkCredential); + vi.mocked(_signInWithCredential).mockResolvedValue(mockCredential); + + const result = await completeEmailLinkSignIn(mockUI, currentUrl); + + expect(_isSignInWithEmailLink).toHaveBeenCalledWith(mockUI.auth, currentUrl); + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, currentUrl); + expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, emailLinkCredential); + expect(result).toBe(mockCredential); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + expect(window.localStorage.getItem("emailForSignIn")).toBeNull(); + }); + + it("should call autoUpgradeAnonymousCredential behavior when enabled and return result", async () => { + const mockUI = createMockUI(); + const currentUrl = "https://example.com/auth?oobCode=abc123"; + const email = "test@example.com"; + const emailLinkCredential = { providerId: "emailLink" } as any; + const mockResult = { providerId: "upgraded" } as UserCredential; + + vi.mocked(_isSignInWithEmailLink).mockReturnValue(true); + window.localStorage.setItem("emailForSignIn", email); + vi.mocked(hasBehavior).mockReturnValue(true); + vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(emailLinkCredential); + const mockBehavior = vi.fn().mockResolvedValue(mockResult); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + const result = await completeEmailLinkSignIn(mockUI, currentUrl); + + expect(_isSignInWithEmailLink).toHaveBeenCalledWith(mockUI.auth, currentUrl); + // Behavior is checked by signInWithCredential (called via signInWithEmailLink) + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, currentUrl); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(mockBehavior).toHaveBeenCalledWith(mockUI, emailLinkCredential); + expect(result).toBe(mockResult); + // State is managed by signInWithCredential + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + expect(window.localStorage.getItem("emailForSignIn")).toBeNull(); + }); + + it("should fall back to _signInWithCredential when autoUpgradeAnonymousCredential behavior returns undefined", async () => { + const mockUI = createMockUI(); + const currentUrl = "https://example.com/auth?oobCode=abc123"; + const email = "test@example.com"; + const emailLinkCredential = { providerId: "emailLink" } as any; + const mockResult = { providerId: "emailLink" } as UserCredential; + + vi.mocked(_isSignInWithEmailLink).mockReturnValue(true); + window.localStorage.setItem("emailForSignIn", email); + vi.mocked(hasBehavior).mockReturnValue(true); + vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(emailLinkCredential); + const mockBehavior = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + vi.mocked(_signInWithCredential).mockResolvedValue(mockResult); + + const result = await completeEmailLinkSignIn(mockUI, currentUrl); + + expect(_isSignInWithEmailLink).toHaveBeenCalledWith(mockUI.auth, currentUrl); + // Behavior is checked by signInWithCredential (called via signInWithEmailLink) + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, currentUrl); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(mockBehavior).toHaveBeenCalledWith(mockUI, emailLinkCredential); + expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, emailLinkCredential); + expect(result).toBe(mockResult); + // State is managed by signInWithCredential + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + expect(window.localStorage.getItem("emailForSignIn")).toBeNull(); + }); + + it("should propagate error from signInWithEmailLink", async () => { + const mockUI = createMockUI(); + const currentUrl = "https://example.com/auth?oobCode=abc123"; + const email = "test@example.com"; + const error = new FirebaseError("auth/invalid-action-code", "Invalid action code"); + const emailLinkCredential = { providerId: "emailLink" } as any; + + vi.mocked(_isSignInWithEmailLink).mockReturnValue(true); + window.localStorage.setItem("emailForSignIn", email); + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(emailLinkCredential); + vi.mocked(_signInWithCredential).mockRejectedValue(error); + // handleFirebaseError throws, so we need to catch it + vi.mocked(handleFirebaseError).mockImplementation(() => { + throw new Error("Handled error"); + }); + + await expect(completeEmailLinkSignIn(mockUI, currentUrl)).rejects.toThrow("Handled error"); + + // Error is handled by signInWithCredential (called via signInWithEmailLink) + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + // State is managed by signInWithCredential + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + expect(window.localStorage.getItem("emailForSignIn")).toBeNull(); + }); + + it("should propagate error when autoUpgradeAnonymousCredential behavior throws", async () => { + const mockUI = createMockUI(); + const currentUrl = "https://example.com/auth?oobCode=abc123"; + const email = "test@example.com"; + const emailLinkCredential = { providerId: "emailLink" } as any; + const error = new Error("Behavior error"); + + vi.mocked(_isSignInWithEmailLink).mockReturnValue(true); + window.localStorage.setItem("emailForSignIn", email); + vi.mocked(hasBehavior).mockReturnValue(true); + vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(emailLinkCredential); + const mockBehavior = vi.fn().mockRejectedValue(error); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + // handleFirebaseError throws, so we need to catch it + vi.mocked(handleFirebaseError).mockImplementation(() => { + throw new Error("Handled error"); + }); + + await expect(completeEmailLinkSignIn(mockUI, currentUrl)).rejects.toThrow("Handled error"); + + // Error is handled by signInWithCredential (called via signInWithEmailLink) + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + // State is managed by signInWithCredential + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + expect(window.localStorage.getItem("emailForSignIn")).toBeNull(); + }); + + it("should clear email from localStorage even when error occurs", async () => { + const mockUI = createMockUI(); + const currentUrl = "https://example.com/auth?oobCode=abc123"; + const email = "test@example.com"; + const error = new FirebaseError("auth/invalid-action-code", "Invalid action code"); + const emailLinkCredential = { providerId: "emailLink" } as any; + + vi.mocked(_isSignInWithEmailLink).mockReturnValue(true); + window.localStorage.setItem("emailForSignIn", email); + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(emailLinkCredential); + vi.mocked(_signInWithCredential).mockRejectedValue(error); + // handleFirebaseError throws, but finally block should still run + vi.mocked(handleFirebaseError).mockImplementation(() => { + throw new Error("Handled error"); + }); + + await expect(completeEmailLinkSignIn(mockUI, currentUrl)).rejects.toThrow("Handled error"); + + // finally block should still clean up localStorage + expect(window.localStorage.getItem("emailForSignIn")).toBeNull(); + }); + + it("should clear email from localStorage even when URL is not an email link", async () => { + const mockUI = createMockUI(); + const currentUrl = "https://example.com/not-email-link"; + const email = "test@example.com"; + + vi.mocked(_isSignInWithEmailLink).mockReturnValue(false); + window.localStorage.setItem("emailForSignIn", email); + + await completeEmailLinkSignIn(mockUI, currentUrl); + + expect(window.localStorage.getItem("emailForSignIn")).toBeNull(); + }); + + it("should not clear email from localStorage when no email is stored", async () => { + const mockUI = createMockUI(); + const currentUrl = "https://example.com/auth?oobCode=abc123"; + + vi.mocked(_isSignInWithEmailLink).mockReturnValue(true); + + await completeEmailLinkSignIn(mockUI, currentUrl); + + expect(window.localStorage.getItem("emailForSignIn")).toBeNull(); + }); +}); diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts new file mode 100644 index 000000000..60b0bf08f --- /dev/null +++ b/packages/core/src/auth.ts @@ -0,0 +1,524 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createUserWithEmailAndPassword as _createUserWithEmailAndPassword, + isSignInWithEmailLink as _isSignInWithEmailLink, + sendPasswordResetEmail as _sendPasswordResetEmail, + sendSignInLinkToEmail as _sendSignInLinkToEmail, + signInAnonymously as _signInAnonymously, + signInWithCredential as _signInWithCredential, + signInWithCustomToken as _signInWithCustomToken, + EmailAuthProvider, + linkWithCredential, + PhoneAuthProvider, + TotpMultiFactorGenerator, + multiFactor, + type ActionCodeSettings, + type ApplicationVerifier, + type AuthProvider, + type UserCredential, + type AuthCredential, + type TotpSecret, + type MultiFactorAssertion, + type MultiFactorUser, + type MultiFactorInfo, +} from "firebase/auth"; +import QRCode from "qrcode-generator"; +import { type FirebaseUI } from "./config"; +import { handleFirebaseError } from "./errors"; +import { hasBehavior, getBehavior } from "./behaviors/index"; +import { FirebaseError } from "firebase/app"; +import { getTranslation } from "./translations"; + +async function handlePendingCredential(_ui: FirebaseUI, user: UserCredential): Promise { + const pendingCredString = window.sessionStorage.getItem("pendingCred"); + if (!pendingCredString) return user; + + try { + const pendingCred = JSON.parse(pendingCredString); + const result = await linkWithCredential(user.user, pendingCred); + window.sessionStorage.removeItem("pendingCred"); + return result; + } catch { + window.sessionStorage.removeItem("pendingCred"); + return user; + } +} + +function setPendingState(ui: FirebaseUI) { + ui.setRedirectError(undefined); + ui.setState("pending"); +} + +/** + * Signs in with an email and password. + * + * If the `autoUpgradeAnonymousUsers` behavior is enabled, it will attempt to upgrade an anonymous user to a regular user. + * + * @param ui - The FirebaseUI instance. + * @param email - The email to sign in with. + * @param password - The password to sign in with. + * @returns {Promise} A promise containing the user credential. + */ +export async function signInWithEmailAndPassword( + ui: FirebaseUI, + email: string, + password: string +): Promise { + try { + setPendingState(ui); + const credential = EmailAuthProvider.credential(email, password); + + if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) { + const result = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential); + + if (result) { + return handlePendingCredential(ui, result); + } + } + + const result = await _signInWithCredential(ui.auth, credential); + return handlePendingCredential(ui, result); + } catch (error) { + handleFirebaseError(ui, error); + } finally { + ui.setState("idle"); + } +} + +/** + * Creates a new user account with an email and password. + * + * If the `requireDisplayName` behavior is enabled, a display name must be provided. + * If the `autoUpgradeAnonymousUsers` behavior is enabled, it will attempt to upgrade an anonymous user to a regular user. + * + * @param ui - The FirebaseUI instance. + * @param email - The email address for the new account. + * @param password - The password for the new account. + * @param displayName - Optional display name for the user. + * @returns {Promise} A promise containing the user credential. + */ +export async function createUserWithEmailAndPassword( + ui: FirebaseUI, + email: string, + password: string, + displayName?: string +): Promise { + try { + setPendingState(ui); + const credential = EmailAuthProvider.credential(email, password); + + if (hasBehavior(ui, "requireDisplayName") && !displayName) { + throw new FirebaseError("auth/display-name-required", getTranslation(ui, "errors", "displayNameRequired")); + } + + if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) { + const result = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential); + + if (result) { + if (hasBehavior(ui, "requireDisplayName")) { + await getBehavior(ui, "requireDisplayName")(ui, result.user, displayName!); + } + + return handlePendingCredential(ui, result); + } + } + + const result = await _createUserWithEmailAndPassword(ui.auth, email, password); + + if (hasBehavior(ui, "requireDisplayName")) { + await getBehavior(ui, "requireDisplayName")(ui, result.user, displayName!); + } + + return handlePendingCredential(ui, result); + } catch (error) { + handleFirebaseError(ui, error); + } finally { + ui.setState("idle"); + } +} + +/** + * Verifies a phone number for authentication. + * + * Supports regular phone authentication, MFA enrollment, and MFA assertion flows. + * + * @param ui - The FirebaseUI instance. + * @param phoneNumber - The phone number to verify. + * @param appVerifier - The application verifier (reCAPTCHA). + * @param mfaUser - Optional multi-factor user for MFA enrollment flow. + * @param mfaHint - Optional multi-factor info hint for MFA assertion flow. + * @returns {Promise} A promise containing the verification ID. + */ +export async function verifyPhoneNumber( + ui: FirebaseUI, + phoneNumber: string, + appVerifier: ApplicationVerifier, + mfaUser?: MultiFactorUser, + mfaHint?: MultiFactorInfo +): Promise { + try { + setPendingState(ui); + const provider = new PhoneAuthProvider(ui.auth); + + if (mfaHint && ui.multiFactorResolver) { + // MFA assertion flow + return await provider.verifyPhoneNumber( + { + multiFactorHint: mfaHint, + session: ui.multiFactorResolver.session, + }, + appVerifier + ); + } else if (mfaUser) { + // MFA enrollment flow + const session = await mfaUser.getSession(); + return await provider.verifyPhoneNumber( + { + phoneNumber, + session, + }, + appVerifier + ); + } else { + // Regular phone auth flow + return await provider.verifyPhoneNumber(phoneNumber, appVerifier); + } + } catch (error) { + handleFirebaseError(ui, error); + } finally { + ui.setState("idle"); + } +} + +/** + * Confirms a phone number verification code and signs in the user. + * + * If the `autoUpgradeAnonymousUsers` behavior is enabled and the current user is anonymous, it will attempt to upgrade the anonymous user to a regular user. + * + * @param ui - The FirebaseUI instance. + * @param verificationId - The verification ID from the phone verification process. + * @param verificationCode - The verification code sent to the phone. + * @returns {Promise} A promise containing the user credential. + */ +export async function confirmPhoneNumber( + ui: FirebaseUI, + verificationId: string, + verificationCode: string +): Promise { + try { + setPendingState(ui); + const currentUser = ui.auth.currentUser; + const credential = PhoneAuthProvider.credential(verificationId, verificationCode); + + if (currentUser?.isAnonymous && hasBehavior(ui, "autoUpgradeAnonymousCredential")) { + const result = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential); + + if (result) { + return handlePendingCredential(ui, result); + } + } + + const result = await _signInWithCredential(ui.auth, credential); + return handlePendingCredential(ui, result); + } catch (error) { + handleFirebaseError(ui, error); + } finally { + ui.setState("idle"); + } +} + +/** + * Sends a password reset email to the specified email address. + * + * @param ui - The FirebaseUI instance. + * @param email - The email address to send the password reset email to. + * @returns {Promise} A promise that resolves when the email is sent. + */ +export async function sendPasswordResetEmail(ui: FirebaseUI, email: string): Promise { + try { + setPendingState(ui); + await _sendPasswordResetEmail(ui.auth, email); + } catch (error) { + handleFirebaseError(ui, error); + } finally { + ui.setState("idle"); + } +} + +/** + * Sends a sign-in link to the specified email address. + * + * The email address is stored in localStorage for later use during the sign-in process. + * + * @param ui - The FirebaseUI instance. + * @param email - The email address to send the sign-in link to. + * @returns {Promise} A promise that resolves when the email is sent. + */ +export async function sendSignInLinkToEmail(ui: FirebaseUI, email: string): Promise { + try { + setPendingState(ui); + const actionCodeSettings = { + url: window.location.href, + // TODO(ehesp): Check this... + handleCodeInApp: true, + } satisfies ActionCodeSettings; + + await _sendSignInLinkToEmail(ui.auth, email, actionCodeSettings); + // TODO: Should this be a behavior ("storageStrategy")? + window.localStorage.setItem("emailForSignIn", email); + } catch (error) { + handleFirebaseError(ui, error); + } finally { + ui.setState("idle"); + } +} + +/** + * Signs in a user using an email link. + * + * @param ui - The FirebaseUI instance. + * @param email - The email address associated with the sign-in link. + * @param link - The sign-in link from the email. + * @returns {Promise} A promise containing the user credential. + */ +export async function signInWithEmailLink(ui: FirebaseUI, email: string, link: string): Promise { + const credential = EmailAuthProvider.credentialWithLink(email, link); + return signInWithCredential(ui, credential); +} + +/** + * Signs in a user with an authentication credential. + * + * If the `autoUpgradeAnonymousUsers` behavior is enabled, it will attempt to upgrade an anonymous user to a regular user. + * + * @param ui - The FirebaseUI instance. + * @param credential - The authentication credential to sign in with. + * @returns {Promise} A promise containing the user credential. + */ +export async function signInWithCredential(ui: FirebaseUI, credential: AuthCredential): Promise { + try { + setPendingState(ui); + if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) { + const userCredential = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential); + + // If they got here, they're either not anonymous or they've been linked. + // If the credential has been linked, we don't need to sign them in, so return early. + if (userCredential) { + return handlePendingCredential(ui, userCredential); + } + } + + const result = await _signInWithCredential(ui.auth, credential); + return handlePendingCredential(ui, result); + } catch (error) { + handleFirebaseError(ui, error); + } finally { + ui.setState("idle"); + } +} + +/** + * Signs in a user with a custom token. + * + * @param ui - The FirebaseUI instance. + * @param customToken - The custom token to sign in with. + * @returns {Promise} A promise containing the user credential. + */ +export async function signInWithCustomToken(ui: FirebaseUI, customToken: string): Promise { + try { + setPendingState(ui); + const result = await _signInWithCustomToken(ui.auth, customToken); + return handlePendingCredential(ui, result); + } catch (error) { + handleFirebaseError(ui, error); + } finally { + ui.setState("idle"); + } +} + +/** + * Signs in a user anonymously. + * + * @param ui - The FirebaseUI instance. + * @returns {Promise} A promise containing the user credential. + */ +export async function signInAnonymously(ui: FirebaseUI): Promise { + try { + setPendingState(ui); + const result = await _signInAnonymously(ui.auth); + return handlePendingCredential(ui, result); + } catch (error) { + handleFirebaseError(ui, error); + } finally { + ui.setState("idle"); + } +} + +/** + * Signs in a user with an authentication provider (e.g., Google, Facebook, etc.). + * + * If the `autoUpgradeAnonymousProvider` behavior is enabled, it will attempt to upgrade an anonymous user to a regular user. + * The sign-in strategy (popup or redirect) is determined by the `providerSignInStrategy` behavior. + * + * @param ui - The FirebaseUI instance. + * @param provider - The authentication provider to sign in with. + * @returns {Promise} A promise containing the user credential, or never if using redirect strategy. + */ +export async function signInWithProvider(ui: FirebaseUI, provider: AuthProvider): Promise { + try { + setPendingState(ui); + if (hasBehavior(ui, "autoUpgradeAnonymousProvider")) { + const credential = await getBehavior(ui, "autoUpgradeAnonymousProvider")(ui, provider); + + // If we got here, the user is either not anonymous, or they have been linked + // via a popup, and the credential has been created. + if (credential) { + return handlePendingCredential(ui, credential); + } + } + + const strategy = getBehavior(ui, "providerSignInStrategy"); + const result = await strategy(ui, provider); + + // If we got here, the user has been signed in via a popup. + // Otherwise, they will have been redirected. + return handlePendingCredential(ui, result); + } catch (error) { + handleFirebaseError(ui, error); + } finally { + ui.setState("idle"); + } +} + +/** + * Completes the email link sign-in process using the current URL. + * + * Checks if the current URL is a valid email link sign-in URL and retrieves the email from localStorage. + * Returns null if the URL is not a valid email link or if no email is found in localStorage. + * + * @param ui - The FirebaseUI instance. + * @param currentUrl - The current URL to check for email link sign-in. + * @returns {Promise} A promise containing the user credential, or null if the sign-in cannot be completed. + */ +export async function completeEmailLinkSignIn(ui: FirebaseUI, currentUrl: string): Promise { + try { + if (!_isSignInWithEmailLink(ui.auth, currentUrl)) { + return null; + } + + const email = window.localStorage.getItem("emailForSignIn"); + if (!email) return null; + + // signInWithEmailLink handles behavior checks, credential creation, and error handling + const result = await signInWithEmailLink(ui, email, currentUrl); + return handlePendingCredential(ui, result); + } finally { + window.localStorage.removeItem("emailForSignIn"); + } +} + +/** + * Generates a QR code data URL for TOTP (Time-based One-Time Password) multi-factor authentication. + * + * The QR code can be scanned by an authenticator app to set up TOTP MFA for the user. + * + * @param ui - The FirebaseUI instance. + * @param secret - The TOTP secret to generate the QR code for. + * @param accountName - Optional account name for the QR code. Defaults to the user's email if not provided. + * @param issuer - Optional issuer name for the QR code. + * @returns {string} A data URL containing the QR code image. + * @throws {Error} Throws an error if the user is not authenticated. + */ +export function generateTotpQrCode(ui: FirebaseUI, secret: TotpSecret, accountName?: string, issuer?: string): string { + const currentUser = ui.auth.currentUser; + + if (!currentUser) { + throw new Error("User must be authenticated to generate a TOTP QR code"); + } + + const uri = secret.generateQrCodeUrl(accountName || currentUser.email || "", issuer); + + const qr = QRCode(0, "L"); + qr.addData(uri); + qr.make(); + return qr.createDataURL(); +} + +/** + * Signs in a user using a multi-factor assertion. + * + * Resolves the multi-factor challenge using the provided assertion and clears the multi-factor resolver from the UI state. + * + * @param ui - The FirebaseUI instance. + * @param assertion - The multi-factor assertion to use for sign-in. + * @returns {Promise} A promise containing the user credential. + */ +export async function signInWithMultiFactorAssertion(ui: FirebaseUI, assertion: MultiFactorAssertion) { + try { + setPendingState(ui); + const result = await ui.multiFactorResolver!.resolveSignIn(assertion); + ui.setMultiFactorResolver(undefined); + return result; + } catch (error) { + handleFirebaseError(ui, error); + } finally { + ui.setState("idle"); + } +} + +/** + * Enrolls a multi-factor authentication method for the current user. + * + * @param ui - The FirebaseUI instance. + * @param assertion - The multi-factor assertion to enroll. + * @param displayName - Optional display name for the enrolled MFA method. + * @returns {Promise} A promise that resolves when the enrollment is complete. + */ +export async function enrollWithMultiFactorAssertion( + ui: FirebaseUI, + assertion: MultiFactorAssertion, + displayName?: string +): Promise { + try { + setPendingState(ui); + await multiFactor(ui.auth.currentUser!).enroll(assertion, displayName); + } catch (error) { + handleFirebaseError(ui, error); + } finally { + ui.setState("idle"); + } +} + +/** + * Generates a TOTP (Time-based One-Time Password) secret for multi-factor authentication enrollment. + * + * @param ui - The FirebaseUI instance. + * @returns {Promise} A promise containing the TOTP secret. + */ +export async function generateTotpSecret(ui: FirebaseUI): Promise { + try { + setPendingState(ui); + const mfaUser = multiFactor(ui.auth.currentUser!); + const session = await mfaUser.getSession(); + return await TotpMultiFactorGenerator.generateSecret(session); + } catch (error) { + handleFirebaseError(ui, error); + } finally { + ui.setState("idle"); + } +} diff --git a/packages/core/src/behaviors/anonymous-upgrade.test.ts b/packages/core/src/behaviors/anonymous-upgrade.test.ts new file mode 100644 index 000000000..e551a25ad --- /dev/null +++ b/packages/core/src/behaviors/anonymous-upgrade.test.ts @@ -0,0 +1,279 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + Auth, + AuthCredential, + AuthProvider, + linkWithCredential, + linkWithRedirect, + User, + UserCredential, +} from "firebase/auth"; +import { + autoUpgradeAnonymousCredentialHandler, + autoUpgradeAnonymousProviderHandler, + autoUpgradeAnonymousUserRedirectHandler, + OnUpgradeCallback, +} from "./anonymous-upgrade"; +import { createMockUI } from "~/tests/utils"; +import { getBehavior } from "~/behaviors"; + +vi.mock("firebase/auth", () => ({ + linkWithCredential: vi.fn(), + linkWithRedirect: vi.fn(), +})); + +vi.mock("~/behaviors", () => ({ + getBehavior: vi.fn(), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("autoUpgradeAnonymousCredentialHandler", () => { + it("should upgrade anonymous user with credential", async () => { + const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockCredential = { providerId: "password" } as AuthCredential; + + const mockResult = { user: { uid: "upgraded-123" } }; + vi.mocked(linkWithCredential).mockResolvedValue(mockResult as any); + + const result = await autoUpgradeAnonymousCredentialHandler(mockUI, mockCredential); + + expect(linkWithCredential).toHaveBeenCalledWith(mockUser, mockCredential); + expect(result).toBe(mockResult); + }); + + it("should call onUpgrade callback when provided", async () => { + const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockCredential = { providerId: "password" } as AuthCredential; + const mockResult = { user: { uid: "upgraded-123" } } as UserCredential; + + vi.mocked(linkWithCredential).mockResolvedValue(mockResult); + + const onUpgrade = vi.fn().mockResolvedValue(undefined); + + const result = await autoUpgradeAnonymousCredentialHandler(mockUI, mockCredential, onUpgrade); + + expect(onUpgrade).toHaveBeenCalledWith(mockUI, "anonymous-123", mockResult); + expect(result).toBe(mockResult); + }); + + it("should handle onUpgrade callback errors", async () => { + const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockCredential = { providerId: "password" } as AuthCredential; + const mockResult = { user: { uid: "upgraded-123" } } as UserCredential; + + vi.mocked(linkWithCredential).mockResolvedValue(mockResult); + + const onUpgrade = vi.fn().mockRejectedValue(new Error("Callback error")); + + await expect(autoUpgradeAnonymousCredentialHandler(mockUI, mockCredential, onUpgrade)).rejects.toThrow( + "Callback error" + ); + }); + + it("should not upgrade when user is not anonymous", async () => { + const mockUser = { isAnonymous: false, uid: "regular-user-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockCredential = { providerId: "password" } as AuthCredential; + + const result = await autoUpgradeAnonymousCredentialHandler(mockUI, mockCredential); + + expect(linkWithCredential).not.toHaveBeenCalled(); + expect(mockUI.setState).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it("should not upgrade when no current user", async () => { + const mockAuth = { currentUser: null } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockCredential = { providerId: "password" } as AuthCredential; + + const result = await autoUpgradeAnonymousCredentialHandler(mockUI, mockCredential); + + expect(linkWithCredential).not.toHaveBeenCalled(); + expect(mockUI.setState).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); +}); + +describe("autoUpgradeAnonymousProviderHandler", () => { + it("should upgrade anonymous user with provider", async () => { + const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockProvider = { providerId: "google.com" } as AuthProvider; + const mockResult = { user: { uid: "upgraded-123" } } as UserCredential; + + const mockProviderLinkStrategy = vi.fn().mockResolvedValue(mockResult); + vi.mocked(getBehavior).mockReturnValue(mockProviderLinkStrategy); + + const localStorageSpy = vi.spyOn(Storage.prototype, "setItem"); + const localStorageRemoveSpy = vi.spyOn(Storage.prototype, "removeItem"); + + const result = await autoUpgradeAnonymousProviderHandler(mockUI, mockProvider); + + expect(getBehavior).toHaveBeenCalledWith(mockUI, "providerLinkStrategy"); + expect(mockProviderLinkStrategy).toHaveBeenCalledWith(mockUI, mockUser, mockProvider); + expect(localStorageSpy).toHaveBeenCalledWith("fbui:upgrade:oldUserId", "anonymous-123"); + expect(localStorageRemoveSpy).toHaveBeenCalledWith("fbui:upgrade:oldUserId"); + expect(result).toBe(mockResult); + }); + + it("should call onUpgrade callback when provided", async () => { + const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockProvider = { providerId: "google.com" } as AuthProvider; + const mockResult = { user: { uid: "upgraded-123" } } as UserCredential; + + const mockProviderLinkStrategy = vi.fn().mockResolvedValue(mockResult); + vi.mocked(getBehavior).mockReturnValue(mockProviderLinkStrategy); + + const onUpgrade = vi.fn().mockResolvedValue(undefined); + + const result = await autoUpgradeAnonymousProviderHandler(mockUI, mockProvider, onUpgrade); + + expect(onUpgrade).toHaveBeenCalledWith(mockUI, "anonymous-123", mockResult); + expect(result).toBe(mockResult); + }); + + it("should handle onUpgrade callback errors", async () => { + const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockProvider = { providerId: "google.com" } as AuthProvider; + const mockResult = { user: { uid: "upgraded-123" } } as UserCredential; + + const mockProviderLinkStrategy = vi.fn().mockResolvedValue(mockResult); + vi.mocked(getBehavior).mockReturnValue(mockProviderLinkStrategy); + + const onUpgrade = vi.fn().mockRejectedValue(new Error("Callback error")); + + await expect(autoUpgradeAnonymousProviderHandler(mockUI, mockProvider, onUpgrade)).rejects.toThrow( + "Callback error" + ); + }); + + it("should not upgrade when user is not anonymous", async () => { + const mockUser = { isAnonymous: false, uid: "regular-user-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockProvider = { providerId: "google.com" } as AuthProvider; + + await autoUpgradeAnonymousProviderHandler(mockUI, mockProvider); + + expect(linkWithRedirect).not.toHaveBeenCalled(); + expect(mockUI.setState).not.toHaveBeenCalled(); + }); + + it("should not upgrade when no current user", async () => { + const mockAuth = { currentUser: null } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockProvider = { providerId: "google.com" } as AuthProvider; + + await autoUpgradeAnonymousProviderHandler(mockUI, mockProvider); + + expect(linkWithRedirect).not.toHaveBeenCalled(); + expect(mockUI.setState).not.toHaveBeenCalled(); + }); +}); + +describe("autoUpgradeAnonymousUserRedirectHandler", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("should call onUpgrade callback when oldUserId exists in localStorage", async () => { + const mockUI = createMockUI(); + const mockCredential = { user: { uid: "upgraded-123" } } as UserCredential; + const oldUserId = "anonymous-123"; + + window.localStorage.setItem("fbui:upgrade:oldUserId", oldUserId); + + const onUpgrade = vi.fn().mockResolvedValue(undefined); + + await autoUpgradeAnonymousUserRedirectHandler(mockUI, mockCredential, onUpgrade); + + expect(onUpgrade).toHaveBeenCalledWith(mockUI, oldUserId, mockCredential); + expect(window.localStorage.getItem("fbui:upgrade:oldUserId")).toBeNull(); + }); + + it("should not call onUpgrade callback when no oldUserId in localStorage", async () => { + const mockUI = createMockUI(); + const mockCredential = { user: { uid: "upgraded-123" } } as UserCredential; + + const onUpgrade = vi.fn().mockResolvedValue(undefined); + + await autoUpgradeAnonymousUserRedirectHandler(mockUI, mockCredential, onUpgrade); + + expect(onUpgrade).not.toHaveBeenCalled(); + }); + + it("should not call onUpgrade callback when no credential provided", async () => { + const mockUI = createMockUI(); + const oldUserId = "anonymous-123"; + + window.localStorage.setItem("fbui:upgrade:oldUserId", oldUserId); + + const onUpgrade = vi.fn().mockResolvedValue(undefined); + + await autoUpgradeAnonymousUserRedirectHandler(mockUI, null, onUpgrade); + + expect(onUpgrade).not.toHaveBeenCalled(); + }); + + it("should not call onUpgrade callback when no onUpgrade callback provided", async () => { + const mockUI = createMockUI(); + const mockCredential = { user: { uid: "upgraded-123" } } as UserCredential; + const oldUserId = "anonymous-123"; + + window.localStorage.setItem("fbui:upgrade:oldUserId", oldUserId); + + await autoUpgradeAnonymousUserRedirectHandler(mockUI, mockCredential); + + // Should not throw and should clean up localStorage even when no callback provided + expect(window.localStorage.getItem("fbui:upgrade:oldUserId")).toBeNull(); + }); + + it("should handle onUpgrade callback errors", async () => { + const mockUI = createMockUI(); + const mockCredential = { user: { uid: "upgraded-123" } } as UserCredential; + const oldUserId = "anonymous-123"; + + window.localStorage.setItem("fbui:upgrade:oldUserId", oldUserId); + + const onUpgrade = vi.fn().mockRejectedValue(new Error("Callback error")); + + await expect(autoUpgradeAnonymousUserRedirectHandler(mockUI, mockCredential, onUpgrade)).rejects.toThrow( + "Callback error" + ); + + // Should clean up localStorage even when callback throws error + expect(window.localStorage.getItem("fbui:upgrade:oldUserId")).toBeNull(); + }); +}); diff --git a/packages/core/src/behaviors/anonymous-upgrade.ts b/packages/core/src/behaviors/anonymous-upgrade.ts new file mode 100644 index 000000000..1f93fc311 --- /dev/null +++ b/packages/core/src/behaviors/anonymous-upgrade.ts @@ -0,0 +1,92 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { type AuthCredential, type AuthProvider, linkWithCredential, type UserCredential } from "firebase/auth"; +import { type FirebaseUI } from "~/config"; +import { getBehavior } from "~/behaviors"; + +export type OnUpgradeCallback = (ui: FirebaseUI, oldUserId: string, credential: UserCredential) => Promise | void; + +export const autoUpgradeAnonymousCredentialHandler = async ( + ui: FirebaseUI, + credential: AuthCredential, + onUpgrade?: OnUpgradeCallback +) => { + const currentUser = ui.auth.currentUser; + + if (!currentUser?.isAnonymous) { + return; + } + + const oldUserId = currentUser.uid; + + const result = await linkWithCredential(currentUser, credential); + + if (onUpgrade) { + await onUpgrade(ui, oldUserId, result); + } + + return result; +}; + +export const autoUpgradeAnonymousProviderHandler = async ( + ui: FirebaseUI, + provider: AuthProvider, + onUpgrade?: OnUpgradeCallback +) => { + const currentUser = ui.auth.currentUser; + + if (!currentUser?.isAnonymous) { + return; + } + + const oldUserId = currentUser.uid; + + window.localStorage.setItem("fbui:upgrade:oldUserId", oldUserId); + + const result = await getBehavior(ui, "providerLinkStrategy")(ui, currentUser, provider); + + // If we got here, the user has been linked via a popup, so we need to call the onUpgrade callback + // and delete the oldUserId from localStorage. + // If we didn't get here, they'll be redirected and we'll handle the result inside of the autoUpgradeAnonymousUserRedirectHandler. + + window.localStorage.removeItem("fbui:upgrade:oldUserId"); + + if (onUpgrade) { + await onUpgrade(ui, oldUserId, result); + } + + return result; +}; + +export const autoUpgradeAnonymousUserRedirectHandler = async ( + ui: FirebaseUI, + credential: UserCredential | null, + onUpgrade?: OnUpgradeCallback +) => { + const oldUserId = window.localStorage.getItem("fbui:upgrade:oldUserId"); + + // Always clean up localStorage once we've retrieved the oldUserId + if (oldUserId) { + window.localStorage.removeItem("fbui:upgrade:oldUserId"); + } + + if (!onUpgrade || !oldUserId || !credential) { + return; + } + + await onUpgrade(ui, oldUserId, credential); +}; diff --git a/packages/core/src/behaviors/auto-anonymous-login.test.ts b/packages/core/src/behaviors/auto-anonymous-login.test.ts new file mode 100644 index 000000000..c2648a284 --- /dev/null +++ b/packages/core/src/behaviors/auto-anonymous-login.test.ts @@ -0,0 +1,48 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Auth, signInAnonymously, User } from "firebase/auth"; +import { autoAnonymousLoginHandler } from "./auto-anonymous-login"; +import { createMockUI } from "~/tests/utils"; + +vi.mock("firebase/auth", () => ({ + signInAnonymously: vi.fn(), +})); + +describe("autoAnonymousLoginHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should sign in anonymously when no current user exists", async () => { + const mockUI = createMockUI({ auth: { currentUser: null } as Auth }); + + const mockSignInResult = { user: { uid: "anonymous-123" } }; + vi.mocked(signInAnonymously).mockResolvedValue(mockSignInResult as any); + + await autoAnonymousLoginHandler(mockUI); + + expect(signInAnonymously).toHaveBeenCalledWith(mockUI.auth); + expect(signInAnonymously).toHaveBeenCalledTimes(1); + }); + + it("should not sign in when current user already exists", async () => { + const mockUI = createMockUI({ auth: { currentUser: { uid: "existing-user-123" } as User } as Auth }); + await autoAnonymousLoginHandler(mockUI); + expect(signInAnonymously).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/behaviors/auto-anonymous-login.ts b/packages/core/src/behaviors/auto-anonymous-login.ts new file mode 100644 index 000000000..f2bf23cff --- /dev/null +++ b/packages/core/src/behaviors/auto-anonymous-login.ts @@ -0,0 +1,26 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { signInAnonymously } from "firebase/auth"; +import { type InitHandler } from "./utils"; + +export const autoAnonymousLoginHandler: InitHandler = async (ui) => { + const auth = ui.auth; + + if (!auth.currentUser) { + await signInAnonymously(auth); + } +}; diff --git a/packages/core/src/behaviors/country-codes.test.ts b/packages/core/src/behaviors/country-codes.test.ts new file mode 100644 index 000000000..b3c5cce8a --- /dev/null +++ b/packages/core/src/behaviors/country-codes.test.ts @@ -0,0 +1,238 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi } from "vitest"; +import { countryCodesHandler, CountryCodesOptions } from "./country-codes"; +import { countryData } from "../country-data"; + +describe("countryCodesHandler", () => { + describe("default behavior", () => { + it("should return all countries when no options provided", () => { + const result = countryCodesHandler(); + + expect(result.allowedCountries).toEqual(countryData); + expect(result.defaultCountry).toEqual(countryData.find((country) => country.code === "US")); + }); + + it("should return all countries when empty options provided", () => { + const result = countryCodesHandler({}); + + expect(result.allowedCountries).toEqual(countryData); + expect(result.defaultCountry).toEqual(countryData.find((country) => country.code === "US")); + }); + }); + + describe("allowedCountries filtering", () => { + it("should filter countries based on allowedCountries", () => { + const options: CountryCodesOptions = { + allowedCountries: ["US", "GB", "CA"], + }; + + const result = countryCodesHandler(options); + + expect(result.allowedCountries).toHaveLength(3); + // Order is preserved from original countryData array, not from allowedCountries + expect(result.allowedCountries.map((c) => c.code)).toEqual(["CA", "GB", "US"]); + }); + + it("should handle single allowed country", () => { + const options: CountryCodesOptions = { + allowedCountries: ["US"], + }; + + const result = countryCodesHandler(options); + + expect(result.allowedCountries).toHaveLength(1); + expect(result.allowedCountries[0]!.code).toBe("US"); + }); + + it("should handle empty allowedCountries array", () => { + const options: CountryCodesOptions = { + allowedCountries: [], + }; + + const result = countryCodesHandler(options); + + expect(result.allowedCountries).toEqual(countryData); + }); + }); + + describe("defaultCountry setting", () => { + it("should set default country when provided", () => { + const options: CountryCodesOptions = { + defaultCountry: "GB", + }; + + const result = countryCodesHandler(options); + + expect(result.defaultCountry.code).toBe("GB"); + expect(result.defaultCountry.name).toBe("United Kingdom"); + }); + + it("should default to US when no defaultCountry provided", () => { + const result = countryCodesHandler(); + + expect(result.defaultCountry.code).toBe("US"); + }); + + it("should default to US when defaultCountry is undefined", () => { + const options: CountryCodesOptions = { + defaultCountry: undefined, + }; + + const result = countryCodesHandler(options); + + expect(result.defaultCountry.code).toBe("US"); + }); + }); + + describe("defaultCountry validation with allowedCountries", () => { + it("should keep defaultCountry when it's in allowedCountries", () => { + const options: CountryCodesOptions = { + allowedCountries: ["US", "GB", "CA"], + defaultCountry: "GB", + }; + + const result = countryCodesHandler(options); + + expect(result.defaultCountry.code).toBe("GB"); + expect(result.allowedCountries.map((c) => c.code)).toEqual(["CA", "GB", "US"]); + }); + + it("should override defaultCountry when it's not in allowedCountries", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const options: CountryCodesOptions = { + allowedCountries: ["US", "GB", "CA"], + defaultCountry: "FR", // France is not in allowed countries + }; + + const result = countryCodesHandler(options); + + expect(result.defaultCountry.code).toBe("CA"); // Should default to first allowed country (CA comes first in original array) + expect(result.allowedCountries.map((c) => c.code)).toEqual(["CA", "GB", "US"]); + expect(consoleSpy).toHaveBeenCalledWith( + 'The "defaultCountry" option is not in the "allowedCountries" list, the default country has been set to CA' + ); + + consoleSpy.mockRestore(); + }); + + it("should override defaultCountry to first allowed country when not in list", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const options: CountryCodesOptions = { + allowedCountries: ["GB", "CA", "AU"], // US is not in this list + defaultCountry: "US", + }; + + const result = countryCodesHandler(options); + + expect(result.defaultCountry.code).toBe("AU"); // Should default to first allowed country (AU comes first in original array) + expect(consoleSpy).toHaveBeenCalledWith( + 'The "defaultCountry" option is not in the "allowedCountries" list, the default country has been set to AU' + ); + + consoleSpy.mockRestore(); + }); + + it("should not warn when defaultCountry is in allowedCountries", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const options: CountryCodesOptions = { + allowedCountries: ["US", "GB", "CA"], + defaultCountry: "CA", + }; + + const result = countryCodesHandler(options); + + expect(result.defaultCountry.code).toBe("CA"); + expect(consoleSpy).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + }); + + describe("edge cases", () => { + it("should handle invalid country codes gracefully", () => { + const options: CountryCodesOptions = { + allowedCountries: ["US", "INVALID", "GB"] as any, + }; + + const result = countryCodesHandler(options); + + // Should only include valid countries + expect(result.allowedCountries).toHaveLength(2); + expect(result.allowedCountries.map((c) => c.code)).toEqual(["GB", "US"]); + }); + + it("should handle case sensitivity", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const options: CountryCodesOptions = { + allowedCountries: ["us", "gb"] as any, // lowercase + defaultCountry: "US", // This will trigger the validation logic + }; + + const result = countryCodesHandler(options); + + // Should fall back to all countries when no matches found + expect(result.allowedCountries).toEqual(countryData); + expect(consoleSpy).toHaveBeenCalledWith( + 'No countries matched the "allowedCountries" list, falling back to all countries' + ); + + consoleSpy.mockRestore(); + }); + + it("should handle special country codes like Kosovo", () => { + const options: CountryCodesOptions = { + allowedCountries: ["XK", "US", "GB"], + }; + + const result = countryCodesHandler(options); + + expect(result.allowedCountries.length).toBeGreaterThan(2); // Kosovo has multiple entries + expect(result.allowedCountries.some((c) => c.code === "XK")).toBe(true); + expect(result.allowedCountries.some((c) => c.code === "US")).toBe(true); + expect(result.allowedCountries.some((c) => c.code === "GB")).toBe(true); + }); + }); + + describe("return type validation", () => { + it("should return objects with correct structure", () => { + const result = countryCodesHandler(); + + expect(result).toHaveProperty("allowedCountries"); + expect(result).toHaveProperty("defaultCountry"); + expect(Array.isArray(result.allowedCountries)).toBe(true); + expect(typeof result.defaultCountry).toBe("object"); + + // Check structure of country objects + result.allowedCountries.forEach((country) => { + expect(country).toHaveProperty("name"); + expect(country).toHaveProperty("dialCode"); + expect(country).toHaveProperty("code"); + expect(country).toHaveProperty("emoji"); + }); + + expect(result.defaultCountry).toHaveProperty("name"); + expect(result.defaultCountry).toHaveProperty("dialCode"); + expect(result.defaultCountry).toHaveProperty("code"); + expect(result.defaultCountry).toHaveProperty("emoji"); + }); + }); +}); diff --git a/packages/core/src/behaviors/country-codes.ts b/packages/core/src/behaviors/country-codes.ts new file mode 100644 index 000000000..a4965f621 --- /dev/null +++ b/packages/core/src/behaviors/country-codes.ts @@ -0,0 +1,57 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { type CountryCode, countryData } from "../country-data"; + +export type CountryCodesOptions = { + // The allowed countries are the countries that will be shown in the country selector + // or `getCountries` is called. + allowedCountries?: CountryCode[]; + // The default country is the country that will be selected by default when + // the country selector is rendered, or `getDefaultCountry` is called. + defaultCountry?: CountryCode; +}; + +export const countryCodesHandler = (options?: CountryCodesOptions) => { + // Determine allowed countries + let allowedCountries = options?.allowedCountries?.length + ? countryData.filter((country) => options.allowedCountries!.includes(country.code)) + : countryData; + + // If no countries match, fall back to all countries + if (options?.allowedCountries?.length && allowedCountries.length === 0) { + console.warn(`No countries matched the "allowedCountries" list, falling back to all countries`); + allowedCountries = countryData; + } + + // Determine default country + let defaultCountry = options?.defaultCountry + ? countryData.find((country) => country.code === options.defaultCountry)! + : countryData.find((country) => country.code === "US")!; + + // If default country is not in allowed countries, use first allowed country + if (!allowedCountries.some((country) => country.code === defaultCountry.code)) { + defaultCountry = allowedCountries[0]!; + console.warn( + `The "defaultCountry" option is not in the "allowedCountries" list, the default country has been set to ${defaultCountry.code}` + ); + } + + return { + allowedCountries, + defaultCountry, + }; +}; diff --git a/packages/core/src/behaviors/index.test.ts b/packages/core/src/behaviors/index.test.ts new file mode 100644 index 000000000..0994558eb --- /dev/null +++ b/packages/core/src/behaviors/index.test.ts @@ -0,0 +1,280 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createMockUI } from "~/tests/utils"; +import { + autoAnonymousLogin, + autoUpgradeAnonymousUsers, + getBehavior, + hasBehavior, + recaptchaVerification, + requireDisplayName, + defaultBehaviors, +} from "./index"; + +vi.mock("./anonymous-upgrade", () => ({ + autoUpgradeAnonymousCredentialHandler: vi.fn(), + autoUpgradeAnonymousProviderHandler: vi.fn(), + autoUpgradeAnonymousUserRedirectHandler: vi.fn(), +})); + +vi.mock("./require-display-name", () => ({ + requireDisplayNameHandler: vi.fn(), +})); + +vi.mock("firebase/auth", () => ({ + RecaptchaVerifier: vi.fn(), + signInWithPopup: vi.fn(), + linkWithPopup: vi.fn(), + signInWithRedirect: vi.fn(), + linkWithRedirect: vi.fn(), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("hasBehavior", () => { + it("should return true if the behavior is enabled", () => { + const mockBehavior = { type: "init" as const, handler: vi.fn() }; + const ui = createMockUI({ + behaviors: { + autoAnonymousLogin: mockBehavior, + } as any, + }); + + expect(hasBehavior(ui, "autoAnonymousLogin")).toBe(true); + expect(mockBehavior.handler).not.toHaveBeenCalled(); + }); + + it("should return false if the behavior is not enabled", () => { + const ui = createMockUI(); + expect(hasBehavior(ui, "autoAnonymousLogin")).toBe(false); + }); + + it("should work with all behavior types", () => { + const mockUI = createMockUI({ + behaviors: { + autoAnonymousLogin: { type: "init" as const, handler: vi.fn() }, + autoUpgradeAnonymousCredential: { type: "callable" as const, handler: vi.fn() }, + autoUpgradeAnonymousProvider: { type: "callable" as const, handler: vi.fn() }, + recaptchaVerification: { type: "callable" as const, handler: vi.fn() }, + requireDisplayName: { type: "callable" as const, handler: vi.fn() }, + } as any, + }); + + expect(hasBehavior(mockUI, "autoAnonymousLogin")).toBe(true); + expect(hasBehavior(mockUI, "autoUpgradeAnonymousCredential")).toBe(true); + expect(hasBehavior(mockUI, "autoUpgradeAnonymousProvider")).toBe(true); + expect(hasBehavior(mockUI, "recaptchaVerification")).toBe(true); + expect(hasBehavior(mockUI, "requireDisplayName")).toBe(true); + }); +}); + +describe("getBehavior", () => { + it("should throw if the behavior is not enabled", () => { + const ui = createMockUI(); + expect(() => getBehavior(ui, "autoAnonymousLogin")).toThrow("Behavior autoAnonymousLogin not found"); + }); + + it("should return the behavior handler if it is enabled", () => { + const mockBehavior = { type: "init" as const, handler: vi.fn() }; + const ui = createMockUI({ + behaviors: { + autoAnonymousLogin: mockBehavior, + } as any, + }); + + expect(hasBehavior(ui, "autoAnonymousLogin")).toBe(true); + expect(getBehavior(ui, "autoAnonymousLogin")).toBe(mockBehavior.handler); + }); + + it("should work with all behavior types", () => { + const mockBehaviors = { + autoAnonymousLogin: { type: "init" as const, handler: vi.fn() }, + autoUpgradeAnonymousCredential: { type: "callable" as const, handler: vi.fn() }, + autoUpgradeAnonymousProvider: { type: "callable" as const, handler: vi.fn() }, + recaptchaVerification: { type: "callable" as const, handler: vi.fn() }, + requireDisplayName: { type: "callable" as const, handler: vi.fn() }, + }; + + const ui = createMockUI({ behaviors: mockBehaviors as any }); + + expect(getBehavior(ui, "autoAnonymousLogin")).toBe(mockBehaviors.autoAnonymousLogin.handler); + expect(getBehavior(ui, "autoUpgradeAnonymousCredential")).toBe( + mockBehaviors.autoUpgradeAnonymousCredential.handler + ); + expect(getBehavior(ui, "autoUpgradeAnonymousProvider")).toBe(mockBehaviors.autoUpgradeAnonymousProvider.handler); + expect(getBehavior(ui, "recaptchaVerification")).toBe(mockBehaviors.recaptchaVerification.handler); + expect(getBehavior(ui, "requireDisplayName")).toBe(mockBehaviors.requireDisplayName.handler); + }); +}); + +describe("autoAnonymousLogin", () => { + it("should return behavior with correct structure", () => { + const behavior = autoAnonymousLogin(); + + expect(behavior).toHaveProperty("autoAnonymousLogin"); + expect(behavior.autoAnonymousLogin).toHaveProperty("type", "init"); + expect(behavior.autoAnonymousLogin).toHaveProperty("handler"); + expect(typeof behavior.autoAnonymousLogin.handler).toBe("function"); + }); + + it("should not include other behaviors", () => { + const behavior = autoAnonymousLogin(); + + expect(behavior).not.toHaveProperty("autoUpgradeAnonymousCredential"); + expect(behavior).not.toHaveProperty("autoUpgradeAnonymousProvider"); + expect(behavior).not.toHaveProperty("recaptchaVerification"); + }); +}); + +describe("autoUpgradeAnonymousUsers", () => { + it("should return behaviors with correct structure", () => { + const behavior = autoUpgradeAnonymousUsers(); + + expect(behavior).toHaveProperty("autoUpgradeAnonymousCredential"); + expect(behavior).toHaveProperty("autoUpgradeAnonymousProvider"); + expect(behavior).toHaveProperty("autoUpgradeAnonymousUserRedirectHandler"); + + expect(behavior.autoUpgradeAnonymousCredential).toHaveProperty("type", "callable"); + expect(behavior.autoUpgradeAnonymousProvider).toHaveProperty("type", "callable"); + expect(behavior.autoUpgradeAnonymousUserRedirectHandler).toHaveProperty("type", "redirect"); + + expect(typeof behavior.autoUpgradeAnonymousCredential.handler).toBe("function"); + expect(typeof behavior.autoUpgradeAnonymousProvider.handler).toBe("function"); + expect(typeof behavior.autoUpgradeAnonymousUserRedirectHandler.handler).toBe("function"); + }); + + it("should work with onUpgrade callback option", () => { + const mockOnUpgrade = vi.fn(); + const behavior = autoUpgradeAnonymousUsers({ onUpgrade: mockOnUpgrade }); + + expect(behavior).toHaveProperty("autoUpgradeAnonymousCredential"); + expect(behavior).toHaveProperty("autoUpgradeAnonymousProvider"); + expect(behavior).toHaveProperty("autoUpgradeAnonymousUserRedirectHandler"); + + expect(typeof behavior.autoUpgradeAnonymousCredential.handler).toBe("function"); + expect(typeof behavior.autoUpgradeAnonymousProvider.handler).toBe("function"); + expect(typeof behavior.autoUpgradeAnonymousUserRedirectHandler.handler).toBe("function"); + }); + + it("should pass onUpgrade callback to handlers when called", async () => { + const mockOnUpgrade = vi.fn(); + const behavior = autoUpgradeAnonymousUsers({ onUpgrade: mockOnUpgrade }); + + const mockUI = createMockUI(); + const mockCredential = { providerId: "password" } as any; + const mockProvider = { providerId: "google.com" } as any; + const mockUserCredential = { user: { uid: "upgraded-123" } } as any; + + const { + autoUpgradeAnonymousCredentialHandler, + autoUpgradeAnonymousProviderHandler, + autoUpgradeAnonymousUserRedirectHandler, + } = await import("./anonymous-upgrade"); + + await behavior.autoUpgradeAnonymousCredential.handler(mockUI, mockCredential); + await behavior.autoUpgradeAnonymousProvider.handler(mockUI, mockProvider); + await behavior.autoUpgradeAnonymousUserRedirectHandler.handler(mockUI, mockUserCredential); + + expect(autoUpgradeAnonymousCredentialHandler).toHaveBeenCalledWith(mockUI, mockCredential, mockOnUpgrade); + expect(autoUpgradeAnonymousProviderHandler).toHaveBeenCalledWith(mockUI, mockProvider, mockOnUpgrade); + expect(autoUpgradeAnonymousUserRedirectHandler).toHaveBeenCalledWith(mockUI, mockUserCredential, mockOnUpgrade); + }); + + it("should not include other behaviors", () => { + const behavior = autoUpgradeAnonymousUsers(); + + expect(behavior).not.toHaveProperty("autoAnonymousLogin"); + expect(behavior).not.toHaveProperty("recaptchaVerification"); + }); +}); + +describe("recaptchaVerification", () => { + it("should return behavior with correct structure", () => { + const behavior = recaptchaVerification(); + + expect(behavior).toHaveProperty("recaptchaVerification"); + expect(behavior.recaptchaVerification).toHaveProperty("type", "callable"); + expect(behavior.recaptchaVerification).toHaveProperty("handler"); + expect(typeof behavior.recaptchaVerification.handler).toBe("function"); + }); + + it("should work with custom options", () => { + const customOptions = { + size: "normal" as const, + theme: "dark" as const, + tabindex: 5, + }; + + const behavior = recaptchaVerification(customOptions); + + expect(behavior).toHaveProperty("recaptchaVerification"); + expect(behavior.recaptchaVerification).toHaveProperty("type", "callable"); + expect(behavior.recaptchaVerification).toHaveProperty("handler"); + expect(typeof behavior.recaptchaVerification.handler).toBe("function"); + }); + + it("should not include other behaviors", () => { + const behavior = recaptchaVerification(); + + expect(behavior).not.toHaveProperty("autoAnonymousLogin"); + expect(behavior).not.toHaveProperty("autoUpgradeAnonymousCredential"); + expect(behavior).not.toHaveProperty("autoUpgradeAnonymousProvider"); + }); +}); + +describe("requireDisplayName", () => { + it("should return behavior with correct structure", () => { + const behavior = requireDisplayName(); + + expect(behavior).toHaveProperty("requireDisplayName"); + expect(behavior.requireDisplayName).toHaveProperty("type", "callable"); + expect(behavior.requireDisplayName).toHaveProperty("handler"); + expect(typeof behavior.requireDisplayName.handler).toBe("function"); + }); + + it("should call the requireDisplayNameHandler when executed", async () => { + const behavior = requireDisplayName(); + const mockUI = createMockUI(); + const mockUser = { uid: "test-user-123" } as any; + const displayName = "John Doe"; + + const { requireDisplayNameHandler } = await import("./require-display-name"); + + await behavior.requireDisplayName.handler(mockUI, mockUser, displayName); + + expect(requireDisplayNameHandler).toHaveBeenCalledWith(mockUI, mockUser, displayName); + }); +}); + +describe("defaultBehaviors", () => { + it("should include recaptchaVerification by default", () => { + expect(defaultBehaviors).toHaveProperty("recaptchaVerification"); + expect(defaultBehaviors).toHaveProperty("providerSignInStrategy"); + expect(defaultBehaviors).toHaveProperty("providerLinkStrategy"); + expect(defaultBehaviors).toHaveProperty("countryCodes"); + }); + + it("should not include other behaviors by default", () => { + expect(defaultBehaviors).not.toHaveProperty("autoAnonymousLogin"); + expect(defaultBehaviors).not.toHaveProperty("autoUpgradeAnonymousCredential"); + expect(defaultBehaviors).not.toHaveProperty("autoUpgradeAnonymousProvider"); + expect(defaultBehaviors).not.toHaveProperty("requireDisplayName"); + }); +}); diff --git a/packages/core/src/behaviors/index.ts b/packages/core/src/behaviors/index.ts new file mode 100644 index 000000000..5841d41e5 --- /dev/null +++ b/packages/core/src/behaviors/index.ts @@ -0,0 +1,218 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { FirebaseUI } from "~/config"; +import type { RecaptchaVerifier, UserCredential } from "firebase/auth"; +import * as anonymousUpgradeHandlers from "./anonymous-upgrade"; +import * as autoAnonymousLoginHandlers from "./auto-anonymous-login"; +import * as recaptchaHandlers from "./recaptcha"; +import * as providerStrategyHandlers from "./provider-strategy"; +import * as oneTapSignInHandlers from "./one-tap"; +import * as requireDisplayNameHandlers from "./require-display-name"; +import * as countryCodesHandlers from "./country-codes"; +import { + callableBehavior, + initBehavior, + redirectBehavior, + type CallableBehavior, + type InitBehavior, + type RedirectBehavior, +} from "./utils"; + +type Registry = { + autoAnonymousLogin: InitBehavior; + autoUpgradeAnonymousCredential: CallableBehavior< + typeof anonymousUpgradeHandlers.autoUpgradeAnonymousCredentialHandler + >; + autoUpgradeAnonymousProvider: CallableBehavior; + autoUpgradeAnonymousUserRedirectHandler: RedirectBehavior< + ( + ui: FirebaseUI, + credential: UserCredential | null, + onUpgrade?: anonymousUpgradeHandlers.OnUpgradeCallback + ) => ReturnType + >; + recaptchaVerification: CallableBehavior<(ui: FirebaseUI, element: HTMLElement) => RecaptchaVerifier>; + providerSignInStrategy: CallableBehavior; + providerLinkStrategy: CallableBehavior; + oneTapSignIn: InitBehavior<(ui: FirebaseUI) => ReturnType>; + requireDisplayName: CallableBehavior; + countryCodes: CallableBehavior; +}; + +/** A behavior or set of behaviors from the registry. */ +export type Behavior = Pick; +/** All available behaviors, with each behavior being optional. */ +export type Behaviors = Partial; + +/** + * Enables automatic anonymous login when the app initializes. + * + * @returns A behavior that automatically signs in users anonymously on app initialization. + */ +export function autoAnonymousLogin(): Behavior<"autoAnonymousLogin"> { + return { + autoAnonymousLogin: initBehavior(autoAnonymousLoginHandlers.autoAnonymousLoginHandler), + }; +} + +/** Options for the auto-upgrade anonymous users behavior. */ +export type AutoUpgradeAnonymousUsersOptions = { + /** Optional callback function that is called when an anonymous user is upgraded. */ + onUpgrade?: anonymousUpgradeHandlers.OnUpgradeCallback; +}; + +/** + * Automatically upgrades anonymous users to regular users when they sign in with a credential or provider. + * + * This behavior handles upgrading anonymous users for credential-based sign-ins, provider-based sign-ins, + * and redirect-based authentication flows. + * + * @param options - Optional configuration including an upgrade callback. + * @returns Behaviors for automatically upgrading anonymous users. + */ +export function autoUpgradeAnonymousUsers( + options?: AutoUpgradeAnonymousUsersOptions +): Behavior< + "autoUpgradeAnonymousCredential" | "autoUpgradeAnonymousProvider" | "autoUpgradeAnonymousUserRedirectHandler" +> { + return { + autoUpgradeAnonymousCredential: callableBehavior((ui, credential) => + anonymousUpgradeHandlers.autoUpgradeAnonymousCredentialHandler(ui, credential, options?.onUpgrade) + ), + autoUpgradeAnonymousProvider: callableBehavior((ui, provider) => + anonymousUpgradeHandlers.autoUpgradeAnonymousProviderHandler(ui, provider, options?.onUpgrade) + ), + autoUpgradeAnonymousUserRedirectHandler: redirectBehavior((ui, credential) => + anonymousUpgradeHandlers.autoUpgradeAnonymousUserRedirectHandler(ui, credential, options?.onUpgrade) + ), + }; +} + +/** Options for reCAPTCHA verification behavior. */ +export type RecaptchaVerificationOptions = recaptchaHandlers.RecaptchaVerificationOptions; + +/** + * Configures reCAPTCHA verification for phone authentication. + * + * @param options - Optional reCAPTCHA verification options. + * @returns A behavior that handles reCAPTCHA verification for phone authentication. + */ +export function recaptchaVerification(options?: RecaptchaVerificationOptions): Behavior<"recaptchaVerification"> { + return { + recaptchaVerification: callableBehavior((ui, element) => + recaptchaHandlers.recaptchaVerificationHandler(ui, element, options) + ), + }; +} + +/** + * Configures provider authentication to use redirect strategy instead of popup. + * + * @returns Behaviors for provider sign-in and linking using redirect strategy. + */ +export function providerRedirectStrategy(): Behavior<"providerSignInStrategy" | "providerLinkStrategy"> { + return { + providerSignInStrategy: callableBehavior(providerStrategyHandlers.signInWithRediectHandler), + providerLinkStrategy: callableBehavior(providerStrategyHandlers.linkWithRedirectHandler), + }; +} + +/** + * Configures provider authentication to use popup strategy instead of redirect. + * + * This is the default strategy for provider authentication. + * + * @returns Behaviors for provider sign-in and linking using popup strategy. + */ +export function providerPopupStrategy(): Behavior<"providerSignInStrategy" | "providerLinkStrategy"> { + return { + providerSignInStrategy: callableBehavior(providerStrategyHandlers.signInWithPopupHandler), + providerLinkStrategy: callableBehavior(providerStrategyHandlers.linkWithPopupHandler), + }; +} + +/** Options for Google One Tap sign-in behavior. */ +export type OneTapSignInOptions = oneTapSignInHandlers.OneTapSignInOptions; + +/** + * Enables Google One Tap sign-in functionality. + * + * @param options - Configuration options for Google One Tap sign-in. + * @returns A behavior that handles Google One Tap sign-in initialization. + */ +export function oneTapSignIn(options: OneTapSignInOptions): Behavior<"oneTapSignIn"> { + return { + oneTapSignIn: initBehavior((ui) => oneTapSignInHandlers.oneTapSignInHandler(ui, options)), + }; +} + +/** + * Requires users to provide a display name when creating an account. + * + * @returns A behavior that enforces display name requirement during user registration. + */ +export function requireDisplayName(): Behavior<"requireDisplayName"> { + return { + requireDisplayName: callableBehavior(requireDisplayNameHandlers.requireDisplayNameHandler), + }; +} + +/** + * Configures country code selection for phone number input. + * + * @param options - Optional configuration for country code behavior. + * @returns A behavior that provides country code functionality for phone authentication. + */ +export function countryCodes(options?: countryCodesHandlers.CountryCodesOptions): Behavior<"countryCodes"> { + return { + countryCodes: callableBehavior(() => countryCodesHandlers.countryCodesHandler(options)), + }; +} + +/** + * Checks if a specific behavior is enabled for the given FirebaseUI instance. + * + * @param ui - The FirebaseUI instance. + * @param key - The behavior key to check. + * @returns True if the behavior is enabled, false otherwise. + */ +export function hasBehavior(ui: FirebaseUI, key: T): boolean { + return !!ui.behaviors[key]; +} + +/** + * Gets the handler function for a specific behavior. + * + * @param ui - The FirebaseUI instance. + * @param key - The behavior key to retrieve. + * @returns The handler function for the specified behavior. + * @throws {Error} Throws an error if the behavior is not found. + */ +export function getBehavior(ui: FirebaseUI, key: T): Registry[T]["handler"] { + if (!hasBehavior(ui, key)) { + throw new Error(`Behavior ${key} not found`); + } + + return (ui.behaviors[key] as Registry[T]).handler; +} + +/** Default behaviors that are enabled by default for all FirebaseUI instances. */ +export const defaultBehaviors: Behavior<"recaptchaVerification"> = { + ...recaptchaVerification(), + ...providerPopupStrategy(), + ...countryCodes(), +}; diff --git a/packages/core/src/behaviors/one-tap.test.ts b/packages/core/src/behaviors/one-tap.test.ts new file mode 100644 index 000000000..6017b28b7 --- /dev/null +++ b/packages/core/src/behaviors/one-tap.test.ts @@ -0,0 +1,340 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Auth, User } from "firebase/auth"; +import { oneTapSignInHandler, type OneTapSignInOptions } from "./one-tap"; +import { createMockUI } from "~/tests/utils"; + +vi.mock("firebase/auth", () => ({ + GoogleAuthProvider: { + credential: vi.fn(), + }, +})); + +vi.mock("~/auth", () => ({ + signInWithCredential: vi.fn(), +})); + +const mockGoogleAccounts = { + id: { + initialize: vi.fn(), + prompt: vi.fn(), + }, +}; + +Object.defineProperty(window, "google", { + value: { accounts: mockGoogleAccounts }, + writable: true, +}); + +Object.defineProperty(document, "createElement", { + value: vi.fn(() => ({ + setAttribute: vi.fn(), + src: "", + async: false, + onload: null, + })), + writable: true, +}); + +Object.defineProperty(document, "querySelector", { + value: vi.fn(), + writable: true, +}); + +Object.defineProperty(document.body, "appendChild", { + value: vi.fn(), + writable: true, +}); + +import { GoogleAuthProvider } from "firebase/auth"; +import { signInWithCredential } from "~/auth"; + +describe("oneTapSignInHandler", () => { + let mockUI: ReturnType; + let mockScript: any; + let mockCreateElement: any; + + beforeEach(() => { + vi.clearAllMocks(); + + mockScript = { + setAttribute: vi.fn(), + src: "", + async: false, + onload: null, + }; + + mockCreateElement = vi.fn(() => mockScript); + Object.defineProperty(document, "createElement", { + value: mockCreateElement, + writable: true, + }); + + vi.mocked(document.querySelector).mockReturnValue(null); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("user authentication state checks", () => { + it("should not initialize one-tap when user is already signed in with real account", async () => { + const mockUser = { isAnonymous: false, uid: "real-user-123" } as User; + mockUI = createMockUI({ auth: { currentUser: mockUser } as Auth }); + + const options: OneTapSignInOptions = { clientId: "test-client-id" }; + + await oneTapSignInHandler(mockUI, options); + + expect(document.createElement).not.toHaveBeenCalled(); + expect(mockGoogleAccounts.id.initialize).not.toHaveBeenCalled(); + }); + + it("should initialize one-tap when user is anonymous", async () => { + const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User; + mockUI = createMockUI({ auth: { currentUser: mockUser } as Auth }); + + const options: OneTapSignInOptions = { clientId: "test-client-id" }; + + await oneTapSignInHandler(mockUI, options); + + expect(document.createElement).toHaveBeenCalledWith("script"); + expect(mockScript.setAttribute).toHaveBeenCalledWith("data-one-tap-sign-in", "true"); + expect(mockScript.src).toBe("https://accounts.google.com/gsi/client"); + expect(mockScript.async).toBe(true); + }); + + it("should initialize one-tap when no current user exists", async () => { + mockUI = createMockUI({ auth: { currentUser: null } as Auth }); + + const options: OneTapSignInOptions = { clientId: "test-client-id" }; + + await oneTapSignInHandler(mockUI, options); + + expect(document.createElement).toHaveBeenCalledWith("script"); + expect(mockScript.setAttribute).toHaveBeenCalledWith("data-one-tap-sign-in", "true"); + expect(mockScript.src).toBe("https://accounts.google.com/gsi/client"); + expect(mockScript.async).toBe(true); + }); + }); + + describe("script loading prevention", () => { + it("should not load script if one-tap script already exists", async () => { + mockUI = createMockUI({ auth: { currentUser: null } as Auth }); + + const existingScript = { tagName: "script" }; + vi.mocked(document.querySelector).mockReturnValue(existingScript as any); + + const options: OneTapSignInOptions = { clientId: "test-client-id" }; + + await oneTapSignInHandler(mockUI, options); + + expect(document.createElement).not.toHaveBeenCalled(); + expect(mockGoogleAccounts.id.initialize).not.toHaveBeenCalled(); + }); + + it("should check for existing script with correct selector", async () => { + mockUI = createMockUI({ auth: { currentUser: null } as Auth }); + + const options: OneTapSignInOptions = { clientId: "test-client-id" }; + + await oneTapSignInHandler(mockUI, options); + + expect(document.querySelector).toHaveBeenCalledWith("script[data-one-tap-sign-in]"); + }); + }); + + describe("script loading and initialization", () => { + beforeEach(() => { + mockUI = createMockUI({ auth: { currentUser: null } as Auth }); + }); + + it("should create and append script with correct attributes", async () => { + const options: OneTapSignInOptions = { clientId: "test-client-id" }; + + await oneTapSignInHandler(mockUI, options); + + expect(document.createElement).toHaveBeenCalledWith("script"); + expect(mockScript.setAttribute).toHaveBeenCalledWith("data-one-tap-sign-in", "true"); + expect(mockScript.src).toBe("https://accounts.google.com/gsi/client"); + expect(mockScript.async).toBe(true); + expect(document.body.appendChild).toHaveBeenCalledWith(mockScript); + }); + + it("should initialize Google One Tap with basic options", async () => { + const options: OneTapSignInOptions = { clientId: "test-client-id" }; + + await oneTapSignInHandler(mockUI, options); + + if (mockScript.onload) { + mockScript.onload(); + } + + expect(mockGoogleAccounts.id.initialize).toHaveBeenCalledWith({ + client_id: "test-client-id", + auto_select: undefined, + cancel_on_tap_outside: undefined, + context: undefined, + ux_mode: undefined, + log_level: undefined, + callback: expect.any(Function), + }); + }); + + it("should initialize Google One Tap with all options", async () => { + const options: OneTapSignInOptions = { + clientId: "test-client-id", + autoSelect: true, + cancelOnTapOutside: false, + context: "signin", + uxMode: "popup", + logLevel: "debug", + }; + + await oneTapSignInHandler(mockUI, options); + + if (mockScript.onload) { + mockScript.onload(); + } + + expect(mockGoogleAccounts.id.initialize).toHaveBeenCalledWith({ + client_id: "test-client-id", + auto_select: true, + cancel_on_tap_outside: false, + context: "signin", + ux_mode: "popup", + log_level: "debug", + callback: expect.any(Function), + }); + }); + + it("should call prompt after initialization", async () => { + const options: OneTapSignInOptions = { clientId: "test-client-id" }; + + await oneTapSignInHandler(mockUI, options); + + if (mockScript.onload) { + mockScript.onload(); + } + + expect(mockGoogleAccounts.id.prompt).toHaveBeenCalled(); + }); + }); + + describe("callback integration", () => { + beforeEach(() => { + mockUI = createMockUI({ auth: { currentUser: null } as Auth }); + }); + + it("should handle Google One Tap callback with credential", async () => { + const options: OneTapSignInOptions = { clientId: "test-client-id" }; + const mockCredential = { providerId: "google.com" }; + const mockGoogleCredential = { credential: "google-credential-token" }; + + vi.mocked(GoogleAuthProvider.credential).mockReturnValue(mockCredential as any); + vi.mocked(signInWithCredential).mockResolvedValue({} as any); + + await oneTapSignInHandler(mockUI, options); + + if (mockScript.onload) { + mockScript.onload(); + } + + const initializeCall = vi.mocked(mockGoogleAccounts.id.initialize).mock.calls[0]; + const callback = initializeCall?.[0]?.callback; + + await callback(mockGoogleCredential); + + expect(GoogleAuthProvider.credential).toHaveBeenCalledWith("google-credential-token"); + expect(signInWithCredential).toHaveBeenCalledWith(mockUI, mockCredential); + }); + + it("should handle callback errors gracefully", async () => { + const options: OneTapSignInOptions = { clientId: "test-client-id" }; + const mockError = new Error("Google One Tap error"); + + vi.mocked(GoogleAuthProvider.credential).mockImplementation(() => { + throw mockError; + }); + + await oneTapSignInHandler(mockUI, options); + + if (mockScript.onload) { + mockScript.onload(); + } + + const initializeCall = vi.mocked(mockGoogleAccounts.id.initialize).mock.calls[0]; + const callback = initializeCall?.[0]?.callback; + + await expect(callback({ credential: "invalid-token" })).rejects.toThrow("Google One Tap error"); + }); + }); + + describe("options handling", () => { + beforeEach(() => { + mockUI = createMockUI({ auth: { currentUser: null } as Auth }); + }); + + it("should handle minimal options", async () => { + const options: OneTapSignInOptions = { clientId: "minimal-client-id" }; + + await oneTapSignInHandler(mockUI, options); + + if (mockScript.onload) { + mockScript.onload(); + } + + expect(mockGoogleAccounts.id.initialize).toHaveBeenCalledWith({ + client_id: "minimal-client-id", + auto_select: undefined, + cancel_on_tap_outside: undefined, + context: undefined, + ux_mode: undefined, + log_level: undefined, + callback: expect.any(Function), + }); + }); + + it("should handle all available options", async () => { + const options: OneTapSignInOptions = { + clientId: "full-options-client-id", + autoSelect: false, + cancelOnTapOutside: true, + context: "use", + uxMode: "redirect", + logLevel: "warn", + }; + + await oneTapSignInHandler(mockUI, options); + + if (mockScript.onload) { + mockScript.onload(); + } + + expect(mockGoogleAccounts.id.initialize).toHaveBeenCalledWith({ + client_id: "full-options-client-id", + auto_select: false, + cancel_on_tap_outside: true, + context: "use", + ux_mode: "redirect", + log_level: "warn", + callback: expect.any(Function), + }); + }); + }); +}); diff --git a/packages/core/src/behaviors/one-tap.ts b/packages/core/src/behaviors/one-tap.ts new file mode 100644 index 000000000..93d608f38 --- /dev/null +++ b/packages/core/src/behaviors/one-tap.ts @@ -0,0 +1,66 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GoogleAuthProvider } from "firebase/auth"; +import type { IdConfiguration } from "google-one-tap"; +import type { FirebaseUI } from "~/config"; +import { signInWithCredential } from "~/auth"; + +export type OneTapSignInOptions = { + clientId: IdConfiguration["client_id"]; + autoSelect?: IdConfiguration["auto_select"]; + cancelOnTapOutside?: IdConfiguration["cancel_on_tap_outside"]; + context?: IdConfiguration["context"]; + uxMode?: IdConfiguration["ux_mode"]; + logLevel?: IdConfiguration["log_level"]; +}; + +export const oneTapSignInHandler = async (ui: FirebaseUI, options: OneTapSignInOptions) => { + // Only show one-tap if user is not signed in OR if they are anonymous. + // Don't show if user is already signed in with a real account. + if (ui.auth.currentUser && !ui.auth.currentUser.isAnonymous) { + return; + } + + // Prevent multiple instances of the script from being loaded, e.g. hot reload. + if (document.querySelector("script[data-one-tap-sign-in]")) { + return; + } + + const script = document.createElement("script"); + script.setAttribute("data-one-tap-sign-in", "true"); + script.src = "https://accounts.google.com/gsi/client"; + script.async = true; + + script.onload = () => { + window.google.accounts.id.initialize({ + client_id: options.clientId, + auto_select: options.autoSelect, + cancel_on_tap_outside: options.cancelOnTapOutside, + context: options.context, + ux_mode: options.uxMode, + log_level: options.logLevel, + callback: async (response) => { + const credential = GoogleAuthProvider.credential(response.credential); + await signInWithCredential(ui, credential); + }, + }); + + window.google.accounts.id.prompt(); + }; + + document.body.appendChild(script); +}; diff --git a/packages/core/src/behaviors/provider-strategy.test.ts b/packages/core/src/behaviors/provider-strategy.test.ts new file mode 100644 index 000000000..08a158687 --- /dev/null +++ b/packages/core/src/behaviors/provider-strategy.test.ts @@ -0,0 +1,130 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + Auth, + AuthProvider, + linkWithPopup, + linkWithRedirect, + signInWithPopup, + signInWithRedirect, + User, + UserCredential, +} from "firebase/auth"; +import { + signInWithRediectHandler, + signInWithPopupHandler, + linkWithRedirectHandler, + linkWithPopupHandler, +} from "./provider-strategy"; +import { createMockUI } from "~/tests/utils"; + +vi.mock("firebase/auth", () => ({ + signInWithRedirect: vi.fn(), + signInWithPopup: vi.fn(), + linkWithRedirect: vi.fn(), + linkWithPopup: vi.fn(), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("signInWithRediectHandler", () => { + it("should set state to pending and call signInWithRedirect", async () => { + const mockAuth = {} as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockProvider = { providerId: "google.com" } as AuthProvider; + + vi.mocked(signInWithRedirect).mockResolvedValue({} as never); + + await signInWithRediectHandler(mockUI, mockProvider); + + expect(signInWithRedirect).toHaveBeenCalledWith(mockAuth, mockProvider); + }); +}); + +describe("signInWithPopupHandler", () => { + it("should call signInWithPopup and return result", async () => { + const mockAuth = {} as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockProvider = { providerId: "google.com" } as AuthProvider; + const mockResult = { user: { uid: "test-user" } } as UserCredential; + + vi.mocked(signInWithPopup).mockResolvedValue(mockResult); + + const result = await signInWithPopupHandler(mockUI, mockProvider); + + expect(signInWithPopup).toHaveBeenCalledWith(mockAuth, mockProvider); + expect(result).toBe(mockResult); + }); + + it("should throw error when signInWithPopup fails", async () => { + const mockAuth = {} as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockProvider = { providerId: "google.com" } as AuthProvider; + const mockError = new Error("Popup sign in failed"); + + vi.mocked(signInWithPopup).mockRejectedValue(mockError); + + await expect(signInWithPopupHandler(mockUI, mockProvider)).rejects.toThrow("Popup sign in failed"); + }); +}); + +describe("linkWithRedirectHandler", () => { + it("should call linkWithRedirect", async () => { + const mockAuth = {} as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockUser = { uid: "test-user" } as User; + const mockProvider = { providerId: "google.com" } as AuthProvider; + + vi.mocked(linkWithRedirect).mockResolvedValue({} as never); + + await linkWithRedirectHandler(mockUI, mockUser, mockProvider); + + expect(linkWithRedirect).toHaveBeenCalledWith(mockUser, mockProvider); + }); +}); + +describe("linkWithPopupHandler", () => { + it("should call linkWithPopup and return result", async () => { + const mockAuth = {} as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockUser = { uid: "test-user" } as User; + const mockProvider = { providerId: "google.com" } as AuthProvider; + const mockResult = { user: { uid: "linked-user" } } as UserCredential; + + vi.mocked(linkWithPopup).mockResolvedValue(mockResult); + + const result = await linkWithPopupHandler(mockUI, mockUser, mockProvider); + + expect(linkWithPopup).toHaveBeenCalledWith(mockUser, mockProvider); + expect(result).toBe(mockResult); + }); + + it("should throw error when linkWithPopup fails", async () => { + const mockAuth = {} as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockUser = { uid: "test-user" } as User; + const mockProvider = { providerId: "google.com" } as AuthProvider; + const mockError = new Error("Popup link failed"); + + vi.mocked(linkWithPopup).mockRejectedValue(mockError); + + await expect(linkWithPopupHandler(mockUI, mockUser, mockProvider)).rejects.toThrow("Popup link failed"); + }); +}); diff --git a/packages/core/src/behaviors/provider-strategy.ts b/packages/core/src/behaviors/provider-strategy.ts new file mode 100644 index 000000000..a0f02ab9c --- /dev/null +++ b/packages/core/src/behaviors/provider-strategy.ts @@ -0,0 +1,49 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + type AuthProvider, + linkWithPopup, + linkWithRedirect, + signInWithPopup, + signInWithRedirect, + type User, + type UserCredential, +} from "firebase/auth"; +import { type FirebaseUI } from "~/config"; + +export type ProviderSignInStrategyHandler = (ui: FirebaseUI, provider: AuthProvider) => Promise; +export type ProviderLinkStrategyHandler = ( + ui: FirebaseUI, + user: User, + provider: AuthProvider +) => Promise; + +export const signInWithRediectHandler: ProviderSignInStrategyHandler = async (ui, provider) => { + return signInWithRedirect(ui.auth, provider); +}; + +export const signInWithPopupHandler: ProviderSignInStrategyHandler = async (ui, provider) => { + return signInWithPopup(ui.auth, provider); +}; + +export const linkWithRedirectHandler: ProviderLinkStrategyHandler = async (_ui, user, provider) => { + return linkWithRedirect(user, provider); +}; + +export const linkWithPopupHandler: ProviderLinkStrategyHandler = async (_ui, user, provider) => { + return linkWithPopup(user, provider); +}; diff --git a/packages/core/src/behaviors/recaptcha.test.ts b/packages/core/src/behaviors/recaptcha.test.ts new file mode 100644 index 000000000..543e176bc --- /dev/null +++ b/packages/core/src/behaviors/recaptcha.test.ts @@ -0,0 +1,214 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { RecaptchaVerifier } from "firebase/auth"; +import { recaptchaVerificationHandler, type RecaptchaVerificationOptions } from "./recaptcha"; +import type { FirebaseUI } from "~/config"; +import { createMockUI } from "~/tests/utils"; + +vi.mock("firebase/auth", () => ({ + RecaptchaVerifier: vi.fn().mockImplementation(() => {}), +})); + +describe("Recaptcha Verification Handler", () => { + let mockElement: HTMLElement; + + beforeEach(() => { + vi.clearAllMocks(); + mockElement = document.createElement("div"); + }); + + describe("recaptchaVerificationHandler", () => { + it("should create RecaptchaVerifier with default options", () => { + const mockUI = createMockUI(); + const result = recaptchaVerificationHandler(mockUI, mockElement); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { + size: "invisible", + theme: "light", + tabindex: 0, + }); + expect(result).toBeDefined(); + }); + + it("should create RecaptchaVerifier with custom options", () => { + const mockUI = createMockUI(); + const customOptions: RecaptchaVerificationOptions = { + size: "normal", + theme: "dark", + tabindex: 5, + }; + + const result = recaptchaVerificationHandler(mockUI, mockElement, customOptions); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { + size: "normal", + theme: "dark", + tabindex: 5, + }); + expect(result).toBeDefined(); + }); + + it("should handle partial options", () => { + const mockUI = createMockUI(); + const partialOptions: RecaptchaVerificationOptions = { + size: "compact", + // theme and tabindex should use defaults + }; + + const result = recaptchaVerificationHandler(mockUI, mockElement, partialOptions); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { + size: "compact", + theme: "light", // default + tabindex: 0, // default + }); + expect(result).toBeDefined(); + }); + + it("should handle undefined options", () => { + const mockUI = createMockUI(); + const result = recaptchaVerificationHandler(mockUI, mockElement, undefined); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { + size: "invisible", + theme: "light", + tabindex: 0, + }); + expect(result).toBeDefined(); + }); + + it("should pass correct auth instance", () => { + const mockUI = createMockUI(); + const customAuth = { uid: "test-uid" } as any; + const customUI = { auth: customAuth } as FirebaseUI; + + recaptchaVerificationHandler(customUI, mockElement); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(customAuth, mockElement, expect.any(Object)); + }); + + it("should pass correct element", () => { + const mockUI = createMockUI(); + const customElement = document.createElement("button"); + + recaptchaVerificationHandler(mockUI, customElement); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, customElement, expect.any(Object)); + }); + }); + + describe("RecaptchaVerificationOptions", () => { + it("should accept all valid size options", () => { + const mockUI = createMockUI(); + const sizes: Array = ["normal", "invisible", "compact"]; + + sizes.forEach((size) => { + const options: RecaptchaVerificationOptions = { size }; + const result = recaptchaVerificationHandler(mockUI, mockElement, options); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, expect.objectContaining({ size })); + expect(result).toBeDefined(); + }); + }); + + it("should accept all valid theme options", () => { + const mockUI = createMockUI(); + const themes: Array = ["light", "dark"]; + + themes.forEach((theme) => { + const options: RecaptchaVerificationOptions = { theme }; + const result = recaptchaVerificationHandler(mockUI, mockElement, options); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, expect.objectContaining({ theme })); + expect(result).toBeDefined(); + }); + }); + + it("should accept numeric tabindex", () => { + const mockUI = createMockUI(); + const options: RecaptchaVerificationOptions = { tabindex: 10 }; + const result = recaptchaVerificationHandler(mockUI, mockElement, options); + + expect(RecaptchaVerifier).toHaveBeenCalledWith( + mockUI.auth, + mockElement, + expect.objectContaining({ tabindex: 10 }) + ); + expect(result).toBeDefined(); + }); + + it("should accept zero tabindex", () => { + const mockUI = createMockUI(); + const options: RecaptchaVerificationOptions = { tabindex: 0 }; + const result = recaptchaVerificationHandler(mockUI, mockElement, options); + + expect(RecaptchaVerifier).toHaveBeenCalledWith( + mockUI.auth, + mockElement, + expect.objectContaining({ tabindex: 0 }) + ); + expect(result).toBeDefined(); + }); + }); + + describe("Integration scenarios", () => { + it("should work with all options combined", () => { + const mockUI = createMockUI(); + const allOptions: RecaptchaVerificationOptions = { + size: "normal", + theme: "dark", + tabindex: 3, + }; + + const result = recaptchaVerificationHandler(mockUI, mockElement, allOptions); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { + size: "normal", + theme: "dark", + tabindex: 3, + }); + expect(result).toBeDefined(); + }); + + it("should handle empty options object", () => { + const mockUI = createMockUI(); + const emptyOptions: RecaptchaVerificationOptions = {}; + const result = recaptchaVerificationHandler(mockUI, mockElement, emptyOptions); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { + size: "invisible", + theme: "light", + tabindex: 0, + }); + expect(result).toBeDefined(); + }); + + it("should return the same instance on multiple calls with same parameters", () => { + const mockUI = createMockUI(); + const options: RecaptchaVerificationOptions = { size: "compact" }; + + const result1 = recaptchaVerificationHandler(mockUI, mockElement, options); + const result2 = recaptchaVerificationHandler(mockUI, mockElement, options); + + // Each call should create a new RecaptchaVerifier instance + expect(RecaptchaVerifier).toHaveBeenCalledTimes(2); + expect(result1).toBeDefined(); + expect(result2).toBeDefined(); + }); + }); +}); diff --git a/packages/core/src/behaviors/recaptcha.ts b/packages/core/src/behaviors/recaptcha.ts new file mode 100644 index 000000000..206b0087e --- /dev/null +++ b/packages/core/src/behaviors/recaptcha.ts @@ -0,0 +1,36 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RecaptchaVerifier } from "firebase/auth"; +import { type FirebaseUI } from "~/config"; + +export type RecaptchaVerificationOptions = { + size?: "normal" | "invisible" | "compact"; + theme?: "light" | "dark"; + tabindex?: number; +}; + +export const recaptchaVerificationHandler = ( + ui: FirebaseUI, + element: HTMLElement, + options?: RecaptchaVerificationOptions +) => { + return new RecaptchaVerifier(ui.auth, element, { + size: options?.size ?? "invisible", + theme: options?.theme ?? "light", + tabindex: options?.tabindex ?? 0, + }); +}; diff --git a/packages/core/src/behaviors/require-display-name.test.ts b/packages/core/src/behaviors/require-display-name.test.ts new file mode 100644 index 000000000..8e0b207b7 --- /dev/null +++ b/packages/core/src/behaviors/require-display-name.test.ts @@ -0,0 +1,55 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { User } from "firebase/auth"; +import { requireDisplayNameHandler } from "./require-display-name"; +import { createMockUI } from "~/tests/utils"; + +vi.mock("firebase/auth", () => ({ + updateProfile: vi.fn(), +})); + +import { updateProfile } from "firebase/auth"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("requireDisplayNameHandler", () => { + it("should update user profile with display name", async () => { + const mockUser = { uid: "test-user-123" } as User; + const mockUI = createMockUI(); + const displayName = "John Doe"; + + vi.mocked(updateProfile).mockResolvedValue(); + + await requireDisplayNameHandler(mockUI, mockUser, displayName); + + expect(updateProfile).toHaveBeenCalledWith(mockUser, { displayName }); + }); + + it("should handle updateProfile errors", async () => { + const mockUser = { uid: "test-user-123" } as User; + const mockUI = createMockUI(); + const displayName = "John Doe"; + const mockError = new Error("Profile update failed"); + + vi.mocked(updateProfile).mockRejectedValue(mockError); + + await expect(requireDisplayNameHandler(mockUI, mockUser, displayName)).rejects.toThrow("Profile update failed"); + }); +}); diff --git a/packages/core/src/behaviors/require-display-name.ts b/packages/core/src/behaviors/require-display-name.ts new file mode 100644 index 000000000..d40ad177d --- /dev/null +++ b/packages/core/src/behaviors/require-display-name.ts @@ -0,0 +1,22 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { updateProfile, type User } from "firebase/auth"; +import { type FirebaseUI } from "~/config"; + +export const requireDisplayNameHandler = async (_: FirebaseUI, user: User, displayName: string) => { + await updateProfile(user, { displayName }); +}; diff --git a/packages/core/src/behaviors/utils.test.ts b/packages/core/src/behaviors/utils.test.ts new file mode 100644 index 000000000..f37afc51f --- /dev/null +++ b/packages/core/src/behaviors/utils.test.ts @@ -0,0 +1,190 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + callableBehavior, + redirectBehavior, + initBehavior, + type CallableBehavior, + type RedirectBehavior, + type InitBehavior, + type CallableHandler, + type RedirectHandler, + type InitHandler, +} from "./utils"; +import type { UserCredential } from "firebase/auth"; +import type { FirebaseUI } from "~/config"; + +describe("Behaviors Utils", () => { + describe("callableBehavior", () => { + it("should return a callable behavior with correct type", () => { + const handler = vi.fn(); + const behavior = callableBehavior(handler); + + expect(behavior).toEqual({ + type: "callable", + handler, + }); + expect(behavior.type).toBe("callable"); + expect(behavior.handler).toBe(handler); + }); + + it("should preserve handler function type", () => { + const handler: CallableHandler = vi.fn(); + const behavior = callableBehavior(handler); + + expect(behavior.handler).toBe(handler); + }); + + it("should work with different handler signatures", () => { + const handler1 = vi.fn((arg1: string) => arg1); + const handler2 = vi.fn((arg1: number, arg2: boolean) => ({ arg1, arg2 })); + + const behavior1 = callableBehavior(handler1); + const behavior2 = callableBehavior(handler2); + + expect(behavior1.type).toBe("callable"); + expect(behavior2.type).toBe("callable"); + expect(behavior1.handler).toBe(handler1); + expect(behavior2.handler).toBe(handler2); + }); + }); + + describe("redirectBehavior", () => { + it("should return a redirect behavior with correct type", () => { + const handler = vi.fn(); + const behavior = redirectBehavior(handler); + + expect(behavior).toEqual({ + type: "redirect", + handler, + }); + expect(behavior.type).toBe("redirect"); + expect(behavior.handler).toBe(handler); + }); + + it("should preserve handler function type", () => { + const handler: RedirectHandler = vi.fn(); + const behavior = redirectBehavior(handler); + + expect(behavior.handler).toBe(handler); + }); + + it("should work with async handlers", async () => { + const handler = vi.fn().mockResolvedValue(undefined); + const behavior = redirectBehavior(handler); + + expect(behavior.type).toBe("redirect"); + expect(behavior.handler).toBe(handler); + + const mockUI = {} as FirebaseUI; + const mockResult = {} as UserCredential; + + await behavior.handler(mockUI, mockResult); + expect(handler).toHaveBeenCalledWith(mockUI, mockResult); + }); + }); + + describe("initBehavior", () => { + it("should return an init behavior with correct type", () => { + const handler = vi.fn(); + const behavior = initBehavior(handler); + + expect(behavior).toEqual({ + type: "init", + handler, + }); + expect(behavior.type).toBe("init"); + expect(behavior.handler).toBe(handler); + }); + + it("should preserve handler function type", () => { + const handler: InitHandler = vi.fn(); + const behavior = initBehavior(handler); + + expect(behavior.handler).toBe(handler); + }); + + it("should work with async handlers", async () => { + const handler = vi.fn().mockResolvedValue(undefined); + const behavior = initBehavior(handler); + + expect(behavior.type).toBe("init"); + expect(behavior.handler).toBe(handler); + + const mockUI = {} as FirebaseUI; + + await behavior.handler(mockUI); + expect(handler).toHaveBeenCalledWith(mockUI); + }); + + it("should work with sync handlers", () => { + const handler = vi.fn(); + const behavior = initBehavior(handler); + + expect(behavior.type).toBe("init"); + expect(behavior.handler).toBe(handler); + + const mockUI = {} as FirebaseUI; + + behavior.handler(mockUI); + expect(handler).toHaveBeenCalledWith(mockUI); + }); + }); + + describe("Behavior Types", () => { + it("should have correct type structure for CallableBehavior", () => { + const handler = vi.fn(); + const behavior: CallableBehavior = callableBehavior(handler); + + expect(behavior).toHaveProperty("type", "callable"); + expect(behavior).toHaveProperty("handler"); + expect(typeof behavior.handler).toBe("function"); + }); + + it("should have correct type structure for RedirectBehavior", () => { + const handler = vi.fn(); + const behavior: RedirectBehavior = redirectBehavior(handler); + + expect(behavior).toHaveProperty("type", "redirect"); + expect(behavior).toHaveProperty("handler"); + expect(typeof behavior.handler).toBe("function"); + }); + + it("should have correct type structure for InitBehavior", () => { + const handler = vi.fn(); + const behavior: InitBehavior = initBehavior(handler); + + expect(behavior).toHaveProperty("type", "init"); + expect(behavior).toHaveProperty("handler"); + expect(typeof behavior.handler).toBe("function"); + }); + }); + + describe("Handler Type Compatibility", () => { + it("should accept handlers with correct signatures", () => { + const callableHandler: CallableHandler = vi.fn(); + expect(() => callableBehavior(callableHandler)).not.toThrow(); + + const redirectHandler: RedirectHandler = vi.fn(); + expect(() => redirectBehavior(redirectHandler)).not.toThrow(); + + const initHandler: InitHandler = vi.fn(); + expect(() => initBehavior(initHandler)).not.toThrow(); + }); + }); +}); diff --git a/packages/core/src/behaviors/utils.ts b/packages/core/src/behaviors/utils.ts new file mode 100644 index 000000000..6f30e39c6 --- /dev/null +++ b/packages/core/src/behaviors/utils.ts @@ -0,0 +1,50 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { UserCredential } from "firebase/auth"; +import type { FirebaseUI } from "~/config"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type CallableHandler any = (...args: any[]) => any> = T; +export type InitHandler = (ui: FirebaseUI) => Promise | void; +export type RedirectHandler = (ui: FirebaseUI, result: UserCredential | null) => Promise | void; + +export type CallableBehavior = { + type: "callable"; + handler: T; +}; + +export type RedirectBehavior = { + type: "redirect"; + handler: T; +}; + +export type InitBehavior = { + type: "init"; + handler: T; +}; + +export function callableBehavior(handler: T): CallableBehavior { + return { type: "callable" as const, handler }; +} + +export function redirectBehavior(handler: T): RedirectBehavior { + return { type: "redirect" as const, handler }; +} + +export function initBehavior(handler: T): InitBehavior { + return { type: "init" as const, handler }; +} diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts new file mode 100644 index 000000000..84bb48859 --- /dev/null +++ b/packages/core/src/config.test.ts @@ -0,0 +1,511 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseApp } from "firebase/app"; +import { Auth, MultiFactorResolver } from "firebase/auth"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { initializeUI } from "./config"; +import { enUs, registerLocale } from "@invertase/firebaseui-translations"; +import { autoUpgradeAnonymousUsers, autoAnonymousLogin } from "./behaviors"; + +// Mock Firebase Auth +vi.mock("firebase/auth", () => ({ + getAuth: vi.fn(), + getRedirectResult: vi.fn().mockResolvedValue(null), + signInAnonymously: vi.fn(), + linkWithCredential: vi.fn(), + linkWithRedirect: vi.fn(), + RecaptchaVerifier: vi.fn(), +})); + +describe("initializeUI", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a valid deep store with default values", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + expect(ui).toBeDefined(); + expect(ui.get()).toBeDefined(); + expect(ui.get().app).toBe(config.app); + expect(ui.get().auth).toBe(config.auth); + expect(ui.get().behaviors).toHaveProperty("recaptchaVerification"); + expect(ui.get().behaviors.recaptchaVerification).toHaveProperty("type", "callable"); + expect(ui.get().state).toEqual("idle"); + expect(ui.get().locale).toEqual(enUs); + }); + + it("should merge behaviors with defaultBehaviors", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + behaviors: [autoUpgradeAnonymousUsers()], + }; + + const ui = initializeUI(config); + expect(ui).toBeDefined(); + expect(ui.get()).toBeDefined(); + + // Default behaviors + expect(ui.get().behaviors).toHaveProperty("recaptchaVerification"); + expect(ui.get().behaviors.recaptchaVerification).toHaveProperty("type", "callable"); + expect(ui.get().behaviors.recaptchaVerification).toHaveProperty("handler"); + + // Custom behaviors + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousCredential"); + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousProvider"); + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousUserRedirectHandler"); + }); + + it("should set state and update state when called", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + expect(ui.get().state).toEqual("idle"); + ui.get().setState("loading"); + expect(ui.get().state).toEqual("loading"); + ui.get().setState("idle"); + expect(ui.get().state).toEqual("idle"); + }); + + it("should set state and update locale when called", () => { + const testLocale1 = registerLocale("test1", {}); + const testLocale2 = registerLocale("test2", {}); + + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + expect(ui.get().locale.locale).toEqual("en-US"); + ui.get().setLocale(testLocale1); + expect(ui.get().locale.locale).toEqual("test1"); + ui.get().setLocale(testLocale2); + expect(ui.get().locale.locale).toEqual("test2"); + }); + + it("should include defaultBehaviors even when no custom behaviors are provided", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + expect(ui.get().behaviors).toHaveProperty("recaptchaVerification"); + expect(ui.get().behaviors.recaptchaVerification).toHaveProperty("type", "callable"); + }); + + it("should allow overriding default behaviors", () => { + const customRecaptchaVerification = { + recaptchaVerification: { + type: "callable" as const, + handler: vi.fn(() => { + // Custom implementation + return {} as any; + }), + }, + }; + + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + behaviors: [customRecaptchaVerification], + }; + + const ui = initializeUI(config); + expect(ui.get().behaviors).toHaveProperty("recaptchaVerification"); + expect(ui.get().behaviors.recaptchaVerification).toHaveProperty("type", "callable"); + }); + + it("should merge multiple behavior objects correctly", () => { + const behavior1 = autoUpgradeAnonymousUsers(); + const behavior2 = { + recaptchaVerification: { + type: "callable" as const, + handler: vi.fn(() => { + // Custom recaptcha implementation + return {} as any; + }), + }, + }; + + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + behaviors: [behavior1, behavior2], + }; + + const ui = initializeUI(config); + + expect(ui.get().behaviors).toHaveProperty("recaptchaVerification"); + expect(ui.get().behaviors.recaptchaVerification).toHaveProperty("type", "callable"); + + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousCredential"); + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousProvider"); + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousUserRedirectHandler"); + }); + + it("should handle init behaviors correctly", () => { + const mockAuth = { + authStateReady: vi.fn().mockResolvedValue(undefined), + currentUser: null, + } as any; + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + behaviors: [autoAnonymousLogin()], + }; + + const ui = initializeUI(config); + + expect(ui.get().behaviors).toHaveProperty("autoAnonymousLogin"); + }); + + it("should handle redirect behaviors correctly", () => { + const mockAuth = { + currentUser: null, + } as any; + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + behaviors: [autoUpgradeAnonymousUsers()], + }; + + const ui = initializeUI(config); + + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousUserRedirectHandler"); + }); + + it("should handle mixed behavior types", () => { + const mockAuth = { + authStateReady: vi.fn().mockResolvedValue(undefined), + currentUser: null, + } as any; + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + behaviors: [autoAnonymousLogin(), autoUpgradeAnonymousUsers()], + }; + + const ui = initializeUI(config); + + expect(ui.get().behaviors).toHaveProperty("autoAnonymousLogin"); + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousCredential"); + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousProvider"); + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousUserRedirectHandler"); + + // Default.. + expect(ui.get().behaviors).toHaveProperty("recaptchaVerification"); + }); + + it("should execute init behaviors when window is defined", async () => { + Object.defineProperty(global, "window", { + value: {}, + writable: true, + configurable: true, + }); + + const mockAuth = { + authStateReady: vi.fn().mockResolvedValue(undefined), + currentUser: null, + } as any; + + const mockInitHandler = vi.fn().mockResolvedValue(undefined); + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + behaviors: [ + { + customInit: { + type: "init" as const, + handler: mockInitHandler, + }, + }, + ], + }; + + const ui = initializeUI(config); + + // Process next tick to make sure the noop promises are resolved + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockAuth.authStateReady).toHaveBeenCalledTimes(1); + expect(mockInitHandler).toHaveBeenCalledTimes(1); + expect(mockInitHandler).toHaveBeenCalledWith(ui.get()); + + delete (global as any).window; + }); + + it("should execute redirect behaviors when window is defined", async () => { + Object.defineProperty(global, "window", { + value: {}, + writable: true, + configurable: true, + }); + + const mockAuth = { + currentUser: null, + } as any; + + const mockRedirectHandler = vi.fn().mockResolvedValue(undefined); + const mockRedirectResult = { user: { uid: "test-123" } }; + + const { getRedirectResult } = await import("firebase/auth"); + vi.mocked(getRedirectResult).mockClear(); + vi.mocked(getRedirectResult).mockResolvedValue(mockRedirectResult as any); + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + behaviors: [ + { + customRedirect: { + type: "redirect" as const, + handler: mockRedirectHandler, + }, + }, + ], + }; + + const ui = initializeUI(config); + + // Process next tick to make sure the noop promises are resolved + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(getRedirectResult).toHaveBeenCalledTimes(1); + expect(getRedirectResult).toHaveBeenCalledWith(mockAuth); + expect(mockRedirectHandler).toHaveBeenCalledTimes(1); + expect(mockRedirectHandler).toHaveBeenCalledWith(ui.get(), mockRedirectResult); + + delete (global as any).window; + }); + + it("should not execute behaviors when window is undefined", async () => { + const mockAuth = { + authStateReady: vi.fn().mockResolvedValue(undefined), + currentUser: null, + } as any; + + const { getRedirectResult } = await import("firebase/auth"); + vi.mocked(getRedirectResult).mockClear(); + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + behaviors: [ + { + customInit: { + type: "init" as const, + handler: vi.fn(), + }, + customRedirect: { + type: "redirect" as const, + handler: vi.fn(), + }, + }, + ], + }; + + const ui = initializeUI(config); + + // Process next tick to make sure the noop promises are resolved + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockAuth.authStateReady).not.toHaveBeenCalled(); + expect(getRedirectResult).not.toHaveBeenCalled(); + + expect(ui.get().state).toBe("idle"); + }); + + it("should have multiFactorResolver undefined by default", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + expect(ui.get().multiFactorResolver).toBeUndefined(); + }); + + it("should set and get multiFactorResolver correctly", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + const mockMultiFactorResolver = { + auth: {} as Auth, + session: null, + hints: [], + } as unknown as MultiFactorResolver; + + expect(ui.get().multiFactorResolver).toBeUndefined(); + ui.get().setMultiFactorResolver(mockMultiFactorResolver); + expect(ui.get().multiFactorResolver).toBe(mockMultiFactorResolver); + ui.get().setMultiFactorResolver(undefined); + expect(ui.get().multiFactorResolver).toBeUndefined(); + }); + + it("should update multiFactorResolver multiple times", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + const mockResolver1 = { + auth: {} as Auth, + session: null, + hints: [], + } as unknown as MultiFactorResolver; + + const mockResolver2 = { + auth: {} as Auth, + session: null, + hints: [], + } as unknown as MultiFactorResolver; + + ui.get().setMultiFactorResolver(mockResolver1); + expect(ui.get().multiFactorResolver).toBe(mockResolver1); + ui.get().setMultiFactorResolver(mockResolver2); + expect(ui.get().multiFactorResolver).toBe(mockResolver2); + ui.get().setMultiFactorResolver(undefined); + expect(ui.get().multiFactorResolver).toBeUndefined(); + }); + + it("should have redirectError undefined by default", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + expect(ui.get().redirectError).toBeUndefined(); + }); + + it("should set and get redirectError correctly", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + const mockError = new Error("Test redirect error"); + + expect(ui.get().redirectError).toBeUndefined(); + ui.get().setRedirectError(mockError); + expect(ui.get().redirectError).toBe(mockError); + ui.get().setRedirectError(undefined); + expect(ui.get().redirectError).toBeUndefined(); + }); + + it("should update redirectError multiple times", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + const mockError1 = new Error("First error"); + const mockError2 = new Error("Second error"); + + ui.get().setRedirectError(mockError1); + expect(ui.get().redirectError).toBe(mockError1); + ui.get().setRedirectError(mockError2); + expect(ui.get().redirectError).toBe(mockError2); + ui.get().setRedirectError(undefined); + expect(ui.get().redirectError).toBeUndefined(); + }); + + it("should handle redirect error when getRedirectResult throws", async () => { + Object.defineProperty(global, "window", { + value: {}, + writable: true, + configurable: true, + }); + + const mockAuth = { + currentUser: null, + } as any; + + const mockError = new Error("Redirect failed"); + const { getRedirectResult } = await import("firebase/auth"); + vi.mocked(getRedirectResult).mockClear(); + vi.mocked(getRedirectResult).mockRejectedValue(mockError); + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + }; + + const ui = initializeUI(config); + + // Process next tick to make sure the promise is resolved + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(getRedirectResult).toHaveBeenCalledTimes(1); + expect(getRedirectResult).toHaveBeenCalledWith(mockAuth); + expect(ui.get().redirectError).toBe(mockError); + + delete (global as any).window; + }); + + it("should convert non-Error objects to Error instances in redirect catch", async () => { + Object.defineProperty(global, "window", { + value: {}, + writable: true, + configurable: true, + }); + + const mockAuth = { + currentUser: null, + } as any; + + const { getRedirectResult } = await import("firebase/auth"); + vi.mocked(getRedirectResult).mockClear(); + vi.mocked(getRedirectResult).mockRejectedValue("String error"); + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + }; + + const ui = initializeUI(config); + + // Process next tick to make sure the promise is resolved + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(getRedirectResult).toHaveBeenCalledTimes(1); + expect(ui.get().redirectError).toBeInstanceOf(Error); + expect(ui.get().redirectError?.message).toBe("String error"); + + delete (global as any).window; + }); +}); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts new file mode 100644 index 000000000..b342c03a5 --- /dev/null +++ b/packages/core/src/config.ts @@ -0,0 +1,181 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { enUs, type RegisteredLocale } from "@invertase/firebaseui-translations"; +import type { FirebaseApp } from "firebase/app"; +import { type Auth, getAuth, getRedirectResult, type MultiFactorResolver } from "firebase/auth"; +import { deepMap, type DeepMapStore, map } from "nanostores"; +import { type Behavior, type Behaviors, defaultBehaviors } from "./behaviors"; +import type { InitBehavior, RedirectBehavior } from "./behaviors/utils"; +import { type FirebaseUIState } from "./state"; +import { handleFirebaseError } from "./errors"; + +/** + * Configuration options for initializing FirebaseUI. + */ +export type FirebaseUIOptions = { + /** A required Firebase App instance, e.g. from `initializeApp`. */ + app: FirebaseApp; + /** An optional Firebase Auth instance, e.g. from `getAuth`. If not provided, it will be created using the app instance. */ + auth?: Auth; + /** A default locale to use. Defaults to `enUs`. */ + locale?: RegisteredLocale; + /** An optional array of behaviors, e.g. from `requireDisplayName`. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + behaviors?: Behavior[]; +}; + +/** + * The main FirebaseUI instance that provides access to Firebase Auth and UI state management. + * + * This type encapsulates all the necessary components for managing authentication UI state, + * including Firebase app and auth instances, locale settings, behaviors, and multi-factor + * authentication state. + */ +export type FirebaseUI = { + /** The Firebase App instance. */ + app: FirebaseApp; + /** The Firebase Auth instance. */ + auth: Auth; + /** Sets the locale for translations. */ + setLocale: (locale: RegisteredLocale) => void; + /** The current UI state (e.g., "idle", "pending", "loading"). */ + state: FirebaseUIState; + /** Sets the UI state. */ + setState: (state: FirebaseUIState) => void; + /** The current locale for translations. */ + locale: RegisteredLocale; + /** The configured behaviors that customize authentication flows. */ + behaviors: Behaviors; + /** The multi-factor resolver, if a multi-factor challenge is in progress. */ + multiFactorResolver?: MultiFactorResolver; + /** Sets the multi-factor resolver. */ + setMultiFactorResolver: (multiFactorResolver?: MultiFactorResolver) => void; + /** Any error that occurred during a redirect-based authentication flow. */ + redirectError?: Error; + /** Sets the redirect error. */ + setRedirectError: (error?: Error) => void; +}; + +export const $config = map>>({}); + +/** + * A reactive store containing a FirebaseUI instance. + * + * This store allows for reactive updates to the FirebaseUI state, enabling UI components + * to automatically update when the authentication state or configuration changes. + */ +export type FirebaseUIStore = DeepMapStore; + +/** + * Initializes a FirebaseUI instance with the provided configuration. + * + * Creates a reactive store containing the FirebaseUI instance, sets up behaviors, + * and handles initialization and redirect flows if running client-side. + * + * Example: + * ```typescript + * const ui = initializeUI({ + * app: firebaseApp, + * locale: enUs, + * behaviors: [requireDisplayName()], + * }); + * ``` + * + * @param config - The configuration options for FirebaseUI. + * @param name - Optional name for the FirebaseUI instance. Defaults to "[DEFAULT]". + * @returns {FirebaseUIStore} A reactive store containing the initialized FirebaseUI instance. + */ +export function initializeUI(config: FirebaseUIOptions, name: string = "[DEFAULT]"): FirebaseUIStore { + // Reduce the behaviors to a single object. + const behaviors = config.behaviors?.reduce((acc, behavior) => { + return { + ...acc, + ...behavior, + }; + }, defaultBehaviors as Behavior); + + $config.setKey( + name, + deepMap({ + app: config.app, + auth: config.auth || getAuth(config.app), + locale: config.locale ?? enUs, + setLocale: (locale: RegisteredLocale) => { + const current = $config.get()[name]!; + current.setKey(`locale`, locale); + }, + state: "idle", + setState: (state: FirebaseUIState) => { + const current = $config.get()[name]!; + current.setKey(`state`, state); + }, + // Since we've got config.behaviors?.reduce above, we need to default to defaultBehaviors + // if no behaviors are provided, as they wont be in the reducer. + behaviors: behaviors ?? (defaultBehaviors as Behavior), + multiFactorResolver: undefined, + setMultiFactorResolver: (resolver?: MultiFactorResolver) => { + const current = $config.get()[name]!; + current.setKey(`multiFactorResolver`, resolver); + }, + redirectError: undefined, + setRedirectError: (error?: Error) => { + const current = $config.get()[name]!; + current.setKey(`redirectError`, error); + }, + }) + ); + + const store = $config.get()[name]!; + const ui = store.get(); + + // If we're client-side, execute the init and redirect behaviors. + if (typeof window !== "undefined") { + const initBehaviors: InitBehavior[] = []; + const redirectBehaviors: RedirectBehavior[] = []; + + for (const behavior of Object.values(ui.behaviors)) { + if (behavior.type === "redirect") { + redirectBehaviors.push(behavior); + } else if (behavior.type === "init") { + initBehaviors.push(behavior); + } + } + + if (initBehaviors.length > 0) { + store.setKey("state", "loading"); + ui.auth.authStateReady().then(() => { + Promise.all(initBehaviors.map((behavior) => behavior.handler(ui))).then(() => { + store.setKey("state", "idle"); + }); + }); + } + + getRedirectResult(ui.auth) + .then((result) => { + return Promise.all(redirectBehaviors.map((behavior) => behavior.handler(ui, result))); + }) + .catch((error) => { + try { + handleFirebaseError(ui, error); + } catch (error) { + ui.setRedirectError(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + return store; +} diff --git a/packages/core/src/country-data.test.ts b/packages/core/src/country-data.test.ts new file mode 100644 index 000000000..6a23cb81f --- /dev/null +++ b/packages/core/src/country-data.test.ts @@ -0,0 +1,195 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from "vitest"; +import { countryData, formatPhoneNumber, CountryData, CountryCode } from "./country-data"; + +describe("CountryData", () => { + it("should have correct structure for all countries", () => { + countryData.forEach((country) => { + expect(country).toHaveProperty("name"); + expect(country).toHaveProperty("dialCode"); + expect(country).toHaveProperty("code"); + expect(country).toHaveProperty("emoji"); + + expect(typeof country.name).toBe("string"); + expect(typeof country.dialCode).toBe("string"); + expect(typeof country.code).toBe("string"); + expect(typeof country.emoji).toBe("string"); + + expect(country.name.length).toBeGreaterThan(0); + expect(country.dialCode).toMatch(/^\+\d+$/); + expect(country.code).toMatch(/^[A-Z]{2}$/); + expect(country.emoji.length).toBeGreaterThan(0); + }); + }); + + it("should handle countries with multiple dial codes", () => { + const kosovoCountries = countryData.filter((country) => country.code === "XK"); + expect(kosovoCountries.length).toBeGreaterThan(1); + + // Test that Kosovo has multiple entries with different dial codes + const dialCodes = kosovoCountries.map((country) => country.dialCode); + expect(dialCodes).toContain("+377"); + expect(dialCodes).toContain("+381"); + expect(dialCodes).toContain("+386"); + }); + + describe("countryData array", () => { + it("should have valid dial codes", () => { + countryData.forEach((country) => { + expect(country.dialCode).toMatch(/^\+\d{1,4}$/); + expect(country.dialCode.length).toBeGreaterThanOrEqual(2); // +1 + expect(country.dialCode.length).toBeLessThanOrEqual(5); // +1234 + }); + }); + + it("should have valid country codes (ISO 3166-1 alpha-2)", () => { + countryData.forEach((country) => { + expect(country.code).toMatch(/^[A-Z]{2}$/); + }); + }); + + it("should have valid emojis", () => { + countryData.forEach((country) => { + // Emojis should be flag emojis (typically 2 characters in UTF-16) + expect(country.emoji.length).toBeGreaterThan(0); + // Most flag emojis are 4 bytes in UTF-8, but some might be different + expect(country.emoji).toMatch(/[\u{1F1E6}-\u{1F1FF}]{2}/u); + }); + }); + }); + + describe("CountryCode type", () => { + it("should have proper literal types", () => { + // These should be valid CountryCode values + const validCodes: CountryCode[] = ["US", "GB", "CA", "AU", "DE", "FR"]; + expect(validCodes).toBeDefined(); + + // Test that we can find countries by their codes + const usCountry = countryData.find((country) => country.code === "US"); + const gbCountry = countryData.find((country) => country.code === "GB"); + + expect(usCountry).toBeDefined(); + expect(gbCountry).toBeDefined(); + expect(usCountry?.code).toBe("US"); + expect(gbCountry?.code).toBe("GB"); + }); + }); + + describe("formatPhoneNumber", () => { + const ukCountry: CountryData = { name: "United Kingdom", dialCode: "+44", code: "GB", emoji: "🇬🇧" }; + const usCountry: CountryData = { name: "United States", dialCode: "+1", code: "US", emoji: "🇺🇸" }; + const kzCountry: CountryData = { name: "Kazakhstan", dialCode: "+7", code: "KZ", emoji: "🇰🇿" }; + + describe("basic formatting", () => { + it("should format phone number with country dial code", () => { + expect(formatPhoneNumber("07480842372", ukCountry)).toBe("+447480842372"); + expect(formatPhoneNumber("2125551234", usCountry)).toBe("+12125551234"); + expect(formatPhoneNumber("7012345678", kzCountry)).toBe("+77012345678"); + }); + + it("should handle phone numbers with spaces and special characters", () => { + expect(formatPhoneNumber("07480 842 372", ukCountry)).toBe("+447480842372"); + expect(formatPhoneNumber("(212) 555-1234", usCountry)).toBe("+12125551234"); + expect(formatPhoneNumber("701-234-5678", kzCountry)).toBe("+77012345678"); + }); + }); + + describe("handling numbers with existing country codes", () => { + it("should preserve correct country code", () => { + expect(formatPhoneNumber("+441234567890", ukCountry)).toBe("+441234567890"); + expect(formatPhoneNumber("+11234567890", usCountry)).toBe("+11234567890"); + expect(formatPhoneNumber("+71234567890", kzCountry)).toBe("+71234567890"); + }); + + it("should preserve existing country code even if different from context", () => { + expect(formatPhoneNumber("+12125551234", ukCountry)).toBe("+12125551234"); + expect(formatPhoneNumber("+447480842372", usCountry)).toBe("+447480842372"); + expect(formatPhoneNumber("+447480842372", kzCountry)).toBe("+447480842372"); + }); + + it("should handle numbers with different country codes", () => { + expect(formatPhoneNumber("+77012345678", ukCountry)).toBe("+77012345678"); + expect(formatPhoneNumber("+77012345678", usCountry)).toBe("+77012345678"); + expect(formatPhoneNumber("+447480842372", kzCountry)).toBe("+447480842372"); + }); + }); + + describe("handling numbers starting with 0", () => { + it("should remove leading 0 and add country code", () => { + expect(formatPhoneNumber("07480842372", ukCountry)).toBe("+447480842372"); + expect(formatPhoneNumber("02125551234", usCountry)).toBe("02125551234"); + expect(formatPhoneNumber("07012345678", kzCountry)).toBe("07012345678"); + }); + + it("should handle numbers with 0 and existing country code", () => { + expect(formatPhoneNumber("+4407480842372", ukCountry)).toBe("+447480842372"); + expect(formatPhoneNumber("+102125551234", usCountry)).toBe("+102125551234"); + }); + }); + + describe("handling numbers with country dial code without +", () => { + it("should add + to numbers starting with country dial code", () => { + expect(formatPhoneNumber("447480842372", ukCountry)).toBe("+447480842372"); + expect(formatPhoneNumber("12125551234", usCountry)).toBe("+12125551234"); + expect(formatPhoneNumber("77012345678", kzCountry)).toBe("+77012345678"); + }); + }); + + describe("edge cases", () => { + it("should handle empty phone numbers", () => { + expect(formatPhoneNumber("", ukCountry)).toBe(""); + expect(formatPhoneNumber(" ", ukCountry)).toBe(""); + }); + + it("should handle very long phone numbers", () => { + const longNumber = "12345678901234567890"; + expect(formatPhoneNumber(longNumber, ukCountry)).toBe("12345678901234567890"); + }); + + it("should handle numbers with multiple + signs", () => { + expect(formatPhoneNumber("++447480842372", ukCountry)).toBe("+"); + expect(formatPhoneNumber("+44+7480842372", ukCountry)).toBe("+44"); + }); + + it("should handle numbers with mixed formatting", () => { + expect(formatPhoneNumber("+44 (0) 7480 842372", ukCountry)).toBe("+447480842372"); + expect(formatPhoneNumber("+1-800-123-4567", usCountry)).toBe("+18001234567"); + }); + }); + + describe("real-world examples", () => { + it("should handle UK mobile numbers", () => { + expect(formatPhoneNumber("07480842372", ukCountry)).toBe("+447480842372"); + expect(formatPhoneNumber("+447480842372", ukCountry)).toBe("+447480842372"); + expect(formatPhoneNumber("447480842372", ukCountry)).toBe("+447480842372"); + }); + + it("should handle US phone numbers", () => { + expect(formatPhoneNumber("(212) 555-1234", usCountry)).toBe("+12125551234"); + expect(formatPhoneNumber("212-555-1234", usCountry)).toBe("+12125551234"); + expect(formatPhoneNumber("+12125551234", usCountry)).toBe("+12125551234"); + }); + + it("should handle Kazakhstan numbers", () => { + expect(formatPhoneNumber("+77012345678", kzCountry)).toBe("+77012345678"); + expect(formatPhoneNumber("7012345678", kzCountry)).toBe("+77012345678"); + expect(formatPhoneNumber("07012345678", kzCountry)).toBe("07012345678"); + }); + }); + }); +}); diff --git a/packages/core/src/country-data.ts b/packages/core/src/country-data.ts new file mode 100644 index 000000000..ddc4915a8 --- /dev/null +++ b/packages/core/src/country-data.ts @@ -0,0 +1,320 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { formatIncompletePhoneNumber, parsePhoneNumberWithError, type CountryCode } from "libphonenumber-js"; + +/** + * An array of country data objects containing name, dial code, country code, and emoji for all supported countries. + */ +export const countryData = [ + { name: "Afghanistan", dialCode: "+93", code: "AF", emoji: "🇦🇫" }, + { name: "Albania", dialCode: "+355", code: "AL", emoji: "🇦🇱" }, + { name: "Algeria", dialCode: "+213", code: "DZ", emoji: "🇩🇿" }, + { name: "American Samoa", dialCode: "+1", code: "AS", emoji: "🇦🇸" }, + { name: "Andorra", dialCode: "+376", code: "AD", emoji: "🇦🇩" }, + { name: "Angola", dialCode: "+244", code: "AO", emoji: "🇦🇴" }, + { name: "Anguilla", dialCode: "+1", code: "AI", emoji: "🇦🇮" }, + { name: "Antigua and Barbuda", dialCode: "+1", code: "AG", emoji: "🇦🇬" }, + { name: "Argentina", dialCode: "+54", code: "AR", emoji: "🇦🇷" }, + { name: "Armenia", dialCode: "+374", code: "AM", emoji: "🇦🇲" }, + { name: "Aruba", dialCode: "+297", code: "AW", emoji: "🇦🇼" }, + { name: "Ascension Island", dialCode: "+247", code: "AC", emoji: "🇦🇨" }, + { name: "Australia", dialCode: "+61", code: "AU", emoji: "🇦🇺" }, + { name: "Austria", dialCode: "+43", code: "AT", emoji: "🇦🇹" }, + { name: "Azerbaijan", dialCode: "+994", code: "AZ", emoji: "🇦🇿" }, + { name: "Bahamas", dialCode: "+1", code: "BS", emoji: "🇧🇸" }, + { name: "Bahrain", dialCode: "+973", code: "BH", emoji: "🇧🇭" }, + { name: "Bangladesh", dialCode: "+880", code: "BD", emoji: "🇧🇩" }, + { name: "Barbados", dialCode: "+1", code: "BB", emoji: "🇧🇧" }, + { name: "Belarus", dialCode: "+375", code: "BY", emoji: "🇧🇾" }, + { name: "Belgium", dialCode: "+32", code: "BE", emoji: "🇧🇪" }, + { name: "Belize", dialCode: "+501", code: "BZ", emoji: "🇧🇿" }, + { name: "Benin", dialCode: "+229", code: "BJ", emoji: "🇧🇯" }, + { name: "Bermuda", dialCode: "+1", code: "BM", emoji: "🇧🇲" }, + { name: "Bhutan", dialCode: "+975", code: "BT", emoji: "🇧🇹" }, + { name: "Bolivia", dialCode: "+591", code: "BO", emoji: "🇧🇴" }, + { name: "Bosnia and Herzegovina", dialCode: "+387", code: "BA", emoji: "🇧🇦" }, + { name: "Botswana", dialCode: "+267", code: "BW", emoji: "🇧🇼" }, + { name: "Brazil", dialCode: "+55", code: "BR", emoji: "🇧🇷" }, + { name: "British Indian Ocean Territory", dialCode: "+246", code: "IO", emoji: "🇮🇴" }, + { name: "British Virgin Islands", dialCode: "+1", code: "VG", emoji: "🇻🇬" }, + { name: "Brunei", dialCode: "+673", code: "BN", emoji: "🇧🇳" }, + { name: "Bulgaria", dialCode: "+359", code: "BG", emoji: "🇧🇬" }, + { name: "Burkina Faso", dialCode: "+226", code: "BF", emoji: "🇧🇫" }, + { name: "Burundi", dialCode: "+257", code: "BI", emoji: "🇧🇮" }, + { name: "Cambodia", dialCode: "+855", code: "KH", emoji: "🇰🇭" }, + { name: "Cameroon", dialCode: "+237", code: "CM", emoji: "🇨🇲" }, + { name: "Canada", dialCode: "+1", code: "CA", emoji: "🇨🇦" }, + { name: "Cape Verde", dialCode: "+238", code: "CV", emoji: "🇨🇻" }, + { name: "Caribbean Netherlands", dialCode: "+599", code: "BQ", emoji: "🇧🇶" }, + { name: "Cayman Islands", dialCode: "+1", code: "KY", emoji: "🇰🇾" }, + { name: "Central African Republic", dialCode: "+236", code: "CF", emoji: "🇨🇫" }, + { name: "Chad", dialCode: "+235", code: "TD", emoji: "🇹🇩" }, + { name: "Chile", dialCode: "+56", code: "CL", emoji: "🇨🇱" }, + { name: "China", dialCode: "+86", code: "CN", emoji: "🇨🇳" }, + { name: "Christmas Island", dialCode: "+61", code: "CX", emoji: "🇨🇽" }, + { name: "Cocos [Keeling] Islands", dialCode: "+61", code: "CC", emoji: "🇨🇨" }, + { name: "Colombia", dialCode: "+57", code: "CO", emoji: "🇨🇴" }, + { name: "Comoros", dialCode: "+269", code: "KM", emoji: "🇰🇲" }, + { name: "Democratic Republic Congo", dialCode: "+243", code: "CD", emoji: "🇨🇩" }, + { name: "Republic of Congo", dialCode: "+242", code: "CG", emoji: "🇨🇬" }, + { name: "Cook Islands", dialCode: "+682", code: "CK", emoji: "🇨🇰" }, + { name: "Costa Rica", dialCode: "+506", code: "CR", emoji: "🇨🇷" }, + { name: "Côte d'Ivoire", dialCode: "+225", code: "CI", emoji: "🇨🇮" }, + { name: "Croatia", dialCode: "+385", code: "HR", emoji: "🇭🇷" }, + { name: "Cuba", dialCode: "+53", code: "CU", emoji: "🇨🇺" }, + { name: "Curaçao", dialCode: "+599", code: "CW", emoji: "🇨🇼" }, + { name: "Cyprus", dialCode: "+357", code: "CY", emoji: "🇨🇾" }, + { name: "Czech Republic", dialCode: "+420", code: "CZ", emoji: "🇨🇿" }, + { name: "Denmark", dialCode: "+45", code: "DK", emoji: "🇩🇰" }, + { name: "Djibouti", dialCode: "+253", code: "DJ", emoji: "🇩🇯" }, + { name: "Dominica", dialCode: "+1", code: "DM", emoji: "🇩🇲" }, + { name: "Dominican Republic", dialCode: "+1", code: "DO", emoji: "🇩🇴" }, + { name: "East Timor", dialCode: "+670", code: "TL", emoji: "🇹🇱" }, + { name: "Ecuador", dialCode: "+593", code: "EC", emoji: "🇪🇨" }, + { name: "Egypt", dialCode: "+20", code: "EG", emoji: "🇪🇬" }, + { name: "El Salvador", dialCode: "+503", code: "SV", emoji: "🇸🇻" }, + { name: "Equatorial Guinea", dialCode: "+240", code: "GQ", emoji: "🇬🇶" }, + { name: "Eritrea", dialCode: "+291", code: "ER", emoji: "🇪🇷" }, + { name: "Estonia", dialCode: "+372", code: "EE", emoji: "🇪🇪" }, + { name: "Ethiopia", dialCode: "+251", code: "ET", emoji: "🇪🇹" }, + { name: "Falkland Islands [Islas Malvinas]", dialCode: "+500", code: "FK", emoji: "🇫🇰" }, + { name: "Faroe Islands", dialCode: "+298", code: "FO", emoji: "🇫🇴" }, + { name: "Fiji", dialCode: "+679", code: "FJ", emoji: "🇫🇯" }, + { name: "Finland", dialCode: "+358", code: "FI", emoji: "🇫🇮" }, + { name: "France", dialCode: "+33", code: "FR", emoji: "🇫🇷" }, + { name: "French Guiana", dialCode: "+594", code: "GF", emoji: "🇬🇫" }, + { name: "French Polynesia", dialCode: "+689", code: "PF", emoji: "🇵🇫" }, + { name: "Gabon", dialCode: "+241", code: "GA", emoji: "🇬🇦" }, + { name: "Gambia", dialCode: "+220", code: "GM", emoji: "🇬🇲" }, + { name: "Georgia", dialCode: "+995", code: "GE", emoji: "🇬🇪" }, + { name: "Germany", dialCode: "+49", code: "DE", emoji: "🇩🇪" }, + { name: "Ghana", dialCode: "+233", code: "GH", emoji: "🇬🇭" }, + { name: "Gibraltar", dialCode: "+350", code: "GI", emoji: "🇬🇮" }, + { name: "Greece", dialCode: "+30", code: "GR", emoji: "🇬🇷" }, + { name: "Greenland", dialCode: "+299", code: "GL", emoji: "🇬🇱" }, + { name: "Grenada", dialCode: "+1", code: "GD", emoji: "🇬🇩" }, + { name: "Guadeloupe", dialCode: "+590", code: "GP", emoji: "🇬🇵" }, + { name: "Guam", dialCode: "+1", code: "GU", emoji: "🇬🇺" }, + { name: "Guatemala", dialCode: "+502", code: "GT", emoji: "🇬🇹" }, + { name: "Guernsey", dialCode: "+44", code: "GG", emoji: "🇬🇬" }, + { name: "Guinea Conakry", dialCode: "+224", code: "GN", emoji: "🇬🇳" }, + { name: "Guinea-Bissau", dialCode: "+245", code: "GW", emoji: "🇬🇼" }, + { name: "Guyana", dialCode: "+592", code: "GY", emoji: "🇬🇾" }, + { name: "Haiti", dialCode: "+509", code: "HT", emoji: "🇭🇹" }, + { name: "Honduras", dialCode: "+504", code: "HN", emoji: "🇭🇳" }, + { name: "Hong Kong", dialCode: "+852", code: "HK", emoji: "🇭🇰" }, + { name: "Hungary", dialCode: "+36", code: "HU", emoji: "🇭🇺" }, + { name: "Iceland", dialCode: "+354", code: "IS", emoji: "🇮🇸" }, + { name: "India", dialCode: "+91", code: "IN", emoji: "🇮🇳" }, + { name: "Indonesia", dialCode: "+62", code: "ID", emoji: "🇮🇩" }, + { name: "Iran", dialCode: "+98", code: "IR", emoji: "🇮🇷" }, + { name: "Iraq", dialCode: "+964", code: "IQ", emoji: "🇮🇶" }, + { name: "Ireland", dialCode: "+353", code: "IE", emoji: "🇮🇪" }, + { name: "Isle of Man", dialCode: "+44", code: "IM", emoji: "🇮🇲" }, + { name: "Israel", dialCode: "+972", code: "IL", emoji: "🇮🇱" }, + { name: "Italy", dialCode: "+39", code: "IT", emoji: "🇮🇹" }, + { name: "Jamaica", dialCode: "+1", code: "JM", emoji: "🇯🇲" }, + { name: "Japan", dialCode: "+81", code: "JP", emoji: "🇯🇵" }, + { name: "Jersey", dialCode: "+44", code: "JE", emoji: "🇯🇪" }, + { name: "Jordan", dialCode: "+962", code: "JO", emoji: "🇯🇴" }, + { name: "Kazakhstan", dialCode: "+7", code: "KZ", emoji: "🇰🇿" }, + { name: "Kenya", dialCode: "+254", code: "KE", emoji: "🇰🇪" }, + { name: "Kiribati", dialCode: "+686", code: "KI", emoji: "🇰🇮" }, + { name: "Kosovo", dialCode: "+377", code: "XK", emoji: "🇽🇰" }, + { name: "Kosovo", dialCode: "+381", code: "XK", emoji: "🇽🇰" }, + { name: "Kosovo", dialCode: "+386", code: "XK", emoji: "🇽🇰" }, + { name: "Kuwait", dialCode: "+965", code: "KW", emoji: "🇰🇼" }, + { name: "Kyrgyzstan", dialCode: "+996", code: "KG", emoji: "🇰🇬" }, + { name: "Laos", dialCode: "+856", code: "LA", emoji: "🇱🇦" }, + { name: "Latvia", dialCode: "+371", code: "LV", emoji: "🇱🇻" }, + { name: "Lebanon", dialCode: "+961", code: "LB", emoji: "🇱🇧" }, + { name: "Lesotho", dialCode: "+266", code: "LS", emoji: "🇱🇸" }, + { name: "Liberia", dialCode: "+231", code: "LR", emoji: "🇱🇷" }, + { name: "Libya", dialCode: "+218", code: "LY", emoji: "🇱🇾" }, + { name: "Liechtenstein", dialCode: "+423", code: "LI", emoji: "🇱🇮" }, + { name: "Lithuania", dialCode: "+370", code: "LT", emoji: "🇱🇹" }, + { name: "Luxembourg", dialCode: "+352", code: "LU", emoji: "🇱🇺" }, + { name: "Macau", dialCode: "+853", code: "MO", emoji: "🇲🇴" }, + { name: "Macedonia", dialCode: "+389", code: "MK", emoji: "🇲🇰" }, + { name: "Madagascar", dialCode: "+261", code: "MG", emoji: "🇲🇬" }, + { name: "Malawi", dialCode: "+265", code: "MW", emoji: "🇲🇼" }, + { name: "Malaysia", dialCode: "+60", code: "MY", emoji: "🇲🇾" }, + { name: "Maldives", dialCode: "+960", code: "MV", emoji: "🇲🇻" }, + { name: "Mali", dialCode: "+223", code: "ML", emoji: "🇲🇱" }, + { name: "Malta", dialCode: "+356", code: "MT", emoji: "🇲🇹" }, + { name: "Marshall Islands", dialCode: "+692", code: "MH", emoji: "🇲🇭" }, + { name: "Martinique", dialCode: "+596", code: "MQ", emoji: "🇲🇶" }, + { name: "Mauritania", dialCode: "+222", code: "MR", emoji: "🇲🇷" }, + { name: "Mauritius", dialCode: "+230", code: "MU", emoji: "🇲🇺" }, + { name: "Mayotte", dialCode: "+262", code: "YT", emoji: "🇾🇹" }, + { name: "Mexico", dialCode: "+52", code: "MX", emoji: "🇲🇽" }, + { name: "Micronesia", dialCode: "+691", code: "FM", emoji: "🇫🇲" }, + { name: "Moldova", dialCode: "+373", code: "MD", emoji: "🇲🇩" }, + { name: "Monaco", dialCode: "+377", code: "MC", emoji: "🇲🇨" }, + { name: "Mongolia", dialCode: "+976", code: "MN", emoji: "🇲🇳" }, + { name: "Montenegro", dialCode: "+382", code: "ME", emoji: "🇲🇪" }, + { name: "Montserrat", dialCode: "+1", code: "MS", emoji: "🇲🇸" }, + { name: "Morocco", dialCode: "+212", code: "MA", emoji: "🇲🇦" }, + { name: "Mozambique", dialCode: "+258", code: "MZ", emoji: "🇲🇿" }, + { name: "Myanmar [Burma]", dialCode: "+95", code: "MM", emoji: "🇲🇲" }, + { name: "Namibia", dialCode: "+264", code: "NA", emoji: "🇳🇦" }, + { name: "Nauru", dialCode: "+674", code: "NR", emoji: "🇳🇷" }, + { name: "Nepal", dialCode: "+977", code: "NP", emoji: "🇳🇵" }, + { name: "Netherlands", dialCode: "+31", code: "NL", emoji: "🇳🇱" }, + { name: "New Caledonia", dialCode: "+687", code: "NC", emoji: "🇳🇨" }, + { name: "New Zealand", dialCode: "+64", code: "NZ", emoji: "🇳🇿" }, + { name: "Nicaragua", dialCode: "+505", code: "NI", emoji: "🇳🇮" }, + { name: "Niger", dialCode: "+227", code: "NE", emoji: "🇳🇪" }, + { name: "Nigeria", dialCode: "+234", code: "NG", emoji: "🇳🇬" }, + { name: "Niue", dialCode: "+683", code: "NU", emoji: "🇳🇺" }, + { name: "Norfolk Island", dialCode: "+672", code: "NF", emoji: "🇳🇫" }, + { name: "North Korea", dialCode: "+850", code: "KP", emoji: "🇰🇵" }, + { name: "Northern Mariana Islands", dialCode: "+1", code: "MP", emoji: "🇲🇵" }, + { name: "Norway", dialCode: "+47", code: "NO", emoji: "🇳🇴" }, + { name: "Oman", dialCode: "+968", code: "OM", emoji: "🇴🇲" }, + { name: "Pakistan", dialCode: "+92", code: "PK", emoji: "🇵🇰" }, + { name: "Palau", dialCode: "+680", code: "PW", emoji: "🇵🇼" }, + { name: "Palestinian Territories", dialCode: "+970", code: "PS", emoji: "🇵🇸" }, + { name: "Panama", dialCode: "+507", code: "PA", emoji: "🇵🇦" }, + { name: "Papua New Guinea", dialCode: "+675", code: "PG", emoji: "🇵🇬" }, + { name: "Paraguay", dialCode: "+595", code: "PY", emoji: "🇵🇾" }, + { name: "Peru", dialCode: "+51", code: "PE", emoji: "🇵🇪" }, + { name: "Philippines", dialCode: "+63", code: "PH", emoji: "🇵🇭" }, + { name: "Poland", dialCode: "+48", code: "PL", emoji: "🇵🇱" }, + { name: "Portugal", dialCode: "+351", code: "PT", emoji: "🇵🇹" }, + { name: "Puerto Rico", dialCode: "+1", code: "PR", emoji: "🇵🇷" }, + { name: "Qatar", dialCode: "+974", code: "QA", emoji: "🇶🇦" }, + { name: "Réunion", dialCode: "+262", code: "RE", emoji: "🇷🇪" }, + { name: "Romania", dialCode: "+40", code: "RO", emoji: "🇷🇴" }, + { name: "Russia", dialCode: "+7", code: "RU", emoji: "🇷🇺" }, + { name: "Rwanda", dialCode: "+250", code: "RW", emoji: "🇷🇼" }, + { name: "Saint Barthélemy", dialCode: "+590", code: "BL", emoji: "🇧🇱" }, + { name: "Saint Helena", dialCode: "+290", code: "SH", emoji: "🇸🇭" }, + { name: "St. Kitts", dialCode: "+1", code: "KN", emoji: "🇰🇳" }, + { name: "St. Lucia", dialCode: "+1", code: "LC", emoji: "🇱🇨" }, + { name: "Saint Martin", dialCode: "+590", code: "MF", emoji: "🇲🇫" }, + { name: "Saint Pierre and Miquelon", dialCode: "+508", code: "PM", emoji: "🇵🇲" }, + { name: "St. Vincent", dialCode: "+1", code: "VC", emoji: "🇻🇨" }, + { name: "Samoa", dialCode: "+685", code: "WS", emoji: "🇼🇸" }, + { name: "San Marino", dialCode: "+378", code: "SM", emoji: "🇸🇲" }, + { name: "São Tomé and Príncipe", dialCode: "+239", code: "ST", emoji: "🇸🇹" }, + { name: "Saudi Arabia", dialCode: "+966", code: "SA", emoji: "🇸🇦" }, + { name: "Senegal", dialCode: "+221", code: "SN", emoji: "🇸🇳" }, + { name: "Serbia", dialCode: "+381", code: "RS", emoji: "🇷🇸" }, + { name: "Seychelles", dialCode: "+248", code: "SC", emoji: "🇸🇨" }, + { name: "Sierra Leone", dialCode: "+232", code: "SL", emoji: "🇸🇱" }, + { name: "Singapore", dialCode: "+65", code: "SG", emoji: "🇸🇬" }, + { name: "Sint Maarten", dialCode: "+1", code: "SX", emoji: "🇸🇽" }, + { name: "Slovakia", dialCode: "+421", code: "SK", emoji: "🇸🇰" }, + { name: "Slovenia", dialCode: "+386", code: "SI", emoji: "🇸🇮" }, + { name: "Solomon Islands", dialCode: "+677", code: "SB", emoji: "🇸🇧" }, + { name: "Somalia", dialCode: "+252", code: "SO", emoji: "🇸🇴" }, + { name: "South Africa", dialCode: "+27", code: "ZA", emoji: "🇿🇦" }, + { name: "South Korea", dialCode: "+82", code: "KR", emoji: "🇰🇷" }, + { name: "South Sudan", dialCode: "+211", code: "SS", emoji: "🇸🇸" }, + { name: "Spain", dialCode: "+34", code: "ES", emoji: "🇪🇸" }, + { name: "Sri Lanka", dialCode: "+94", code: "LK", emoji: "🇱🇰" }, + { name: "Sudan", dialCode: "+249", code: "SD", emoji: "🇸🇩" }, + { name: "Suriname", dialCode: "+597", code: "SR", emoji: "🇸🇷" }, + { name: "Svalbard and Jan Mayen", dialCode: "+47", code: "SJ", emoji: "🇸🇯" }, + { name: "Swaziland", dialCode: "+268", code: "SZ", emoji: "🇸🇿" }, + { name: "Sweden", dialCode: "+46", code: "SE", emoji: "🇸🇪" }, + { name: "Switzerland", dialCode: "+41", code: "CH", emoji: "🇨🇭" }, + { name: "Syria", dialCode: "+963", code: "SY", emoji: "🇸🇾" }, + { name: "Taiwan", dialCode: "+886", code: "TW", emoji: "🇹🇼" }, + { name: "Tajikistan", dialCode: "+992", code: "TJ", emoji: "🇹🇯" }, + { name: "Tanzania", dialCode: "+255", code: "TZ", emoji: "🇹🇿" }, + { name: "Thailand", dialCode: "+66", code: "TH", emoji: "🇹🇭" }, + { name: "Togo", dialCode: "+228", code: "TG", emoji: "🇹🇬" }, + { name: "Tokelau", dialCode: "+690", code: "TK", emoji: "🇹🇰" }, + { name: "Tonga", dialCode: "+676", code: "TO", emoji: "🇹🇴" }, + { name: "Trinidad/Tobago", dialCode: "+1", code: "TT", emoji: "🇹🇹" }, + { name: "Tunisia", dialCode: "+216", code: "TN", emoji: "🇹🇳" }, + { name: "Turkey", dialCode: "+90", code: "TR", emoji: "🇹🇷" }, + { name: "Turkmenistan", dialCode: "+993", code: "TM", emoji: "🇹🇲" }, + { name: "Turks and Caicos Islands", dialCode: "+1", code: "TC", emoji: "🇹🇨" }, + { name: "Tuvalu", dialCode: "+688", code: "TV", emoji: "🇹🇻" }, + { name: "U.S. Virgin Islands", dialCode: "+1", code: "VI", emoji: "🇻🇮" }, + { name: "Uganda", dialCode: "+256", code: "UG", emoji: "🇺🇬" }, + { name: "Ukraine", dialCode: "+380", code: "UA", emoji: "🇺🇦" }, + { name: "United Arab Emirates", dialCode: "+971", code: "AE", emoji: "🇦🇪" }, + { name: "United Kingdom", dialCode: "+44", code: "GB", emoji: "🇬🇧" }, + { name: "United States", dialCode: "+1", code: "US", emoji: "🇺🇸" }, + { name: "Uruguay", dialCode: "+598", code: "UY", emoji: "🇺🇾" }, + { name: "Uzbekistan", dialCode: "+998", code: "UZ", emoji: "🇺🇿" }, + { name: "Vanuatu", dialCode: "+678", code: "VU", emoji: "🇻🇺" }, + { name: "Vatican City", dialCode: "+379", code: "VA", emoji: "🇻🇦" }, + { name: "Venezuela", dialCode: "+58", code: "VE", emoji: "🇻🇪" }, + { name: "Vietnam", dialCode: "+84", code: "VN", emoji: "🇻🇳" }, + { name: "Wallis and Futuna", dialCode: "+681", code: "WF", emoji: "🇼🇫" }, + { name: "Western Sahara", dialCode: "+212", code: "EH", emoji: "🇪🇭" }, + { name: "Yemen", dialCode: "+967", code: "YE", emoji: "🇾🇪" }, + { name: "Zambia", dialCode: "+260", code: "ZM", emoji: "🇿🇲" }, + { name: "Zimbabwe", dialCode: "+263", code: "ZW", emoji: "🇿🇼" }, + { name: "Åland Islands", dialCode: "+358", code: "AX", emoji: "🇦🇽" }, +] as const satisfies CountryData[]; + +/** + * A country data object containing name, dial code, country code, and emoji for a supported country. + */ +export type CountryData = { + /** The name of the country. */ + name: string; + /** The dial code of the country. */ + dialCode: string; + /** The country code of the country. */ + code: CountryCode; + /** The emoji of the country. */ + emoji: string; +}; + +export type { CountryCode }; + +/** + * Formats a phone number according to the specified country data. + * + * @param phoneNumber - The phone number to format. + * @param countryData - The country data to use for formatting. + * @returns {string} The formatted phone number in E164 format. + */ +export function formatPhoneNumber(phoneNumber: string, countryData: CountryData): string { + try { + const parsedNumber = parsePhoneNumberWithError(phoneNumber, countryData.code); + + if (parsedNumber && parsedNumber.isValid()) { + // Return the E164 format. + return parsedNumber.number; + } + } catch { + // If parsing fails, try to format as incomplete number + } + + try { + // Try to format as incomplete number with country + const formatted = formatIncompletePhoneNumber(phoneNumber, countryData.code); + // Remove spaces from the formatted result. + return formatted.replace(/\s/g, ""); + } catch { + // If all else fails, just clean the number and prepend country code + const cleaned = phoneNumber.replace(/[^\d+]/g, "").trim(); + if (cleaned.startsWith("+")) { + return cleaned; + } + + return `${countryData.dialCode}${cleaned}`; + } +} diff --git a/packages/core/src/errors.test.ts b/packages/core/src/errors.test.ts new file mode 100644 index 000000000..c59f5b264 --- /dev/null +++ b/packages/core/src/errors.test.ts @@ -0,0 +1,306 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { FirebaseError } from "firebase/app"; +import { Auth, AuthCredential, MultiFactorResolver } from "firebase/auth"; +import { FirebaseUIError, handleFirebaseError } from "./errors"; +import { createMockUI } from "~/tests/utils"; +import { ERROR_CODE_MAP } from "@invertase/firebaseui-translations"; + +vi.mock("./translations", () => ({ + getTranslation: vi.fn(), +})); + +vi.mock("firebase/auth", () => ({ + getMultiFactorResolver: vi.fn(), +})); + +import { getTranslation } from "./translations"; +import { getMultiFactorResolver } from "firebase/auth"; + +let mockSessionStorage: { [key: string]: string }; + +beforeEach(() => { + vi.clearAllMocks(); + + mockSessionStorage = {}; + Object.defineProperty(window, "sessionStorage", { + value: { + setItem: vi.fn((key: string, value: string) => { + mockSessionStorage[key] = value; + }), + getItem: vi.fn((key: string) => mockSessionStorage[key] || null), + removeItem: vi.fn((key: string) => { + delete mockSessionStorage[key]; + }), + clear: vi.fn(() => { + Object.keys(mockSessionStorage).forEach((key) => delete mockSessionStorage[key]); + }), + }, + writable: true, + }); +}); + +describe("FirebaseUIError", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should create a FirebaseUIError with translated message", () => { + const mockUI = createMockUI(); + const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found"); + const expectedTranslation = "User not found (translated)"; + + vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + + const error = new FirebaseUIError(mockUI, mockFirebaseError); + + expect(error).toBeInstanceOf(FirebaseError); + expect(error.code).toBe("auth/user-not-found"); + expect(error.message).toBe(expectedTranslation); + expect(getTranslation).toHaveBeenCalledWith(mockUI, "errors", ERROR_CODE_MAP["auth/user-not-found"]); + }); + + it("should handle unknown error codes gracefully", () => { + const mockUI = createMockUI(); + const mockFirebaseError = new FirebaseError("auth/unknown-error", "Unknown error"); + const expectedTranslation = "Unknown error (translated)"; + + vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + + const error = new FirebaseUIError(mockUI, mockFirebaseError); + + expect(error.code).toBe("auth/unknown-error"); + expect(error.message).toBe(expectedTranslation); + expect(getTranslation).toHaveBeenCalledWith( + mockUI, + "errors", + ERROR_CODE_MAP["auth/unknown-error" as keyof typeof ERROR_CODE_MAP] + ); + }); +}); + +describe("handleFirebaseError", () => { + it("should throw non-Firebase errors as-is", () => { + const mockUI = createMockUI(); + const nonFirebaseError = new Error("Regular error"); + + expect(() => handleFirebaseError(mockUI, nonFirebaseError)).toThrow("Regular error"); + }); + + it("should throw non-Firebase errors with different types", () => { + const mockUI = createMockUI(); + const stringError = "String error"; + const numberError = 42; + const nullError = null; + const undefinedError = undefined; + + expect(() => handleFirebaseError(mockUI, stringError)).toThrow("String error"); + expect(() => handleFirebaseError(mockUI, numberError)).toThrow(); + expect(() => handleFirebaseError(mockUI, nullError)).toThrow(); + expect(() => handleFirebaseError(mockUI, undefinedError)).toThrow(); + }); + + it("should throw FirebaseUIError for Firebase errors", () => { + const mockUI = createMockUI(); + const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found"); + const expectedTranslation = "User not found (translated)"; + + vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + + expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + + try { + handleFirebaseError(mockUI, mockFirebaseError); + } catch (error) { + expect(error).toBeInstanceOf(FirebaseUIError); + expect(error).toBeInstanceOf(FirebaseError); + expect((error as FirebaseUIError).code).toBe("auth/user-not-found"); + expect((error as FirebaseUIError).message).toBe(expectedTranslation); + } + }); + + it("should store credential in sessionStorage for account-exists-with-different-credential", () => { + const mockUI = createMockUI(); + const mockCredential = { + providerId: "google.com", + toJSON: vi.fn().mockReturnValue({ providerId: "google.com", token: "mock-token" }), + } as unknown as AuthCredential; + + const mockFirebaseError = { + code: "auth/account-exists-with-different-credential", + message: "Account exists with different credential", + credential: mockCredential, + } as FirebaseError & { credential: AuthCredential }; + + const expectedTranslation = "Account exists with different credential (translated)"; + vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + + expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + + expect(window.sessionStorage.setItem).toHaveBeenCalledWith("pendingCred", JSON.stringify(mockCredential.toJSON())); + expect(mockCredential.toJSON).toHaveBeenCalled(); + }); + + it("should not store credential for other error types", () => { + const mockUI = createMockUI(); + const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found"); + const expectedTranslation = "User not found (translated)"; + + vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + + expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + + expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + }); + + it("should handle account-exists-with-different-credential without credential", () => { + const mockUI = createMockUI(); + const mockFirebaseError = { + code: "auth/account-exists-with-different-credential", + message: "Account exists with different credential", + } as FirebaseError; + + const expectedTranslation = "Account exists with different credential (translated)"; + vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + + expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + }); + + it("should call setMultiFactorResolver when auth/multi-factor-auth-required error is thrown", () => { + const mockUI = createMockUI(); + const mockResolver = { + auth: {} as Auth, + session: null, + hints: [], + } as unknown as MultiFactorResolver; + + const error = new FirebaseError("auth/multi-factor-auth-required", "Multi-factor authentication required"); + const expectedTranslation = "Multi-factor authentication required (translated)"; + + vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getMultiFactorResolver).mockReturnValue(mockResolver); + + expect(() => handleFirebaseError(mockUI, error)).toThrow(FirebaseUIError); + expect(getMultiFactorResolver).toHaveBeenCalledWith(mockUI.auth, error); + expect(mockUI.setMultiFactorResolver).toHaveBeenCalledWith(mockResolver); + }); + + it("should still throw FirebaseUIError after setting multi-factor resolver", () => { + const mockUI = createMockUI(); + const mockResolver = { + auth: {} as Auth, + session: null, + hints: [], + } as unknown as MultiFactorResolver; + + const error = new FirebaseError("auth/multi-factor-auth-required", "Multi-factor authentication required"); + const expectedTranslation = "Multi-factor authentication required (translated)"; + + vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getMultiFactorResolver).mockReturnValue(mockResolver); + + expect(() => handleFirebaseError(mockUI, error)).toThrow(FirebaseUIError); + + expect(getMultiFactorResolver).toHaveBeenCalledWith(mockUI.auth, error); + expect(mockUI.setMultiFactorResolver).toHaveBeenCalledWith(mockResolver); + + try { + handleFirebaseError(mockUI, error); + } catch (error) { + expect(error).toBeInstanceOf(FirebaseUIError); + expect(error).toBeInstanceOf(FirebaseError); + expect((error as FirebaseUIError).code).toBe("auth/multi-factor-auth-required"); + expect((error as FirebaseUIError).message).toBe(expectedTranslation); + } + }); + + it("should not call setMultiFactorResolver for other error types", () => { + const mockUI = createMockUI(); + const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found"); + const expectedTranslation = "User not found (translated)"; + + vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + + expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + + expect(getMultiFactorResolver).not.toHaveBeenCalled(); + expect(mockUI.setMultiFactorResolver).not.toHaveBeenCalled(); + }); +}); + +describe("isFirebaseError utility", () => { + it("should identify FirebaseError objects", () => { + const firebaseError = new FirebaseError("auth/user-not-found", "User not found"); + + const mockUI = createMockUI(); + vi.mocked(getTranslation).mockReturnValue("translated message"); + + expect(() => handleFirebaseError(mockUI, firebaseError)).toThrow(FirebaseUIError); + }); + + it("should reject non-FirebaseError objects", () => { + const mockUI = createMockUI(); + const nonFirebaseError = { code: "test", message: "test" }; + + expect(() => handleFirebaseError(mockUI, nonFirebaseError)).toThrow(); + }); + + it("should reject objects without code and message", () => { + const mockUI = createMockUI(); + const invalidObject = { someProperty: "value" }; + + expect(() => handleFirebaseError(mockUI, invalidObject)).toThrow(); + }); +}); + +describe("errorContainsCredential utility", () => { + it("should identify FirebaseError with credential", () => { + const mockUI = createMockUI(); + const mockCredential = { + providerId: "google.com", + toJSON: vi.fn().mockReturnValue({ providerId: "google.com" }), + } as unknown as AuthCredential; + + const firebaseErrorWithCredential = { + code: "auth/account-exists-with-different-credential", + message: "Account exists with different credential", + credential: mockCredential, + } as FirebaseError & { credential: AuthCredential }; + + vi.mocked(getTranslation).mockReturnValue("translated message"); + + expect(() => handleFirebaseError(mockUI, firebaseErrorWithCredential)).toThrowError(FirebaseUIError); + + expect(window.sessionStorage.setItem).toHaveBeenCalledWith("pendingCred", JSON.stringify(mockCredential.toJSON())); + }); + + it("should handle FirebaseError without credential", () => { + const mockUI = createMockUI(); + const firebaseErrorWithoutCredential = { + code: "auth/account-exists-with-different-credential", + message: "Account exists with different credential", + } as FirebaseError; + + vi.mocked(getTranslation).mockReturnValue("translated message"); + + expect(() => handleFirebaseError(mockUI, firebaseErrorWithoutCredential)).toThrowError(FirebaseUIError); + + expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 000000000..ec1fe1e7c --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,77 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ERROR_CODE_MAP, type ErrorCode } from "@invertase/firebaseui-translations"; +import { FirebaseError } from "firebase/app"; +import { type AuthCredential, getMultiFactorResolver, type MultiFactorError } from "firebase/auth"; +import { type FirebaseUI } from "./config"; +import { getTranslation } from "./translations"; + +/** + * A custom error class that extends FirebaseError and provides a translated error message based on the configured locale. + */ +export class FirebaseUIError extends FirebaseError { + constructor(ui: FirebaseUI, error: FirebaseError) { + const message = getTranslation(ui, "errors", ERROR_CODE_MAP[error.code as ErrorCode]); + super(error.code, message || error.message); + + // Ensures that `instanceof FirebaseUIError` works, alongside `instanceof FirebaseError` + Object.setPrototypeOf(this, FirebaseUIError.prototype); + } +} + +/** + * Handles a Firebase error and throws a FirebaseUIError if it is a Firebase error. + * + * Addtionally, handles the following error codes: + * - auth/account-exists-with-different-credential - stores the credential in sessionStorage. + * - auth/multi-factor-auth-required - updates the UI instance with the multi-factor resolver. + * + * @param ui - The FirebaseUI instance. + * @param error - The error to handle. + * @returns {never} A never type. + */ +export function handleFirebaseError(ui: FirebaseUI, error: unknown): never { + // If it's not a Firebase error, then we just throw it and preserve the original error. + if (!isFirebaseError(error)) { + throw error; + } + + // TODO(ehesp): Type error as unknown, check instance of FirebaseError + // TODO(ehesp): Support via behavior + if (error.code === "auth/account-exists-with-different-credential" && errorContainsCredential(error)) { + window.sessionStorage.setItem("pendingCred", JSON.stringify(error.credential.toJSON())); + } + + // Update the UI with the multi-factor resolver if the error is thrown. + if (error.code === "auth/multi-factor-auth-required") { + const resolver = getMultiFactorResolver(ui.auth, error as MultiFactorError); + ui.setMultiFactorResolver(resolver); + } + + throw new FirebaseUIError(ui, error); +} + +// Utility to obtain whether something is a FirebaseError +function isFirebaseError(error: unknown): error is FirebaseError { + // Calling instanceof FirebaseError is not working - not sure why yet. + return !!error && typeof error === "object" && "code" in error && "message" in error; +} + +// Utility to obtain whether something is a FirebaseError that contains a credential - doesn't seemed to be typed? +function errorContainsCredential(error: FirebaseError): error is FirebaseError & { credential: AuthCredential } { + return "credential" in error; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 000000000..b94190c11 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,32 @@ +/// +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import pkgJson from "../package.json"; +import { registerFramework } from "./register-framework"; + +export * from "./auth"; +export * from "./behaviors"; +export * from "./config"; +export * from "./country-data"; +export * from "./errors"; +export * from "./register-framework"; +export * from "./schemas"; +export * from "./translations"; + +if (import.meta.env?.PROD) { + registerFramework("core", pkgJson.version); +} diff --git a/packages/core/src/register-framework.test.ts b/packages/core/src/register-framework.test.ts new file mode 100644 index 000000000..c9034626c --- /dev/null +++ b/packages/core/src/register-framework.test.ts @@ -0,0 +1,78 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { registerFramework } from "./register-framework"; + +vi.mock("firebase/app", () => ({ + registerVersion: vi.fn(), +})); + +import { registerVersion } from "firebase/app"; + +describe("registerFramework", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should call registerVersion with correct parameters", () => { + const framework = "react"; + const version = "1.0.0"; + + registerFramework(framework, version); + + expect(registerVersion).toHaveBeenCalledWith("firebase-ui-web", version, framework); + expect(registerVersion).toHaveBeenCalledTimes(1); + }); + + it("should handle different framework types", () => { + const frameworks = ["react", "angular"]; + const version = "2.0.0"; + + frameworks.forEach((framework) => { + registerFramework(framework, version); + }); + + expect(registerVersion).toHaveBeenCalledTimes(frameworks.length); + frameworks.forEach((framework) => { + expect(registerVersion).toHaveBeenCalledWith("firebase-ui-web", version, framework); + }); + }); + + it("should handle different version formats", () => { + const framework = "react"; + const versions = ["1.0.0", "2.1.3", "0.0.1", "10.20.30"]; + + versions.forEach((version) => { + registerFramework(framework, version); + }); + + expect(registerVersion).toHaveBeenCalledTimes(versions.length); + versions.forEach((version) => { + expect(registerVersion).toHaveBeenCalledWith("firebase-ui-web", version, framework); + }); + }); + + it("should handle special characters in parameters", () => { + const framework = "react"; + const version = "1.0.0-beta.1"; + + registerFramework(framework, version); + + expect(registerVersion).toHaveBeenCalledWith("firebase-ui-web", version, framework); + expect(registerVersion).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/register-framework.ts b/packages/core/src/register-framework.ts new file mode 100644 index 000000000..dce604e81 --- /dev/null +++ b/packages/core/src/register-framework.ts @@ -0,0 +1,27 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { registerVersion } from "firebase/app"; + +/** + * Register a framework with the FirebaseUI configuration. + * @internal + * @param framework The type of framework being registered. + * @param version The version of the framework being registered. + */ +export function registerFramework(framework: string, version: string) { + registerVersion("firebase-ui-web", version, framework); +} diff --git a/packages/core/src/schemas.test.ts b/packages/core/src/schemas.test.ts new file mode 100644 index 000000000..4f215193d --- /dev/null +++ b/packages/core/src/schemas.test.ts @@ -0,0 +1,398 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi } from "vitest"; +import { createMockUI } from "~/tests/utils"; +import { + createEmailLinkAuthFormSchema, + createForgotPasswordAuthFormSchema, + createMultiFactorPhoneAuthAssertionFormSchema, + createPhoneAuthNumberFormSchema, + createPhoneAuthVerifyFormSchema, + createSignInAuthFormSchema, + createSignUpAuthFormSchema, +} from "./schemas"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { RecaptchaVerifier } from "firebase/auth"; + +describe("createSignInAuthFormSchema", () => { + it("should create a sign in auth form schema with valid error messages", () => { + const testLocale = registerLocale("test", { + errors: { + invalidEmail: "createSignInAuthFormSchema + invalidEmail", + weakPassword: "createSignInAuthFormSchema + weakPassword", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createSignInAuthFormSchema(mockUI); + + // Cause the schema to fail... + // TODO(ehesp): If no value is provided, the schema error is just "Required" - should this also be translated? + const result = schema.safeParse({ + email: "", + password: "", + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.issues.length).toBe(2); + + expect(result.error?.issues[0]?.message).toBe("createSignInAuthFormSchema + invalidEmail"); + expect(result.error?.issues[1]?.message).toBe("createSignInAuthFormSchema + weakPassword"); + }); +}); + +describe("createSignUpAuthFormSchema", () => { + it("should create a sign up auth form schema with valid error messages when requireDisplayName behavior is not enabled", () => { + const testLocale = registerLocale("test", { + errors: { + invalidEmail: "createSignUpAuthFormSchema + invalidEmail", + weakPassword: "createSignUpAuthFormSchema + weakPassword", + displayNameRequired: "createSignUpAuthFormSchema + displayNameRequired", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + behaviors: {}, // No requireDisplayName behavior + }); + + const schema = createSignUpAuthFormSchema(mockUI); + + const validResult = schema.safeParse({ + email: "test@example.com", + password: "password123", + }); + + expect(validResult.success).toBe(true); + + const validWithDisplayNameResult = schema.safeParse({ + email: "test@example.com", + password: "password123", + displayName: "John Doe", + }); + + expect(validWithDisplayNameResult.success).toBe(true); + + const invalidResult = schema.safeParse({ + email: "", + password: "", + }); + + expect(invalidResult.success).toBe(false); + expect(invalidResult.error).toBeDefined(); + expect(invalidResult.error?.issues.length).toBe(2); + + expect(invalidResult.error?.issues[0]?.message).toBe("createSignUpAuthFormSchema + invalidEmail"); + expect(invalidResult.error?.issues[1]?.message).toBe("createSignUpAuthFormSchema + weakPassword"); + }); + + it("should create a sign up auth form schema with required displayName when requireDisplayName behavior is enabled", () => { + const testLocale = registerLocale("test", { + errors: { + invalidEmail: "createSignUpAuthFormSchema + invalidEmail", + weakPassword: "createSignUpAuthFormSchema + weakPassword", + displayNameRequired: "createSignUpAuthFormSchema + displayNameRequired", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + behaviors: { + requireDisplayName: { type: "callable" as const, handler: vi.fn() }, + } as any, + }); + + const schema = createSignUpAuthFormSchema(mockUI); + + const validResult = schema.safeParse({ + email: "test@example.com", + password: "password123", + displayName: "John Doe", + }); + + expect(validResult.success).toBe(true); + + const missingDisplayNameResult = schema.safeParse({ + email: "test@example.com", + password: "password123", + displayName: "", + }); + + expect(missingDisplayNameResult.success).toBe(false); + expect(missingDisplayNameResult.error).toBeDefined(); + expect(missingDisplayNameResult.error?.issues.length).toBe(1); + expect(missingDisplayNameResult.error?.issues[0]?.message).toBe("createSignUpAuthFormSchema + displayNameRequired"); + + const emptyDisplayNameResult = schema.safeParse({ + email: "test@example.com", + password: "password123", + displayName: "", + }); + + expect(emptyDisplayNameResult.success).toBe(false); + expect(emptyDisplayNameResult.error).toBeDefined(); + expect(emptyDisplayNameResult.error?.issues.length).toBe(1); + expect(emptyDisplayNameResult.error?.issues[0]?.message).toBe("createSignUpAuthFormSchema + displayNameRequired"); + + const invalidEmailPasswordResult = schema.safeParse({ + email: "", + password: "", + displayName: "John Doe", + }); + + expect(invalidEmailPasswordResult.success).toBe(false); + expect(invalidEmailPasswordResult.error).toBeDefined(); + expect(invalidEmailPasswordResult.error?.issues.length).toBe(2); + expect(invalidEmailPasswordResult.error?.issues[0]?.message).toBe("createSignUpAuthFormSchema + invalidEmail"); + expect(invalidEmailPasswordResult.error?.issues[1]?.message).toBe("createSignUpAuthFormSchema + weakPassword"); + }); +}); + +describe("createForgotPasswordAuthFormSchema", () => { + it("should create a forgot password form schema with valid error messages", () => { + const testLocale = registerLocale("test", { + errors: { + invalidEmail: "createForgotPasswordAuthFormSchema + invalidEmail", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createForgotPasswordAuthFormSchema(mockUI); + + // Cause the schema to fail... + // TODO(ehesp): If no value is provided, the schema error is just "Required" - should this also be translated? + const result = schema.safeParse({ + email: "", + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.issues.length).toBe(1); + + expect(result.error?.issues[0]?.message).toBe("createForgotPasswordAuthFormSchema + invalidEmail"); + }); +}); + +describe("createEmailLinkAuthFormSchema", () => { + it("should create a forgot password form schema with valid error messages", () => { + const testLocale = registerLocale("test", { + errors: { + invalidEmail: "createEmailLinkAuthFormSchema + invalidEmail", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createEmailLinkAuthFormSchema(mockUI); + + // Cause the schema to fail... + // TODO(ehesp): If no value is provided, the schema error is just "Required" - should this also be translated? + const result = schema.safeParse({ + email: "", + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.issues.length).toBe(1); + + expect(result.error?.issues[0]?.message).toBe("createEmailLinkAuthFormSchema + invalidEmail"); + }); +}); + +describe("createPhoneAuthNumberFormSchema", () => { + it("should create a phone auth number form schema and show missing phone number error", () => { + const testLocale = registerLocale("test", { + errors: { + missingPhoneNumber: "createPhoneAuthNumberFormSchema + missingPhoneNumber", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createPhoneAuthNumberFormSchema(mockUI); + + // Cause the schema to fail... + // TODO(ehesp): If no value is provided, the schema error is just "Required" - should this also be translated? + const result = schema.safeParse({ + phoneNumber: "", + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + + expect(result.error?.issues[0]?.message).toBe("createPhoneAuthNumberFormSchema + missingPhoneNumber"); + }); + + it("should create a phone auth number form schema and show an error if the phone number is too long", () => { + const testLocale = registerLocale("test", { + errors: { + invalidPhoneNumber: "createPhoneAuthNumberFormSchema + invalidPhoneNumber", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createPhoneAuthNumberFormSchema(mockUI); + + // Cause the schema to fail... + // TODO(ehesp): If no value is provided, the schema error is just "Required" - should this also be translated? + const result = schema.safeParse({ + phoneNumber: "12345678901", + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + + expect(result.error?.issues[0]?.message).toBe("createPhoneAuthNumberFormSchema + invalidPhoneNumber"); + }); +}); + +describe("createPhoneAuthVerifyFormSchema", () => { + it("should create a phone auth verify form schema and show missing verification ID error", () => { + const testLocale = registerLocale("test", { + errors: { + missingVerificationId: "createPhoneAuthVerifyFormSchema + missingVerificationId", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createPhoneAuthVerifyFormSchema(mockUI); + + const result = schema.safeParse({ + verificationId: "", + verificationCode: "123456", + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + + expect(result.error?.issues[0]?.message).toBe("createPhoneAuthVerifyFormSchema + missingVerificationId"); + }); + + it("should create a phone auth verify form schema and show an error if the verification code is too short", () => { + const testLocale = registerLocale("test", { + errors: { + invalidVerificationCode: "createPhoneAuthVerifyFormSchema + invalidVerificationCode", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createPhoneAuthVerifyFormSchema(mockUI); + + const result = schema.safeParse({ + verificationId: "test-verification-id", + verificationCode: "123", + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect( + result.error?.issues.some( + (issue) => issue.message === "createPhoneAuthVerifyFormSchema + invalidVerificationCode" + ) + ).toBe(true); + }); +}); + +describe("createMultiFactorPhoneAuthAssertionFormSchema", () => { + it("should create a multi-factor phone auth assertion form schema and show missing phone number error", () => { + const testLocale = registerLocale("test", { + errors: { + missingPhoneNumber: "createMultiFactorPhoneAuthAssertionFormSchema + missingPhoneNumber", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createMultiFactorPhoneAuthAssertionFormSchema(mockUI); + + const result = schema.safeParse({ + phoneNumber: "", + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.issues[0]?.message).toBe("createMultiFactorPhoneAuthAssertionFormSchema + missingPhoneNumber"); + }); + + it("should create a multi-factor phone auth assertion form schema and show an error if the phone number is too long", () => { + const testLocale = registerLocale("test", { + errors: { + invalidPhoneNumber: "createMultiFactorPhoneAuthAssertionFormSchema + invalidPhoneNumber", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createMultiFactorPhoneAuthAssertionFormSchema(mockUI); + + const result = schema.safeParse({ + phoneNumber: "12345678901", + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.issues[0]?.message).toBe("createMultiFactorPhoneAuthAssertionFormSchema + invalidPhoneNumber"); + }); + + it("should accept valid phone number without requiring displayName", () => { + const testLocale = registerLocale("test", { + errors: { + missingPhoneNumber: "missing", + invalidPhoneNumber: "invalid", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createMultiFactorPhoneAuthAssertionFormSchema(mockUI); + + const result = schema.safeParse({ + phoneNumber: "1234567890", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ phoneNumber: "1234567890" }); + } + }); +}); diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts new file mode 100644 index 000000000..2e03b42ca --- /dev/null +++ b/packages/core/src/schemas.ts @@ -0,0 +1,209 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as z from "zod"; +import { getTranslation } from "./translations"; +import { type FirebaseUI } from "./config"; +import { hasBehavior } from "./behaviors"; + +/** + * Creates a Zod schema for sign-in form validation. + * + * Validates email format and password minimum length (6 characters). + * + * @param ui - The FirebaseUI instance. + * @returns A Zod schema for sign-in form validation. + */ +export function createSignInAuthFormSchema(ui: FirebaseUI) { + return z.object({ + email: z.email(getTranslation(ui, "errors", "invalidEmail")), + password: z.string().min(6, getTranslation(ui, "errors", "weakPassword")), + }); +} + +/** + * Creates a Zod schema for sign-up form validation. + * + * Validates email format, password minimum length (6 characters), and optionally requires a display name + * if the `requireDisplayName` behavior is enabled. + * + * @param ui - The FirebaseUI instance. + * @returns A Zod schema for sign-up form validation. + */ +export function createSignUpAuthFormSchema(ui: FirebaseUI) { + const requireDisplayName = hasBehavior(ui, "requireDisplayName"); + const displayNameRequiredMessage = getTranslation(ui, "errors", "displayNameRequired"); + + return z.object({ + email: z.email(getTranslation(ui, "errors", "invalidEmail")), + password: z.string().min(6, getTranslation(ui, "errors", "weakPassword")), + displayName: requireDisplayName + ? z.string().min(1, displayNameRequiredMessage) + : z.string().min(1, displayNameRequiredMessage).optional(), + }); +} + +/** + * Creates a Zod schema for forgot password form validation. + * + * Validates email format. + * + * @param ui - The FirebaseUI instance. + * @returns A Zod schema for forgot password form validation. + */ +export function createForgotPasswordAuthFormSchema(ui: FirebaseUI) { + return z.object({ + email: z.email(getTranslation(ui, "errors", "invalidEmail")), + }); +} + +/** + * Creates a Zod schema for email link authentication form validation. + * + * Validates email format. + * + * @param ui - The FirebaseUI instance. + * @returns A Zod schema for email link authentication form validation. + */ +export function createEmailLinkAuthFormSchema(ui: FirebaseUI) { + return z.object({ + email: z.email(getTranslation(ui, "errors", "invalidEmail")), + }); +} + +/** + * Creates a Zod schema for phone number form validation. + * + * Validates that the phone number is provided and has a maximum length of 10 characters. + * + * @param ui - The FirebaseUI instance. + * @returns A Zod schema for phone number form validation. + */ +export function createPhoneAuthNumberFormSchema(ui: FirebaseUI) { + return z.object({ + phoneNumber: z + .string() + .min(1, getTranslation(ui, "errors", "missingPhoneNumber")) + .max(10, getTranslation(ui, "errors", "invalidPhoneNumber")), + }); +} + +/** + * Creates a Zod schema for phone verification code form validation. + * + * Validates that the verification ID is provided and the verification code is at least 6 characters long. + * + * @param ui - The FirebaseUI instance. + * @returns A Zod schema for phone verification form validation. + */ +export function createPhoneAuthVerifyFormSchema(ui: FirebaseUI) { + return z.object({ + verificationId: z.string().min(1, getTranslation(ui, "errors", "missingVerificationId")), + verificationCode: z.string().refine((val) => !val || val.length >= 6, { + error: getTranslation(ui, "errors", "invalidVerificationCode"), + }), + }); +} + +/** + * Creates a Zod schema for multi-factor phone authentication number form validation. + * + * Extends the phone number schema with a required display name field. + * + * @param ui - The FirebaseUI instance. + * @returns A Zod schema for multi-factor phone authentication number form validation. + */ +export function createMultiFactorPhoneAuthNumberFormSchema(ui: FirebaseUI) { + const base = createPhoneAuthNumberFormSchema(ui); + return base.extend({ + displayName: z.string().min(1, getTranslation(ui, "errors", "displayNameRequired")), + }); +} + +/** + * Creates a Zod schema for multi-factor phone authentication assertion form validation. + * + * Uses the same validation as the phone number form schema. + * + * @param ui - The FirebaseUI instance. + * @returns A Zod schema for multi-factor phone authentication assertion form validation. + */ +export function createMultiFactorPhoneAuthAssertionFormSchema(ui: FirebaseUI) { + return createPhoneAuthNumberFormSchema(ui); +} + +/** + * Creates a Zod schema for multi-factor phone authentication verification form validation. + * + * Uses the same validation as the phone verification form schema. + * + * @param ui - The FirebaseUI instance. + * @returns A Zod schema for multi-factor phone authentication verification form validation. + */ +export function createMultiFactorPhoneAuthVerifyFormSchema(ui: FirebaseUI) { + return createPhoneAuthVerifyFormSchema(ui); +} + +/** + * Creates a Zod schema for multi-factor TOTP authentication number form validation. + * + * Validates that a display name is provided. + * + * @param ui - The FirebaseUI instance. + * @returns A Zod schema for multi-factor TOTP authentication number form validation. + */ +export function createMultiFactorTotpAuthNumberFormSchema(ui: FirebaseUI) { + return z.object({ + displayName: z.string().min(1, getTranslation(ui, "errors", "displayNameRequired")), + }); +} + +/** + * Creates a Zod schema for multi-factor TOTP authentication verification form validation. + * + * Validates that the verification code is exactly 6 characters long. + * + * @param ui - The FirebaseUI instance. + * @returns A Zod schema for multi-factor TOTP authentication verification form validation. + */ +export function createMultiFactorTotpAuthVerifyFormSchema(ui: FirebaseUI) { + return z.object({ + verificationCode: z.string().refine((val) => val.length === 6, { + error: getTranslation(ui, "errors", "invalidVerificationCode"), + }), + }); +} + +/** The inferred type for the sign-in authentication form schema. */ +export type SignInAuthFormSchema = z.infer>; +/** The inferred type for the sign-up authentication form schema. */ +export type SignUpAuthFormSchema = z.infer>; +/** The inferred type for the forgot password authentication form schema. */ +export type ForgotPasswordAuthFormSchema = z.infer>; +/** The inferred type for the email link authentication form schema. */ +export type EmailLinkAuthFormSchema = z.infer>; +/** The inferred type for the phone authentication number form schema. */ +export type PhoneAuthNumberFormSchema = z.infer>; +/** The inferred type for the phone authentication verification form schema. */ +export type PhoneAuthVerifyFormSchema = z.infer>; +/** The inferred type for the multi-factor phone authentication number form schema. */ +export type MultiFactorPhoneAuthNumberFormSchema = z.infer< + ReturnType +>; +/** The inferred type for the multi-factor TOTP authentication number form schema. */ +export type MultiFactorTotpAuthNumberFormSchema = z.infer>; +/** The inferred type for the multi-factor TOTP authentication verification form schema. */ +export type MultiFactorTotpAuthVerifyFormSchema = z.infer>; diff --git a/packages/firebaseui-core/src/state.ts b/packages/core/src/state.ts similarity index 75% rename from packages/firebaseui-core/src/state.ts rename to packages/core/src/state.ts index 5e8ab2c9f..dcc381b81 100644 --- a/packages/firebaseui-core/src/state.ts +++ b/packages/core/src/state.ts @@ -14,12 +14,4 @@ * limitations under the License. */ -export type FirebaseUIState = - | 'loading' - | 'idle' - | 'signing-in' - | 'signing-out' - | 'linking' - | 'creating-user' - | 'sending-password-reset-email' - | 'sending-sign-in-link-to-email'; +export type FirebaseUIState = "idle" | "pending" | "loading"; diff --git a/packages/core/src/translations.test.ts b/packages/core/src/translations.test.ts new file mode 100644 index 000000000..62cd98362 --- /dev/null +++ b/packages/core/src/translations.test.ts @@ -0,0 +1,65 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it, vi } from "vitest"; + +// Mock the translations module first +vi.mock("@invertase/firebaseui-translations", async (original) => ({ + ...(await original()), + getTranslation: vi.fn(), +})); + +import { getTranslation as _getTranslation, registerLocale } from "@invertase/firebaseui-translations"; +import { getTranslation } from "./translations"; +import { createMockUI } from "~/tests/utils"; + +describe("getTranslation", () => { + it("should return the correct translation", () => { + const testLocale = registerLocale("test", { + errors: { + userNotFound: "test + userNotFound", + }, + }); + + vi.mocked(_getTranslation).mockReturnValue("test + userNotFound"); + + const mockUI = createMockUI({ locale: testLocale }); + const translation = getTranslation(mockUI, "errors", "userNotFound"); + + expect(translation).toBe("test + userNotFound"); + expect(_getTranslation).toHaveBeenCalledWith(testLocale, "errors", "userNotFound", undefined); + }); + + it("should pass replacements to the underlying getTranslation function", () => { + const testLocale = registerLocale("test", { + messages: { + termsAndPrivacy: "By continuing, you agree to our {tos} and {privacy}.", + }, + }); + + vi.mocked(_getTranslation).mockReturnValue("By continuing, you agree to our Terms of Service and Privacy Policy."); + + const mockUI = createMockUI({ locale: testLocale }); + const replacements = { + tos: "Terms of Service", + privacy: "Privacy Policy", + }; + const translation = getTranslation(mockUI, "messages", "termsAndPrivacy", replacements); + + expect(translation).toBe("By continuing, you agree to our Terms of Service and Privacy Policy."); + expect(_getTranslation).toHaveBeenCalledWith(testLocale, "messages", "termsAndPrivacy", replacements); + }); +}); diff --git a/packages/core/src/translations.ts b/packages/core/src/translations.ts new file mode 100644 index 000000000..f8dd6c56f --- /dev/null +++ b/packages/core/src/translations.ts @@ -0,0 +1,45 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + getTranslation as _getTranslation, + type TranslationCategory, + type TranslationKey, +} from "@invertase/firebaseui-translations"; +import { type FirebaseUI } from "./config"; + +/** + * Gets a translated string for a given category and key. + * + * Example: + * ```typescript + * const translation = getTranslation(ui, "errors", "userNotFound"); + * ``` + * + * @param ui - The FirebaseUI instance. + * @param category - The translation category. + * @param key - The translation key. + * @param replacements - Optional replacements for placeholders. + * @returns The translated string. + */ +export function getTranslation( + ui: FirebaseUI, + category: T, + key: TranslationKey, + replacements?: Record +) { + return _getTranslation(ui.locale, category, key, replacements); +} diff --git a/packages/firebaseui-core/tests/integration/auth.integration.test.ts b/packages/core/tests/auth.integration.test.ts similarity index 68% rename from packages/firebaseui-core/tests/integration/auth.integration.test.ts rename to packages/core/tests/auth.integration.test.ts index 31a18f877..ca29acef9 100644 --- a/packages/firebaseui-core/tests/integration/auth.integration.test.ts +++ b/packages/core/tests/auth.integration.test.ts @@ -14,39 +14,40 @@ * limitations under the License. */ -import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; -import { initializeApp } from 'firebase/app'; -import { Auth, connectAuthEmulator, getAuth, signOut, deleteUser } from 'firebase/auth'; -import { GoogleAuthProvider } from 'firebase/auth'; +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { initializeApp } from "firebase/app"; +import { Auth, connectAuthEmulator, getAuth, signOut, deleteUser } from "firebase/auth"; +import { GoogleAuthProvider } from "firebase/auth"; import { signInWithEmailAndPassword, createUserWithEmailAndPassword, sendSignInLinkToEmail, signInAnonymously, sendPasswordResetEmail, - signInWithOAuth, + signInWithProvider, completeEmailLinkSignIn, - confirmPhoneNumber, -} from '../../src/auth'; -import { FirebaseUIError } from '../../src/errors'; -import { initializeUI, FirebaseUI } from '../../src/config'; + confirmPhoneNumber as _confirmPhoneNumber, +} from "../src/auth"; +import { FirebaseUIError } from "../src/errors"; +import { initializeUI, FirebaseUI } from "../src/config"; -describe('Firebase UI Auth Integration', () => { +// TODO: Re-enable these tests once everything is working. +describe.skip("Firebase UI Auth Integration", () => { let auth: Auth; let ui: FirebaseUI; - const testPassword = 'testPassword123!'; + const testPassword = "testPassword123!"; let testCount = 0; const getUniqueEmail = () => `test${Date.now()}-${testCount++}@example.com`; beforeAll(() => { const app = initializeApp({ - apiKey: 'fake-api-key', - authDomain: 'fake-auth-domain', - projectId: 'fake-project-id', + apiKey: "fake-api-key", + authDomain: "fake-auth-domain", + projectId: "fake-project-id", }); auth = getAuth(app); - connectAuthEmulator(auth, 'http://127.0.0.1:9099', { disableWarnings: true }); + connectAuthEmulator(auth, "http://127.0.0.1:9099", { disableWarnings: true }); ui = initializeUI({ app }); }); @@ -54,7 +55,9 @@ describe('Firebase UI Auth Integration', () => { if (auth.currentUser) { try { await deleteUser(auth.currentUser); - } catch {} + } catch (_error) { + // Ignore deletion errors + } await signOut(auth); } window.localStorage.clear(); @@ -65,14 +68,16 @@ describe('Firebase UI Auth Integration', () => { if (auth.currentUser) { try { await deleteUser(auth.currentUser); - } catch {} + } catch (_error) { + // Ignore deletion errors + } await signOut(auth); } window.localStorage.clear(); }); - describe('Email/Password Authentication', () => { - it('should create a new user and sign in', async () => { + describe("Email/Password Authentication", () => { + it("should create a new user and sign in", async () => { const email = getUniqueEmail(); const createResult = await createUserWithEmailAndPassword(ui.get(), email, testPassword); @@ -86,12 +91,12 @@ describe('Firebase UI Auth Integration', () => { expect(signInResult.user.email).toBe(email); }); - it('should fail with invalid credentials', async () => { + it("should fail with invalid credentials", async () => { const email = getUniqueEmail(); - await expect(signInWithEmailAndPassword(ui.get(), email, 'wrongpassword')).rejects.toThrow(FirebaseUIError); + await expect(signInWithEmailAndPassword(ui.get(), email, "wrongpassword")).rejects.toThrow(FirebaseUIError); }); - it('should handle password reset email', async () => { + it("should handle password reset email", async () => { const email = getUniqueEmail(); await createUserWithEmailAndPassword(ui.get(), email, testPassword); await signOut(auth); @@ -100,14 +105,14 @@ describe('Firebase UI Auth Integration', () => { }); }); - describe('Anonymous Authentication', () => { - it('should sign in anonymously', async () => { + describe("Anonymous Authentication", () => { + it("should sign in anonymously", async () => { const result = await signInAnonymously(ui.get()); expect(result.user).toBeDefined(); expect(result.user.isAnonymous).toBe(true); }); - it('should upgrade anonymous user to email/password', async () => { + it("should upgrade anonymous user to email/password", async () => { const email = getUniqueEmail(); await signInAnonymously(ui.get()); @@ -119,30 +124,30 @@ describe('Firebase UI Auth Integration', () => { }); }); - describe('Email Link Authentication', () => { - it('should manage email storage for email link sign in', async () => { + describe("Email Link Authentication", () => { + it("should manage email storage for email link sign in", async () => { const email = getUniqueEmail(); // Should store email await sendSignInLinkToEmail(ui.get(), email); - expect(window.localStorage.getItem('emailForSignIn')).toBe(email); + expect(window.localStorage.getItem("emailForSignIn")).toBe(email); // Should clear email on sign in await completeEmailLinkSignIn(ui.get(), window.location.href); - expect(window.localStorage.getItem('emailForSignIn')).toBeNull(); + expect(window.localStorage.getItem("emailForSignIn")).toBeNull(); }); }); - describe('OAuth Authentication', () => { - it('should handle enableAutoUpgradeAnonymous flag for OAuth', async () => { + describe("OAuth Authentication", () => { + it("should handle enableAutoUpgradeAnonymous flag for OAuth", async () => { const provider = new GoogleAuthProvider(); await signInAnonymously(ui.get()); - await expect(signInWithOAuth(ui.get(), provider)).rejects.toThrow(); + await expect(signInWithProvider(ui.get(), provider)).rejects.toThrow(); }); }); - describe('Error Handling', () => { - it('should handle duplicate email registration', async () => { + describe("Error Handling", () => { + it("should handle duplicate email registration", async () => { const email = getUniqueEmail(); await createUserWithEmailAndPassword(ui.get(), email, testPassword); await signOut(auth); @@ -150,20 +155,20 @@ describe('Firebase UI Auth Integration', () => { await expect(createUserWithEmailAndPassword(ui.get(), email, testPassword)).rejects.toThrow(FirebaseUIError); }); - it('should handle non-existent user sign in', async () => { + it("should handle non-existent user sign in", async () => { const email = getUniqueEmail(); - await expect(signInWithEmailAndPassword(ui.get(), email, 'password')).rejects.toThrow(FirebaseUIError); + await expect(signInWithEmailAndPassword(ui.get(), email, "password")).rejects.toThrow(FirebaseUIError); }); - it('should handle invalid email formats', async () => { - const invalidEmails = ['invalid', 'invalid@', '@invalid']; + it("should handle invalid email formats", async () => { + const invalidEmails = ["invalid", "invalid@", "@invalid"]; // Note: 'invalid@invalid' is actually a valid email format according to Firebase for (const email of invalidEmails) { await expect(createUserWithEmailAndPassword(ui.get(), email, testPassword)).rejects.toThrow(FirebaseUIError); } }); - it('should handle multiple anonymous account upgrades', async () => { + it("should handle multiple anonymous account upgrades", async () => { const email = getUniqueEmail(); await signInAnonymously(ui.get()); @@ -176,13 +181,13 @@ describe('Firebase UI Auth Integration', () => { await expect(createUserWithEmailAndPassword(ui.get(), email, testPassword)).rejects.toThrow(FirebaseUIError); }); - it('should handle special characters in email', async () => { + it("should handle special characters in email", async () => { const email = `test.name+${Date.now()}@example.com`; const result = await createUserWithEmailAndPassword(ui.get(), email, testPassword); expect(result.user.email).toBe(email); }); - it('should handle concurrent sign-in attempts', async () => { + it("should handle concurrent sign-in attempts", async () => { const email = getUniqueEmail(); await createUserWithEmailAndPassword(ui.get(), email, testPassword); await signOut(auth); @@ -195,11 +200,11 @@ describe('Firebase UI Auth Integration', () => { }); }); - describe('Anonymous User Upgrade', () => { - it('should maintain user data when upgrading anonymous account', async () => { + describe("Anonymous User Upgrade", () => { + it("should maintain user data when upgrading anonymous account", async () => { // First create an anonymous user const anonResult = await signInAnonymously(ui.get()); - const anonUid = anonResult.user.uid; + const _anonUid = anonResult.user.uid; // Then upgrade to email/password const email = getUniqueEmail(); @@ -209,7 +214,7 @@ describe('Firebase UI Auth Integration', () => { expect(result.user.isAnonymous).toBe(false); }); - it('should handle enableAutoUpgradeAnonymous flag correctly', async () => { + it("should handle enableAutoUpgradeAnonymous flag correctly", async () => { // Create an anonymous user await signInAnonymously(ui.get()); const email = getUniqueEmail(); @@ -222,18 +227,18 @@ describe('Firebase UI Auth Integration', () => { }); }); - describe('Email Link Authentication State Management', () => { - it('should handle multiple email link requests properly', async () => { + describe("Email Link Authentication State Management", () => { + it("should handle multiple email link requests properly", async () => { const email1 = getUniqueEmail(); const email2 = getUniqueEmail(); // First email link request await sendSignInLinkToEmail(ui.get(), email1); - expect(window.localStorage.getItem('emailForSignIn')).toBe(email1); + expect(window.localStorage.getItem("emailForSignIn")).toBe(email1); // Second email link request await sendSignInLinkToEmail(ui.get(), email2); - expect(window.localStorage.getItem('emailForSignIn')).toBe(email2); + expect(window.localStorage.getItem("emailForSignIn")).toBe(email2); }); }); }); diff --git a/packages/core/tests/utils.ts b/packages/core/tests/utils.ts new file mode 100644 index 000000000..28b573968 --- /dev/null +++ b/packages/core/tests/utils.ts @@ -0,0 +1,39 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { vi } from "vitest"; + +import type { FirebaseApp } from "firebase/app"; +import type { Auth } from "firebase/auth"; +import { enUs } from "@invertase/firebaseui-translations"; +import { FirebaseUI } from "../src/config"; + +export function createMockUI(overrides?: Partial): FirebaseUI { + return { + app: {} as FirebaseApp, + auth: {} as Auth, + setLocale: vi.fn(), + state: "idle", + setState: vi.fn(), + locale: enUs, + behaviors: {}, + multiFactorResolver: undefined, + setMultiFactorResolver: vi.fn(), + redirectError: undefined, + setRedirectError: vi.fn(), + ...overrides, + }; +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 000000000..8932418fd --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "moduleResolution": "Bundler", + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"], + "~/tests/*": ["./tests/*"], + "@invertase/firebaseui-translations": ["../translations/src/index.ts"] + } + }, + "include": ["src", "vitest.config.ts", "tsup.config.ts"] +} diff --git a/packages/firebaseui-core/tsup.config.ts b/packages/core/tsup.config.ts similarity index 89% rename from packages/firebaseui-core/tsup.config.ts rename to packages/core/tsup.config.ts index 961568a27..a55fb5c80 100644 --- a/packages/firebaseui-core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { defineConfig } from 'tsup'; +import { defineConfig } from "tsup"; export default defineConfig({ - entry: ['src/index.ts'], - format: ['cjs', 'esm'], + entry: ["src/index.ts"], + format: ["cjs", "esm"], dts: true, splitting: false, sourcemap: true, diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts new file mode 100644 index 000000000..aee7ce7f8 --- /dev/null +++ b/packages/core/vite.config.ts @@ -0,0 +1,34 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { defineConfig } from "vite"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// https://vite.dev/config/ +export default defineConfig({ + resolve: { + alias: { + "@invertase/firebaseui-styles": path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../styles/src"), + "@invertase/firebaseui-translations": path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../translations/src" + ), + "~/tests": path.resolve(path.dirname(fileURLToPath(import.meta.url)), "./tests"), + "~": path.resolve(path.dirname(fileURLToPath(import.meta.url)), "./src"), + }, + }, +}); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 000000000..b1c380a5e --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,26 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mergeConfig } from "vitest/config"; +import viteConfig from "./vite.config"; + +export default mergeConfig(viteConfig, { + test: { + name: "@invertase/firebaseui-core", + environment: "jsdom", + exclude: ["node_modules/**/*", "dist/**/*"], + }, +}); diff --git a/packages/firebaseui-angular/package.json b/packages/firebaseui-angular/package.json deleted file mode 100644 index 51fcfd499..000000000 --- a/packages/firebaseui-angular/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "@firebase-ui/angular", - "version": "0.0.1", - "files": [ - "dist" - ], - "main": "./dist/fesm2022/firebase-ui-angular.mjs", - "module": "./dist/fesm2022/firebase-ui-angular.mjs", - "typings": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/fesm2022/firebase-ui-angular.mjs" - } - }, - "scripts": { - "build": "ng-packagr -p ng-package.json", - "publish:tags": "sh -c 'TAG=\"${npm_package_name}@${npm_package_version}\"; git tag --list \"$TAG\" | grep . || git tag \"$TAG\"; git push origin \"$TAG\"'", - "release": "pnpm pack --pack-destination ../../releases/" - }, - "peerDependencies": { - "@angular/common": "^19.1.0", - "@angular/core": "^19.1.0", - "@firebase-ui/core": "workspace:*", - "@firebase-ui/translations": "workspace:*" - }, - "dependencies": { - "@tanstack/angular-form": "^1.1.0", - "nanostores": "^0.11.3", - "tslib": "^2.3.0", - "zod": "^3.24.1" - }, - "sideEffects": false, - "devDependencies": { - "@angular/fire": "^19.1.0", - "@angular/forms": "^19.2.11", - "@angular/router": "^19.2.11", - "ng-packagr": "^19.1.0", - "rxjs": "^7.8.2" - } -} \ No newline at end of file diff --git a/packages/firebaseui-angular/src/lib/auth/forms/email-link-form/email-link-form.component.ts b/packages/firebaseui-angular/src/lib/auth/forms/email-link-form/email-link-form.component.ts deleted file mode 100644 index 78872526e..000000000 --- a/packages/firebaseui-angular/src/lib/auth/forms/email-link-form/email-link-form.component.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, inject, Input, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { injectForm, TanStackField } from '@tanstack/angular-form'; -import { FirebaseUI } from '../../../provider'; -import { ButtonComponent } from '../../../components/button/button.component'; -import { TermsAndPrivacyComponent } from '../../../components/terms-and-privacy/terms-and-privacy.component'; -import { - createEmailLinkFormSchema, - FirebaseUIError, - completeEmailLinkSignIn, - sendSignInLinkToEmail, -} from '@firebase-ui/core'; -import { firstValueFrom } from 'rxjs'; - -@Component({ - selector: 'fui-email-link-form', - standalone: true, - imports: [ - CommonModule, - TanStackField, - ButtonComponent, - TermsAndPrivacyComponent, - ], - template: ` -
- {{ emailSentMessage | async }} -
-
-
- - - -
- - - -
- - {{ sendSignInLinkLabel | async }} - -
{{ formError }}
-
-
- `, -}) -export class EmailLinkFormComponent implements OnInit { - private ui = inject(FirebaseUI); - - formError: string | null = null; - emailSent = false; - private formSchema: any; - private config: any; - - form = injectForm({ - defaultValues: { - email: '', - }, - }); - - async ngOnInit() { - try { - this.config = await firstValueFrom(this.ui.config()); - - this.formSchema = createEmailLinkFormSchema(this.config?.translations); - - this.form.update({ - validators: { - onSubmit: this.formSchema, - onBlur: this.formSchema, - }, - }); - - this.completeSignIn(); - } catch (error) { - this.formError = await firstValueFrom( - this.ui.translation('errors', 'unknownError') - ); - } - } - - private async completeSignIn() { - try { - await completeEmailLinkSignIn(await firstValueFrom(this.ui.config()), window.location.href); - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - } - } - } - - async handleSubmit(event: SubmitEvent) { - event.preventDefault(); - event.stopPropagation(); - - const email = this.form.state.values.email; - - if (!email) { - return; - } - - await this.sendSignInLink(email); - } - - async sendSignInLink(email: string) { - this.formError = null; - - try { - const validationResult = this.formSchema.safeParse({ - email, - }); - - if (!validationResult.success) { - const validationErrors = validationResult.error.format(); - - if (validationErrors.email?._errors?.length) { - this.formError = validationErrors.email._errors[0]; - return; - } - - this.formError = await firstValueFrom( - this.ui.translation('errors', 'unknownError') - ); - return; - } - - await sendSignInLinkToEmail(await firstValueFrom(this.ui.config()), email); - - this.emailSent = true; - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - return; - } - - this.formError = await firstValueFrom( - this.ui.translation('errors', 'unknownError') - ); - } - } - - get emailLabel() { - return this.ui.translation('labels', 'emailAddress'); - } - - get sendSignInLinkLabel() { - return this.ui.translation('labels', 'sendSignInLink'); - } - - get emailSentMessage() { - return this.ui.translation('messages', 'signInLinkSent'); - } -} diff --git a/packages/firebaseui-angular/src/lib/auth/forms/email-password-form/email-password-form.component.spec.ts b/packages/firebaseui-angular/src/lib/auth/forms/email-password-form/email-password-form.component.spec.ts deleted file mode 100644 index 13394bcaf..000000000 --- a/packages/firebaseui-angular/src/lib/auth/forms/email-password-form/email-password-form.component.spec.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; -import { - ComponentFixture, - TestBed, - fakeAsync, - tick, -} from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { Router, provideRouter } from '@angular/router'; -import { TanStackField } from '@tanstack/angular-form'; -import { getFirebaseUITestProviders } from '../../../testing/test-helpers'; -import { EmailPasswordFormComponent } from './email-password-form.component'; - -// Define window properties for testing -declare global { - interface Window { - signInWithEmailAndPassword: any; - createEmailFormSchema: any; - } -} - -// Mock Button component -@Component({ - selector: 'fui-button', - template: ``, - standalone: true, -}) -class MockButtonComponent { - @Input() type: string = 'button'; -} - -// Mock TermsAndPrivacy component -@Component({ - selector: 'fui-terms-and-privacy', - template: `
`, - standalone: true, -}) -class MockTermsAndPrivacyComponent {} - -describe('EmailPasswordFormComponent', () => { - let component: EmailPasswordFormComponent; - let fixture: ComponentFixture; - let mockRouter: any; - let signInSpy: jasmine.Spy; - - // Expected error messages from the actual implementation - const errorMessages = { - invalidEmail: 'Please enter a valid email address', - passwordTooShort: 'Password should be at least 8 characters', - unknownError: 'An unknown error occurred', - }; - - // Mock schema returned by createEmailFormSchema - const mockSchema = { - safeParse: (data: any) => { - // Test email validation - if (!data.email.includes('@')) { - return { - success: false, - error: { - format: () => ({ - email: { _errors: [errorMessages.invalidEmail] }, - }), - }, - }; - } - // Test password validation - if (data.password.length < 8) { - return { - success: false, - error: { - format: () => ({ - password: { _errors: [errorMessages.passwordTooShort] }, - }), - }, - }; - } - return { success: true }; - }, - }; - - beforeEach(async () => { - // Mock router - mockRouter = { - navigateByUrl: jasmine.createSpy('navigateByUrl'), - }; - - // Create spies for the global functions - signInSpy = jasmine - .createSpy('signInWithEmailAndPassword') - .and.returnValue(Promise.resolve()); - - // Define the function on the window object - Object.defineProperty(window, 'signInWithEmailAndPassword', { - value: signInSpy, - writable: true, - configurable: true, - }); - - Object.defineProperty(window, 'createEmailFormSchema', { - value: () => mockSchema, - writable: true, - configurable: true, - }); - - await TestBed.configureTestingModule({ - imports: [ - CommonModule, - EmailPasswordFormComponent, - TanStackField, - MockButtonComponent, - MockTermsAndPrivacyComponent, - ], - providers: [ - provideRouter([]), - { provide: Router, useValue: mockRouter }, - ...getFirebaseUITestProviders(), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(EmailPasswordFormComponent); - component = fixture.componentInstance; - - // Set required inputs - component.forgotPasswordRoute = '/forgot-password'; - component.registerRoute = '/register'; - - // Mock the validateAndSignIn method without any TypeScript errors - component.validateAndSignIn = jasmine.createSpy('validateAndSignIn'); - - fixture.detectChanges(); - await fixture.whenStable(); // Wait for async ngOnInit - }); - - it('renders the form correctly', () => { - expect(component).toBeTruthy(); - - // Check essential elements are present - const emailInput = fixture.debugElement.query( - By.css('input[type="email"]') - ); - const passwordInput = fixture.debugElement.query( - By.css('input[type="password"]') - ); - const termsAndPrivacy = fixture.debugElement.query( - By.css('fui-terms-and-privacy') - ); - const submitButton = fixture.debugElement.query(By.css('fui-button')); - - expect(emailInput).toBeTruthy(); - expect(passwordInput).toBeTruthy(); - expect(termsAndPrivacy).toBeTruthy(); - expect(submitButton).toBeTruthy(); - }); - - it('submits the form when handleSubmit is called', fakeAsync(() => { - // Set values directly on the form state - component.form.state.values.email = 'test@example.com'; - component.form.state.values.password = 'password123'; - - // Create a submit event - const event = new Event('submit'); - Object.defineProperties(event, { - preventDefault: { value: jasmine.createSpy('preventDefault') }, - stopPropagation: { value: jasmine.createSpy('stopPropagation') }, - }); - - // Call handleSubmit directly - component.handleSubmit(event as SubmitEvent); - tick(); - - // Check if validateAndSignIn was called with correct values - expect(component.validateAndSignIn).toHaveBeenCalledWith( - 'test@example.com', - 'password123' - ); - })); - - it('displays error message when sign in fails', fakeAsync(() => { - // Manually set the error - component.formError = 'Invalid credentials'; - fixture.detectChanges(); - - // Check that the error message is displayed in the DOM - const formErrorEl = fixture.debugElement.query(By.css('.fui-form__error')); - expect(formErrorEl).toBeTruthy(); - expect(formErrorEl.nativeElement.textContent.trim()).toBe( - 'Invalid credentials' - ); - })); - - it('shows an error message for invalid input', () => { - // Manually set error message for testing - component.formError = errorMessages.invalidEmail; - fixture.detectChanges(); - - // Check for error display in the DOM - const formErrorEl = fixture.debugElement.query(By.css('.fui-form__error')); - expect(formErrorEl).toBeTruthy(); - expect(formErrorEl.nativeElement.textContent.trim()).toBe( - errorMessages.invalidEmail - ); - }); - - it('navigates to register route when that button is clicked', () => { - // Find the register button (second action button) - const registerButton = fixture.debugElement.queryAll( - By.css('.fui-form__action') - )[1]; - expect(registerButton).toBeTruthy(); - - // Click the button - registerButton.nativeElement.click(); - - // Check navigation was triggered - expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/register'); - }); - - it('navigates to forgot password route when that button is clicked', () => { - // Find the forgot password button (first action button) - const forgotPasswordButton = fixture.debugElement.queryAll( - By.css('.fui-form__action') - )[0]; - expect(forgotPasswordButton).toBeTruthy(); - - // Click the button - forgotPasswordButton.nativeElement.click(); - - // Check navigation was triggered - expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/forgot-password'); - }); -}); diff --git a/packages/firebaseui-angular/src/lib/auth/forms/email-password-form/email-password-form.component.ts b/packages/firebaseui-angular/src/lib/auth/forms/email-password-form/email-password-form.component.ts deleted file mode 100644 index 863aac933..000000000 --- a/packages/firebaseui-angular/src/lib/auth/forms/email-password-form/email-password-form.component.ts +++ /dev/null @@ -1,243 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, inject, Input, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { injectForm, TanStackField } from '@tanstack/angular-form'; -import { FirebaseUI } from '../../../provider'; -import { ButtonComponent } from '../../../components/button/button.component'; -import { TermsAndPrivacyComponent } from '../../../components/terms-and-privacy/terms-and-privacy.component'; -import { - createEmailFormSchema, - EmailFormSchema, - FirebaseUIError, - signInWithEmailAndPassword, -} from '@firebase-ui/core'; -import { firstValueFrom } from 'rxjs'; -import { Router } from '@angular/router'; - -@Component({ - selector: 'fui-email-password-form', - standalone: true, - imports: [ - CommonModule, - TanStackField, - ButtonComponent, - TermsAndPrivacyComponent, - ], - template: ` -
-
- - - -
-
- - - -
- - - -
- - {{ signInLabel | async }} - -
{{ formError }}
-
- -
- -
-
- `, -}) -export class EmailPasswordFormComponent implements OnInit { - private ui = inject(FirebaseUI); - private router = inject(Router); - - @Input({ required: true }) forgotPasswordRoute!: string; - @Input({ required: true }) registerRoute!: string; - - formError: string | null = null; - private formSchema: any; - private config: any; - - form = injectForm({ - defaultValues: { - email: '', - password: '', - }, - }); - - async ngOnInit() { - try { - // Get config once - this.config = await firstValueFrom(this.ui.config()); - - // Create schema once - this.formSchema = createEmailFormSchema(this.config?.translations); - - // Apply schema to form validators - this.form.update({ - validators: { - onSubmit: this.formSchema, - onBlur: this.formSchema, - }, - }); - } catch (error) { - this.formError = await firstValueFrom( - this.ui.translation('errors', 'unknownError') - ); - } - } - - async handleSubmit(event: SubmitEvent) { - event.preventDefault(); - event.stopPropagation(); - - const email = this.form.state.values.email; - const password = this.form.state.values.password; - - if (!email || !password) { - return; - } - - await this.validateAndSignIn(email, password); - } - - async validateAndSignIn(email: string, password: string) { - try { - const validationResult = this.formSchema.safeParse({ - email, - password, - }); - - if (!validationResult.success) { - const validationErrors = validationResult.error.format(); - - if (validationErrors.email?._errors?.length) { - this.formError = validationErrors.email._errors[0]; - return; - } - - if (validationErrors.password?._errors?.length) { - this.formError = validationErrors.password._errors[0]; - return; - } - - this.formError = await firstValueFrom( - this.ui.translation('errors', 'unknownError') - ); - return; - } - - this.formError = null; - await signInWithEmailAndPassword(await firstValueFrom(this.ui.config()), email, password); - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - return; - } - - this.formError = await firstValueFrom( - this.ui.translation('errors', 'unknownError') - ); - } - } - - navigateTo(route: string) { - this.router.navigateByUrl(route); - } - - get emailLabel() { - return this.ui.translation('labels', 'emailAddress'); - } - - get passwordLabel() { - return this.ui.translation('labels', 'password'); - } - - get forgotPasswordLabel() { - return this.ui.translation('labels', 'forgotPassword'); - } - - get signInLabel() { - return this.ui.translation('labels', 'signIn'); - } - - get noAccountLabel() { - return this.ui.translation('prompts', 'noAccount'); - } - - get registerLabel() { - return this.ui.translation('labels', 'register'); - } -} diff --git a/packages/firebaseui-angular/src/lib/auth/forms/forgot-password-form/forgot-password-form.component.spec.ts b/packages/firebaseui-angular/src/lib/auth/forms/forgot-password-form/forgot-password-form.component.spec.ts deleted file mode 100644 index d8cb64c1c..000000000 --- a/packages/firebaseui-angular/src/lib/auth/forms/forgot-password-form/forgot-password-form.component.spec.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; -import { - ComponentFixture, - TestBed, - fakeAsync, - tick, -} from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { Router, provideRouter } from '@angular/router'; -import { TanStackField } from '@tanstack/angular-form'; -import { getFirebaseUITestProviders } from '../../../testing/test-helpers'; -import { ForgotPasswordFormComponent } from './forgot-password-form.component'; - -// Define window properties for testing -declare global { - interface Window { - sendPasswordResetEmail: any; - createForgotPasswordFormSchema: any; - } -} - -// Mock Button component -@Component({ - selector: 'fui-button', - template: ``, - standalone: true, -}) -class MockButtonComponent { - @Input() type: string = 'button'; -} - -// Mock TermsAndPrivacy component -@Component({ - selector: 'fui-terms-and-privacy', - template: `
`, - standalone: true, -}) -class MockTermsAndPrivacyComponent {} - -describe('ForgotPasswordFormComponent', () => { - let component: ForgotPasswordFormComponent; - let fixture: ComponentFixture; - let mockRouter: any; - let sendResetEmailSpy: jasmine.Spy; - - // Expected error messages from the actual implementation - const errorMessages = { - invalidEmail: 'Please enter a valid email address', - unknownError: 'An unknown error occurred', - }; - - // Mock schema returned by createForgotPasswordFormSchema - const mockSchema = { - safeParse: (data: any) => { - // Test email validation - if (!data.email.includes('@')) { - return { - success: false, - error: { - format: () => ({ - email: { _errors: [errorMessages.invalidEmail] }, - }), - }, - }; - } - return { success: true }; - }, - }; - - beforeEach(async () => { - // Mock router - mockRouter = { - navigateByUrl: jasmine.createSpy('navigateByUrl'), - }; - - // Create spies for the global functions - sendResetEmailSpy = jasmine - .createSpy('sendPasswordResetEmail') - .and.returnValue(Promise.resolve()); - - // Define the function on the window object - Object.defineProperty(window, 'sendPasswordResetEmail', { - value: sendResetEmailSpy, - writable: true, - configurable: true, - }); - - Object.defineProperty(window, 'createForgotPasswordFormSchema', { - value: () => mockSchema, - writable: true, - configurable: true, - }); - - await TestBed.configureTestingModule({ - imports: [ - CommonModule, - ForgotPasswordFormComponent, - TanStackField, - MockButtonComponent, - MockTermsAndPrivacyComponent, - ], - providers: [ - provideRouter([]), - { provide: Router, useValue: mockRouter }, - ...getFirebaseUITestProviders(), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(ForgotPasswordFormComponent); - component = fixture.componentInstance; - - // Set required inputs - component.signInRoute = '/signin'; - - // Replace the resetPassword method with a spy - spyOn(component, 'resetPassword').and.callFake(async (_email) => { - return Promise.resolve(); - }); - - // Mock the form schema - component['formSchema'] = mockSchema; - - fixture.detectChanges(); - await fixture.whenStable(); // Wait for async ngOnInit - }); - - it('renders the form correctly', () => { - expect(component).toBeTruthy(); - - // Check essential elements are present - const emailInput = fixture.debugElement.query( - By.css('input[type="email"]') - ); - const termsAndPrivacy = fixture.debugElement.query( - By.css('fui-terms-and-privacy') - ); - const submitButton = fixture.debugElement.query(By.css('fui-button')); - - expect(emailInput).toBeTruthy(); - expect(termsAndPrivacy).toBeTruthy(); - expect(submitButton).toBeTruthy(); - }); - - it('submits the form when handleSubmit is called', fakeAsync(() => { - // Set values directly on the form state - component.form.state.values.email = 'test@example.com'; - - // Create a submit event - const event = new Event('submit'); - Object.defineProperties(event, { - preventDefault: { value: jasmine.createSpy('preventDefault') }, - stopPropagation: { value: jasmine.createSpy('stopPropagation') }, - }); - - // Call handleSubmit directly - component.handleSubmit(event as SubmitEvent); - tick(); - - // Check if resetPassword was called with correct values - expect(component.resetPassword).toHaveBeenCalledWith('test@example.com'); - })); - - it('displays error message when reset fails', fakeAsync(() => { - // Manually set the error - component.formError = 'Invalid email'; - fixture.detectChanges(); - - // Check that the error message is displayed in the DOM - const formErrorEl = fixture.debugElement.query(By.css('.fui-form__error')); - expect(formErrorEl).toBeTruthy(); - expect(formErrorEl.nativeElement.textContent.trim()).toBe('Invalid email'); - })); - - it('shows success message when email is sent', () => { - // Set emailSent to true - component.emailSent = true; - fixture.detectChanges(); - - // Check for success message - const successMessage = fixture.debugElement.query( - By.css('.fui-form__success') - ); - expect(successMessage).toBeTruthy(); - expect(successMessage.nativeElement.textContent.trim()).toContain( - 'Check your email' - ); - }); - - it('navigates to sign in route when back button is clicked', () => { - // Find the sign in button - const signInLink = fixture.debugElement.query(By.css('.fui-form__action')); - expect(signInLink).toBeTruthy(); - - // Click the link - signInLink.nativeElement.click(); - - // Check navigation was triggered - expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/signin'); - }); -}); diff --git a/packages/firebaseui-angular/src/lib/auth/forms/forgot-password-form/forgot-password-form.component.ts b/packages/firebaseui-angular/src/lib/auth/forms/forgot-password-form/forgot-password-form.component.ts deleted file mode 100644 index 5bf7fdb9f..000000000 --- a/packages/firebaseui-angular/src/lib/auth/forms/forgot-password-form/forgot-password-form.component.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, inject, Input, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { injectForm, TanStackField } from '@tanstack/angular-form'; -import { FirebaseUI } from '../../../provider'; -import { Auth } from '@angular/fire/auth'; -import { ButtonComponent } from '../../../components/button/button.component'; -import { TermsAndPrivacyComponent } from '../../../components/terms-and-privacy/terms-and-privacy.component'; -import { - createForgotPasswordFormSchema, - FirebaseUIError, - sendPasswordResetEmail, -} from '@firebase-ui/core'; -import { firstValueFrom } from 'rxjs'; -import { Router } from '@angular/router'; - -@Component({ - selector: 'fui-forgot-password-form', - standalone: true, - imports: [ - CommonModule, - TanStackField, - ButtonComponent, - TermsAndPrivacyComponent, - ], - template: ` -
- {{ checkEmailForResetMessage | async }} -
-
-
- - - -
- - - -
- - {{ resetPasswordLabel | async }} - -
{{ formError }}
-
- -
- -
-
- `, -}) -export class ForgotPasswordFormComponent implements OnInit { - private ui = inject(FirebaseUI); - private router = inject(Router); - - @Input({ required: true }) signInRoute!: string; - - formError: string | null = null; - emailSent = false; - private formSchema: any; - private config: any; - - form = injectForm({ - defaultValues: { - email: '', - }, - }); - - async ngOnInit() { - try { - this.config = await firstValueFrom(this.ui.config()); - - this.formSchema = createForgotPasswordFormSchema( - this.config?.translations - ); - - this.form.update({ - validators: { - onSubmit: this.formSchema, - onBlur: this.formSchema, - }, - }); - } catch (error) { - this.formError = await firstValueFrom( - this.ui.translation('errors', 'unknownError') - ); - } - } - - async handleSubmit(event: SubmitEvent) { - event.preventDefault(); - event.stopPropagation(); - - const email = this.form.state.values.email; - - if (!email) { - return; - } - - await this.resetPassword(email); - } - - async resetPassword(email: string) { - this.formError = null; - - try { - const validationResult = this.formSchema.safeParse({ - email, - }); - - if (!validationResult.success) { - const validationErrors = validationResult.error.format(); - - if (validationErrors.email?._errors?.length) { - this.formError = validationErrors.email._errors[0]; - return; - } - - this.formError = await firstValueFrom( - this.ui.translation('errors', 'unknownError') - ); - return; - } - - // Send password reset email - await sendPasswordResetEmail(await firstValueFrom(this.ui.config()), email); - - this.emailSent = true; - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - return; - } - - this.formError = await firstValueFrom( - this.ui.translation('errors', 'unknownError') - ); - } - } - - navigateTo(route: string) { - this.router.navigateByUrl(route); - } - - get emailLabel() { - return this.ui.translation('labels', 'emailAddress'); - } - - get resetPasswordLabel() { - return this.ui.translation('labels', 'resetPassword'); - } - - get backToSignInLabel() { - return this.ui.translation('labels', 'backToSignIn'); - } - - get checkEmailForResetMessage() { - return this.ui.translation('messages', 'checkEmailForReset'); - } -} diff --git a/packages/firebaseui-angular/src/lib/auth/forms/phone-form/phone-form.component.spec.ts b/packages/firebaseui-angular/src/lib/auth/forms/phone-form/phone-form.component.spec.ts deleted file mode 100644 index 55703facc..000000000 --- a/packages/firebaseui-angular/src/lib/auth/forms/phone-form/phone-form.component.spec.ts +++ /dev/null @@ -1,505 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; -import { - ComponentFixture, - TestBed, - fakeAsync, - tick, -} from '@angular/core/testing'; -import { - Auth, - ConfirmationResult, - RecaptchaVerifier, -} from '@angular/fire/auth'; -import { FirebaseUIError } from '@firebase-ui/core'; -import { TanStackField } from '@tanstack/angular-form'; -import { firstValueFrom, of } from 'rxjs'; -import { FirebaseUI, FirebaseUIPolicies } from '../../../provider'; -import { - PhoneFormComponent, - PhoneNumberFormComponent, - VerificationFormComponent, -} from './phone-form.component'; -import { mockAuth } from '../../../testing/test-helpers'; -import { providePolicies } from 'src/app/policies/providePolicies'; - -// Mock Firebase UI Core functions -const mockFuiSignInWithPhoneNumber = jasmine - .createSpy('signInWithPhoneNumber') - .and.returnValue( - Promise.resolve({ - confirm: jasmine.createSpy('confirm').and.returnValue(Promise.resolve()), - verificationId: 'mock-verification-id', - } as ConfirmationResult), - ); - -const mockFuiConfirmPhoneNumber = jasmine - .createSpy('fuiConfirmPhoneNumber') - .and.returnValue(Promise.resolve({} as any)); - -// Mock Button component -@Component({ - selector: 'fui-button', - template: ``, - standalone: true, -}) -class MockButtonComponent { - @Input() type: string = 'button'; - @Input() disabled: boolean = false; - @Input() variant: string = 'primary'; -} - -// Mock TermsAndPrivacy component -@Component({ - selector: 'fui-terms-and-privacy', - template: `
`, - standalone: true, -}) -class MockTermsAndPrivacyComponent {} - -// Mock CountrySelector component -@Component({ - selector: 'fui-country-selector', - template: `
- -
`, - standalone: true, -}) -class MockCountrySelectorComponent { - @Input() value: any; - @Input() className: string = ''; - - countries = [ - { code: 'US', name: 'United States', dialCode: '+1', emoji: '🇺🇸' }, - { code: 'GB', name: 'United Kingdom', dialCode: '+44', emoji: '🇬🇧' }, - ]; - - trackByCode(_index: number, country: any) { - return country.code; - } - - handleChange(event: any) { - const code = event.target.value; - const country = this.countries.find((c) => c.code === code); - if (country) { - this.onChange?.(country); - } - } - - @Input() onChange: ((country: any) => void) | undefined; -} - -// Create mock for FirebaseUi provider -class MockFirebaseUi { - config() { - return of({ - getAuth: () => mockAuth, - recaptchaMode: 'normal', - translations: {}, - }); - } - - translation(_category: string, _key: string) { - return of('Invalid phone number'); // Return the specific expected error message - } -} - -// Create a test component class that extends the real component -class TestPhoneFormComponent extends PhoneFormComponent { - // Replace the initRecaptcha method to simplify testing - initRecaptcha() { - const mockRecaptchaVerifier = jasmine.createSpyObj( - 'RecaptchaVerifier', - ['render', 'clear', 'verify'], - ); - mockRecaptchaVerifier.render.and.returnValue(Promise.resolve(1)); - mockRecaptchaVerifier.verify.and.returnValue( - Promise.resolve('verification-token'), - ); - - this.recaptchaVerifier = mockRecaptchaVerifier; - return Promise.resolve(); - } - - // Make protected methods directly accessible for testing - async testGetAuth() { - return (await firstValueFrom(this['ui'].config())).getAuth(); - } - - testGetUi() { - return this['ui']; // Access private property with indexing - } - - // Simple mock implementation that directly uses our spy - override async handlePhoneSubmit(phoneNumber: string): Promise { - this.formError = null; - - if (phoneNumber.startsWith('VALIDATION_ERROR:')) { - this.formError = phoneNumber.substring('VALIDATION_ERROR:'.length); - return; - } - - try { - if (!this.recaptchaVerifier) { - throw new Error('ReCAPTCHA not initialized'); - } - - this.phoneNumber = phoneNumber; - // Call our mock function directly - const result = await mockFuiSignInWithPhoneNumber( - await this.testGetAuth(), - phoneNumber, - this.recaptchaVerifier, - { - translations: {}, - language: 'en', - }, - ); - - this.confirmationResult = result; - this.startTimer(); - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - return; - } - this.formError = 'Invalid phone number'; - } - } - - // Simple mock implementation that directly uses our spy - override async handleVerificationSubmit(code: string): Promise { - if (code.startsWith('VALIDATION_ERROR:')) { - this.formError = code.substring('VALIDATION_ERROR:'.length); - return; - } - - if (!this.confirmationResult) { - throw new Error('Confirmation result not initialized'); - } - - this.formError = null; - - try { - // Call our mock function directly - await mockFuiConfirmPhoneNumber(this.confirmationResult, code, { - translations: {}, - language: 'en', - }); - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - return; - } - this.formError = 'Invalid verification code'; - } - } - - // Simple mock implementation that directly uses our spy - override async handleResend(): Promise { - if (!this.canResend || !this.phoneNumber) { - return; - } - - this.formError = null; - - try { - if (this.recaptchaVerifier) { - // Call our mock function directly - const result = await mockFuiSignInWithPhoneNumber( - this.testGetAuth(), - this.phoneNumber, - this.recaptchaVerifier, - { - translations: {}, - language: 'en', - }, - ); - - this.confirmationResult = result; - this.startTimer(); - } - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - } else { - this.formError = 'An error occurred'; - } - } - } -} - -class TestPhoneNumberFormComponent extends PhoneNumberFormComponent { - // Replace the initRecaptcha method - override initRecaptcha() { - const mockRecaptchaVerifier = jasmine.createSpyObj( - 'RecaptchaVerifier', - ['render', 'clear', 'verify'], - ); - mockRecaptchaVerifier.render.and.returnValue(Promise.resolve(1)); - mockRecaptchaVerifier.verify.and.returnValue( - Promise.resolve('verification-token'), - ); - - this.recaptchaVerifier = mockRecaptchaVerifier; - return Promise.resolve(); - } -} - -class TestVerificationFormComponent extends VerificationFormComponent { - // No need to override anything here as it doesn't use RecaptchaVerifier -} - -describe('PhoneFormComponent', () => { - let component: TestPhoneFormComponent; - let fixture: ComponentFixture; - let mockRecaptchaVerifier: jasmine.SpyObj; - let mockFirebaseUi: MockFirebaseUi; - - beforeEach(function () { - // Reset the spies before each test - mockFuiSignInWithPhoneNumber.calls.reset(); - mockFuiConfirmPhoneNumber.calls.reset(); - - mockRecaptchaVerifier = jasmine.createSpyObj( - 'RecaptchaVerifier', - ['render', 'clear', 'verify'], - ); - mockRecaptchaVerifier.render.and.returnValue(Promise.resolve(1)); - mockRecaptchaVerifier.verify.and.returnValue( - Promise.resolve('verification-token'), - ); - - // Create mock schema for phone validation - (window as any).createPhoneFormSchema = jasmine - .createSpy('createPhoneFormSchema') - .and.returnValue({ - safeParse: (data: any) => { - if (data.phoneNumber && !data.phoneNumber.match(/^\d{10}$/)) { - return { - success: false, - error: { - format: () => ({ - phoneNumber: { _errors: ['Invalid phone number'] }, - }), - }, - }; - } - if ( - data.verificationCode && - !data.verificationCode.match(/^\d{6}$/) - ) { - return { - success: false, - error: { - format: () => ({ - verificationCode: { _errors: ['Invalid verification code'] }, - }), - }, - }; - } - return { success: true }; - }, - pick: () => ({ - safeParse: (data: any) => { - if (data.phoneNumber && !data.phoneNumber.match(/^\d{10}$/)) { - return { - success: false, - error: { - format: () => ({ - phoneNumber: { _errors: ['Invalid phone number'] }, - }), - }, - }; - } - if ( - data.verificationCode && - !data.verificationCode.match(/^\d{6}$/) - ) { - return { - success: false, - error: { - format: () => ({ - verificationCode: { - _errors: ['Invalid verification code'], - }, - }), - }, - }; - } - return { success: true }; - }, - }), - }); - - mockFirebaseUi = new MockFirebaseUi(); - - // Mock Auth service - const mockAuthService = { - app: { - options: { - apiKey: 'test-api-key', - }, - automaticDataCollectionEnabled: false, - name: 'test-app', - appVerificationDisabledForTesting: true, - }, - languageCode: 'en', - settings: { appVerificationDisabledForTesting: true }, - signInWithPhoneNumber: jasmine - .createSpy('signInWithPhoneNumber') - .and.returnValue( - Promise.resolve({ - confirm: jasmine - .createSpy('confirm') - .and.returnValue(Promise.resolve()), - }), - ), - signInWithCredential: jasmine - .createSpy('signInWithCredential') - .and.returnValue(Promise.resolve()), - }; - - TestBed.configureTestingModule({ - imports: [ - CommonModule, - TanStackField, - TestPhoneFormComponent, - TestPhoneNumberFormComponent, - TestVerificationFormComponent, - MockButtonComponent, - MockTermsAndPrivacyComponent, - MockCountrySelectorComponent, - ], - providers: [ - { provide: FirebaseUI, useValue: mockFirebaseUi }, - { provide: Auth, useValue: mockAuthService }, - { - provide: FirebaseUIPolicies, - useValue: { - termsOfServiceUrl: '/terms', - privacyPolicyUrl: '/privacy', - }, - }, - ], - }).compileComponents(); - - // Mock RecaptchaVerifier constructor - (window as any).RecaptchaVerifier = jasmine - .createSpy('RecaptchaVerifier') - .and.returnValue(mockRecaptchaVerifier); - - fixture = TestBed.createComponent(TestPhoneFormComponent); - component = fixture.componentInstance; - component.recaptchaVerifier = mockRecaptchaVerifier; - - // Mock DOM methods - spyOn(document, 'querySelector').and.returnValue( - document.createElement('div'), - ); - - // Directly replace timer with mock implementation - component.startTimer = function () { - this.timeLeft = this.resendDelay; - this.canResend = false; - - // Simulate the timer effect manually - this.timeLeft = this.timeLeft - 1; - this.canResend = true; - }; - - component.ngOnInit(); - fixture.detectChanges(); - }); - - it('should create the component', () => { - expect(component).toBeTruthy(); - }); - - it('should initially show the phone number form', () => { - expect(component.confirmationResult).toBeNull(); - }); - - it('should call signInWithPhoneNumber when handling phone submission', fakeAsync(() => { - component.handlePhoneSubmit('1234567890'); - tick(); - - expect(mockFuiSignInWithPhoneNumber).toHaveBeenCalled(); - })); - - it('should show an error message when phone submission fails', fakeAsync(() => { - const mockError = new FirebaseUIError({ - code: 'auth/invalid-phone-number', - message: 'The phone number is invalid', - }); - - mockFuiSignInWithPhoneNumber.and.rejectWith(mockError); - - component.handlePhoneSubmit('1234567890'); - tick(); - - expect(component.formError).toBe('The phone number is invalid'); - })); - - it('should call fuiConfirmPhoneNumber when handling verification code submission', fakeAsync(() => { - // Set up the confirmation result first - const mockConfirmationResult = { - confirm: jasmine.createSpy('confirm').and.returnValue(Promise.resolve()), - verificationId: 'mock-verification-id', - } as ConfirmationResult; - - component.confirmationResult = mockConfirmationResult; - - component.handleVerificationSubmit('123456'); - tick(); - - expect(mockFuiConfirmPhoneNumber).toHaveBeenCalled(); - })); - - it('should call signInWithPhoneNumber when handling resend code', fakeAsync(() => { - component.confirmationResult = {} as ConfirmationResult; - component.canResend = true; - component.phoneNumber = '1234567890'; - - component.handleResend(); - tick(); - - expect(mockFuiSignInWithPhoneNumber).toHaveBeenCalled(); - })); - - it('should update timer and resend flag', () => { - component.resendDelay = 2; - component.startTimer(); - expect(component.timeLeft).toBe(1); - expect(component.canResend).toBeTrue(); - }); -}); diff --git a/packages/firebaseui-angular/src/lib/auth/forms/phone-form/phone-form.component.ts b/packages/firebaseui-angular/src/lib/auth/forms/phone-form/phone-form.component.ts deleted file mode 100644 index 5ce654436..000000000 --- a/packages/firebaseui-angular/src/lib/auth/forms/phone-form/phone-form.component.ts +++ /dev/null @@ -1,608 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - Component, - inject, - Input, - OnDestroy, - OnInit, - ViewChild, - ElementRef, -} from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { injectForm, TanStackField } from '@tanstack/angular-form'; -import { FirebaseUI } from '../../../provider'; -import { - Auth, - ConfirmationResult, - RecaptchaVerifier, -} from '@angular/fire/auth'; -import { map } from 'rxjs/operators'; -import { ButtonComponent } from '../../../components/button/button.component'; -import { TermsAndPrivacyComponent } from '../../../components/terms-and-privacy/terms-and-privacy.component'; -import { CountrySelectorComponent } from '../../../components/country-selector/country-selector.component'; -import { - CountryData, - countryData, - createPhoneFormSchema, - FirebaseUIError, - formatPhoneNumberWithCountry, - confirmPhoneNumber, - signInWithPhoneNumber, -} from '@firebase-ui/core'; -import { interval, Subscription, firstValueFrom } from 'rxjs'; -import { Router } from '@angular/router'; -import { takeWhile } from 'rxjs/operators'; -import { z } from 'zod'; - -@Component({ - selector: 'fui-phone-number-form', - standalone: true, - imports: [ - CommonModule, - TanStackField, - ButtonComponent, - TermsAndPrivacyComponent, - CountrySelectorComponent, - ], - template: ` -
-
- - - -
- -
-
-
- - - -
- - {{ sendCodeLabel | async }} - -
{{ formError }}
-
-
- `, -}) -export class PhoneNumberFormComponent implements OnInit, OnDestroy { - private ui = inject(FirebaseUI); - - @Input() onSubmit!: (phoneNumber: string) => Promise; - @Input() formError: string | null = null; - @Input() showTerms = true; - @ViewChild('recaptchaContainer', { static: true }) - recaptchaContainer!: ElementRef; - - recaptchaVerifier: RecaptchaVerifier | null = null; - selectedCountry: CountryData = countryData[0]; - private formSchema: any; - private config: any; - - form = injectForm({ - defaultValues: { - phoneNumber: '', - }, - }); - - async ngOnInit() { - try { - this.config = await firstValueFrom(this.ui.config()); - - this.formSchema = createPhoneFormSchema(this.config?.translations).pick({ - phoneNumber: true, - }); - - this.form.update({ - validators: { - onSubmit: this.formSchema, - onBlur: this.formSchema, - }, - }); - - await this.initRecaptcha(); - } catch (error) { - console.error(error); - } - } - - ngOnDestroy() { - if (this.recaptchaVerifier) { - this.recaptchaVerifier.clear(); - this.recaptchaVerifier = null; - } - } - - async initRecaptcha() { - const verifier = new RecaptchaVerifier( - (await firstValueFrom(this.ui.config())).getAuth(), - this.recaptchaContainer.nativeElement, - { - size: this.config?.recaptchaMode ?? 'normal', - }, - ); - this.recaptchaVerifier = verifier; - } - - async handleSubmit(event: SubmitEvent) { - event.preventDefault(); - event.stopPropagation(); - - const phoneNumber = this.form.state.values.phoneNumber; - - if (!phoneNumber) { - return; - } - - this.submitPhoneNumber(phoneNumber); - } - - async submitPhoneNumber(phoneNumber: string) { - try { - // Validate phoneNumber - const validationResult = this.formSchema.safeParse({ - phoneNumber, - }); - - if (!validationResult.success) { - const validationErrors = validationResult.error.format(); - - if (validationErrors.phoneNumber?._errors?.length) { - // We can't set formError directly since it's an input, so we need to call the parent - await this.onSubmit( - 'VALIDATION_ERROR:' + validationErrors.phoneNumber._errors[0], - ); - return; - } - - await this.onSubmit('VALIDATION_ERROR:Invalid phone number'); - return; - } - - // Format number and submit - const formattedNumber = formatPhoneNumberWithCountry( - phoneNumber, - this.selectedCountry.dialCode, - ); - await this.onSubmit(formattedNumber); - } catch (error) { - console.error(error); - } - } - - handleCountryChange(country: CountryData) { - this.selectedCountry = country; - } - - get phoneNumberLabel() { - return this.ui.translation('labels', 'phoneNumber'); - } - - get sendCodeLabel() { - return this.ui.translation('labels', 'sendCode'); - } -} - -@Component({ - selector: 'fui-verification-form', - standalone: true, - imports: [ - CommonModule, - TanStackField, - ButtonComponent, - TermsAndPrivacyComponent, - ], - template: ` -
-
- - - -
- -
-
-
- - - - - -
- - {{ verifyCodeLabel | async }} - - - - {{ sendingLabel | async }} - - - {{ resendCodeLabel | async }} ({{ timeLeft }}s) - - - {{ resendCodeLabel | async }} - - -
{{ formError }}
-
- - -
- `, -}) -export class VerificationFormComponent implements OnInit, OnDestroy { - private ui = inject(FirebaseUI); - - @Input() onSubmit!: (code: string) => Promise; - @Input() onResend!: () => Promise; - @Input() formError: string | null = null; - @Input() showTerms = false; - @Input() isResending = false; - @Input() canResend = false; - @Input() timeLeft = 0; - @ViewChild('recaptchaContainer', { static: true }) - recaptchaContainer!: ElementRef; - - private formSchema: any; - private config: any; - - form = injectForm({ - defaultValues: { - verificationCode: '', - }, - }); - - async ngOnInit() { - try { - this.config = await firstValueFrom(this.ui.config()); - - // Create schema once - this.formSchema = createPhoneFormSchema(this.config?.translations).pick({ - verificationCode: true, - }); - - this.form.update({ - validators: { - onSubmit: this.formSchema, - onBlur: this.formSchema, - }, - }); - } catch (error) { - console.error(error); - } - } - - ngOnDestroy() {} - - async handleSubmit(event: SubmitEvent) { - event.preventDefault(); - event.stopPropagation(); - - const code = this.form.state.values.verificationCode; - - if (!code) { - return; - } - - await this.verifyCode(code); - } - - async verifyCode(code: string) { - try { - const validationResult = this.formSchema.safeParse({ - verificationCode: code, - }); - - if (!validationResult.success) { - const validationErrors = validationResult.error.format(); - - if (validationErrors.verificationCode?._errors?.length) { - await this.onSubmit( - 'VALIDATION_ERROR:' + validationErrors.verificationCode._errors[0], - ); - return; - } - - await this.onSubmit('VALIDATION_ERROR:Invalid verification code'); - return; - } - - await this.onSubmit(code); - } catch (error) { - console.error(error); - } - } - - get verificationCodeLabel() { - return this.ui.translation('labels', 'verificationCode'); - } - - get verifyCodeLabel() { - return this.ui.translation('labels', 'verifyCode'); - } - - get resendCodeLabel() { - return this.ui.translation('labels', 'resendCode'); - } - - get sendingLabel() { - return this.ui.translation('labels', 'sending'); - } -} - -@Component({ - selector: 'fui-phone-form', - standalone: true, - imports: [CommonModule, PhoneNumberFormComponent, VerificationFormComponent], - template: ` -
- - - - - - -
- `, -}) -export class PhoneFormComponent implements OnInit, OnDestroy { - private ui = inject(FirebaseUI); - private config: any; - - @Input() resendDelay = 30; - - formError: string | null = null; - confirmationResult: ConfirmationResult | null = null; - recaptchaVerifier: RecaptchaVerifier | null = null; - phoneNumber = ''; - isResending = false; - timeLeft = 0; - canResend = false; - timerSubscription: Subscription | null = null; - - async ngOnInit() { - try { - this.config = await firstValueFrom(this.ui.config()); - } catch (error) { - console.error(error); - } - } - - ngOnDestroy() { - if (this.timerSubscription) { - this.timerSubscription.unsubscribe(); - } - } - - async handlePhoneSubmit(number: string): Promise { - this.formError = null; - - if (number.startsWith('VALIDATION_ERROR:')) { - this.formError = number.substring('VALIDATION_ERROR:'.length); - return; - } - - try { - if (!this.recaptchaVerifier) { - throw new Error('ReCAPTCHA not initialized'); - } - - const result = await signInWithPhoneNumber( - await firstValueFrom(this.ui.config()), - number, - this.recaptchaVerifier, - ); - - this.phoneNumber = number; - this.confirmationResult = result; - this.startTimer(); - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - return; - } - console.error(error); - this.formError = await firstValueFrom( - this.ui.translation('errors', 'unknownError'), - ); - } - } - - async handleResend(): Promise { - if (this.isResending || !this.canResend || !this.phoneNumber) { - return; - } - - this.isResending = true; - this.formError = null; - - try { - if (this.recaptchaVerifier) { - this.recaptchaVerifier.clear(); - } - - // We need to get the recaptcha container from the verification form - // This is a bit hacky, but it works for now - const recaptchaContainer = document.querySelector( - '.fui-recaptcha-container', - ) as HTMLDivElement; - if (!recaptchaContainer) { - throw new Error('ReCAPTCHA container not found'); - } - - const verifier = new RecaptchaVerifier( - (await firstValueFrom(this.ui.config())).getAuth(), - recaptchaContainer, - { - size: this.config?.recaptchaMode ?? 'normal', - }, - ); - this.recaptchaVerifier = verifier; - - const result = await signInWithPhoneNumber( - await firstValueFrom(this.ui.config()), - this.phoneNumber, - verifier, - ); - - this.confirmationResult = result; - this.startTimer(); - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - } else { - console.error(error); - this.ui.translation('errors', 'unknownError').subscribe((message) => { - this.formError = message; - }); - } - } finally { - this.isResending = false; - } - } - - async handleVerificationSubmit(code: string): Promise { - if (code.startsWith('VALIDATION_ERROR:')) { - this.formError = code.substring('VALIDATION_ERROR:'.length); - return; - } - - if (!this.confirmationResult) { - throw new Error('Confirmation result not initialized'); - } - - this.formError = null; - - try { - await confirmPhoneNumber( - await firstValueFrom(this.ui.config()), - this.confirmationResult, - code, - ); - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - return; - } - console.error(error); - this.formError = await firstValueFrom( - this.ui.translation('errors', 'unknownError'), - ); - } - } - - startTimer() { - if (this.timerSubscription) { - this.timerSubscription.unsubscribe(); - } - - this.timeLeft = this.resendDelay; - this.canResend = false; - - this.timerSubscription = interval(1000) - .pipe(takeWhile(() => this.timeLeft > 0)) - .subscribe(() => { - this.timeLeft--; - if (this.timeLeft === 0) { - this.canResend = true; - if (this.timerSubscription) { - this.timerSubscription.unsubscribe(); - } - } - }); - } -} diff --git a/packages/firebaseui-angular/src/lib/auth/forms/register-form/register-form.component.spec.ts b/packages/firebaseui-angular/src/lib/auth/forms/register-form/register-form.component.spec.ts deleted file mode 100644 index 3a302122e..000000000 --- a/packages/firebaseui-angular/src/lib/auth/forms/register-form/register-form.component.spec.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; -import { - ComponentFixture, - TestBed, - fakeAsync, - tick, -} from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { Router, provideRouter } from '@angular/router'; -import { TanStackField } from '@tanstack/angular-form'; -import { getFirebaseUITestProviders } from '../../../testing/test-helpers'; -import { RegisterFormComponent } from './register-form.component'; - -// Define window properties for testing -declare global { - interface Window { - fuiCreateUserWithEmailAndPassword: any; - createEmailFormSchema: any; - } -} - -// Mock Button component -@Component({ - selector: 'fui-button', - template: ``, - standalone: true, -}) -class MockButtonComponent { - @Input() type: string = 'button'; -} - -// Mock TermsAndPrivacy component -@Component({ - selector: 'fui-terms-and-privacy', - template: `
`, - standalone: true, -}) -class MockTermsAndPrivacyComponent {} - -describe('RegisterFormComponent', () => { - let component: RegisterFormComponent; - let fixture: ComponentFixture; - let mockRouter: any; - let signUpSpy: jasmine.Spy; - - // Mock schema returned by createEmailFormSchema - const mockSchema = { - safeParse: (data: any) => { - // Test email validation - if (!data.email.includes('@')) { - return { - success: false, - error: { - format: () => ({ - email: { _errors: ['Please enter a valid email address'] }, - }), - }, - }; - } - // Test password validation - if (data.password.length < 8) { - return { - success: false, - error: { - format: () => ({ - password: { - _errors: ['Password should be at least 8 characters'], - }, - }), - }, - }; - } - return { success: true }; - }, - }; - - beforeEach(async () => { - // Mock router - mockRouter = { - navigateByUrl: jasmine.createSpy('navigateByUrl'), - }; - - // Create spies for the global functions - signUpSpy = jasmine - .createSpy('fuiCreateUserWithEmailAndPassword') - .and.returnValue(Promise.resolve()); - - // Define the function on the window object - Object.defineProperty(window, 'fuiCreateUserWithEmailAndPassword', { - value: signUpSpy, - writable: true, - configurable: true, - }); - - Object.defineProperty(window, 'createEmailFormSchema', { - value: () => mockSchema, - writable: true, - configurable: true, - }); - - await TestBed.configureTestingModule({ - imports: [ - CommonModule, - RegisterFormComponent, - TanStackField, - MockButtonComponent, - MockTermsAndPrivacyComponent, - ], - providers: [ - provideRouter([]), - { provide: Router, useValue: mockRouter }, - ...getFirebaseUITestProviders(), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(RegisterFormComponent); - component = fixture.componentInstance; - - // Set required inputs - component.signInRoute = '/auth/sign-in'; - - // Replace the registerUser method with a spy - spyOn(component, 'registerUser').and.callFake(async (_email, _password) => { - return Promise.resolve(); - }); - - // Mock the form schema - component['formSchema'] = mockSchema; - - fixture.detectChanges(); - await fixture.whenStable(); // Wait for async ngOnInit - }); - - it('renders the form correctly', () => { - expect(component).toBeTruthy(); - - // Check essential elements are present - const emailInput = fixture.debugElement.query( - By.css('input[type="email"]') - ); - const passwordInput = fixture.debugElement.query( - By.css('input[type="password"]') - ); - const termsAndPrivacy = fixture.debugElement.query( - By.css('fui-terms-and-privacy') - ); - const submitButton = fixture.debugElement.query(By.css('fui-button')); - - expect(emailInput).toBeTruthy(); - expect(passwordInput).toBeTruthy(); - expect(termsAndPrivacy).toBeTruthy(); - expect(submitButton).toBeTruthy(); - }); - - it('submits the form when handleSubmit is called', fakeAsync(() => { - // Set values directly on the form state - component.form.state.values.email = 'test@example.com'; - component.form.state.values.password = 'password123'; - - // Create a submit event - const event = new Event('submit'); - Object.defineProperties(event, { - preventDefault: { value: jasmine.createSpy('preventDefault') }, - stopPropagation: { value: jasmine.createSpy('stopPropagation') }, - }); - - // Call handleSubmit directly - component.handleSubmit(event as SubmitEvent); - tick(); - - // Check if registerUser was called with correct values - expect(component.registerUser).toHaveBeenCalledWith( - 'test@example.com', - 'password123' - ); - })); - - it('displays error message when registration fails', fakeAsync(() => { - // Manually set the error - component.formError = 'Email already in use'; - fixture.detectChanges(); - - // Check that the error message is displayed in the DOM - const formErrorEl = fixture.debugElement.query(By.css('.fui-form__error')); - expect(formErrorEl).toBeTruthy(); - expect(formErrorEl.nativeElement.textContent.trim()).toBe( - 'Email already in use' - ); - })); - - it('navigates to sign in route when the link is clicked', () => { - // Find the sign in link - const signInLink = fixture.debugElement.query(By.css('.fui-form__action')); - expect(signInLink).toBeTruthy(); - - // Click the link - signInLink.nativeElement.click(); - - // Check navigation was triggered - expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/auth/sign-in'); - }); -}); diff --git a/packages/firebaseui-angular/src/lib/auth/forms/register-form/register-form.component.ts b/packages/firebaseui-angular/src/lib/auth/forms/register-form/register-form.component.ts deleted file mode 100644 index 0edc25947..000000000 --- a/packages/firebaseui-angular/src/lib/auth/forms/register-form/register-form.component.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, inject, Input, OnInit } from '@angular/core'; -import { ButtonComponent } from '../../../components/button/button.component'; -import { FirebaseUI } from '../../../provider'; -import { CommonModule } from '@angular/common'; -import { injectForm, TanStackField } from '@tanstack/angular-form'; -import { - createEmailFormSchema, - EmailFormSchema, - FirebaseUIError, - createUserWithEmailAndPassword, -} from '@firebase-ui/core'; -import { Auth } from '@angular/fire/auth'; -import { TermsAndPrivacyComponent } from '../../../components/terms-and-privacy/terms-and-privacy.component'; -import { firstValueFrom } from 'rxjs'; -import { Router } from '@angular/router'; - -@Component({ - selector: 'fui-register-form', - imports: [ - CommonModule, - TanStackField, - ButtonComponent, - TermsAndPrivacyComponent, - ], - template: ` -
-
- - - -
-
- - - -
- - - -
- - {{ createAccountLabel | async }} - -
{{ formError }}
-
- -
- -
-
- `, - standalone: true, -}) -export class RegisterFormComponent implements OnInit { - private ui = inject(FirebaseUI); - private router = inject(Router); - - @Input({ required: true }) signInRoute!: string; - - formError: string | null = null; - private formSchema: any; - private config: any; - - form = injectForm({ - defaultValues: { - email: '', - password: '', - }, - }); - - async ngOnInit() { - try { - this.config = await firstValueFrom(this.ui.config()); - - this.formSchema = createEmailFormSchema(this.config?.translations); - - this.form.update({ - validators: { - onSubmit: this.formSchema, - onBlur: this.formSchema, - }, - }); - } catch (error) { - this.formError = await firstValueFrom( - this.ui.translation('errors', 'unknownError') - ); - } - } - - async handleSubmit(event: SubmitEvent) { - event.preventDefault(); - event.stopPropagation(); - - const email = this.form.state.values.email; - const password = this.form.state.values.password; - - if (!email || !password) { - return; - } - - await this.registerUser(email, password); - } - - async registerUser(email: string, password: string) { - this.formError = null; - - try { - const validationResult = this.formSchema.safeParse({ - email, - password, - }); - - if (!validationResult.success) { - const validationErrors = validationResult.error.format(); - - if (validationErrors.email?._errors?.length) { - this.formError = validationErrors.email._errors[0]; - return; - } - - if (validationErrors.password?._errors?.length) { - this.formError = validationErrors.password._errors[0]; - return; - } - - this.formError = await firstValueFrom( - this.ui.translation('errors', 'unknownError') - ); - return; - } - - await createUserWithEmailAndPassword( - await firstValueFrom(this.ui.config()), - email, - password, - ); - } catch (error) { - if (error instanceof FirebaseUIError) { - this.formError = error.message; - return; - } - - this.formError = await firstValueFrom( - this.ui.translation('errors', 'unknownError') - ); - } - } - - navigateTo(route: string) { - this.router.navigateByUrl(route); - } - - get emailLabel() { - return this.ui.translation('labels', 'emailAddress'); - } - - get passwordLabel() { - return this.ui.translation('labels', 'password'); - } - - get createAccountLabel() { - return this.ui.translation('labels', 'createAccount'); - } - - get haveAccountLabel() { - return this.ui.translation('prompts', 'haveAccount'); - } - - get signInLabel() { - return this.ui.translation('labels', 'signIn'); - } -} diff --git a/packages/firebaseui-angular/src/lib/auth/oauth/google-sign-in-button.component.spec.ts b/packages/firebaseui-angular/src/lib/auth/oauth/google-sign-in-button.component.spec.ts deleted file mode 100644 index e2306c6b6..000000000 --- a/packages/firebaseui-angular/src/lib/auth/oauth/google-sign-in-button.component.spec.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Auth, GoogleAuthProvider } from '@angular/fire/auth'; -import { By } from '@angular/platform-browser'; -import { of } from 'rxjs'; -import { FirebaseUI } from '../../provider'; -import { GoogleSignInButtonComponent } from './google-sign-in-button.component'; - -// Mock OAuthButton component -@Component({ - selector: 'fui-oauth-button', - template: `
- -
`, - standalone: true, -}) -class MockOAuthButtonComponent { - @Input() provider: any; -} - -// Create mock for FirebaseUi provider -class MockFirebaseUi { - translation(category: string, key: string) { - if (category === 'labels' && key === 'signInWithGoogle') { - return of('Sign in with Google'); - } - return of(`${category}.${key}`); - } -} - -// Create a test component that extends GoogleSignInButtonComponent -class TestGoogleSignInButtonComponent extends GoogleSignInButtonComponent { - // Override GoogleAuthProvider creation to avoid Auth dependency - constructor() { - super(); - this.googleProvider = new GoogleAuthProvider(); - } -} - -describe('GoogleSignInButtonComponent', () => { - let component: TestGoogleSignInButtonComponent; - let fixture: ComponentFixture; - let mockFirebaseUi: MockFirebaseUi; - let mockAuth: jasmine.SpyObj; - - beforeEach(async () => { - mockFirebaseUi = new MockFirebaseUi(); - mockAuth = jasmine.createSpyObj('Auth', [ - 'signInWithPopup', - 'signInWithRedirect', - ]); - - await TestBed.configureTestingModule({ - imports: [ - CommonModule, - TestGoogleSignInButtonComponent, - MockOAuthButtonComponent, - ], - providers: [ - { provide: FirebaseUI, useValue: mockFirebaseUi }, - { provide: Auth, useValue: mockAuth }, - ], - }).compileComponents(); - - // Override the OAuthButtonComponent - TestBed.overrideComponent(TestGoogleSignInButtonComponent, { - set: { - template: ` - - - - - - {{ signInWithGoogleLabel | async }} - - `, - }, - }); - - fixture = TestBed.createComponent(TestGoogleSignInButtonComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should use the GoogleAuthProvider', () => { - expect(component.googleProvider instanceof GoogleAuthProvider).toBeTrue(); - }); - - it('should render with the correct provider', () => { - const oauthButton = fixture.debugElement.query( - By.css('[data-testid="oauth-button"]') - ); - // Skip this test if the element isn't found - it's likely not rendering correctly in test environment - if (!oauthButton) { - console.warn('OAuth button element not found in test environment'); - pending('Test environment issue - OAuth button not rendered'); - return; - } - expect(oauthButton.nativeElement.getAttribute('data-provider')).toBe( - 'GoogleAuthProvider' - ); - }); - - it('should render with the Google icon SVG', () => { - const svg = fixture.debugElement.query(By.css('svg')); - // Skip this test if the element isn't found - if (!svg) { - console.warn('SVG element not found in test environment'); - pending('Test environment issue - SVG not rendered'); - return; - } - expect( - svg.nativeElement.classList.contains('fui-provider__icon') - ).toBeTrue(); - }); - - it('should display the correct sign-in text', () => { - fixture.detectChanges(); // Make sure the async pipe is resolved - const span = fixture.debugElement.query(By.css('span')); - // Skip this test if the element isn't found - if (!span) { - console.warn('Span element not found in test environment'); - pending('Test environment issue - span not rendered'); - return; - } - expect(span.nativeElement.textContent).toBe('Sign in with Google'); - }); -}); diff --git a/packages/firebaseui-angular/src/lib/auth/oauth/google-sign-in-button.component.ts b/packages/firebaseui-angular/src/lib/auth/oauth/google-sign-in-button.component.ts deleted file mode 100644 index 0a2cdef19..000000000 --- a/packages/firebaseui-angular/src/lib/auth/oauth/google-sign-in-button.component.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { OAuthButtonComponent } from './oauth-button.component'; -import { FirebaseUI } from '../../provider'; -import { GoogleAuthProvider } from '@angular/fire/auth'; - -@Component({ - selector: 'fui-google-sign-in-button', - standalone: true, - imports: [CommonModule, OAuthButtonComponent], - template: ` - - - - - - - - {{ signInWithGoogleLabel | async }} - - ` -}) -export class GoogleSignInButtonComponent { - private ui = inject(FirebaseUI); - googleProvider = new GoogleAuthProvider(); - - get signInWithGoogleLabel() { - return this.ui.translation('labels', 'signInWithGoogle'); - } -} diff --git a/packages/firebaseui-angular/src/lib/auth/oauth/oauth-button.component.spec.ts b/packages/firebaseui-angular/src/lib/auth/oauth/oauth-button.component.spec.ts deleted file mode 100644 index a3fcff3e3..000000000 --- a/packages/firebaseui-angular/src/lib/auth/oauth/oauth-button.component.spec.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; -import { - ComponentFixture, - TestBed, - fakeAsync, - tick, -} from '@angular/core/testing'; -import { Auth, AuthProvider } from '@angular/fire/auth'; -import { FirebaseUIError } from '@firebase-ui/core'; -import { firstValueFrom, of } from 'rxjs'; -import { FirebaseUI } from '../../provider'; -import { OAuthButtonComponent } from './oauth-button.component'; - -// Create a spy for fuiSignInWithOAuth -const mockFuiSignInWithOAuth = jasmine - .createSpy('signInWithOAuth') - .and.returnValue(Promise.resolve()); - -// Mock the firebase-ui/core module -jasmine.createSpyObj('@firebase-ui/core', ['signInWithOAuth']); - -// Mock Button component -@Component({ - selector: 'fui-button', - template: ``, - standalone: true, -}) -class MockButtonComponent { - @Input() type: string = 'button'; - @Input() disabled: boolean = false; - @Input() variant: string = 'primary'; - - handleClick() { - // Simplified to just call dispatchEvent - this.dispatchEvent(); - } - - // Method to dispatch the click event - dispatchEvent() { - // The parent component will handle this - } -} - -// Create mock for FirebaseUi provider -class MockFirebaseUi { - config() { - return of({ - language: 'en', - translations: {}, - enableAutoUpgradeAnonymous: false, - enableHandleExistingCredential: false, - }); - } - - translation(category: string, key: string) { - // Return the specific error message that matches the expected one in the test - if (category === 'errors' && key === 'auth/popup-closed-by-user') { - return of('The popup was closed by the user'); - } - if (category === 'errors' && key === 'unknownError') { - return of('An unknown error occurred'); - } - return of(`${category}.${key}`); - } -} - -// Create a test component that extends OAuthButtonComponent -class TestOAuthButtonComponent extends OAuthButtonComponent { - // Override handleOAuthSignIn to use our mock function - override async handleOAuthSignIn() { - this.error = null; - try { - const config = await firstValueFrom(this['ui'].config()); - - await mockFuiSignInWithOAuth(config, this.provider); - } catch (error) { - if (error instanceof FirebaseUIError) { - this.error = error.message; - return; - } - console.error(error); - - try { - const errorMessage = await firstValueFrom( - this['ui'].translation('errors', 'unknownError'), - ); - this.error = errorMessage ?? 'Unknown error'; - } catch { - this.error = 'Unknown error'; - } - } - } -} - -describe('OAuthButtonComponent', () => { - let component: TestOAuthButtonComponent; - let fixture: ComponentFixture; - let mockProvider: jasmine.SpyObj; - let mockAuth: jasmine.SpyObj; - let mockFirebaseUi: MockFirebaseUi; - - beforeEach(async () => { - // Create spy objects for Auth and AuthProvider - mockProvider = jasmine.createSpyObj('AuthProvider', [], { - providerId: 'google.com', - }); - - mockAuth = jasmine.createSpyObj('Auth', [ - 'signInWithPopup', - 'signInWithRedirect', - ]); - - mockFirebaseUi = new MockFirebaseUi(); - - // Reset mock before each test - mockFuiSignInWithOAuth.calls.reset(); - - await TestBed.configureTestingModule({ - imports: [CommonModule, TestOAuthButtonComponent, MockButtonComponent], - providers: [ - { provide: FirebaseUI, useValue: mockFirebaseUi }, - { provide: Auth, useValue: mockAuth }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(TestOAuthButtonComponent); - component = fixture.componentInstance; - component.provider = mockProvider; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should show a console error when provider is not set', () => { - spyOn(console, 'error'); - component.provider = undefined as unknown as AuthProvider; - component.ngOnInit(); - expect(console.error).toHaveBeenCalledWith( - 'Provider is required for OAuthButtonComponent', - ); - }); - - it('should call signInWithOAuth when button is clicked', fakeAsync(() => { - // Spy on handleOAuthSignIn - spyOn(component, 'handleOAuthSignIn').and.callThrough(); - - // Call the method directly instead of relying on button click - component.handleOAuthSignIn(); - - // Check if handleOAuthSignIn was called - expect(component.handleOAuthSignIn).toHaveBeenCalled(); - - // Advance the tick to allow promises to resolve - tick(); - - // Check if the mock function was called with the correct arguments - expect(mockFuiSignInWithOAuth).toHaveBeenCalledWith( - jasmine.objectContaining({ - language: 'en', - translations: {}, - enableAutoUpgradeAnonymous: false, - enableHandleExistingCredential: false, - }), - mockProvider, - ); - })); - - it('should display error message when FirebaseUIError occurs', fakeAsync(() => { - // Create a FirebaseUIError - const firebaseUIError = new FirebaseUIError({ - code: 'auth/popup-closed-by-user', - message: 'The popup was closed by the user', - }); - - // Make the mock function throw a FirebaseUIError - mockFuiSignInWithOAuth.and.rejectWith(firebaseUIError); - - // Trigger the sign-in - component.handleOAuthSignIn(); - tick(); - - // In the test environment, the error message becomes 'An unexpected error occurred' - expect(component.error).toBe('An unexpected error occurred'); - })); - - it('should display generic error message when non-Firebase error occurs', fakeAsync(() => { - // Spy on console.error - spyOn(console, 'error'); - - // Create a regular Error - const regularError = new Error('Regular error'); - - // Make the mock function throw a regular Error - mockFuiSignInWithOAuth.and.rejectWith(regularError); - - // Trigger the sign-in - component.handleOAuthSignIn(); - tick(100); // Allow time for the async operations to complete - - // Check if console.error was called with the error - expect(console.error).toHaveBeenCalledWith(regularError); - - // Update the error expectation - in our mock it gets the 'An unknown error occurred' message - expect(component.error).toBe('An unknown error occurred'); - })); -}); diff --git a/packages/firebaseui-angular/src/lib/auth/oauth/oauth-button.component.ts b/packages/firebaseui-angular/src/lib/auth/oauth/oauth-button.component.ts deleted file mode 100644 index 9d06f66c8..000000000 --- a/packages/firebaseui-angular/src/lib/auth/oauth/oauth-button.component.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, inject, Input, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { ButtonComponent } from '../../components/button/button.component'; -import { FirebaseUI } from '../../provider'; -import { Auth, AuthProvider } from '@angular/fire/auth'; -import { FirebaseUIError, signInWithOAuth } from '@firebase-ui/core'; -import { firstValueFrom } from 'rxjs'; - -@Component({ - selector: 'fui-oauth-button', - standalone: true, - imports: [CommonModule, ButtonComponent], - template: ` -
- - - -
{{ error }}
-
- `, -}) -export class OAuthButtonComponent implements OnInit { - private ui = inject(FirebaseUI); - - @Input() provider!: AuthProvider; - - error: string | null = null; - - ngOnInit() { - if (!this.provider) { - console.error('Provider is required for OAuthButtonComponent'); - } - } - - async handleOAuthSignIn() { - this.error = null; - try { - await signInWithOAuth(await firstValueFrom(this.ui.config()), this.provider); - } catch (error) { - if (error instanceof FirebaseUIError) { - this.error = error.message; - return; - } - console.error(error); - firstValueFrom(this.ui.translation('errors', 'unknownError')) - .then((message) => (this.error = message)) - .catch(() => (this.error = 'Unknown error')); - } - } -} diff --git a/packages/firebaseui-angular/src/lib/auth/screens/email-link-auth-screen/email-link-auth-screen.component.spec.ts b/packages/firebaseui-angular/src/lib/auth/screens/email-link-auth-screen/email-link-auth-screen.component.spec.ts deleted file mode 100644 index 0b4f21cb7..000000000 --- a/packages/firebaseui-angular/src/lib/auth/screens/email-link-auth-screen/email-link-auth-screen.component.spec.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { of } from 'rxjs'; -import { FirebaseUI } from '../../../provider'; -import { EmailLinkAuthScreenComponent } from './email-link-auth-screen.component'; - -// Mock EmailLinkForm component -@Component({ - selector: 'fui-email-link-form', - template: '
Email Link Form
', - standalone: true, -}) -class MockEmailLinkFormComponent {} - -// Mock Card components -@Component({ - selector: 'fui-card', - template: '
', - standalone: true, -}) -class MockCardComponent {} - -@Component({ - selector: 'fui-card-header', - template: '
', - standalone: true, -}) -class MockCardHeaderComponent {} - -@Component({ - selector: 'fui-card-title', - template: '

', - standalone: true, -}) -class MockCardTitleComponent {} - -@Component({ - selector: 'fui-card-subtitle', - template: '

', - standalone: true, -}) -class MockCardSubtitleComponent {} - -// Mock Divider component -@Component({ - selector: 'fui-divider', - template: '
', - standalone: true, -}) -class MockDividerComponent {} - -// Create mock for FirebaseUi provider -class MockFirebaseUi { - translation(category: string, key: string) { - if (category === 'labels' && key === 'signIn') { - return of('Sign In'); - } - if (category === 'prompts' && key === 'signInToAccount') { - return of('Sign in to your account'); - } - if (category === 'messages' && key === 'dividerOr') { - return of('or'); - } - return of(`${category}.${key}`); - } -} - -// Test component with content projection -@Component({ - template: ` - -
Test Child
-
- `, - standalone: true, - imports: [EmailLinkAuthScreenComponent], -}) -class TestHostWithChildrenComponent {} - -// Test component without content projection -@Component({ - template: ` `, - standalone: true, - imports: [EmailLinkAuthScreenComponent], -}) -class TestHostWithoutChildrenComponent {} - -describe('EmailLinkAuthScreenComponent', () => { - let mockFirebaseUi: MockFirebaseUi; - - beforeEach(async () => { - mockFirebaseUi = new MockFirebaseUi(); - - await TestBed.configureTestingModule({ - imports: [ - CommonModule, - EmailLinkAuthScreenComponent, - TestHostWithChildrenComponent, - TestHostWithoutChildrenComponent, - MockCardComponent, - MockCardHeaderComponent, - MockCardTitleComponent, - MockCardSubtitleComponent, - MockEmailLinkFormComponent, - MockDividerComponent, - ], - providers: [{ provide: FirebaseUI, useValue: mockFirebaseUi }], - }).compileComponents(); - - TestBed.overrideComponent(EmailLinkAuthScreenComponent, { - set: { - imports: [ - CommonModule, - MockCardComponent, - MockCardHeaderComponent, - MockCardTitleComponent, - MockCardSubtitleComponent, - MockEmailLinkFormComponent, - MockDividerComponent, - ], - }, - }); - }); - - it('should create', () => { - const fixture = TestBed.createComponent(EmailLinkAuthScreenComponent); - const component = fixture.componentInstance; - expect(component).toBeTruthy(); - }); - - it('renders with correct title and subtitle', () => { - const fixture = TestBed.createComponent(EmailLinkAuthScreenComponent); - fixture.detectChanges(); - - const titleEl = fixture.debugElement.query(By.css('.fui-card-title')); - const subtitleEl = fixture.debugElement.query(By.css('.fui-card-subtitle')); - - expect(titleEl.nativeElement.textContent).toBe('Sign In'); - expect(subtitleEl.nativeElement.textContent).toBe( - 'Sign in to your account' - ); - }); - - it('includes the EmailLinkForm component', () => { - const fixture = TestBed.createComponent(EmailLinkAuthScreenComponent); - fixture.detectChanges(); - - const formEl = fixture.debugElement.query( - By.css('[data-testid="email-link-form"]') - ); - expect(formEl).toBeTruthy(); - expect(formEl.nativeElement.textContent).toBe('Email Link Form'); - }); - - it('does not render divider and children when no children are provided', fakeAsync(() => { - const fixture = TestBed.createComponent(TestHostWithoutChildrenComponent); - fixture.detectChanges(); - - // Initially hasContent will be true - // We need to wait for the setTimeout in ngAfterContentInit - tick(0); - fixture.detectChanges(); - - const dividerEl = fixture.debugElement.query(By.css('.fui-divider')); - expect(dividerEl).toBeFalsy(); - })); - - it('renders divider and children when children are provided', fakeAsync(() => { - const fixture = TestBed.createComponent(TestHostWithChildrenComponent); - fixture.detectChanges(); - - // Wait for the setTimeout in ngAfterContentInit - tick(0); - fixture.detectChanges(); - - const dividerEl = fixture.debugElement.query(By.css('.fui-divider')); - expect(dividerEl).toBeTruthy(); - expect(dividerEl.nativeElement.textContent).toBe('or'); - - const childEl = fixture.debugElement.query(By.css('.test-child')); - expect(childEl).toBeTruthy(); - expect(childEl.nativeElement.textContent).toBe('Test Child'); - })); -}); diff --git a/packages/firebaseui-angular/src/lib/auth/screens/email-link-auth-screen/email-link-auth-screen.component.ts b/packages/firebaseui-angular/src/lib/auth/screens/email-link-auth-screen/email-link-auth-screen.component.ts deleted file mode 100644 index 8e65ecb99..000000000 --- a/packages/firebaseui-angular/src/lib/auth/screens/email-link-auth-screen/email-link-auth-screen.component.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, inject, Input, AfterContentInit, ViewChild, ElementRef } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent } from '../../../components/card/card.component'; -import { FirebaseUI } from '../../../provider'; -import { EmailLinkFormComponent } from '../../forms/email-link-form/email-link-form.component'; -import { DividerComponent } from '../../../components/divider/divider.component'; - -@Component({ - selector: 'fui-email-link-auth-screen', - standalone: true, - imports: [ - CommonModule, - CardComponent, - CardHeaderComponent, - CardTitleComponent, - CardSubtitleComponent, - EmailLinkFormComponent, - DividerComponent, - ], - template: ` -
- - - {{ titleText | async }} - {{ subtitleText | async }} - - - - - {{ dividerOrLabel | async }} -
- -
-
-
-
- ` -}) -export class EmailLinkAuthScreenComponent implements AfterContentInit { - private ui = inject(FirebaseUI); - - - @ViewChild('contentContainer') contentContainer!: ElementRef; - private _hasProjectedContent = false; - - get hasContent(): boolean { - return this._hasProjectedContent; - } - - get titleText() { - return this.ui.translation('labels', 'signIn'); - } - - get subtitleText() { - return this.ui.translation('prompts', 'signInToAccount'); - } - - get dividerOrLabel() { - return this.ui.translation('messages', 'dividerOr'); - } - - ngAfterContentInit() { - // Set to true initially to ensure the container is rendered - this._hasProjectedContent = true; - - // We need to use setTimeout to check after the view is rendered - setTimeout(() => { - // Check if there's any actual content in the container - if (this.contentContainer && this.contentContainer.nativeElement) { - const container = this.contentContainer.nativeElement; - // Only consider it to have content if there are child nodes that aren't just whitespace - this._hasProjectedContent = Array.from(container.childNodes as NodeListOf).some((node: Node) => { - return node.nodeType === Node.ELEMENT_NODE || - (node.nodeType === Node.TEXT_NODE && node.textContent && node.textContent.trim() !== ''); - }); - } else { - this._hasProjectedContent = false; - } - }); - } -} diff --git a/packages/firebaseui-angular/src/lib/auth/screens/oauth-screen/oauth-screen.component.spec.ts b/packages/firebaseui-angular/src/lib/auth/screens/oauth-screen/oauth-screen.component.spec.ts deleted file mode 100644 index 56ea5bfd7..000000000 --- a/packages/firebaseui-angular/src/lib/auth/screens/oauth-screen/oauth-screen.component.spec.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { of } from 'rxjs'; -import { FirebaseUI } from '../../../provider'; -import { OAuthScreenComponent } from './oauth-screen.component'; - -// Mock Card components -@Component({ - selector: 'fui-card', - template: '
', - standalone: true, -}) -class MockCardComponent {} - -@Component({ - selector: 'fui-card-header', - template: '
', - standalone: true, -}) -class MockCardHeaderComponent {} - -@Component({ - selector: 'fui-card-title', - template: '

', - standalone: true, -}) -class MockCardTitleComponent {} - -@Component({ - selector: 'fui-card-subtitle', - template: '

', - standalone: true, -}) -class MockCardSubtitleComponent {} - -// Mock TermsAndPrivacy component -@Component({ - selector: 'fui-terms-and-privacy', - template: '
Terms and Privacy
', - standalone: true, -}) -class MockTermsAndPrivacyComponent {} - -// Create mock for FirebaseUi provider -class MockFirebaseUi { - translation(category: string, key: string) { - if (category === 'labels' && key === 'signIn') { - return of('Sign In'); - } - if (category === 'prompts' && key === 'signInToAccount') { - return of('Sign in to your account'); - } - return of(`${category}.${key}`); - } -} - -// Test component with content projection -@Component({ - template: ` - -
OAuth Provider
-
- `, - standalone: true, - imports: [OAuthScreenComponent], -}) -class TestHostWithSingleChildComponent {} - -// Test component with multiple providers -@Component({ - template: ` - -
Provider 1
-
Provider 2
-
- `, - standalone: true, - imports: [OAuthScreenComponent], -}) -class TestHostWithMultipleChildrenComponent {} - -describe('OAuthScreenComponent', () => { - let mockFirebaseUi: MockFirebaseUi; - - beforeEach(async () => { - mockFirebaseUi = new MockFirebaseUi(); - - await TestBed.configureTestingModule({ - imports: [ - CommonModule, - OAuthScreenComponent, - TestHostWithSingleChildComponent, - TestHostWithMultipleChildrenComponent, - MockCardComponent, - MockCardHeaderComponent, - MockCardTitleComponent, - MockCardSubtitleComponent, - MockTermsAndPrivacyComponent, - ], - providers: [{ provide: FirebaseUI, useValue: mockFirebaseUi }], - }).compileComponents(); - - TestBed.overrideComponent(OAuthScreenComponent, { - set: { - imports: [ - CommonModule, - MockCardComponent, - MockCardHeaderComponent, - MockCardTitleComponent, - MockCardSubtitleComponent, - MockTermsAndPrivacyComponent, - ], - }, - }); - }); - - it('should create', () => { - const fixture = TestBed.createComponent(OAuthScreenComponent); - const component = fixture.componentInstance; - expect(component).toBeTruthy(); - }); - - it('renders with correct title and subtitle', () => { - const fixture = TestBed.createComponent(OAuthScreenComponent); - fixture.detectChanges(); - - const titleEl = fixture.debugElement.query(By.css('.fui-card-title')); - const subtitleEl = fixture.debugElement.query(By.css('.fui-card-subtitle')); - - expect(titleEl.nativeElement.textContent).toBe('Sign In'); - expect(subtitleEl.nativeElement.textContent).toBe( - 'Sign in to your account' - ); - }); - - it('renders children when provided', () => { - const fixture = TestBed.createComponent(TestHostWithSingleChildComponent); - fixture.detectChanges(); - - const providerEl = fixture.debugElement.query(By.css('.test-provider')); - expect(providerEl).toBeTruthy(); - expect(providerEl.nativeElement.textContent).toBe('OAuth Provider'); - }); - - it('renders multiple children when provided', () => { - const fixture = TestBed.createComponent( - TestHostWithMultipleChildrenComponent - ); - fixture.detectChanges(); - - const provider1El = fixture.debugElement.query(By.css('.test-provider-1')); - const provider2El = fixture.debugElement.query(By.css('.test-provider-2')); - - expect(provider1El).toBeTruthy(); - expect(provider1El.nativeElement.textContent).toBe('Provider 1'); - - expect(provider2El).toBeTruthy(); - expect(provider2El.nativeElement.textContent).toBe('Provider 2'); - }); - - it('includes the TermsAndPrivacy component', () => { - const fixture = TestBed.createComponent(OAuthScreenComponent); - fixture.detectChanges(); - - const termsEl = fixture.debugElement.query( - By.css('[data-testid="terms-and-privacy"]') - ); - expect(termsEl).toBeTruthy(); - }); -}); diff --git a/packages/firebaseui-angular/src/lib/auth/screens/oauth-screen/oauth-screen.component.ts b/packages/firebaseui-angular/src/lib/auth/screens/oauth-screen/oauth-screen.component.ts deleted file mode 100644 index eb8623ba4..000000000 --- a/packages/firebaseui-angular/src/lib/auth/screens/oauth-screen/oauth-screen.component.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent } from '../../../components/card/card.component'; -import { FirebaseUI } from '../../../provider'; -import { TermsAndPrivacyComponent } from '../../../components/terms-and-privacy/terms-and-privacy.component'; - -@Component({ - selector: 'fui-oauth-screen', - standalone: true, - imports: [ - CommonModule, - CardComponent, - CardHeaderComponent, - CardTitleComponent, - CardSubtitleComponent, - TermsAndPrivacyComponent, - ], - template: ` -
- - - {{ titleText | async }} - {{ subtitleText | async }} - - - - -
- ` -}) -export class OAuthScreenComponent { - private ui = inject(FirebaseUI); - - get titleText() { - return this.ui.translation('labels', 'signIn'); - } - - get subtitleText() { - return this.ui.translation('prompts', 'signInToAccount'); - } -} diff --git a/packages/firebaseui-angular/src/lib/auth/screens/password-reset-screen/password-reset-screen.component.spec.ts b/packages/firebaseui-angular/src/lib/auth/screens/password-reset-screen/password-reset-screen.component.spec.ts deleted file mode 100644 index 660edccd8..000000000 --- a/packages/firebaseui-angular/src/lib/auth/screens/password-reset-screen/password-reset-screen.component.spec.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Component, Input } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { By } from '@angular/platform-browser'; -import { of } from 'rxjs'; -import { FirebaseUI } from '../../../provider'; -import { PasswordResetScreenComponent } from './password-reset-screen.component'; - -// Mock Card components -@Component({ - selector: 'fui-card', - template: '
', - standalone: true, -}) -class MockCardComponent {} - -@Component({ - selector: 'fui-card-header', - template: '
', - standalone: true, -}) -class MockCardHeaderComponent {} - -@Component({ - selector: 'fui-card-title', - template: '

', - standalone: true, -}) -class MockCardTitleComponent {} - -@Component({ - selector: 'fui-card-subtitle', - template: '

', - standalone: true, -}) -class MockCardSubtitleComponent {} - -// Mock ForgotPasswordForm component -@Component({ - selector: 'fui-forgot-password-form', - template: ` -
- Forgot Password Form -

Sign In Route: {{ signInRoute }}

-
- `, - standalone: true, -}) -class MockForgotPasswordFormComponent { - @Input() signInRoute: string = ''; -} - -// Create mock for FirebaseUi provider -class MockFirebaseUi { - translation(category: string, key: string) { - if (category === 'labels' && key === 'resetPassword') { - return of('Reset Password'); - } - if (category === 'prompts' && key === 'enterEmailToReset') { - return of('Enter your email to reset your password'); - } - return of(`${category}.${key}`); - } -} - -describe('PasswordResetScreenComponent', () => { - let component: PasswordResetScreenComponent; - let fixture: ComponentFixture; - let mockFirebaseUi: MockFirebaseUi; - - beforeEach(async () => { - mockFirebaseUi = new MockFirebaseUi(); - - await TestBed.configureTestingModule({ - imports: [ - CommonModule, - PasswordResetScreenComponent, - MockCardComponent, - MockCardHeaderComponent, - MockCardTitleComponent, - MockCardSubtitleComponent, - MockForgotPasswordFormComponent, - ], - providers: [{ provide: FirebaseUI, useValue: mockFirebaseUi }], - }).compileComponents(); - - TestBed.overrideComponent(PasswordResetScreenComponent, { - set: { - imports: [ - CommonModule, - MockCardComponent, - MockCardHeaderComponent, - MockCardTitleComponent, - MockCardSubtitleComponent, - MockForgotPasswordFormComponent, - ], - }, - }); - - fixture = TestBed.createComponent(PasswordResetScreenComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('renders with correct title and subtitle', () => { - fixture.detectChanges(); - - const titleEl = fixture.debugElement.query(By.css('.fui-card-title')); - const subtitleEl = fixture.debugElement.query(By.css('.fui-card-subtitle')); - - expect(titleEl.nativeElement.textContent).toBe('Reset Password'); - expect(subtitleEl.nativeElement.textContent).toBe( - 'Enter your email to reset your password' - ); - }); - - it('includes the ForgotPasswordForm component', () => { - fixture.detectChanges(); - - const formEl = fixture.debugElement.query( - By.css('[data-testid="forgot-password-form"]') - ); - expect(formEl).toBeTruthy(); - expect(formEl.nativeElement.textContent).toContain('Forgot Password Form'); - }); - - it('passes signInRoute to ForgotPasswordForm', () => { - component.signInRoute = '/custom-sign-in-route'; - fixture.detectChanges(); - - const formEl = fixture.debugElement.query( - By.css('[data-testid="forgot-password-form"]') - ); - expect(formEl.nativeElement.textContent).toContain( - 'Sign In Route: /custom-sign-in-route' - ); - }); -}); diff --git a/packages/firebaseui-angular/src/lib/auth/screens/password-reset-screen/password-reset-screen.component.ts b/packages/firebaseui-angular/src/lib/auth/screens/password-reset-screen/password-reset-screen.component.ts deleted file mode 100644 index 12b2e8660..000000000 --- a/packages/firebaseui-angular/src/lib/auth/screens/password-reset-screen/password-reset-screen.component.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, EventEmitter, inject, Input, Output } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent } from '../../../components/card/card.component'; -import { FirebaseUI } from '../../../provider'; -import { ForgotPasswordFormComponent } from '../../forms/forgot-password-form/forgot-password-form.component'; - -@Component({ - selector: 'fui-password-reset-screen', - standalone: true, - imports: [ - CommonModule, - CardComponent, - CardHeaderComponent, - CardTitleComponent, - CardSubtitleComponent, - ForgotPasswordFormComponent, - ], - template: ` -
- - - {{ titleText | async }} - {{ subtitleText | async }} - - - -
- ` -}) -export class PasswordResetScreenComponent { - private ui = inject(FirebaseUI); - - @Input() signInRoute: string = ''; - - get titleText() { - return this.ui.translation('labels', 'resetPassword'); - } - - get subtitleText() { - return this.ui.translation('prompts', 'enterEmailToReset'); - } -} diff --git a/packages/firebaseui-angular/src/lib/auth/screens/phone-auth-screen/phone-auth-screen.component.spec.ts b/packages/firebaseui-angular/src/lib/auth/screens/phone-auth-screen/phone-auth-screen.component.spec.ts deleted file mode 100644 index ad29479d2..000000000 --- a/packages/firebaseui-angular/src/lib/auth/screens/phone-auth-screen/phone-auth-screen.component.spec.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { of } from 'rxjs'; -import { FirebaseUI } from '../../../provider'; -import { PhoneAuthScreenComponent } from './phone-auth-screen.component'; - -// Mock Card components -@Component({ - selector: 'fui-card', - template: '
', - standalone: true, -}) -class MockCardComponent {} - -@Component({ - selector: 'fui-card-header', - template: '
', - standalone: true, -}) -class MockCardHeaderComponent {} - -@Component({ - selector: 'fui-card-title', - template: '

', - standalone: true, -}) -class MockCardTitleComponent {} - -@Component({ - selector: 'fui-card-subtitle', - template: '

', - standalone: true, -}) -class MockCardSubtitleComponent {} - -// Mock PhoneForm component -@Component({ - selector: 'fui-phone-form', - template: ` -
- Phone Form -

Resend Delay: {{ resendDelay }}

-
- `, - standalone: true, -}) -class MockPhoneFormComponent { - @Input() resendDelay: number = 30; -} - -// Mock Divider component -@Component({ - selector: 'fui-divider', - template: '
', - standalone: true, -}) -class MockDividerComponent {} - -// Create mock for FirebaseUi provider -class MockFirebaseUi { - translation(category: string, key: string) { - if (category === 'labels' && key === 'signIn') { - return of('Sign in'); - } - if (category === 'prompts' && key === 'signInToAccount') { - return of('Sign in to your account'); - } - if (category === 'messages' && key === 'dividerOr') { - return of('OR'); - } - return of(`${category}.${key}`); - } -} - -// Test component with content projection -@Component({ - template: ` - - - - `, - standalone: true, - imports: [PhoneAuthScreenComponent], -}) -class TestHostWithChildrenComponent {} - -// Test component without content projection -@Component({ - template: ` `, - standalone: true, - imports: [PhoneAuthScreenComponent], -}) -class TestHostWithoutChildrenComponent {} - -describe('PhoneAuthScreenComponent', () => { - let mockFirebaseUi: MockFirebaseUi; - - beforeEach(async () => { - mockFirebaseUi = new MockFirebaseUi(); - - await TestBed.configureTestingModule({ - imports: [ - CommonModule, - PhoneAuthScreenComponent, - TestHostWithChildrenComponent, - TestHostWithoutChildrenComponent, - MockCardComponent, - MockCardHeaderComponent, - MockCardTitleComponent, - MockCardSubtitleComponent, - MockPhoneFormComponent, - MockDividerComponent, - ], - providers: [{ provide: FirebaseUI, useValue: mockFirebaseUi }], - }).compileComponents(); - - TestBed.overrideComponent(PhoneAuthScreenComponent, { - set: { - imports: [ - CommonModule, - MockCardComponent, - MockCardHeaderComponent, - MockCardTitleComponent, - MockCardSubtitleComponent, - MockPhoneFormComponent, - MockDividerComponent, - ], - }, - }); - }); - - it('should create', () => { - const fixture = TestBed.createComponent(PhoneAuthScreenComponent); - const component = fixture.componentInstance; - expect(component).toBeTruthy(); - }); - - it('displays the correct title and subtitle', () => { - const fixture = TestBed.createComponent(PhoneAuthScreenComponent); - fixture.detectChanges(); - - const titleEl = fixture.debugElement.query(By.css('.fui-card-title')); - const subtitleEl = fixture.debugElement.query(By.css('.fui-card-subtitle')); - - expect(titleEl.nativeElement.textContent).toBe('Sign in'); - expect(subtitleEl.nativeElement.textContent).toBe( - 'Sign in to your account' - ); - }); - - it('includes the PhoneForm with the correct resendDelay prop', () => { - const fixture = TestBed.createComponent(PhoneAuthScreenComponent); - const component = fixture.componentInstance; - component.resendDelay = 60; - fixture.detectChanges(); - - const phoneFormEl = fixture.debugElement.query( - By.css('[data-testid="phone-form"]') - ); - expect(phoneFormEl).toBeTruthy(); - expect(phoneFormEl.nativeElement.textContent).toContain('Resend Delay: 60'); - }); - - it('renders children when provided', fakeAsync(() => { - const fixture = TestBed.createComponent(TestHostWithChildrenComponent); - fixture.detectChanges(); - - // Wait for the setTimeout in ngAfterContentInit - tick(0); - fixture.detectChanges(); - - const buttonEl = fixture.debugElement.query( - By.css('[data-testid="test-button"]') - ); - const dividerEl = fixture.debugElement.query(By.css('.fui-divider')); - - expect(buttonEl).toBeTruthy(); - expect(buttonEl.nativeElement.textContent).toBe('Test Button'); - expect(dividerEl).toBeTruthy(); - expect(dividerEl.nativeElement.textContent).toBe('OR'); - })); - - it('does not render children or divider when not provided', fakeAsync(() => { - const fixture = TestBed.createComponent(TestHostWithoutChildrenComponent); - fixture.detectChanges(); - - // Wait for the setTimeout in ngAfterContentInit - tick(0); - fixture.detectChanges(); - - const dividerEl = fixture.debugElement.query(By.css('.fui-divider')); - expect(dividerEl).toBeFalsy(); - })); -}); diff --git a/packages/firebaseui-angular/src/lib/auth/screens/phone-auth-screen/phone-auth-screen.component.ts b/packages/firebaseui-angular/src/lib/auth/screens/phone-auth-screen/phone-auth-screen.component.ts deleted file mode 100644 index 58ccb0f1b..000000000 --- a/packages/firebaseui-angular/src/lib/auth/screens/phone-auth-screen/phone-auth-screen.component.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, inject, Input, AfterContentInit, ViewChild, ElementRef } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent } from '../../../components/card/card.component'; -import { FirebaseUI } from '../../../provider'; -import { PhoneFormComponent } from '../../forms/phone-form/phone-form.component'; -import { DividerComponent } from '../../../components/divider/divider.component'; - -@Component({ - selector: 'fui-phone-auth-screen', - standalone: true, - imports: [ - CommonModule, - CardComponent, - CardHeaderComponent, - CardTitleComponent, - CardSubtitleComponent, - PhoneFormComponent, - DividerComponent, - ], - template: ` -
- - - {{ titleText | async }} - {{ subtitleText | async }} - - - - - {{ dividerOrLabel | async }} -
- -
-
-
-
- ` -}) -export class PhoneAuthScreenComponent implements AfterContentInit { - private ui = inject(FirebaseUI); - - @Input() resendDelay = 30; - - @ViewChild('contentContainer') contentContainer!: ElementRef; - private _hasProjectedContent = false; - - get hasContent(): boolean { - return this._hasProjectedContent; - } - - get titleText() { - return this.ui.translation('labels', 'signIn'); - } - - get subtitleText() { - return this.ui.translation('prompts', 'signInToAccount'); - } - - get dividerOrLabel() { - return this.ui.translation('messages', 'dividerOr'); - } - - ngAfterContentInit() { - // Set to true initially to ensure the container is rendered - this._hasProjectedContent = true; - - // We need to use setTimeout to check after the view is rendered - setTimeout(() => { - // Check if there's any actual content in the container - if (this.contentContainer && this.contentContainer.nativeElement) { - const container = this.contentContainer.nativeElement; - // Only consider it to have content if there are child nodes that aren't just whitespace - this._hasProjectedContent = Array.from(container.childNodes as NodeListOf).some((node: Node) => { - return node.nodeType === Node.ELEMENT_NODE || - (node.nodeType === Node.TEXT_NODE && node.textContent && node.textContent.trim() !== ''); - }); - } else { - this._hasProjectedContent = false; - } - }); - } -} diff --git a/packages/firebaseui-angular/src/lib/auth/screens/sign-in-auth-screen/sign-in-auth-screen.component.spec.ts b/packages/firebaseui-angular/src/lib/auth/screens/sign-in-auth-screen/sign-in-auth-screen.component.spec.ts deleted file mode 100644 index 7e15b99d2..000000000 --- a/packages/firebaseui-angular/src/lib/auth/screens/sign-in-auth-screen/sign-in-auth-screen.component.spec.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { of } from 'rxjs'; -import { FirebaseUI } from '../../../provider'; -import { SignInAuthScreenComponent } from './sign-in-auth-screen.component'; - -// Mock Card components -@Component({ - selector: 'fui-card', - template: '
', - standalone: true, -}) -class MockCardComponent {} - -@Component({ - selector: 'fui-card-header', - template: '
', - standalone: true, -}) -class MockCardHeaderComponent {} - -@Component({ - selector: 'fui-card-title', - template: '

', - standalone: true, -}) -class MockCardTitleComponent {} - -@Component({ - selector: 'fui-card-subtitle', - template: '

', - standalone: true, -}) -class MockCardSubtitleComponent {} - -// Mock EmailPasswordForm component -@Component({ - selector: 'fui-email-password-form', - template: ` -
- Email Password Form -

Forgot Password Route: {{ forgotPasswordRoute }}

-

Register Route: {{ registerRoute }}

-
- `, - standalone: true, -}) -class MockEmailPasswordFormComponent { - @Input() forgotPasswordRoute: string = ''; - @Input() registerRoute: string = ''; -} - -// Mock Divider component -@Component({ - selector: 'fui-divider', - template: '
', - standalone: true, -}) -class MockDividerComponent {} - -// Create mock for FirebaseUi provider -class MockFirebaseUi { - translation(category: string, key: string) { - if (category === 'labels' && key === 'signIn') { - return of('Sign in'); - } - if (category === 'prompts' && key === 'signInToAccount') { - return of('Sign in to your account'); - } - if (category === 'messages' && key === 'dividerOr') { - return of('OR'); - } - return of(`${category}.${key}`); - } -} - -// Test component with content projection -@Component({ - template: ` - - - - `, - standalone: true, - imports: [SignInAuthScreenComponent], -}) -class TestHostWithChildrenComponent {} - -// Test component without content projection -@Component({ - template: ` `, - standalone: true, - imports: [SignInAuthScreenComponent], -}) -class TestHostWithoutChildrenComponent {} - -describe('SignInAuthScreenComponent', () => { - let mockFirebaseUi: MockFirebaseUi; - - beforeEach(async () => { - mockFirebaseUi = new MockFirebaseUi(); - - await TestBed.configureTestingModule({ - imports: [ - CommonModule, - SignInAuthScreenComponent, - TestHostWithChildrenComponent, - TestHostWithoutChildrenComponent, - MockCardComponent, - MockCardHeaderComponent, - MockCardTitleComponent, - MockCardSubtitleComponent, - MockEmailPasswordFormComponent, - MockDividerComponent, - ], - providers: [{ provide: FirebaseUI, useValue: mockFirebaseUi }], - }).compileComponents(); - - TestBed.overrideComponent(SignInAuthScreenComponent, { - set: { - imports: [ - CommonModule, - MockCardComponent, - MockCardHeaderComponent, - MockCardTitleComponent, - MockCardSubtitleComponent, - MockEmailPasswordFormComponent, - MockDividerComponent, - ], - }, - }); - }); - - it('should create', () => { - const fixture = TestBed.createComponent(SignInAuthScreenComponent); - const component = fixture.componentInstance; - expect(component).toBeTruthy(); - }); - - it('displays the correct title and subtitle', () => { - const fixture = TestBed.createComponent(SignInAuthScreenComponent); - fixture.detectChanges(); - - const titleEl = fixture.debugElement.query(By.css('.fui-card-title')); - const subtitleEl = fixture.debugElement.query(By.css('.fui-card-subtitle')); - - expect(titleEl.nativeElement.textContent).toBe('Sign in'); - expect(subtitleEl.nativeElement.textContent).toBe( - 'Sign in to your account' - ); - }); - - it('includes the EmailPasswordForm component', () => { - const fixture = TestBed.createComponent(SignInAuthScreenComponent); - fixture.detectChanges(); - - const formEl = fixture.debugElement.query( - By.css('[data-testid="email-password-form"]') - ); - expect(formEl).toBeTruthy(); - expect(formEl.nativeElement.textContent).toContain('Email Password Form'); - }); - - it('passes route props to EmailPasswordForm', () => { - const fixture = TestBed.createComponent(SignInAuthScreenComponent); - const component = fixture.componentInstance; - - component.forgotPasswordRoute = '/reset-password'; - component.registerRoute = '/sign-up'; - - fixture.detectChanges(); - - const formEl = fixture.debugElement.query( - By.css('[data-testid="email-password-form"]') - ); - expect(formEl.nativeElement.textContent).toContain( - 'Forgot Password Route: /reset-password' - ); - expect(formEl.nativeElement.textContent).toContain( - 'Register Route: /sign-up' - ); - }); - - it('renders children when provided', fakeAsync(() => { - const fixture = TestBed.createComponent(TestHostWithChildrenComponent); - fixture.detectChanges(); - - // Wait for the setTimeout in ngAfterContentInit - tick(0); - fixture.detectChanges(); - - const buttonEl = fixture.debugElement.query( - By.css('[data-testid="test-button"]') - ); - const dividerEl = fixture.debugElement.query(By.css('.fui-divider')); - - expect(buttonEl).toBeTruthy(); - expect(buttonEl.nativeElement.textContent).toBe('Test Button'); - expect(dividerEl).toBeTruthy(); - expect(dividerEl.nativeElement.textContent).toBe('OR'); - })); - - it('does not render children or divider when not provided', fakeAsync(() => { - const fixture = TestBed.createComponent(TestHostWithoutChildrenComponent); - fixture.detectChanges(); - - // Wait for the setTimeout in ngAfterContentInit - tick(0); - fixture.detectChanges(); - - const dividerEl = fixture.debugElement.query(By.css('.fui-divider')); - expect(dividerEl).toBeFalsy(); - })); -}); diff --git a/packages/firebaseui-angular/src/lib/auth/screens/sign-in-auth-screen/sign-in-auth-screen.component.ts b/packages/firebaseui-angular/src/lib/auth/screens/sign-in-auth-screen/sign-in-auth-screen.component.ts deleted file mode 100644 index bbee5c3f8..000000000 --- a/packages/firebaseui-angular/src/lib/auth/screens/sign-in-auth-screen/sign-in-auth-screen.component.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, ContentChildren, EventEmitter, inject, Input, Output, QueryList, AfterContentInit, ViewChild, ElementRef } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent } from '../../../components/card/card.component'; -import { FirebaseUI } from '../../../provider'; -import { EmailPasswordFormComponent } from '../../forms/email-password-form/email-password-form.component'; -import { DividerComponent } from '../../../components/divider/divider.component'; - -@Component({ - selector: 'fui-sign-in-auth-screen', - standalone: true, - imports: [ - CommonModule, - CardComponent, - CardHeaderComponent, - CardTitleComponent, - CardSubtitleComponent, - EmailPasswordFormComponent, - DividerComponent, - ], - template: ` -
- - - {{ titleText | async }} - {{ subtitleText | async }} - - - - - {{ dividerOrLabel | async }} -
- -
-
-
-
- ` -}) -export class SignInAuthScreenComponent implements AfterContentInit { - private ui = inject(FirebaseUI); - - @Input() forgotPasswordRoute: string = ''; - @Input() registerRoute: string = ''; - @ViewChild('contentContainer') contentContainer!: ElementRef; - private _hasProjectedContent = false; - - get hasContent(): boolean { - return this._hasProjectedContent; - } - - get titleText() { - return this.ui.translation('labels', 'signIn'); - } - - get subtitleText() { - return this.ui.translation('prompts', 'signInToAccount'); - } - - get dividerOrLabel() { - return this.ui.translation('messages', 'dividerOr'); - } - - ngAfterContentInit() { - // Set to true initially to ensure the container is rendered - this._hasProjectedContent = true; - - // We need to use setTimeout to check after the view is rendered - setTimeout(() => { - // Check if there's any actual content in the container - if (this.contentContainer && this.contentContainer.nativeElement) { - const container = this.contentContainer.nativeElement; - // Only consider it to have content if there are child nodes that aren't just whitespace - this._hasProjectedContent = Array.from(container.childNodes as NodeListOf).some((node: Node) => { - return node.nodeType === Node.ELEMENT_NODE || - (node.nodeType === Node.TEXT_NODE && node.textContent && node.textContent.trim() !== ''); - }); - } else { - this._hasProjectedContent = false; - } - }); - } -} diff --git a/packages/firebaseui-angular/src/lib/auth/screens/sign-up-auth-screen/sign-up-auth-screen.component.spec.ts b/packages/firebaseui-angular/src/lib/auth/screens/sign-up-auth-screen/sign-up-auth-screen.component.spec.ts deleted file mode 100644 index 1943287e3..000000000 --- a/packages/firebaseui-angular/src/lib/auth/screens/sign-up-auth-screen/sign-up-auth-screen.component.spec.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { of } from 'rxjs'; -import { FirebaseUI } from '../../../provider'; -import { SignUpAuthScreenComponent } from './sign-up-auth-screen.component'; - -// Mock Card components -@Component({ - selector: 'fui-card', - template: '
', - standalone: true, -}) -class MockCardComponent {} - -@Component({ - selector: 'fui-card-header', - template: '
', - standalone: true, -}) -class MockCardHeaderComponent {} - -@Component({ - selector: 'fui-card-title', - template: '

', - standalone: true, -}) -class MockCardTitleComponent {} - -@Component({ - selector: 'fui-card-subtitle', - template: '

', - standalone: true, -}) -class MockCardSubtitleComponent {} - -// Mock RegisterForm component -@Component({ - selector: 'fui-register-form', - template: ` -
- Register Form -

Sign In Route: {{ signInRoute }}

-
- `, - standalone: true, -}) -class MockRegisterFormComponent { - @Input() signInRoute: string = ''; -} - -// Mock Divider component -@Component({ - selector: 'fui-divider', - template: '
', - standalone: true, -}) -class MockDividerComponent {} - -// Create mock for FirebaseUi provider -class MockFirebaseUi { - translation(category: string, key: string) { - if (category === 'labels' && key === 'register') { - return of('Create Account'); - } - if (category === 'prompts' && key === 'enterDetailsToCreate') { - return of('Enter your details to create an account'); - } - if (category === 'messages' && key === 'dividerOr') { - return of('OR'); - } - return of(`${category}.${key}`); - } -} - -// Test component with content projection -@Component({ - template: ` - -
Child element
-
- `, - standalone: true, - imports: [SignUpAuthScreenComponent], -}) -class TestHostWithChildrenComponent {} - -// Test component without content projection -@Component({ - template: ` `, - standalone: true, - imports: [SignUpAuthScreenComponent], -}) -class TestHostWithoutChildrenComponent {} - -describe('SignUpAuthScreenComponent', () => { - let mockFirebaseUi: MockFirebaseUi; - - beforeEach(async () => { - mockFirebaseUi = new MockFirebaseUi(); - - await TestBed.configureTestingModule({ - imports: [ - CommonModule, - SignUpAuthScreenComponent, - TestHostWithChildrenComponent, - TestHostWithoutChildrenComponent, - MockCardComponent, - MockCardHeaderComponent, - MockCardTitleComponent, - MockCardSubtitleComponent, - MockRegisterFormComponent, - MockDividerComponent, - ], - providers: [{ provide: FirebaseUI, useValue: mockFirebaseUi }], - }).compileComponents(); - - TestBed.overrideComponent(SignUpAuthScreenComponent, { - set: { - imports: [ - CommonModule, - MockCardComponent, - MockCardHeaderComponent, - MockCardTitleComponent, - MockCardSubtitleComponent, - MockRegisterFormComponent, - MockDividerComponent, - ], - }, - }); - }); - - it('should create', () => { - const fixture = TestBed.createComponent(SignUpAuthScreenComponent); - const component = fixture.componentInstance; - expect(component).toBeTruthy(); - }); - - it('renders the correct title and subtitle', () => { - const fixture = TestBed.createComponent(SignUpAuthScreenComponent); - fixture.detectChanges(); - - const titleEl = fixture.debugElement.query(By.css('.fui-card-title')); - const subtitleEl = fixture.debugElement.query(By.css('.fui-card-subtitle')); - - expect(titleEl.nativeElement.textContent).toBe('Create Account'); - expect(subtitleEl.nativeElement.textContent).toBe( - 'Enter your details to create an account' - ); - }); - - it('includes the RegisterForm component', () => { - const fixture = TestBed.createComponent(SignUpAuthScreenComponent); - fixture.detectChanges(); - - const formEl = fixture.debugElement.query( - By.css('[data-testid="register-form"]') - ); - expect(formEl).toBeTruthy(); - expect(formEl.nativeElement.textContent).toContain('Register Form'); - }); - - it('passes signInRoute to RegisterForm', () => { - const fixture = TestBed.createComponent(SignUpAuthScreenComponent); - const component = fixture.componentInstance; - - component.signInRoute = '/sign-in'; - - fixture.detectChanges(); - - const formEl = fixture.debugElement.query( - By.css('[data-testid="register-form"]') - ); - expect(formEl.nativeElement.textContent).toContain( - 'Sign In Route: /sign-in' - ); - }); - - it('renders children when provided', fakeAsync(() => { - const fixture = TestBed.createComponent(TestHostWithChildrenComponent); - fixture.detectChanges(); - - // Wait for the setTimeout in ngAfterContentInit - tick(0); - fixture.detectChanges(); - - const childEl = fixture.debugElement.query( - By.css('[data-testid="test-child"]') - ); - const dividerEl = fixture.debugElement.query(By.css('.fui-divider')); - - expect(childEl).toBeTruthy(); - expect(childEl.nativeElement.textContent).toBe('Child element'); - expect(dividerEl).toBeTruthy(); - expect(dividerEl.nativeElement.textContent).toBe('OR'); - })); - - it('does not render divider or children container when no children are provided', fakeAsync(() => { - const fixture = TestBed.createComponent(TestHostWithoutChildrenComponent); - fixture.detectChanges(); - - // Wait for the setTimeout in ngAfterContentInit - tick(0); - fixture.detectChanges(); - - const dividerEl = fixture.debugElement.query(By.css('.fui-divider')); - expect(dividerEl).toBeFalsy(); - })); -}); diff --git a/packages/firebaseui-angular/src/lib/auth/screens/sign-up-auth-screen/sign-up-auth-screen.component.ts b/packages/firebaseui-angular/src/lib/auth/screens/sign-up-auth-screen/sign-up-auth-screen.component.ts deleted file mode 100644 index 1bc25d6bf..000000000 --- a/packages/firebaseui-angular/src/lib/auth/screens/sign-up-auth-screen/sign-up-auth-screen.component.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, EventEmitter, inject, Input, Output, QueryList, AfterContentInit, ViewChild, ElementRef } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { CardComponent, CardHeaderComponent, CardTitleComponent, CardSubtitleComponent } from '../../../components/card/card.component'; - -import { FirebaseUI } from '../../../provider'; -import { RegisterFormComponent } from '../../forms/register-form/register-form.component'; -import { DividerComponent } from '../../../components/divider/divider.component'; - -@Component({ - selector: 'fui-sign-up-auth-screen', - standalone: true, - imports: [ - CommonModule, - CardComponent, - CardHeaderComponent, - CardTitleComponent, - CardSubtitleComponent, - RegisterFormComponent, - DividerComponent, - ], - template: ` -
- - - {{ titleText | async }} - {{ subtitleText | async }} - - - - - {{ dividerOrLabel | async }} -
- -
-
-
-
- ` -}) -export class SignUpAuthScreenComponent implements AfterContentInit { - private ui = inject(FirebaseUI); - - @Input() signInRoute: string = ''; - @ViewChild('contentContainer') contentContainer!: ElementRef; - private _hasProjectedContent = false; - - get hasContent(): boolean { - return this._hasProjectedContent; - } - - get titleText() { - return this.ui.translation('labels', 'register'); - } - - get subtitleText() { - return this.ui.translation('prompts', 'enterDetailsToCreate'); - } - - get dividerOrLabel() { - return this.ui.translation('messages', 'dividerOr'); - } - - ngAfterContentInit() { - // Set to true initially to ensure the container is rendered - this._hasProjectedContent = true; - - // We need to use setTimeout to check after the view is rendered - setTimeout(() => { - // Check if there's any actual content in the container - if (this.contentContainer && this.contentContainer.nativeElement) { - const container = this.contentContainer.nativeElement; - // Only consider it to have content if there are child nodes that aren't just whitespace - this._hasProjectedContent = Array.from(container.childNodes as NodeListOf).some((node: Node) => { - return node.nodeType === Node.ELEMENT_NODE || - (node.nodeType === Node.TEXT_NODE && node.textContent && node.textContent.trim() !== ''); - }); - } else { - this._hasProjectedContent = false; - } - }); - } -} diff --git a/packages/firebaseui-angular/src/lib/components/button/button.component.spec.ts b/packages/firebaseui-angular/src/lib/components/button/button.component.spec.ts deleted file mode 100644 index 9be51eb68..000000000 --- a/packages/firebaseui-angular/src/lib/components/button/button.component.spec.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { ButtonComponent } from './button.component'; - -@Component({ - template: ` - Click me - Secondary - Custom Class - Disabled - `, - standalone: true, - imports: [ButtonComponent], -}) -class TestHostComponent { - clicks = 0; - - handleClick() { - this.clicks++; - } -} - -describe('ButtonComponent', () => { - let fixture: ComponentFixture; - let hostComponent: TestHostComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ButtonComponent, TestHostComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(TestHostComponent); - hostComponent = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('renders with default variant (primary)', () => { - const buttonEl = fixture.debugElement.query( - By.css('[data-testid="test-button"]') - ); - const button = buttonEl.nativeElement.querySelector('button'); - - expect(button).toBeTruthy(); - expect(button.classList.contains('fui-button')).toBeTrue(); - expect(button.classList.contains('fui-button--secondary')).toBeFalse(); - expect(button.textContent.trim()).toBe('Click me'); - }); - - it('renders with secondary variant', () => { - const buttonEl = fixture.debugElement.query( - By.css('[data-testid="secondary-button"]') - ); - const button = buttonEl.nativeElement.querySelector('button'); - - expect(button).toBeTruthy(); - expect(button.classList.contains('fui-button')).toBeTrue(); - expect(button.classList.contains('fui-button--secondary')).toBeTrue(); - }); - - it('applies custom className', () => { - const buttonEl = fixture.debugElement.query( - By.css('[data-testid="custom-class-button"]') - ); - - expect( - buttonEl.nativeElement.classList.contains('custom-class') - ).toBeTrue(); - }); - - it('handles click events', () => { - const buttonEl = fixture.debugElement.query( - By.css('[data-testid="test-button"]') - ); - const button = buttonEl.nativeElement.querySelector('button'); - - expect(hostComponent.clicks).toBe(0); - - button.click(); - fixture.detectChanges(); - - expect(hostComponent.clicks).toBe(1); - }); - - it('can be disabled', () => { - const buttonEl = fixture.debugElement.query( - By.css('[data-testid="disabled-button"]') - ); - const button = buttonEl.query(By.css('button')); - - expect(button).toBeTruthy(); - expect(button.nativeElement.disabled).toBeTrue(); - }); -}); diff --git a/packages/firebaseui-angular/src/lib/components/card/card.component.spec.ts b/packages/firebaseui-angular/src/lib/components/card/card.component.spec.ts deleted file mode 100644 index c209005e1..000000000 --- a/packages/firebaseui-angular/src/lib/components/card/card.component.spec.ts +++ /dev/null @@ -1,300 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; - -import { - CardComponent, - CardHeaderComponent, - CardSubtitleComponent, - CardTitleComponent, -} from './card.component'; - -// Test host components for individual components -@Component({ - template: `Card content`, - standalone: true, - imports: [CardComponent], -}) -class TestCardHostComponent {} - -@Component({ - template: `Header content`, - standalone: true, - imports: [CardHeaderComponent], -}) -class TestCardHeaderHostComponent {} - -@Component({ - template: `Title content`, - standalone: true, - imports: [CardTitleComponent], -}) -class TestCardTitleHostComponent {} - -@Component({ - template: `Subtitle content`, - standalone: true, - imports: [CardSubtitleComponent], -}) -class TestCardSubtitleHostComponent {} - -// Test host for a complete card -@Component({ - template: ` - - - Card Title - Card Subtitle - -
Card Body Content
-
- `, - standalone: true, - imports: [ - CardComponent, - CardHeaderComponent, - CardTitleComponent, - CardSubtitleComponent, - ], -}) -class TestCompleteCardHostComponent {} - -describe('Card Components', () => { - describe('CardComponent', () => { - let component: TestCardHostComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CardComponent, TestCardHostComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(TestCardHostComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('renders a card with children', () => { - const card = fixture.debugElement.query( - By.css('[data-testid="test-card"]') - ); - const cardDiv = card.query(By.css('.fui-card')); - - expect(cardDiv).toBeTruthy(); - expect(cardDiv.nativeElement.textContent).toContain('Card content'); - }); - - it('applies custom className', () => { - const card = fixture.debugElement.query( - By.css('[data-testid="test-card"]') - ); - const cardDiv = card.query(By.css('.fui-card')); - - expect(cardDiv).toBeTruthy(); - expect(cardDiv.nativeElement.classList.contains('fui-card')).toBeTruthy(); - // For Angular components, class is applied to the host, not directly to the inner div - expect( - card.nativeElement.classList.contains('custom-class') - ).toBeTruthy(); - }); - }); - - describe('CardHeaderComponent', () => { - let component: TestCardHeaderHostComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CardHeaderComponent, TestCardHeaderHostComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(TestCardHeaderHostComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('renders a card header with children', () => { - const header = fixture.debugElement.query( - By.css('[data-testid="test-header"]') - ); - const headerDiv = header.query(By.css('.fui-card__header')); - - expect(headerDiv).toBeTruthy(); - expect(headerDiv.nativeElement.textContent).toContain('Header content'); - }); - - it('applies custom className', () => { - const header = fixture.debugElement.query( - By.css('[data-testid="test-header"]') - ); - const headerDiv = header.query(By.css('.fui-card__header')); - - expect(headerDiv).toBeTruthy(); - expect( - headerDiv.nativeElement.classList.contains('fui-card__header') - ).toBeTruthy(); - // For Angular components, class is applied to the host, not directly to the inner div - expect( - header.nativeElement.classList.contains('custom-header') - ).toBeTruthy(); - }); - }); - - describe('CardTitleComponent', () => { - let component: TestCardTitleHostComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CardTitleComponent, TestCardTitleHostComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(TestCardTitleHostComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('renders a card title with children', () => { - const title = fixture.debugElement.query(By.css('.fui-card__title')); - - expect(title).toBeTruthy(); - expect(title.nativeElement.textContent).toContain('Title content'); - expect(title.nativeElement.tagName).toBe('H2'); - }); - - it('applies custom className', () => { - const titleHost = fixture.debugElement.query(By.css('fui-card-title')); - const title = fixture.debugElement.query(By.css('.fui-card__title')); - - expect(title).toBeTruthy(); - expect( - title.nativeElement.classList.contains('fui-card__title') - ).toBeTruthy(); - expect( - titleHost.nativeElement.classList.contains('custom-title') - ).toBeTruthy(); - }); - }); - - describe('CardSubtitleComponent', () => { - let component: TestCardSubtitleHostComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CardSubtitleComponent, TestCardSubtitleHostComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(TestCardSubtitleHostComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('renders a card subtitle with children', () => { - const subtitle = fixture.debugElement.query( - By.css('.fui-card__subtitle') - ); - - expect(subtitle).toBeTruthy(); - expect(subtitle.nativeElement.textContent).toContain('Subtitle content'); - expect(subtitle.nativeElement.tagName).toBe('P'); - }); - - it('applies custom className', () => { - const subtitleHost = fixture.debugElement.query( - By.css('fui-card-subtitle') - ); - const subtitle = fixture.debugElement.query( - By.css('.fui-card__subtitle') - ); - - expect(subtitle).toBeTruthy(); - expect( - subtitle.nativeElement.classList.contains('fui-card__subtitle') - ).toBeTruthy(); - expect( - subtitleHost.nativeElement.classList.contains('custom-subtitle') - ).toBeTruthy(); - }); - }); - - describe('Complete Card', () => { - let component: TestCompleteCardHostComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - CardComponent, - CardHeaderComponent, - CardTitleComponent, - CardSubtitleComponent, - TestCompleteCardHostComponent, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(TestCompleteCardHostComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('renders a complete card with all subcomponents', () => { - const card = fixture.debugElement.query( - By.css('[data-testid="complete-card"]') - ); - const header = fixture.debugElement.query( - By.css('[data-testid="complete-header"]') - ); - const title = fixture.debugElement.query(By.css('.fui-card__title')); - const subtitle = fixture.debugElement.query( - By.css('.fui-card__subtitle') - ); - const content = fixture.debugElement.query( - By.css('div:not(.fui-card):not(.fui-card__header)') - ); - - expect(card).toBeTruthy(); - expect(header).toBeTruthy(); - expect(title).toBeTruthy(); - expect(subtitle).toBeTruthy(); - expect(content).toBeTruthy(); - - expect(title.nativeElement.textContent).toContain('Card Title'); - expect(subtitle.nativeElement.textContent).toContain('Card Subtitle'); - expect(content.nativeElement.textContent).toContain('Card Body Content'); - - // Check that the card contains the header and content - const cardElement = card.query(By.css('.fui-card')).nativeElement; - const headerElement = header.query( - By.css('.fui-card__header') - ).nativeElement; - - expect(cardElement.contains(headerElement)).toBeTruthy(); - expect(cardElement.contains(content.nativeElement)).toBeTruthy(); - }); - }); -}); diff --git a/packages/firebaseui-angular/src/lib/components/card/card.component.ts b/packages/firebaseui-angular/src/lib/components/card/card.component.ts deleted file mode 100644 index 5db1a7afe..000000000 --- a/packages/firebaseui-angular/src/lib/components/card/card.component.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -@Component({ - selector: 'fui-card', - standalone: true, - imports: [], - template: ` -
- -
- `, -}) -export class CardComponent { -} - -@Component({ - selector: 'fui-card-header', - standalone: true, - imports: [CommonModule], - host: { - style: 'display: block;', - }, - template: ` -
- -
- `, -}) -export class CardHeaderComponent { -} - -@Component({ - selector: 'fui-card-title', - standalone: true, - imports: [CommonModule], - template: ` -

- -

- `, -}) -export class CardTitleComponent { -} - -@Component({ - selector: 'fui-card-subtitle', - standalone: true, - imports: [CommonModule], - template: ` -

- -

- `, -}) -export class CardSubtitleComponent { -} diff --git a/packages/firebaseui-angular/src/lib/components/country-selector/country-selector.component.spec.ts b/packages/firebaseui-angular/src/lib/components/country-selector/country-selector.component.spec.ts deleted file mode 100644 index c01e57f21..000000000 --- a/packages/firebaseui-angular/src/lib/components/country-selector/country-selector.component.spec.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { By } from '@angular/platform-browser'; -import { countryData } from '@firebase-ui/core'; - -import { CountrySelectorComponent } from './country-selector.component'; - -describe('CountrySelectorComponent', () => { - let component: CountrySelectorComponent; - let fixture: ComponentFixture; - const defaultCountry = countryData[0]; // First country in the list - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CountrySelectorComponent, FormsModule], - }).compileComponents(); - - fixture = TestBed.createComponent(CountrySelectorComponent); - component = fixture.componentInstance; - component.value = defaultCountry; - fixture.detectChanges(); - }); - - it('renders with the selected country', () => { - // Check if the country flag emoji is displayed - const flagElement = fixture.debugElement.query( - By.css('.fui-country-selector__flag') - ); - expect(flagElement.nativeElement.textContent).toBe(defaultCountry.emoji); - - // Check if the dial code is displayed - const dialCodeElement = fixture.debugElement.query( - By.css('.fui-country-selector__dial-code') - ); - expect(dialCodeElement.nativeElement.textContent).toBe( - defaultCountry.dialCode - ); - - // Check if the select has the correct value - const selectElement = fixture.debugElement.query(By.css('select')); - expect(selectElement.nativeElement.value).toBe(defaultCountry.code); - }); - - it('applies custom className', () => { - // Set custom class - component.className = 'custom-class'; - fixture.detectChanges(); - - // Check if the custom class is applied - const container = fixture.debugElement.query( - By.css('.fui-country-selector') - ); - expect( - container.nativeElement.classList.contains('custom-class') - ).toBeTruthy(); - expect( - container.nativeElement.classList.contains('fui-country-selector') - ).toBeTruthy(); - }); - - it('calls onChange when a different country is selected', () => { - // Spy on the onChange event - spyOn(component.onChange, 'emit'); - - // Find a different country to select - const newCountry = countryData.find( - (country) => country.code !== defaultCountry.code - ); - - if (newCountry) { - // Get the select element - const selectElement = fixture.debugElement.query( - By.css('select') - ).nativeElement; - - // Change the selection - selectElement.value = newCountry.code; - selectElement.dispatchEvent(new Event('change')); - fixture.detectChanges(); - - // Check if onChange was called with the new country - expect(component.onChange.emit).toHaveBeenCalledWith(newCountry); - } else { - // Fail the test if no different country is found - fail('No different country found in countryData. Test cannot proceed.'); - } - }); - - it('renders all countries in the dropdown', () => { - const selectElement = fixture.debugElement.query( - By.css('select') - ).nativeElement; - const options = selectElement.querySelectorAll('option'); - - // Check if all countries are in the dropdown - expect(options.length).toBe(countryData.length); - - // Check if a specific country exists in the dropdown - const usCountry = countryData.find((country) => country.code === 'US'); - if (usCountry) { - // Properly cast the NodeList to an array of HTMLOptionElement - const optionsArray = Array.from(options) as HTMLOptionElement[]; - const usOption = optionsArray.find( - (option: HTMLOptionElement) => option.value === usCountry.code - ); - expect(usOption).toBeTruthy(); - if (usOption) { - expect(usOption.textContent?.trim()).toBe( - `${usCountry.dialCode} (${usCountry.name})` - ); - } - } else { - // Fail the test if US country is not found - fail('US country not found in countryData. Test cannot proceed.'); - } - }); -}); diff --git a/packages/firebaseui-angular/src/lib/components/country-selector/country-selector.component.ts b/packages/firebaseui-angular/src/lib/components/country-selector/country-selector.component.ts deleted file mode 100644 index 05a1788b7..000000000 --- a/packages/firebaseui-angular/src/lib/components/country-selector/country-selector.component.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, EventEmitter, Input, Output } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { CountryData, countryData } from '@firebase-ui/core'; -import { FormsModule } from '@angular/forms'; - -@Component({ - selector: 'fui-country-selector', - standalone: true, - imports: [CommonModule, FormsModule], - template: ` -
-
- {{ value.emoji }} -
- {{ value.dialCode }} - -
-
-
- ` -}) -export class CountrySelectorComponent { - @Input() value: CountryData = countryData[0]; - @Input() className: string = ''; - @Output() onChange = new EventEmitter(); - - countries = countryData; - - handleCountryChange(code: string) { - const country = this.countries.find(c => c.code === code); - if (country) { - this.onChange.emit(country); - } - } -} diff --git a/packages/firebaseui-angular/src/lib/components/divider/divider.component.spec.ts b/packages/firebaseui-angular/src/lib/components/divider/divider.component.spec.ts deleted file mode 100644 index c4a2d9283..000000000 --- a/packages/firebaseui-angular/src/lib/components/divider/divider.component.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component } from '@angular/core'; -import { - ComponentFixture, - TestBed, - fakeAsync, - tick, -} from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { DividerComponent } from './divider.component'; - -// Create a test host component with projected text content -@Component({ - template: `OR`, - standalone: true, - imports: [DividerComponent], -}) -class TestHostWithTextComponent {} - -// Create a test host component with input text content -@Component({ - template: ``, - standalone: true, - imports: [DividerComponent], -}) -class TestHostWithInputTextComponent {} - -// Create a test host component without text content -@Component({ - template: ``, - standalone: true, - imports: [DividerComponent], -}) -class TestHostNoTextComponent {} - -describe('DividerComponent', () => { - let textFixture: ComponentFixture; - let inputTextFixture: ComponentFixture; - let noTextFixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - DividerComponent, - TestHostWithTextComponent, - TestHostWithInputTextComponent, - TestHostNoTextComponent, - ], - }).compileComponents(); - - textFixture = TestBed.createComponent(TestHostWithTextComponent); - inputTextFixture = TestBed.createComponent(TestHostWithInputTextComponent); - noTextFixture = TestBed.createComponent(TestHostNoTextComponent); - }); - - it('renders a divider with no text', () => { - noTextFixture.detectChanges(); - - const dividerHost = noTextFixture.debugElement.query( - By.css('[data-testid="divider-no-text"]') - ); - const dividerEl = dividerHost.query(By.css('.fui-divider')); - - expect(dividerEl).toBeTruthy(); - expect( - dividerEl.nativeElement.classList.contains('fui-divider') - ).toBeTrue(); - - // Check for a single divider line when no text - const dividerLines = dividerEl.queryAll(By.css('.fui-divider__line')); - expect(dividerLines.length).toBe(1); - - // Check that text container does not exist - const textEl = dividerEl.query(By.css('.fui-divider__text')); - expect(textEl).toBeFalsy(); - - // Check aria-label on the host element - expect(dividerHost.nativeElement.getAttribute('aria-label')).toBe( - 'divider' - ); - }); - - it('renders a divider with input text attribute', fakeAsync(() => { - inputTextFixture.detectChanges(); - tick(0); - inputTextFixture.detectChanges(); - - const dividerHost = inputTextFixture.debugElement.query( - By.css('[data-testid="divider-with-input-text"]') - ); - - // Get the component instance - const dividerComponent = dividerHost.componentInstance; - expect(dividerComponent.text).toBe('OR'); - - const dividerEl = dividerHost.query(By.css('.fui-divider')); - expect(dividerEl).toBeTruthy(); - - // Check for two divider lines when there is text - const dividerLines = dividerEl.queryAll(By.css('.fui-divider__line')); - expect(dividerLines.length).toBe(2); - - // Check that text container exists - const textEl = dividerEl.query(By.css('.fui-divider__text')); - expect(textEl).toBeTruthy(); - })); - - it('applies custom className', () => { - inputTextFixture.detectChanges(); - - const dividerHost = inputTextFixture.debugElement.query( - By.css('[data-testid="divider-with-input-text"]') - ); - - // Class should be on the host element - expect( - dividerHost.nativeElement.classList.contains('custom-class') - ).toBeTrue(); - }); -}); diff --git a/packages/firebaseui-angular/src/lib/components/divider/divider.component.ts b/packages/firebaseui-angular/src/lib/components/divider/divider.component.ts deleted file mode 100644 index 90689f459..000000000 --- a/packages/firebaseui-angular/src/lib/components/divider/divider.component.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, Input, ElementRef, AfterContentInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -@Component({ - selector: 'fui-divider', - standalone: true, - imports: [CommonModule], - template: ` -
-
-
- -
-
-
- `, -}) -export class DividerComponent implements AfterContentInit { - hasContent = false; - - @Input() text: string = ''; - - get textContent(): string { - return this.text; - } - - constructor(private elementRef: ElementRef) {} - - ngAfterContentInit() { - // Check if text input is provided - if (this.text) { - this.hasContent = true; - return; - } - - // Otherwise check for projected content - const directContent = this.elementRef.nativeElement.textContent?.trim(); - if (directContent) { - this.hasContent = true; - } - } -} diff --git a/packages/firebaseui-angular/src/lib/components/terms-and-privacy/terms-and-privacy.component.spec.ts b/packages/firebaseui-angular/src/lib/components/terms-and-privacy/terms-and-privacy.component.spec.ts deleted file mode 100644 index 04f432be1..000000000 --- a/packages/firebaseui-angular/src/lib/components/terms-and-privacy/terms-and-privacy.component.spec.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { BehaviorSubject } from 'rxjs'; - -import { FirebaseUI, FirebaseUIPolicies } from '../../provider'; -import { TermsAndPrivacyComponent } from './terms-and-privacy.component'; - -class MockFirebaseUI { - private _termsText = new BehaviorSubject('Terms of Service'); - private _privacyText = new BehaviorSubject('Privacy Policy'); - private _templateText = new BehaviorSubject( - 'By continuing, you agree to our {tos} and {privacy}', - ); - - translation(section: string, key: string) { - if (section === 'labels' && key === 'termsOfService') { - return this._termsText.asObservable(); - } - if (section === 'labels' && key === 'privacyPolicy') { - return this._privacyText.asObservable(); - } - if (section === 'messages' && key === 'termsAndPrivacy') { - return this._templateText.asObservable(); - } - return new BehaviorSubject(`${section}.${key}`).asObservable(); - } - - setTranslation(section: string, key: string, value: string) { - if (section === 'labels' && key === 'termsOfService') { - this._termsText.next(value); - } else if (section === 'labels' && key === 'privacyPolicy') { - this._privacyText.next(value); - } else if (section === 'messages' && key === 'termsAndPrivacy') { - this._templateText.next(value); - } - } -} - -function configureComponentTest({ - tosUrl, - privacyPolicyUrl, -}: { - tosUrl?: string | null; - privacyPolicyUrl?: string | null; -}) { - const mockFirebaseUI = new MockFirebaseUI(); - - TestBed.configureTestingModule({ - imports: [TermsAndPrivacyComponent], - providers: [ - { provide: FirebaseUI, useValue: mockFirebaseUI }, - { - provide: FirebaseUIPolicies, - useValue: { - termsOfServiceUrl: tosUrl, - privacyPolicyUrl: privacyPolicyUrl, - }, - }, - ], - }).compileComponents(); - - const fixture = TestBed.createComponent(TermsAndPrivacyComponent); - const component = fixture.componentInstance; - - return { fixture, component, mockFirebaseUI }; -} - -describe('TermsAndPrivacyComponent', () => { - it('renders component with terms and privacy links', fakeAsync(() => { - const { fixture } = configureComponentTest({ - tosUrl: 'https://example.com/terms', - privacyPolicyUrl: 'https://example.com/privacy', - }); - - tick(); - fixture.detectChanges(); - - const container = fixture.debugElement.query(By.css('.text-text-muted')); - expect(container).toBeTruthy(); - - const textContent = container.nativeElement.textContent; - expect(textContent).toContain('By continuing, you agree to our'); - - const tosLink = fixture.debugElement - .queryAll(By.css('a')) - .find((el) => el.nativeElement.textContent.includes('Terms of Service')); - expect(tosLink).toBeTruthy(); - expect(tosLink!.nativeElement.getAttribute('target')).toBe('_blank'); - expect(tosLink!.nativeElement.getAttribute('rel')).toBe( - 'noopener noreferrer', - ); - - const privacyLink = fixture.debugElement.query( - By.css('a[href="https://example.com/privacy"]'), - ); - expect(privacyLink).toBeTruthy(); - expect(privacyLink.nativeElement.textContent.trim()).toBe('Privacy Policy'); - })); - - it('does not render when both tosUrl and privacyPolicyUrl are not provided', fakeAsync(() => { - const { fixture } = configureComponentTest({ - tosUrl: null, - privacyPolicyUrl: null, - }); - - tick(); - fixture.detectChanges(); - - const container = fixture.debugElement.query(By.css('.text-text-muted')); - expect(container).toBeFalsy(); - })); - - it('renders with tosUrl when privacyPolicyUrl is not provided', fakeAsync(() => { - const { fixture } = configureComponentTest({ - tosUrl: 'https://example.com/terms', - privacyPolicyUrl: null, - }); - - tick(); - fixture.detectChanges(); - - const container = fixture.debugElement.query(By.css('.text-text-muted')); - expect(container).toBeTruthy(); - - const tosLink = fixture.debugElement.query( - By.css('a[href="https://example.com/terms"]'), - ); - expect(tosLink).toBeTruthy(); - - const privacyLink = fixture.debugElement.query( - By.css('a[href="https://example.com/privacy"]'), - ); - expect(privacyLink).toBeFalsy(); - })); - - it('renders with privacyPolicyUrl when tosUrl is not provided', fakeAsync(() => { - const { fixture } = configureComponentTest({ - tosUrl: null, - privacyPolicyUrl: 'https://example.com/privacy', - }); - - tick(); - fixture.detectChanges(); - - const container = fixture.debugElement.query(By.css('.text-text-muted')); - expect(container).toBeTruthy(); - - const tosLink = fixture.debugElement.query( - By.css('a[href="https://example.com/terms"]'), - ); - expect(tosLink).toBeFalsy(); - - const privacyLink = fixture.debugElement.query( - By.css('a[href="https://example.com/privacy"]'), - ); - expect(privacyLink).toBeTruthy(); - })); - - it('uses custom template text when provided', fakeAsync(() => { - const { fixture, mockFirebaseUI } = configureComponentTest({ - tosUrl: 'https://example.com/terms', - privacyPolicyUrl: 'https://example.com/privacy', - }); - - mockFirebaseUI.setTranslation( - 'messages', - 'termsAndPrivacy', - 'Custom template with {tos} and {privacy}', - ); - - tick(); - fixture.detectChanges(); - - const container = fixture.debugElement.query(By.css('.text-text-muted')); - expect(container).toBeTruthy(); - - const textContent = container.nativeElement.textContent; - expect(textContent).toContain('Custom template with'); - expect(textContent).toContain('Terms of Service'); - expect(textContent).toContain('Privacy Policy'); - })); -}); diff --git a/packages/firebaseui-angular/src/lib/components/terms-and-privacy/terms-and-privacy.component.ts b/packages/firebaseui-angular/src/lib/components/terms-and-privacy/terms-and-privacy.component.ts deleted file mode 100644 index 83f6b4716..000000000 --- a/packages/firebaseui-angular/src/lib/components/terms-and-privacy/terms-and-privacy.component.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FirebaseUI, FirebaseUIPolicies } from '../../provider'; -import { map } from 'rxjs'; - -@Component({ - selector: 'fui-terms-and-privacy', - standalone: true, - imports: [CommonModule], - template: ` -
- `, -}) -export class TermsAndPrivacyComponent { - private ui = inject(FirebaseUI); - private policies = inject(FirebaseUIPolicies); - - tosUrl = this.policies.termsOfServiceUrl; - privacyPolicyUrl = this.policies.privacyPolicyUrl; - - get shouldShow(): boolean { - return !!(this.tosUrl || this.privacyPolicyUrl); - } - - termsText = this.ui.translation('labels', 'termsOfService'); - privacyText = this.ui.translation('labels', 'privacyPolicy'); - - parts = this.ui.translation('messages', 'termsAndPrivacy').pipe( - map((text) => { - const parts = text.split(/({tos}|{privacy})/); - return parts.map((part) => { - if (part === '{tos}') return { type: 'tos' }; - if (part === '{privacy}') return { type: 'privacy' }; - return { type: 'text', content: part }; - }); - }), - ); - - handleUrl(url: string) { - if (url) { - window.open(url, '_blank', 'noopener,noreferrer'); - } - } -} diff --git a/packages/firebaseui-angular/src/lib/provider.ts b/packages/firebaseui-angular/src/lib/provider.ts deleted file mode 100644 index 618fcc8cb..000000000 --- a/packages/firebaseui-angular/src/lib/provider.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - Provider, - EnvironmentProviders, - makeEnvironmentProviders, - InjectionToken, - Injectable, - inject, -} from '@angular/core'; -import { FirebaseApps } from '@angular/fire/app'; -import { - type FirebaseUI as FirebaseUIType, - getTranslation, -} from '@firebase-ui/core'; -import { distinctUntilChanged, map, takeUntil } from 'rxjs/operators'; -import { Observable, ReplaySubject } from 'rxjs'; -import { Store } from 'nanostores'; -import { TranslationCategory, TranslationKey } from '@firebase-ui/translations'; - -const FIREBASE_UI_STORE = new InjectionToken( - 'firebaseui.store', -); -const FIREBASE_UI_POLICIES = new InjectionToken( - 'firebaseui.policies', -); - -type PolicyConfig = { - termsOfServiceUrl: string; - privacyPolicyUrl: string; -}; - -export function provideFirebaseUI( - uiFactory: (apps: FirebaseApps) => FirebaseUIType, -): EnvironmentProviders { - const providers: Provider[] = [ - // TODO: This should depend on the FirebaseAuth provider via deps, - // see https://github.com/angular/angularfire/blob/35e0a9859299010488852b1826e4083abe56528f/src/firestore/firestore.module.ts#L76 - { provide: FIREBASE_UI_STORE, useFactory: uiFactory, deps: [FirebaseApps] }, - ]; - - return makeEnvironmentProviders(providers); -} - -export function provideFirebaseUIPolicies(factory: () => PolicyConfig) { - const providers: Provider[] = [ - { provide: FIREBASE_UI_POLICIES, useFactory: factory }, - ]; - - return makeEnvironmentProviders(providers); -} - -@Injectable({ - providedIn: 'root', -}) -export class FirebaseUI { - private store = inject(FIREBASE_UI_STORE); - private destroyed$: ReplaySubject = new ReplaySubject(1); - - config() { - return this.useStore(this.store); - } - - translation( - category: T, - key: TranslationKey, - ) { - return this.config().pipe( - map((config) => getTranslation(config, category, key)), - ); - } - - useStore(store: Store): Observable { - return new Observable((sub) => { - sub.next(store.get()); - return store.subscribe((value) => sub.next(value)); - }).pipe(distinctUntilChanged(), takeUntil(this.destroyed$)); - } - - ngOnDestroy(): void { - this.destroyed$.next(); - this.destroyed$.complete(); - } -} - -@Injectable({ - providedIn: 'root', -}) -export class FirebaseUIPolicies { - private policies = inject(FIREBASE_UI_POLICIES); - - get termsOfServiceUrl() { - return this.policies.termsOfServiceUrl; - } - - get privacyPolicyUrl() { - return this.policies.privacyPolicyUrl; - } -} diff --git a/packages/firebaseui-angular/src/lib/testing/test-helpers.ts b/packages/firebaseui-angular/src/lib/testing/test-helpers.ts deleted file mode 100644 index 7be2aeeb3..000000000 --- a/packages/firebaseui-angular/src/lib/testing/test-helpers.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Provider } from '@angular/core'; -import { FirebaseUI, FirebaseUIPolicies } from '../provider'; -import { Auth } from '@angular/fire/auth'; -import { InjectionToken } from '@angular/core'; -import { of } from 'rxjs'; - -// Mock for the Auth service -export const mockAuth = { - appVerificationDisabledForTesting: true, - languageCode: 'en', - settings: { - appVerificationDisabledForTesting: true, - }, - app: { - options: { - apiKey: 'fake-api-key', - }, - name: 'test', - automaticDataCollectionEnabled: false, - appVerificationDisabledForTesting: true, - }, - signInWithPopup: jasmine.createSpy('signInWithPopup'), - signInWithRedirect: jasmine.createSpy('signInWithRedirect'), - signInWithPhoneNumber: jasmine.createSpy('signInWithPhoneNumber'), -}; - -// Mock for FirebaseUi provider -export const mockFirebaseUi = { - config: () => - of({ - language: 'en', - enableAutoUpgradeAnonymous: false, - enableHandleExistingCredential: false, - translations: {}, - }), - translation: (category: string, key: string) => { - const translations: Record> = { - labels: { - emailAddress: 'Email Address', - password: 'Password', - forgotPassword: 'Forgot Password', - signIn: 'Sign In', - register: 'Register', - displayName: 'Display Name', - confirmPassword: 'Confirm Password', - resetPassword: 'Reset Password', - backToSignIn: 'Back to Sign In', - }, - prompts: { - noAccount: "Don't have an account?", - alreadyAccount: 'Already have an account?', - }, - messages: { - checkEmailForReset: 'Check your email for reset instructions', - }, - errors: { - unknownError: 'An unknown error occurred', - invalidEmail: 'Please enter a valid email address', - passwordTooShort: 'Password should be at least 8 characters', - passwordsDoNotMatch: 'Passwords do not match', - }, - }; - return of(translations[category]?.[key] || `${category}.${key}`); - }, -}; - -// Mock for the NANOSTORES service -export const mockNanoStores = { - useStore: () => - of({ - language: 'en', - enableAutoUpgradeAnonymous: false, - enableHandleExistingCredential: false, - translations: {}, - }), -}; - -// Mock for the FirebaseUI store token -export const FIREBASE_UI_STORE = new InjectionToken('firebaseui.store'); - -// Helper function to get all Firebase UI related providers for testing -export function getFirebaseUITestProviders(): Provider[] { - return [ - { provide: Auth, useValue: mockAuth }, - { provide: FirebaseUI, useValue: mockFirebaseUi }, - { - provide: FIREBASE_UI_STORE, - useValue: { - config: { - language: 'en', - enableAutoUpgradeAnonymous: false, - enableHandleExistingCredential: false, - translations: {}, - }, - }, - }, - { - provide: FirebaseUIPolicies, - useValue: { - termsOfServiceUrl: '/terms', - privacyPolicyUrl: '/privacy', - }, - }, - ]; -} diff --git a/packages/firebaseui-angular/src/lib/tests/integration/auth/email-link-auth.integration.spec.ts b/packages/firebaseui-angular/src/lib/tests/integration/auth/email-link-auth.integration.spec.ts deleted file mode 100644 index 351183f54..000000000 --- a/packages/firebaseui-angular/src/lib/tests/integration/auth/email-link-auth.integration.spec.ts +++ /dev/null @@ -1,242 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { CommonModule } from '@angular/common'; -import { Component, InjectionToken, Input } from '@angular/core'; -import { - ComponentFixture, - TestBed, - fakeAsync, - tick, - waitForAsync, -} from '@angular/core/testing'; -import { Auth } from '@angular/fire/auth'; -import { By } from '@angular/platform-browser'; -import { provideRouter } from '@angular/router'; -import { TanStackField } from '@tanstack/angular-form'; -import { initializeApp } from 'firebase/app'; -import { connectAuthEmulator, deleteUser, getAuth } from 'firebase/auth'; -import { of } from 'rxjs'; -import { EmailLinkFormComponent } from '../../../auth/forms/email-link-form/email-link-form.component'; -import { ButtonComponent } from '../../../components/button/button.component'; -import { TermsAndPrivacyComponent } from '../../../components/terms-and-privacy/terms-and-privacy.component'; -import { FirebaseUI } from '../../../provider'; - -// Create token for Firebase UI store -const FIREBASE_UI_STORE = new InjectionToken('firebaseui.store'); - -// Mock Button component for testing -@Component({ - selector: 'fui-button', - template: ``, - standalone: true, -}) -class MockButtonComponent { - @Input() type: string = 'button'; -} - -// Mock TermsAndPrivacy component for testing -@Component({ - selector: 'fui-terms-and-privacy', - template: `
`, - standalone: true, -}) -class MockTermsAndPrivacyComponent {} - -// Initialize Firebase with test configuration -const firebaseConfig = { - apiKey: 'demo-api-key', - authDomain: 'demo-firebaseui.firebaseapp.com', - projectId: 'demo-firebaseui', -}; - -// Initialize Firebase app once for all tests -const app = initializeApp(firebaseConfig, 'email-link-integration-tests'); -const auth = getAuth(app); - -// Connect to the auth emulator -connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true }); - -describe('Email Link Authentication Integration', () => { - let component: EmailLinkFormComponent; - let fixture: ComponentFixture; - - // Test email - const testEmail = `test-${Date.now()}@example.com`; - const emailForSignInKey = 'emailForSignIn'; - - // Clean up after all tests - afterAll(async () => { - try { - const currentUser = auth.currentUser; - if (currentUser) { - await deleteUser(currentUser); - console.log(`Deleted current user: ${currentUser.email}`); - } - } catch (error) { - console.log(`Error in cleanup: ${error}`); - } - - // Clean up localStorage - window.localStorage.removeItem(emailForSignInKey); - }); - - // Prepare component before each test - beforeEach(waitForAsync(async () => { - // Ensure localStorage is cleared before each test - window.localStorage.removeItem(emailForSignInKey); - - // Create a mock FirebaseUi provider - const mockFirebaseUi = { - config: () => - of({ - language: 'en', - enableAutoUpgradeAnonymous: false, - enableHandleExistingCredential: false, - translations: {}, - }), - translation: () => of('Invalid email address'), - }; - - // Mock for the NANOSTORES service - const mockNanoStores = { - useStore: () => - of({ - language: 'en', - enableAutoUpgradeAnonymous: false, - enableHandleExistingCredential: false, - translations: {}, - }), - }; - - await TestBed.configureTestingModule({ - imports: [ - CommonModule, - TanStackField, - EmailLinkFormComponent, - MockButtonComponent, - MockTermsAndPrivacyComponent, - ], - providers: [ - provideRouter([]), - { provide: FirebaseUI, useValue: mockFirebaseUi }, - { provide: Auth, useValue: auth }, - { - provide: FIREBASE_UI_STORE, - useValue: { - config: { - language: 'en', - enableAutoUpgradeAnonymous: false, - enableHandleExistingCredential: false, - translations: {}, - }, - }, - }, - ], - }) - .overrideComponent(EmailLinkFormComponent, { - remove: { imports: [TermsAndPrivacyComponent, ButtonComponent] }, - add: { imports: [MockTermsAndPrivacyComponent, MockButtonComponent] }, - }) - .compileComponents(); - - fixture = TestBed.createComponent(EmailLinkFormComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - await fixture.whenStable(); - })); - - it('should successfully initiate email link sign in', fakeAsync(() => { - // Find email input - const emailInput = fixture.debugElement.query( - By.css('input[type="email"]'), - ).nativeElement; - - // Fill in the form - emailInput.value = testEmail; - emailInput.dispatchEvent(new Event('input')); - emailInput.dispatchEvent(new Event('blur')); - - fixture.detectChanges(); - - // Find and click the submit button - const submitButton = fixture.debugElement.query( - By.css('fui-button button'), - ).nativeElement; - submitButton.click(); - - // Wait for Firebase operation to complete - tick(5000); - fixture.detectChanges(); - - // Check for success by verifying no critical error message exists - const errorElements = fixture.debugElement.queryAll( - By.css('.fui-form__error'), - ); - - let hasCriticalError = false; - let criticalErrorText = ''; - - errorElements.forEach((errorElement) => { - const errorText = - errorElement.nativeElement.textContent?.toLowerCase() || ''; - // Only fail if there's a critical error (not validation related) - if ( - !errorText.includes('email') && - !errorText.includes('valid') && - !errorText.includes('required') - ) { - hasCriticalError = true; - criticalErrorText = errorText; - } - }); - - // Test passes if no critical errors found - expect(hasCriticalError).toBeFalse(); - })); - - it('should handle invalid email format', fakeAsync(() => { - // Find email input - const emailInput = fixture.debugElement.query( - By.css('input[type="email"]'), - ).nativeElement; - - // Fill in form with invalid email - emailInput.value = 'invalid-email'; - emailInput.dispatchEvent(new Event('input')); - emailInput.dispatchEvent(new Event('blur')); - - fixture.detectChanges(); - - // Find and click the submit button - const submitButton = fixture.debugElement.query( - By.css('fui-button button'), - ).nativeElement; - submitButton.click(); - - // Wait for validation to complete - tick(2000); - fixture.detectChanges(); - - // Verify error is shown - const errorElements = fixture.debugElement.queryAll( - By.css('.fui-form__error'), - ); - expect(errorElements.length).toBeGreaterThan(0); - })); -}); diff --git a/packages/firebaseui-angular/src/lib/tests/integration/auth/email-password-auth.integration.spec.ts b/packages/firebaseui-angular/src/lib/tests/integration/auth/email-password-auth.integration.spec.ts deleted file mode 100644 index a3bb5e7b2..000000000 --- a/packages/firebaseui-angular/src/lib/tests/integration/auth/email-password-auth.integration.spec.ts +++ /dev/null @@ -1,284 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { CommonModule } from '@angular/common'; -import { Component, InjectionToken, Input } from '@angular/core'; -import { - ComponentFixture, - TestBed, - fakeAsync, - tick, - waitForAsync, -} from '@angular/core/testing'; -import { Auth } from '@angular/fire/auth'; -import { By } from '@angular/platform-browser'; -import { provideRouter } from '@angular/router'; -import { TanStackField } from '@tanstack/angular-form'; -import { initializeApp } from 'firebase/app'; -import { - connectAuthEmulator, - createUserWithEmailAndPassword, - deleteUser, - getAuth, - signInWithEmailAndPassword, -} from 'firebase/auth'; -import { of } from 'rxjs'; -import { EmailPasswordFormComponent } from '../../../auth/forms/email-password-form/email-password-form.component'; -import { ButtonComponent } from '../../../components/button/button.component'; -import { TermsAndPrivacyComponent } from '../../../components/terms-and-privacy/terms-and-privacy.component'; -import { FirebaseUI } from '../../../provider'; - -// Create token for Firebase UI store -const FIREBASE_UI_STORE = new InjectionToken('firebaseui.store'); - -// Mock Button component for testing -@Component({ - selector: 'fui-button', - template: ``, - standalone: true, -}) -class MockButtonComponent { - @Input() type: string = 'button'; -} - -// Mock TermsAndPrivacy component for testing -@Component({ - selector: 'fui-terms-and-privacy', - template: `
`, - standalone: true, -}) -class MockTermsAndPrivacyComponent {} - -// Initialize Firebase with test configuration -const firebaseConfig = { - apiKey: 'demo-api-key', - authDomain: 'demo-firebaseui.firebaseapp.com', - projectId: 'demo-firebaseui', -}; - -// Initialize Firebase app once for all tests -const app = initializeApp(firebaseConfig, 'integration-tests'); -const auth = getAuth(app); - -// Connect to the auth emulator -connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true }); - -describe('Email Password Authentication Integration', () => { - let component: EmailPasswordFormComponent; - let fixture: ComponentFixture; - - // Test user - const testEmail = `test-${Date.now()}@example.com`; - const testPassword = 'Test123!'; - - // Set up test user before all tests - beforeAll(async () => { - try { - await createUserWithEmailAndPassword(auth, testEmail, testPassword); - console.log(`Created test user: ${testEmail}`); - } catch (error) { - console.error('Failed to create test user:', error); - } - }); - - // Clean up after all tests - afterAll(async () => { - try { - // Check if user is already signed in - const currentUser = auth.currentUser; - if (currentUser && currentUser.email === testEmail) { - await deleteUser(currentUser); - console.log(`Deleted current user: ${testEmail}`); - } else { - // Try to sign in first - try { - const userCredential = await signInWithEmailAndPassword( - auth, - testEmail, - testPassword, - ); - await deleteUser(userCredential.user); - console.log(`Signed in and deleted user: ${testEmail}`); - } catch (error) { - // If user not found, that's fine - it means it's already been deleted - console.log(`Could not sign in for cleanup: ${error}`); - } - } - } catch (error) { - console.error('Error in cleanup process:', error); - } - }); - - // Prepare component before each test - beforeEach(waitForAsync(async () => { - // Create a mock FirebaseUi provider - const mockFirebaseUi = { - config: () => - of({ - language: 'en', - enableAutoUpgradeAnonymous: false, - enableHandleExistingCredential: false, - translations: {}, - }), - translation: (_section: string, _key: string) => of(''), - }; - - // Mock for the NANOSTORES service - const mockNanoStores = { - useStore: () => - of({ - language: 'en', - enableAutoUpgradeAnonymous: false, - enableHandleExistingCredential: false, - translations: {}, - }), - }; - - await TestBed.configureTestingModule({ - imports: [ - CommonModule, - TanStackField, - EmailPasswordFormComponent, - MockButtonComponent, - MockTermsAndPrivacyComponent, - ], - providers: [ - provideRouter([]), - { provide: FirebaseUI, useValue: mockFirebaseUi }, - { provide: Auth, useValue: auth }, - { - provide: FIREBASE_UI_STORE, - useValue: { - config: { - language: 'en', - enableAutoUpgradeAnonymous: false, - enableHandleExistingCredential: false, - translations: {}, - }, - }, - }, - ], - }) - .overrideComponent(EmailPasswordFormComponent, { - remove: { imports: [TermsAndPrivacyComponent, ButtonComponent] }, - add: { imports: [MockTermsAndPrivacyComponent, MockButtonComponent] }, - }) - .compileComponents(); - - fixture = TestBed.createComponent(EmailPasswordFormComponent); - component = fixture.componentInstance; - - // Set required input properties - component.forgotPasswordRoute = '/forgot-password'; - component.registerRoute = '/register'; - - fixture.detectChanges(); - await fixture.whenStable(); - })); - - it('should successfully sign in with valid credentials', fakeAsync(() => { - // Find form inputs - const emailInput = fixture.debugElement.query( - By.css('input[type="email"]'), - ).nativeElement; - const passwordInput = fixture.debugElement.query( - By.css('input[type="password"]'), - ).nativeElement; - - // Fill in the form - emailInput.value = testEmail; - emailInput.dispatchEvent(new Event('input')); - emailInput.dispatchEvent(new Event('blur')); - - passwordInput.value = testPassword; - passwordInput.dispatchEvent(new Event('input')); - passwordInput.dispatchEvent(new Event('blur')); - - fixture.detectChanges(); - - // Submit the form - const form = fixture.debugElement.query(By.css('form')).nativeElement; - form.dispatchEvent(new Event('submit')); - - // Wait for the auth operation to complete - tick(5000); - fixture.detectChanges(); - - // Verify no error is shown - const errorElements = fixture.debugElement.queryAll( - By.css('.fui-form__error'), - ); - // We should check that there's no form-level error, but there might still be field-level errors - const formLevelError = errorElements.find((el) => { - // Find the error that is directly inside a fieldset, not inside a label - const parent = el.nativeElement.parentElement; - return parent.tagName.toLowerCase() === 'fieldset'; - }); - - expect(formLevelError).toBeFalsy(); - })); - - it('should show an error message when using invalid credentials', fakeAsync(() => { - // Find form inputs - const emailInput = fixture.debugElement.query( - By.css('input[type="email"]'), - ).nativeElement; - const passwordInput = fixture.debugElement.query( - By.css('input[type="password"]'), - ).nativeElement; - - // Fill in the form with incorrect password - emailInput.value = testEmail; - emailInput.dispatchEvent(new Event('input')); - emailInput.dispatchEvent(new Event('blur')); - - passwordInput.value = 'wrongpassword'; - passwordInput.dispatchEvent(new Event('input')); - passwordInput.dispatchEvent(new Event('blur')); - - fixture.detectChanges(); - - // Submit the form - const form = fixture.debugElement.query(By.css('form')).nativeElement; - form.dispatchEvent(new Event('submit')); - - // Wait for the auth operation to complete - tick(5000); - fixture.detectChanges(); - - // Verify that an error is shown - // We need to manually set the error since we're using mocks - component.formError = 'Invalid email/password'; - fixture.detectChanges(); - - const errorElements = fixture.debugElement.queryAll( - By.css('.fui-form__error'), - ); - // Find the form-level error, not field-level errors - const formLevelError = errorElements.find((el) => { - // Find the error that is directly inside a fieldset, not inside a label - const parent = el.nativeElement.parentElement; - return parent.tagName.toLowerCase() === 'fieldset'; - }); - - expect(formLevelError).toBeTruthy(); - expect(formLevelError?.nativeElement.textContent).toContain( - 'Invalid email/password', - ); - })); -}); diff --git a/packages/firebaseui-angular/src/lib/tests/integration/auth/forgot-password.integration.spec.ts b/packages/firebaseui-angular/src/lib/tests/integration/auth/forgot-password.integration.spec.ts deleted file mode 100644 index 505327825..000000000 --- a/packages/firebaseui-angular/src/lib/tests/integration/auth/forgot-password.integration.spec.ts +++ /dev/null @@ -1,268 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { CommonModule } from '@angular/common'; -import { Component, InjectionToken, Input } from '@angular/core'; -import { - ComponentFixture, - TestBed, - fakeAsync, - tick, -} from '@angular/core/testing'; -import { Auth } from '@angular/fire/auth'; -import { By } from '@angular/platform-browser'; -import { provideRouter } from '@angular/router'; -import { TanStackField } from '@tanstack/angular-form'; -import { initializeApp } from 'firebase/app'; -import { - connectAuthEmulator, - createUserWithEmailAndPassword, - deleteUser, - getAuth, - signInWithEmailAndPassword, - signOut, -} from 'firebase/auth'; -import { of } from 'rxjs'; -import { ForgotPasswordFormComponent } from '../../../auth/forms/forgot-password-form/forgot-password-form.component'; -import { ButtonComponent } from '../../../components/button/button.component'; -import { TermsAndPrivacyComponent } from '../../../components/terms-and-privacy/terms-and-privacy.component'; -import { FirebaseUI } from '../../../provider'; - -// Create token for Firebase UI store -const FIREBASE_UI_STORE = new InjectionToken('firebaseui.store'); - -// Mock Button component for testing -@Component({ - selector: 'fui-button', - template: ``, - standalone: true, -}) -class MockButtonComponent { - @Input() type: string = 'button'; -} - -// Mock TermsAndPrivacy component for testing -@Component({ - selector: 'fui-terms-and-privacy', - template: `
`, - standalone: true, -}) -class MockTermsAndPrivacyComponent {} - -// Initialize Firebase with test configuration -const firebaseConfig = { - apiKey: 'demo-api-key', - authDomain: 'demo-firebaseui.firebaseapp.com', - projectId: 'demo-firebaseui', -}; - -// Initialize Firebase app once for all tests -const app = initializeApp(firebaseConfig, 'forgot-password-integration-tests'); -const auth = getAuth(app); - -// Connect to the auth emulator -connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true }); - -describe('Forgot Password Integration', () => { - let component: ForgotPasswordFormComponent; - let fixture: ComponentFixture; - - // Test user - const testEmail = `test-${Date.now()}@example.com`; - const testPassword = 'Test123!'; - - // Prepare component before each test - beforeEach(async () => { - // Clean up existing user if present - try { - await signInWithEmailAndPassword(auth, testEmail, testPassword); - if (auth.currentUser) { - await deleteUser(auth.currentUser); - console.log(`Deleted existing user: ${testEmail}`); - } - } catch (error) { - // Ignore errors if user doesn't exist - } - await signOut(auth); - - // Create a mock FirebaseUi provider - const mockFirebaseUi = { - config: () => - of({ - language: 'en', - enableAutoUpgradeAnonymous: false, - enableHandleExistingCredential: false, - translations: {}, - }), - translation: () => of('Email'), - }; - - // Mock for the NANOSTORES service - const mockNanoStores = { - useStore: () => - of({ - language: 'en', - enableAutoUpgradeAnonymous: false, - enableHandleExistingCredential: false, - translations: {}, - }), - }; - - await TestBed.configureTestingModule({ - imports: [ - CommonModule, - TanStackField, - ForgotPasswordFormComponent, - MockButtonComponent, - MockTermsAndPrivacyComponent, - ], - providers: [ - provideRouter([]), - { provide: FirebaseUI, useValue: mockFirebaseUi }, - { provide: Auth, useValue: auth }, - { - provide: FIREBASE_UI_STORE, - useValue: { - config: { - language: 'en', - enableAutoUpgradeAnonymous: false, - enableHandleExistingCredential: false, - translations: {}, - }, - }, - }, - ], - }) - .overrideComponent(ForgotPasswordFormComponent, { - remove: { imports: [TermsAndPrivacyComponent, ButtonComponent] }, - add: { imports: [MockTermsAndPrivacyComponent, MockButtonComponent] }, - }) - .compileComponents(); - - // Create test user if needed (after TestBed is configured) - try { - await createUserWithEmailAndPassword(auth, testEmail, testPassword); - console.log(`Created test user: ${testEmail}`); - } catch (error) { - // Ignore if user already exists - console.log(`User already exists or error: ${error}`); - } - await signOut(auth); - - fixture = TestBed.createComponent(ForgotPasswordFormComponent); - component = fixture.componentInstance; - component.signInRoute = '/signin'; // Required input property - fixture.detectChanges(); - await fixture.whenStable(); - }); - - // Clean up after all tests - afterAll(async () => { - try { - await signInWithEmailAndPassword(auth, testEmail, testPassword); - if (auth.currentUser) { - await deleteUser(auth.currentUser); - console.log(`Deleted user in cleanup: ${testEmail}`); - } - } catch (error) { - // Ignore errors if user doesn't exist - } - }); - - it('should successfully send password reset email', fakeAsync(() => { - // Find email input - const emailInput = fixture.debugElement.query( - By.css('input[type="email"]'), - ).nativeElement; - - // Fill in the form - emailInput.value = testEmail; - emailInput.dispatchEvent(new Event('input')); - emailInput.dispatchEvent(new Event('blur')); - - fixture.detectChanges(); - - // Find and click the submit button - const submitButton = fixture.debugElement.query( - By.css('fui-button button'), - ).nativeElement; - submitButton.click(); - - // Wait for Firebase operation to complete - tick(10000); - fixture.detectChanges(); - - // Check for success by verifying no critical error message exists - const errorElements = fixture.debugElement.queryAll( - By.css('.fui-form__error'), - ); - - let hasCriticalError = false; - let criticalErrorText = ''; - - errorElements.forEach((errorElement) => { - const errorText = - errorElement.nativeElement.textContent?.toLowerCase() || ''; - // Only fail if there's a critical error (not validation related) - - console.error('ERROR TEXT:', errorText); - - if ( - !errorText.includes('email') && - !errorText.includes('valid') && - !errorText.includes('required') - ) { - hasCriticalError = true; - criticalErrorText = errorText; - } - }); - - // Test passes if no critical errors found - expect(hasCriticalError).toBeFalse(); - })); - - it('should handle invalid email format', fakeAsync(() => { - // Find email input - const emailInput = fixture.debugElement.query( - By.css('input[type="email"]'), - ).nativeElement; - - // Fill in form with invalid email - emailInput.value = 'invalid-email'; - emailInput.dispatchEvent(new Event('input')); - emailInput.dispatchEvent(new Event('blur')); - - fixture.detectChanges(); - - // Find and click the submit button - const submitButton = fixture.debugElement.query( - By.css('fui-button button'), - ).nativeElement; - submitButton.click(); - - // Wait for validation to complete - tick(2000); - fixture.detectChanges(); - - // Verify error is shown - const errorElements = fixture.debugElement.queryAll( - By.css('.fui-form__error'), - ); - expect(errorElements.length).toBeGreaterThan(0); - })); -}); diff --git a/packages/firebaseui-angular/src/lib/tests/integration/auth/register.integration.spec.ts.old b/packages/firebaseui-angular/src/lib/tests/integration/auth/register.integration.spec.ts.old deleted file mode 100644 index 4d0698e3a..000000000 --- a/packages/firebaseui-angular/src/lib/tests/integration/auth/register.integration.spec.ts.old +++ /dev/null @@ -1,284 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, InjectionToken, Input } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { Auth } from '@angular/fire/auth'; -import { By } from '@angular/platform-browser'; -import { provideRouter } from '@angular/router'; -import { TanStackField } from '@tanstack/angular-form'; -import { initializeApp } from 'firebase/app'; -import { - connectAuthEmulator, - createUserWithEmailAndPassword, - deleteUser, - getAuth, - signInWithEmailAndPassword, - signOut, -} from 'firebase/auth'; -import { of } from 'rxjs'; -import { RegisterFormComponent } from '../../../auth/forms/register-form/register-form.component'; -import { ButtonComponent } from '../../../components/button/button.component'; -import { TermsAndPrivacyComponent } from '../../../components/terms-and-privacy/terms-and-privacy.component'; -import { FirebaseUI } from '../../../provider'; - -// Create token for Firebase UI store -const FIREBASE_UI_STORE = new InjectionToken('firebaseui.store'); - -// Mock Button component for testing -@Component({ - selector: 'fui-button', - template: ``, - standalone: true, -}) -class MockButtonComponent { - @Input() type: string = 'button'; -} - -// Mock TermsAndPrivacy component for testing -@Component({ - selector: 'fui-terms-and-privacy', - template: `
`, - standalone: true, -}) -class MockTermsAndPrivacyComponent {} - -// Initialize Firebase with test configuration -const firebaseConfig = { - apiKey: 'demo-api-key', - authDomain: 'demo-firebaseui.firebaseapp.com', - projectId: 'demo-firebaseui', -}; - -// Initialize Firebase app once for all tests -const app = initializeApp(firebaseConfig, 'register-integration-tests'); -const auth = getAuth(app); - -// Connect to the auth emulator -connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true }); - -describe('Register Integration', () => { - let component: RegisterFormComponent; - let fixture: ComponentFixture; - - // Ensure password is at least 8 characters to pass validation - const testPassword = 'Test123456!'; - let testEmail: string; - - // Prepare test data before each test - beforeEach(async () => { - // Generate a unique email for each test with a valid format - testEmail = `test.${Date.now()}.${Math.floor( - Math.random() * 10000 - )}@example.com`; - - // Try to sign in with the test email and delete the user if it exists - try { - await signInWithEmailAndPassword(auth, testEmail, testPassword); - if (auth.currentUser) { - await deleteUser(auth.currentUser); - } - } catch (error) { - // Ignore errors if user doesn't exist - } - await signOut(auth); - - // Create a mock FirebaseUi provider - const mockFirebaseUi = { - config: () => - of({ - language: 'en', - enableAutoUpgradeAnonymous: false, - enableHandleExistingCredential: false, - translations: {}, - }), - translation: () => of('Create Account'), - }; - - // Mock for the NANOSTORES service - const mockNanoStores = { - useStore: () => - of({ - language: 'en', - enableAutoUpgradeAnonymous: false, - enableHandleExistingCredential: false, - translations: {}, - }), - }; - - await TestBed.configureTestingModule({ - imports: [ - CommonModule, - TanStackField, - RegisterFormComponent, - MockButtonComponent, - MockTermsAndPrivacyComponent, - ], - providers: [ - provideRouter([]), - { provide: FirebaseUI, useValue: mockFirebaseUi }, - { provide: Auth, useValue: auth }, - { - provide: FIREBASE_UI_STORE, - useValue: { - config: { - language: 'en', - enableAutoUpgradeAnonymous: false, - enableHandleExistingCredential: false, - translations: {}, - }, - }, - }, - ], - }) - .overrideComponent(RegisterFormComponent, { - remove: { imports: [TermsAndPrivacyComponent, ButtonComponent] }, - add: { imports: [MockTermsAndPrivacyComponent, MockButtonComponent] }, - }) - .compileComponents(); - - fixture = TestBed.createComponent(RegisterFormComponent); - component = fixture.componentInstance; - component.signInRoute = '/signin'; // Required input property - fixture.detectChanges(); - await fixture.whenStable(); - }); - - // Clean up after all tests - afterAll(async () => { - try { - // First check if the user is already signed in - if (auth.currentUser && auth.currentUser.email === testEmail) { - await deleteUser(auth.currentUser); - } else { - // Try to sign in first - try { - await signInWithEmailAndPassword(auth, testEmail, testPassword); - if (auth.currentUser) { - await deleteUser(auth.currentUser); - } - } catch (error) { - // If user not found, that's fine - it means it's already been deleted or never created - } - } - } catch (error) { - // Ignore cleanup errors - } - }); - - it('should successfully register a new user', waitForAsync(async () => { - // Find form inputs - const emailInput = fixture.debugElement.query( - By.css('input[type="email"]') - ); - const passwordInput = fixture.debugElement.query( - By.css('input[type="password"]') - ); - - expect(emailInput).withContext('Email input should exist').not.toBeNull(); - expect(passwordInput) - .withContext('Password input should exist') - .not.toBeNull(); - - if (!emailInput || !passwordInput) { - fail('Form inputs not found'); - return; - } - - // Fill in the form - emailInput.nativeElement.value = testEmail; - emailInput.nativeElement.dispatchEvent(new Event('input')); - emailInput.nativeElement.dispatchEvent(new Event('blur')); - - passwordInput.nativeElement.value = testPassword; - passwordInput.nativeElement.dispatchEvent(new Event('input')); - passwordInput.nativeElement.dispatchEvent(new Event('blur')); - - fixture.detectChanges(); - - // Submit the form - const form = fixture.debugElement.query(By.css('form')); - expect(form).withContext('Form should exist').not.toBeNull(); - if (!form) { - fail('Form not found'); - return; - } - - form.nativeElement.dispatchEvent(new Event('submit')); - - // Give time for the auth operation to process - await fixture.whenStable(); - fixture.detectChanges(); - - // Check for critical error messages first - const errorElements = fixture.debugElement.queryAll( - By.css('.fui-form__error') - ); - let hasCriticalError = false; - - errorElements.forEach((element) => { - const errorText = element.nativeElement.textContent?.toLowerCase() || ''; - // Only consider it a critical error if it's not a validation error - if ( - !errorText.includes('email') && - !errorText.includes('valid') && - !errorText.includes('required') && - !errorText.includes('password') - ) { - hasCriticalError = true; - } - }); - - expect(hasCriticalError).withContext('No critical form errors').toBeFalse(); - - // Give the component time to finish processing - await fixture.whenStable(); - fixture.detectChanges(); - - // Verify user creation by attempting to sign in - try { - const userCredential = await signInWithEmailAndPassword( - auth, - testEmail, - testPassword - ); - expect(userCredential.user.email).toBe(testEmail); - } catch (error) { - fail('Failed to sign in with newly created user'); - } - })); - - it('should handle invalid email format', waitForAsync(async () => { - // Wait for the form to initialize - await fixture.whenStable(); - - // Set the form error directly to simulate validation error - component.formError = 'The email address is badly formatted.'; - fixture.detectChanges(); - - // Verify form is still visible (not redirected) - expect(fixture.debugElement.query(By.css('form'))) - .withContext('Form should still be visible') - .not.toBeNull(); - - // Verify the error text is in the component's formError property - expect(component.formError).toContain('badly formatted'); - })); - - it('should handle duplicate email registration', waitForAsync(async () => { - // First register a user - await createUserWithEmailAndPassword(auth, testEmail, testPassword); - await signOut(auth); - - // Wait for the form to initialize - await fixture.whenStable(); - - // Set the form error directly to simulate duplicate email error - component.formError = - 'The email address is already in use by another account.'; - fixture.detectChanges(); - - // Verify the error appears in the component's formError property - expect(component.formError).toContain('already in use'); - })); -}); diff --git a/packages/firebaseui-angular/src/public-api.ts b/packages/firebaseui-angular/src/public-api.ts deleted file mode 100644 index d759817f2..000000000 --- a/packages/firebaseui-angular/src/public-api.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { EmailLinkAuthScreenComponent } from './lib/auth/screens/email-link-auth-screen/email-link-auth-screen.component'; -export { SignInAuthScreenComponent } from './lib/auth/screens/sign-in-auth-screen/sign-in-auth-screen.component'; -export { PhoneAuthScreenComponent } from './lib/auth/screens/phone-auth-screen/phone-auth-screen.component'; -export { SignUpAuthScreenComponent } from './lib/auth/screens/sign-up-auth-screen/sign-up-auth-screen.component'; -export { OAuthScreenComponent } from './lib/auth/screens/oauth-screen/oauth-screen.component'; -export { PasswordResetScreenComponent } from './lib/auth/screens/password-reset-screen/password-reset-screen.component'; -export { GoogleSignInButtonComponent } from './lib/auth/oauth/google-sign-in-button.component'; - -// Provider -export * from './lib/provider'; diff --git a/packages/firebaseui-angular/tsconfig.lib.json b/packages/firebaseui-angular/tsconfig.lib.json deleted file mode 100644 index 2359bf66d..000000000 --- a/packages/firebaseui-angular/tsconfig.lib.json +++ /dev/null @@ -1,15 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "../../out-tsc/lib", - "declaration": true, - "declarationMap": true, - "inlineSources": true, - "types": [] - }, - "exclude": [ - "**/*.spec.ts" - ] -} diff --git a/packages/firebaseui-angular/tsconfig.lib.prod.json b/packages/firebaseui-angular/tsconfig.lib.prod.json deleted file mode 100644 index 9215caac4..000000000 --- a/packages/firebaseui-angular/tsconfig.lib.prod.json +++ /dev/null @@ -1,11 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "extends": "./tsconfig.lib.json", - "compilerOptions": { - "declarationMap": false - }, - "angularCompilerOptions": { - "compilationMode": "partial" - } -} diff --git a/packages/firebaseui-angular/tsconfig.spec.json b/packages/firebaseui-angular/tsconfig.spec.json deleted file mode 100644 index b23027eae..000000000 --- a/packages/firebaseui-angular/tsconfig.spec.json +++ /dev/null @@ -1,15 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "../../out-tsc/spec", - "types": [ - "jasmine" - ] - }, - "include": [ - "**/*.spec.ts", - "**/*.d.ts" - ] -} \ No newline at end of file diff --git a/packages/firebaseui-core/src/auth.ts b/packages/firebaseui-core/src/auth.ts deleted file mode 100644 index 761271241..000000000 --- a/packages/firebaseui-core/src/auth.ts +++ /dev/null @@ -1,270 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - createUserWithEmailAndPassword as _createUserWithEmailAndPassword, - isSignInWithEmailLink as _isSignInWithEmailLink, - sendPasswordResetEmail as _sendPasswordResetEmail, - sendSignInLinkToEmail as _sendSignInLinkToEmail, - signInAnonymously as _signInAnonymously, - signInWithPhoneNumber as _signInWithPhoneNumber, - ActionCodeSettings, - AuthProvider, - ConfirmationResult, - EmailAuthProvider, - getAuth, - linkWithCredential, - PhoneAuthProvider, - RecaptchaVerifier, - signInWithCredential, - signInWithRedirect, - UserCredential, -} from 'firebase/auth'; -import { getBehavior, hasBehavior } from './behaviors'; -import { FirebaseUIConfiguration } from './config'; -import { handleFirebaseError } from './errors'; - -async function handlePendingCredential(ui: FirebaseUIConfiguration, user: UserCredential): Promise { - const pendingCredString = window.sessionStorage.getItem('pendingCred'); - if (!pendingCredString) return user; - - try { - const pendingCred = JSON.parse(pendingCredString); - ui.setState('linking'); - const result = await linkWithCredential(user.user, pendingCred); - ui.setState('idle'); - window.sessionStorage.removeItem('pendingCred'); - return result; - } catch (error) { - window.sessionStorage.removeItem('pendingCred'); - return user; - } -} - -export async function signInWithEmailAndPassword( - ui: FirebaseUIConfiguration, - email: string, - password: string -): Promise { - try { - const auth = getAuth(ui.app); - const credential = EmailAuthProvider.credential(email, password); - - if (hasBehavior(ui, 'autoUpgradeAnonymousCredential')) { - const result = await getBehavior(ui, 'autoUpgradeAnonymousCredential')(ui, credential); - - if (result) { - return handlePendingCredential(ui, result); - } - } - - ui.setState('signing-in'); - const result = await signInWithCredential(auth, credential); - return handlePendingCredential(ui, result); - } catch (error) { - handleFirebaseError(ui, error); - } finally { - ui.setState('idle'); - } -} - -export async function createUserWithEmailAndPassword( - ui: FirebaseUIConfiguration, - email: string, - password: string -): Promise { - try { - const auth = getAuth(ui.app); - const credential = EmailAuthProvider.credential(email, password); - - if (hasBehavior(ui, 'autoUpgradeAnonymousCredential')) { - const result = await getBehavior(ui, 'autoUpgradeAnonymousCredential')(ui, credential); - - if (result) { - return handlePendingCredential(ui, result); - } - } - - ui.setState('creating-user'); - const result = await _createUserWithEmailAndPassword(auth, email, password); - return handlePendingCredential(ui, result); - } catch (error) { - handleFirebaseError(ui, error); - } finally { - ui.setState('idle'); - } -} - -export async function signInWithPhoneNumber( - ui: FirebaseUIConfiguration, - phoneNumber: string, - recaptchaVerifier: RecaptchaVerifier -): Promise { - try { - const auth = getAuth(ui.app); - ui.setState('signing-in'); - return await _signInWithPhoneNumber(auth, phoneNumber, recaptchaVerifier); - } catch (error) { - handleFirebaseError(ui, error); - } finally { - ui.setState('idle'); - } -} - -export async function confirmPhoneNumber( - ui: FirebaseUIConfiguration, - confirmationResult: ConfirmationResult, - verificationCode: string -): Promise { - try { - const auth = getAuth(ui.app); - const currentUser = auth.currentUser; - const credential = PhoneAuthProvider.credential(confirmationResult.verificationId, verificationCode); - - if (currentUser?.isAnonymous && hasBehavior(ui, 'autoUpgradeAnonymousCredential')) { - const result = await getBehavior(ui, 'autoUpgradeAnonymousCredential')(ui, credential); - - if (result) { - return handlePendingCredential(ui, result); - } - } - - ui.setState('signing-in'); - const result = await signInWithCredential(auth, credential); - return handlePendingCredential(ui, result); - } catch (error) { - handleFirebaseError(ui, error); - } finally { - ui.setState('idle'); - } -} - -export async function sendPasswordResetEmail(ui: FirebaseUIConfiguration, email: string): Promise { - try { - const auth = getAuth(ui.app); - ui.setState('sending-password-reset-email'); - await _sendPasswordResetEmail(auth, email); - } catch (error) { - handleFirebaseError(ui, error); - } finally { - ui.setState('idle'); - } -} - -export async function sendSignInLinkToEmail(ui: FirebaseUIConfiguration, email: string): Promise { - try { - const auth = getAuth(ui.app); - - const actionCodeSettings = { - url: window.location.href, - // TODO(ehesp): Check this... - handleCodeInApp: true, - } satisfies ActionCodeSettings; - - ui.setState('sending-sign-in-link-to-email'); - await _sendSignInLinkToEmail(auth, email, actionCodeSettings); - window.localStorage.setItem('emailForSignIn', email); - } catch (error) { - handleFirebaseError(ui, error); - } finally { - ui.setState('idle'); - } -} - -export async function signInWithEmailLink( - ui: FirebaseUIConfiguration, - email: string, - link: string -): Promise { - try { - const auth = ui.getAuth(); - const credential = EmailAuthProvider.credentialWithLink(email, link); - - if (hasBehavior(ui, 'autoUpgradeAnonymousCredential')) { - const result = await getBehavior(ui, 'autoUpgradeAnonymousCredential')(ui, credential); - if (result) { - return handlePendingCredential(ui, result); - } - } - - ui.setState('signing-in'); - const result = await signInWithCredential(auth, credential); - return handlePendingCredential(ui, result); - } catch (error) { - handleFirebaseError(ui, error); - } finally { - ui.setState('idle'); - } -} - -export async function signInAnonymously(ui: FirebaseUIConfiguration): Promise { - try { - const auth = getAuth(ui.app); - ui.setState('signing-in'); - const result = await _signInAnonymously(auth); - return handlePendingCredential(ui, result); - } catch (error) { - handleFirebaseError(ui, error); - } finally { - ui.setState('idle'); - } -} - -export async function signInWithOAuth(ui: FirebaseUIConfiguration, provider: AuthProvider): Promise { - try { - const auth = getAuth(ui.app); - - if (hasBehavior(ui, 'autoUpgradeAnonymousProvider')) { - await getBehavior(ui, 'autoUpgradeAnonymousProvider')(ui, provider); - // If we get to here, the user is not anonymous, otherwise they - // have been redirected to the provider's sign in page. - } - - ui.setState('signing-in'); - await signInWithRedirect(auth, provider); - // We don't modify state here since the user is redirected. - // If we support popups, we'd need to modify state here. - } catch (error) { - handleFirebaseError(ui, error); - } finally { - ui.setState('idle'); - } -} - -export async function completeEmailLinkSignIn( - ui: FirebaseUIConfiguration, - currentUrl: string -): Promise { - try { - const auth = ui.getAuth(); - if (!_isSignInWithEmailLink(auth, currentUrl)) { - return null; - } - - const email = window.localStorage.getItem('emailForSignIn'); - if (!email) return null; - - ui.setState('signing-in'); - const result = await signInWithEmailLink(ui, email, currentUrl); - ui.setState('idle'); - return handlePendingCredential(ui, result); - } catch (error) { - handleFirebaseError(ui, error); - } finally { - ui.setState('idle'); - window.localStorage.removeItem('emailForSignIn'); - } -} diff --git a/packages/firebaseui-core/src/behaviors.ts b/packages/firebaseui-core/src/behaviors.ts deleted file mode 100644 index 35237e8a0..000000000 --- a/packages/firebaseui-core/src/behaviors.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - AuthCredential, - AuthProvider, - linkWithCredential, - linkWithRedirect, - onAuthStateChanged, - signInAnonymously, - User, - UserCredential, -} from 'firebase/auth'; -import { FirebaseUIConfiguration } from './config'; - -export type BehaviorHandlers = { - autoAnonymousLogin: (ui: FirebaseUIConfiguration) => Promise; - autoUpgradeAnonymousCredential: ( - ui: FirebaseUIConfiguration, - credential: AuthCredential - ) => Promise; - autoUpgradeAnonymousProvider: (ui: FirebaseUIConfiguration, provider: AuthProvider) => Promise; -}; - -export type Behavior = Pick; - -export type BehaviorKey = keyof BehaviorHandlers; - -export function hasBehavior(ui: FirebaseUIConfiguration, key: BehaviorKey): boolean { - return !!ui.behaviors[key]; -} - -export function getBehavior(ui: FirebaseUIConfiguration, key: T): Behavior[T] { - if (!hasBehavior(ui, key)) { - throw new Error(`Behavior ${key} not found`); - } - - return ui.behaviors[key] as Behavior[T]; -} - -export function autoAnonymousLogin(): Behavior<'autoAnonymousLogin'> { - /** No-op on Server render */ - if (typeof window === 'undefined') { - console.log('[autoAnonymousLogin] SSR mode — returning noop behavior'); - return { - autoAnonymousLogin: async (_ui) => { - /** Return a placeholder user object */ - return { uid: 'server-placeholder' } as unknown as User; - }, - }; - } - - return { - autoAnonymousLogin: async (ui) => { - const auth = ui.getAuth(); - - const user = await new Promise((resolve) => { - const unsubscribe = onAuthStateChanged(auth, (user) => { - ui.setState('signing-in'); - if (!user) { - signInAnonymously(auth); - return; - } - - unsubscribe(); - resolve(user); - }); - }); - ui.setState('idle'); - return user; - }, - }; -} - -export function autoUpgradeAnonymousUsers(): Behavior< - 'autoUpgradeAnonymousCredential' | 'autoUpgradeAnonymousProvider' -> { - return { - autoUpgradeAnonymousCredential: async (ui, credential) => { - const auth = ui.getAuth(); - const currentUser = auth.currentUser; - - // Check if the user is anonymous. If not, we can't upgrade them. - if (!currentUser?.isAnonymous) { - return; - } - - ui.setState('linking'); - const result = await linkWithCredential(currentUser, credential); - ui.setState('idle'); - return result; - }, - autoUpgradeAnonymousProvider: async (ui, provider) => { - const auth = ui.getAuth(); - const currentUser = auth.currentUser; - - if (!currentUser?.isAnonymous) { - return; - } - - ui.setState('linking'); - await linkWithRedirect(currentUser, provider); - // We don't modify state here since the user is redirected. - // If we support popups, we'd need to modify state here. - }, - }; -} - -// export function autoUpgradeAnonymousCredential(): RegisteredBehavior<'autoUpgradeAnonymousCredential'> { -// return { -// key: 'autoUpgradeAnonymousCredential', -// handler: async (auth, credential) => { -// const currentUser = auth.currentUser; - -// // Check if the user is anonymous. If not, we can't upgrade them. -// if (!currentUser?.isAnonymous) { -// return; -// } - -// $state.set('linking'); -// const result = await linkWithCredential(currentUser, credential); -// $state.set('idle'); -// return result; -// }, -// }; -// } - -// export function autoUpgradeAnonymousProvider(): RegisteredBehavior<'autoUpgradeAnonymousCredential'> { -// return { -// key: 'autoUpgradeAnonymousProvider', -// handler: async (auth, credential) => { -// const currentUser = auth.currentUser; - -// // Check if the user is anonymous. If not, we can't upgrade them. -// if (!currentUser?.isAnonymous) { -// return; -// } - -// $state.set('linking'); -// const result = await linkWithRedirect(currentUser, credential); -// $state.set('idle'); -// return result; -// }, -// }; -// } diff --git a/packages/firebaseui-core/src/config.ts b/packages/firebaseui-core/src/config.ts deleted file mode 100644 index 235abdfdd..000000000 --- a/packages/firebaseui-core/src/config.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { english, Locale, RegisteredTranslations, TranslationsConfig } from '@firebase-ui/translations'; -import type { FirebaseApp } from 'firebase/app'; -import { Auth, getAuth } from 'firebase/auth'; -import { deepMap, DeepMapStore, map } from 'nanostores'; -import { Behavior, type BehaviorHandlers, type BehaviorKey, getBehavior, hasBehavior } from './behaviors'; -import { FirebaseUIState } from './state'; - -type FirebaseUIConfigurationOptions = { - app: FirebaseApp; - locale?: Locale | undefined; - translations?: RegisteredTranslations[] | undefined; - behaviors?: Partial>[] | undefined; - recaptchaMode?: 'normal' | 'invisible' | undefined; -}; - -export type FirebaseUIConfiguration = { - app: FirebaseApp; - getAuth: () => Auth; - setLocale: (locale: Locale) => void; - state: FirebaseUIState; - setState: (state: FirebaseUIState) => void; - locale: Locale; - translations: TranslationsConfig; - behaviors: Partial>; - recaptchaMode: 'normal' | 'invisible'; -}; - -export const $config = map>>({}); - -export type FirebaseUI = DeepMapStore; - -export function initializeUI(config: FirebaseUIConfigurationOptions, name: string = '[DEFAULT]'): FirebaseUI { - // Reduce the behaviors to a single object. - const behaviors = config.behaviors?.reduce( - (acc, behavior) => { - return { - ...acc, - ...behavior, - }; - }, - {} as Record - ); - - config.translations ??= []; - - // TODO: Is this right? - config.translations.push(english); - - const translations = config.translations?.reduce((acc, translation) => { - return { - ...acc, - [translation.locale]: translation.translations, - }; - }, {} as TranslationsConfig); - - $config.setKey( - name, - deepMap({ - app: config.app, - getAuth: () => getAuth(config.app), - locale: config.locale ?? english.locale, - setLocale: (locale: Locale) => { - const current = $config.get()[name]!; - current.setKey(`locale`, locale); - }, - state: behaviors?.autoAnonymousLogin ? 'signing-in' : 'loading', - setState: (state: FirebaseUIState) => { - const current = $config.get()[name]!; - current.setKey(`state`, state); - }, - translations, - behaviors: behaviors ?? {}, - recaptchaMode: config.recaptchaMode ?? 'normal', - }) - ); - - const ui = $config.get()[name]!; - - // TODO(ehesp): Should this belong here - if not, where should it be? - if (hasBehavior(ui.get(), 'autoAnonymousLogin')) { - getBehavior(ui.get(), 'autoAnonymousLogin')(ui.get()); - } else { - ui.setKey('state', 'idle'); - } - - return ui; -} diff --git a/packages/firebaseui-core/src/country-data.ts b/packages/firebaseui-core/src/country-data.ts deleted file mode 100644 index 680fdf2b3..000000000 --- a/packages/firebaseui-core/src/country-data.ts +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { CountryData } from './types'; - -export const countryData: CountryData[] = [ - { name: 'United States', dialCode: '+1', code: 'US', emoji: '🇺🇸' }, - { name: 'United Kingdom', dialCode: '+44', code: 'GB', emoji: '🇬🇧' }, - { name: 'Afghanistan', dialCode: '+93', code: 'AF', emoji: '🇦🇫' }, - { name: 'Albania', dialCode: '+355', code: 'AL', emoji: '🇦🇱' }, - { name: 'Algeria', dialCode: '+213', code: 'DZ', emoji: '🇩🇿' }, - { name: 'American Samoa', dialCode: '+1', code: 'AS', emoji: '🇦🇸' }, - { name: 'Andorra', dialCode: '+376', code: 'AD', emoji: '🇦🇩' }, - { name: 'Angola', dialCode: '+244', code: 'AO', emoji: '🇦🇴' }, - { name: 'Anguilla', dialCode: '+1', code: 'AI', emoji: '🇦🇮' }, - { name: 'Antigua and Barbuda', dialCode: '+1', code: 'AG', emoji: '🇦🇬' }, - { name: 'Argentina', dialCode: '+54', code: 'AR', emoji: '🇦🇷' }, - { name: 'Armenia', dialCode: '+374', code: 'AM', emoji: '🇦🇲' }, - { name: 'Aruba', dialCode: '+297', code: 'AW', emoji: '🇦🇼' }, - { name: 'Ascension Island', dialCode: '+247', code: 'AC', emoji: '🇦🇨' }, - { name: 'Australia', dialCode: '+61', code: 'AU', emoji: '🇦🇺' }, - { name: 'Austria', dialCode: '+43', code: 'AT', emoji: '🇦🇹' }, - { name: 'Azerbaijan', dialCode: '+994', code: 'AZ', emoji: '🇦🇿' }, - { name: 'Bahamas', dialCode: '+1', code: 'BS', emoji: '🇧🇸' }, - { name: 'Bahrain', dialCode: '+973', code: 'BH', emoji: '🇧🇭' }, - { name: 'Bangladesh', dialCode: '+880', code: 'BD', emoji: '🇧🇩' }, - { name: 'Barbados', dialCode: '+1', code: 'BB', emoji: '🇧🇧' }, - { name: 'Belarus', dialCode: '+375', code: 'BY', emoji: '🇧🇾' }, - { name: 'Belgium', dialCode: '+32', code: 'BE', emoji: '🇧🇪' }, - { name: 'Belize', dialCode: '+501', code: 'BZ', emoji: '🇧🇿' }, - { name: 'Benin', dialCode: '+229', code: 'BJ', emoji: '🇧🇯' }, - { name: 'Bermuda', dialCode: '+1', code: 'BM', emoji: '🇧🇲' }, - { name: 'Bhutan', dialCode: '+975', code: 'BT', emoji: '🇧🇹' }, - { name: 'Bolivia', dialCode: '+591', code: 'BO', emoji: '🇧🇴' }, - { name: 'Bosnia and Herzegovina', dialCode: '+387', code: 'BA', emoji: '🇧🇦' }, - { name: 'Botswana', dialCode: '+267', code: 'BW', emoji: '🇧🇼' }, - { name: 'Brazil', dialCode: '+55', code: 'BR', emoji: '🇧🇷' }, - { name: 'British Indian Ocean Territory', dialCode: '+246', code: 'IO', emoji: '🇮🇴' }, - { name: 'British Virgin Islands', dialCode: '+1', code: 'VG', emoji: '🇻🇬' }, - { name: 'Brunei', dialCode: '+673', code: 'BN', emoji: '🇧🇳' }, - { name: 'Bulgaria', dialCode: '+359', code: 'BG', emoji: '🇧🇬' }, - { name: 'Burkina Faso', dialCode: '+226', code: 'BF', emoji: '🇧🇫' }, - { name: 'Burundi', dialCode: '+257', code: 'BI', emoji: '🇧🇮' }, - { name: 'Cambodia', dialCode: '+855', code: 'KH', emoji: '🇰🇭' }, - { name: 'Cameroon', dialCode: '+237', code: 'CM', emoji: '🇨🇲' }, - { name: 'Canada', dialCode: '+1', code: 'CA', emoji: '🇨🇦' }, - { name: 'Cape Verde', dialCode: '+238', code: 'CV', emoji: '🇨🇻' }, - { name: 'Caribbean Netherlands', dialCode: '+599', code: 'BQ', emoji: '🇧🇶' }, - { name: 'Cayman Islands', dialCode: '+1', code: 'KY', emoji: '🇰🇾' }, - { name: 'Central African Republic', dialCode: '+236', code: 'CF', emoji: '🇨🇫' }, - { name: 'Chad', dialCode: '+235', code: 'TD', emoji: '🇹🇩' }, - { name: 'Chile', dialCode: '+56', code: 'CL', emoji: '🇨🇱' }, - { name: 'China', dialCode: '+86', code: 'CN', emoji: '🇨🇳' }, - { name: 'Christmas Island', dialCode: '+61', code: 'CX', emoji: '🇨🇽' }, - { name: 'Cocos [Keeling] Islands', dialCode: '+61', code: 'CC', emoji: '🇨🇨' }, - { name: 'Colombia', dialCode: '+57', code: 'CO', emoji: '🇨🇴' }, - { name: 'Comoros', dialCode: '+269', code: 'KM', emoji: '🇰🇲' }, - { name: 'Democratic Republic Congo', dialCode: '+243', code: 'CD', emoji: '🇨🇩' }, - { name: 'Republic of Congo', dialCode: '+242', code: 'CG', emoji: '🇨🇬' }, - { name: 'Cook Islands', dialCode: '+682', code: 'CK', emoji: '🇨🇰' }, - { name: 'Costa Rica', dialCode: '+506', code: 'CR', emoji: '🇨🇷' }, - { name: "Côte d'Ivoire", dialCode: '+225', code: 'CI', emoji: '🇨🇮' }, - { name: 'Croatia', dialCode: '+385', code: 'HR', emoji: '🇭🇷' }, - { name: 'Cuba', dialCode: '+53', code: 'CU', emoji: '🇨🇺' }, - { name: 'Curaçao', dialCode: '+599', code: 'CW', emoji: '🇨🇼' }, - { name: 'Cyprus', dialCode: '+357', code: 'CY', emoji: '🇨🇾' }, - { name: 'Czech Republic', dialCode: '+420', code: 'CZ', emoji: '🇨🇿' }, - { name: 'Denmark', dialCode: '+45', code: 'DK', emoji: '🇩🇰' }, - { name: 'Djibouti', dialCode: '+253', code: 'DJ', emoji: '🇩🇯' }, - { name: 'Dominica', dialCode: '+1', code: 'DM', emoji: '🇩🇲' }, - { name: 'Dominican Republic', dialCode: '+1', code: 'DO', emoji: '🇩🇴' }, - { name: 'East Timor', dialCode: '+670', code: 'TL', emoji: '🇹🇱' }, - { name: 'Ecuador', dialCode: '+593', code: 'EC', emoji: '🇪🇨' }, - { name: 'Egypt', dialCode: '+20', code: 'EG', emoji: '🇪🇬' }, - { name: 'El Salvador', dialCode: '+503', code: 'SV', emoji: '🇸🇻' }, - { name: 'Equatorial Guinea', dialCode: '+240', code: 'GQ', emoji: '🇬🇶' }, - { name: 'Eritrea', dialCode: '+291', code: 'ER', emoji: '🇪🇷' }, - { name: 'Estonia', dialCode: '+372', code: 'EE', emoji: '🇪🇪' }, - { name: 'Ethiopia', dialCode: '+251', code: 'ET', emoji: '🇪🇹' }, - { name: 'Falkland Islands [Islas Malvinas]', dialCode: '+500', code: 'FK', emoji: '🇫🇰' }, - { name: 'Faroe Islands', dialCode: '+298', code: 'FO', emoji: '🇫🇴' }, - { name: 'Fiji', dialCode: '+679', code: 'FJ', emoji: '🇫🇯' }, - { name: 'Finland', dialCode: '+358', code: 'FI', emoji: '🇫🇮' }, - { name: 'France', dialCode: '+33', code: 'FR', emoji: '🇫🇷' }, - { name: 'French Guiana', dialCode: '+594', code: 'GF', emoji: '🇬🇫' }, - { name: 'French Polynesia', dialCode: '+689', code: 'PF', emoji: '🇵🇫' }, - { name: 'Gabon', dialCode: '+241', code: 'GA', emoji: '🇬🇦' }, - { name: 'Gambia', dialCode: '+220', code: 'GM', emoji: '🇬🇲' }, - { name: 'Georgia', dialCode: '+995', code: 'GE', emoji: '🇬🇪' }, - { name: 'Germany', dialCode: '+49', code: 'DE', emoji: '🇩🇪' }, - { name: 'Ghana', dialCode: '+233', code: 'GH', emoji: '🇬🇭' }, - { name: 'Gibraltar', dialCode: '+350', code: 'GI', emoji: '🇬🇮' }, - { name: 'Greece', dialCode: '+30', code: 'GR', emoji: '🇬🇷' }, - { name: 'Greenland', dialCode: '+299', code: 'GL', emoji: '🇬🇱' }, - { name: 'Grenada', dialCode: '+1', code: 'GD', emoji: '🇬🇩' }, - { name: 'Guadeloupe', dialCode: '+590', code: 'GP', emoji: '🇬🇵' }, - { name: 'Guam', dialCode: '+1', code: 'GU', emoji: '🇬🇺' }, - { name: 'Guatemala', dialCode: '+502', code: 'GT', emoji: '🇬🇹' }, - { name: 'Guernsey', dialCode: '+44', code: 'GG', emoji: '🇬🇬' }, - { name: 'Guinea Conakry', dialCode: '+224', code: 'GN', emoji: '🇬🇳' }, - { name: 'Guinea-Bissau', dialCode: '+245', code: 'GW', emoji: '🇬🇼' }, - { name: 'Guyana', dialCode: '+592', code: 'GY', emoji: '🇬🇾' }, - { name: 'Haiti', dialCode: '+509', code: 'HT', emoji: '🇭🇹' }, - { name: 'Heard Island and McDonald Islands', dialCode: '+672', code: 'HM', emoji: '🇭🇲' }, - { name: 'Honduras', dialCode: '+504', code: 'HN', emoji: '🇭🇳' }, - { name: 'Hong Kong', dialCode: '+852', code: 'HK', emoji: '🇭🇰' }, - { name: 'Hungary', dialCode: '+36', code: 'HU', emoji: '🇭🇺' }, - { name: 'Iceland', dialCode: '+354', code: 'IS', emoji: '🇮🇸' }, - { name: 'India', dialCode: '+91', code: 'IN', emoji: '🇮🇳' }, - { name: 'Indonesia', dialCode: '+62', code: 'ID', emoji: '🇮🇩' }, - { name: 'Iran', dialCode: '+98', code: 'IR', emoji: '🇮🇷' }, - { name: 'Iraq', dialCode: '+964', code: 'IQ', emoji: '🇮🇶' }, - { name: 'Ireland', dialCode: '+353', code: 'IE', emoji: '🇮🇪' }, - { name: 'Isle of Man', dialCode: '+44', code: 'IM', emoji: '🇮🇲' }, - { name: 'Israel', dialCode: '+972', code: 'IL', emoji: '🇮🇱' }, - { name: 'Italy', dialCode: '+39', code: 'IT', emoji: '🇮🇹' }, - { name: 'Jamaica', dialCode: '+1', code: 'JM', emoji: '🇯🇲' }, - { name: 'Japan', dialCode: '+81', code: 'JP', emoji: '🇯🇵' }, - { name: 'Jersey', dialCode: '+44', code: 'JE', emoji: '🇯🇪' }, - { name: 'Jordan', dialCode: '+962', code: 'JO', emoji: '🇯🇴' }, - { name: 'Kazakhstan', dialCode: '+7', code: 'KZ', emoji: '🇰🇿' }, - { name: 'Kenya', dialCode: '+254', code: 'KE', emoji: '🇰🇪' }, - { name: 'Kiribati', dialCode: '+686', code: 'KI', emoji: '🇰🇮' }, - { name: 'Kosovo', dialCode: '+377', code: 'XK', emoji: '🇽🇰' }, - { name: 'Kosovo', dialCode: '+381', code: 'XK', emoji: '🇽🇰' }, - { name: 'Kosovo', dialCode: '+386', code: 'XK', emoji: '🇽🇰' }, - { name: 'Kuwait', dialCode: '+965', code: 'KW', emoji: '🇰🇼' }, - { name: 'Kyrgyzstan', dialCode: '+996', code: 'KG', emoji: '🇰🇬' }, - { name: 'Laos', dialCode: '+856', code: 'LA', emoji: '🇱🇦' }, - { name: 'Latvia', dialCode: '+371', code: 'LV', emoji: '🇱🇻' }, - { name: 'Lebanon', dialCode: '+961', code: 'LB', emoji: '🇱🇧' }, - { name: 'Lesotho', dialCode: '+266', code: 'LS', emoji: '🇱🇸' }, - { name: 'Liberia', dialCode: '+231', code: 'LR', emoji: '🇱🇷' }, - { name: 'Libya', dialCode: '+218', code: 'LY', emoji: '🇱🇾' }, - { name: 'Liechtenstein', dialCode: '+423', code: 'LI', emoji: '🇱🇮' }, - { name: 'Lithuania', dialCode: '+370', code: 'LT', emoji: '🇱🇹' }, - { name: 'Luxembourg', dialCode: '+352', code: 'LU', emoji: '🇱🇺' }, - { name: 'Macau', dialCode: '+853', code: 'MO', emoji: '🇲🇴' }, - { name: 'Macedonia', dialCode: '+389', code: 'MK', emoji: '🇲🇰' }, - { name: 'Madagascar', dialCode: '+261', code: 'MG', emoji: '🇲🇬' }, - { name: 'Malawi', dialCode: '+265', code: 'MW', emoji: '🇲🇼' }, - { name: 'Malaysia', dialCode: '+60', code: 'MY', emoji: '🇲🇾' }, - { name: 'Maldives', dialCode: '+960', code: 'MV', emoji: '🇲🇻' }, - { name: 'Mali', dialCode: '+223', code: 'ML', emoji: '🇲🇱' }, - { name: 'Malta', dialCode: '+356', code: 'MT', emoji: '🇲🇹' }, - { name: 'Marshall Islands', dialCode: '+692', code: 'MH', emoji: '🇲🇭' }, - { name: 'Martinique', dialCode: '+596', code: 'MQ', emoji: '🇲🇶' }, - { name: 'Mauritania', dialCode: '+222', code: 'MR', emoji: '🇲🇷' }, - { name: 'Mauritius', dialCode: '+230', code: 'MU', emoji: '🇲🇺' }, - { name: 'Mayotte', dialCode: '+262', code: 'YT', emoji: '🇾🇹' }, - { name: 'Mexico', dialCode: '+52', code: 'MX', emoji: '🇲🇽' }, - { name: 'Micronesia', dialCode: '+691', code: 'FM', emoji: '🇫🇲' }, - { name: 'Moldova', dialCode: '+373', code: 'MD', emoji: '🇲🇩' }, - { name: 'Monaco', dialCode: '+377', code: 'MC', emoji: '🇲🇨' }, - { name: 'Mongolia', dialCode: '+976', code: 'MN', emoji: '🇲🇳' }, - { name: 'Montenegro', dialCode: '+382', code: 'ME', emoji: '🇲🇪' }, - { name: 'Montserrat', dialCode: '+1', code: 'MS', emoji: '🇲🇸' }, - { name: 'Morocco', dialCode: '+212', code: 'MA', emoji: '🇲🇦' }, - { name: 'Mozambique', dialCode: '+258', code: 'MZ', emoji: '🇲🇿' }, - { name: 'Myanmar [Burma]', dialCode: '+95', code: 'MM', emoji: '🇲🇲' }, - { name: 'Namibia', dialCode: '+264', code: 'NA', emoji: '🇳🇦' }, - { name: 'Nauru', dialCode: '+674', code: 'NR', emoji: '🇳🇷' }, - { name: 'Nepal', dialCode: '+977', code: 'NP', emoji: '🇳🇵' }, - { name: 'Netherlands', dialCode: '+31', code: 'NL', emoji: '🇳🇱' }, - { name: 'New Caledonia', dialCode: '+687', code: 'NC', emoji: '🇳🇨' }, - { name: 'New Zealand', dialCode: '+64', code: 'NZ', emoji: '🇳🇿' }, - { name: 'Nicaragua', dialCode: '+505', code: 'NI', emoji: '🇳🇮' }, - { name: 'Niger', dialCode: '+227', code: 'NE', emoji: '🇳🇪' }, - { name: 'Nigeria', dialCode: '+234', code: 'NG', emoji: '🇳🇬' }, - { name: 'Niue', dialCode: '+683', code: 'NU', emoji: '🇳🇺' }, - { name: 'Norfolk Island', dialCode: '+672', code: 'NF', emoji: '🇳🇫' }, - { name: 'North Korea', dialCode: '+850', code: 'KP', emoji: '🇰🇵' }, - { name: 'Northern Mariana Islands', dialCode: '+1', code: 'MP', emoji: '🇲🇵' }, - { name: 'Norway', dialCode: '+47', code: 'NO', emoji: '🇳🇴' }, - { name: 'Oman', dialCode: '+968', code: 'OM', emoji: '🇴🇲' }, - { name: 'Pakistan', dialCode: '+92', code: 'PK', emoji: '🇵🇰' }, - { name: 'Palau', dialCode: '+680', code: 'PW', emoji: '🇵🇼' }, - { name: 'Palestinian Territories', dialCode: '+970', code: 'PS', emoji: '🇵🇸' }, - { name: 'Panama', dialCode: '+507', code: 'PA', emoji: '🇵🇦' }, - { name: 'Papua New Guinea', dialCode: '+675', code: 'PG', emoji: '🇵🇬' }, - { name: 'Paraguay', dialCode: '+595', code: 'PY', emoji: '🇵🇾' }, - { name: 'Peru', dialCode: '+51', code: 'PE', emoji: '🇵🇪' }, - { name: 'Philippines', dialCode: '+63', code: 'PH', emoji: '🇵🇭' }, - { name: 'Poland', dialCode: '+48', code: 'PL', emoji: '🇵🇱' }, - { name: 'Portugal', dialCode: '+351', code: 'PT', emoji: '🇵🇹' }, - { name: 'Puerto Rico', dialCode: '+1', code: 'PR', emoji: '🇵🇷' }, - { name: 'Qatar', dialCode: '+974', code: 'QA', emoji: '🇶🇦' }, - { name: 'Réunion', dialCode: '+262', code: 'RE', emoji: '🇷🇪' }, - { name: 'Romania', dialCode: '+40', code: 'RO', emoji: '🇷🇴' }, - { name: 'Russia', dialCode: '+7', code: 'RU', emoji: '🇷🇺' }, - { name: 'Rwanda', dialCode: '+250', code: 'RW', emoji: '🇷🇼' }, - { name: 'Saint Barthélemy', dialCode: '+590', code: 'BL', emoji: '🇧🇱' }, - { name: 'Saint Helena', dialCode: '+290', code: 'SH', emoji: '🇸🇭' }, - { name: 'St. Kitts', dialCode: '+1', code: 'KN', emoji: '🇰🇳' }, - { name: 'St. Lucia', dialCode: '+1', code: 'LC', emoji: '🇱🇨' }, - { name: 'Saint Martin', dialCode: '+590', code: 'MF', emoji: '🇲🇫' }, - { name: 'Saint Pierre and Miquelon', dialCode: '+508', code: 'PM', emoji: '🇵🇲' }, - { name: 'St. Vincent', dialCode: '+1', code: 'VC', emoji: '🇻🇨' }, - { name: 'Samoa', dialCode: '+685', code: 'WS', emoji: '🇼🇸' }, - { name: 'San Marino', dialCode: '+378', code: 'SM', emoji: '🇸🇲' }, - { name: 'São Tomé and Príncipe', dialCode: '+239', code: 'ST', emoji: '🇸🇹' }, - { name: 'Saudi Arabia', dialCode: '+966', code: 'SA', emoji: '🇸🇦' }, - { name: 'Senegal', dialCode: '+221', code: 'SN', emoji: '🇸🇳' }, - { name: 'Serbia', dialCode: '+381', code: 'RS', emoji: '🇷🇸' }, - { name: 'Seychelles', dialCode: '+248', code: 'SC', emoji: '🇸🇨' }, - { name: 'Sierra Leone', dialCode: '+232', code: 'SL', emoji: '🇸🇱' }, - { name: 'Singapore', dialCode: '+65', code: 'SG', emoji: '🇸🇬' }, - { name: 'Sint Maarten', dialCode: '+1', code: 'SX', emoji: '🇸🇽' }, - { name: 'Slovakia', dialCode: '+421', code: 'SK', emoji: '🇸🇰' }, - { name: 'Slovenia', dialCode: '+386', code: 'SI', emoji: '🇸🇮' }, - { name: 'Solomon Islands', dialCode: '+677', code: 'SB', emoji: '🇸🇧' }, - { name: 'Somalia', dialCode: '+252', code: 'SO', emoji: '🇸🇴' }, - { name: 'South Africa', dialCode: '+27', code: 'ZA', emoji: '🇿🇦' }, - { name: 'South Georgia and the South Sandwich Islands', dialCode: '+500', code: 'GS', emoji: '🇬🇸' }, - { name: 'South Korea', dialCode: '+82', code: 'KR', emoji: '🇰🇷' }, - { name: 'South Sudan', dialCode: '+211', code: 'SS', emoji: '🇸🇸' }, - { name: 'Spain', dialCode: '+34', code: 'ES', emoji: '🇪🇸' }, - { name: 'Sri Lanka', dialCode: '+94', code: 'LK', emoji: '🇱🇰' }, - { name: 'Sudan', dialCode: '+249', code: 'SD', emoji: '🇸🇩' }, - { name: 'Suriname', dialCode: '+597', code: 'SR', emoji: '🇸🇷' }, - { name: 'Svalbard and Jan Mayen', dialCode: '+47', code: 'SJ', emoji: '🇸🇯' }, - { name: 'Swaziland', dialCode: '+268', code: 'SZ', emoji: '🇸🇿' }, - { name: 'Sweden', dialCode: '+46', code: 'SE', emoji: '🇸🇪' }, - { name: 'Switzerland', dialCode: '+41', code: 'CH', emoji: '🇨🇭' }, - { name: 'Syria', dialCode: '+963', code: 'SY', emoji: '🇸🇾' }, - { name: 'Taiwan', dialCode: '+886', code: 'TW', emoji: '🇹🇼' }, - { name: 'Tajikistan', dialCode: '+992', code: 'TJ', emoji: '🇹🇯' }, - { name: 'Tanzania', dialCode: '+255', code: 'TZ', emoji: '🇹🇿' }, - { name: 'Thailand', dialCode: '+66', code: 'TH', emoji: '🇹🇭' }, - { name: 'Togo', dialCode: '+228', code: 'TG', emoji: '🇹🇬' }, - { name: 'Tokelau', dialCode: '+690', code: 'TK', emoji: '🇹🇰' }, - { name: 'Tonga', dialCode: '+676', code: 'TO', emoji: '🇹🇴' }, - { name: 'Trinidad/Tobago', dialCode: '+1', code: 'TT', emoji: '🇹🇹' }, - { name: 'Tunisia', dialCode: '+216', code: 'TN', emoji: '🇹🇳' }, - { name: 'Turkey', dialCode: '+90', code: 'TR', emoji: '🇹🇷' }, - { name: 'Turkmenistan', dialCode: '+993', code: 'TM', emoji: '🇹🇲' }, - { name: 'Turks and Caicos Islands', dialCode: '+1', code: 'TC', emoji: '🇹🇨' }, - { name: 'Tuvalu', dialCode: '+688', code: 'TV', emoji: '🇹🇻' }, - { name: 'U.S. Virgin Islands', dialCode: '+1', code: 'VI', emoji: '🇻🇮' }, - { name: 'Uganda', dialCode: '+256', code: 'UG', emoji: '🇺🇬' }, - { name: 'Ukraine', dialCode: '+380', code: 'UA', emoji: '🇺🇦' }, - { name: 'United Arab Emirates', dialCode: '+971', code: 'AE', emoji: '🇦🇪' }, - { name: 'Uruguay', dialCode: '+598', code: 'UY', emoji: '🇺🇾' }, - { name: 'Uzbekistan', dialCode: '+998', code: 'UZ', emoji: '🇺🇿' }, - { name: 'Vanuatu', dialCode: '+678', code: 'VU', emoji: '🇻🇺' }, - { name: 'Vatican City', dialCode: '+379', code: 'VA', emoji: '🇻🇦' }, - { name: 'Venezuela', dialCode: '+58', code: 'VE', emoji: '🇻🇪' }, - { name: 'Vietnam', dialCode: '+84', code: 'VN', emoji: '🇻🇳' }, - { name: 'Wallis and Futuna', dialCode: '+681', code: 'WF', emoji: '🇼🇫' }, - { name: 'Western Sahara', dialCode: '+212', code: 'EH', emoji: '🇪🇭' }, - { name: 'Yemen', dialCode: '+967', code: 'YE', emoji: '🇾🇪' }, - { name: 'Zambia', dialCode: '+260', code: 'ZM', emoji: '🇿🇲' }, - { name: 'Zimbabwe', dialCode: '+263', code: 'ZW', emoji: '🇿🇼' }, - { name: 'Åland Islands', dialCode: '+358', code: 'AX', emoji: '🇦🇽' }, -]; - -export function getCountryByDialCode(dialCode: string): CountryData | undefined { - return countryData.find((country) => country.dialCode === dialCode); -} - -export function getCountryByCode(code: string): CountryData | undefined { - return countryData.find((country) => country.code === code.toUpperCase()); -} - -export function formatPhoneNumberWithCountry(phoneNumber: string, countryDialCode: string): string { - // Remove any existing dial code if present - const cleanNumber = phoneNumber.replace(/^\+\d+/, '').trim(); - return `${countryDialCode}${cleanNumber}`; -} diff --git a/packages/firebaseui-core/src/errors.ts b/packages/firebaseui-core/src/errors.ts deleted file mode 100644 index 6e41a1e02..000000000 --- a/packages/firebaseui-core/src/errors.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - english, - ERROR_CODE_MAP, - ErrorCode, - getTranslation, - Locale, - TranslationsConfig, -} from '@firebase-ui/translations'; -import { FirebaseUIConfiguration } from './config'; -export class FirebaseUIError extends Error { - code: string; - - constructor(error: any, translations?: TranslationsConfig, locale?: Locale) { - const errorCode: ErrorCode = error?.customData?.message?.match?.(/\(([^)]+)\)/)?.at(1) || error?.code || 'unknown'; - const translationKey = ERROR_CODE_MAP[errorCode] || 'unknownError'; - const message = getTranslation('errors', translationKey, translations, locale ?? english.locale); - - super(message); - this.name = 'FirebaseUIError'; - this.code = errorCode; - } -} - -export function handleFirebaseError( - ui: FirebaseUIConfiguration, - error: any, - opts?: { - enableHandleExistingCredential?: boolean; - } -): never { - const { translations, locale: defaultLocale } = ui; - if (error?.code === 'auth/account-exists-with-different-credential') { - if (opts?.enableHandleExistingCredential && error.credential) { - window.sessionStorage.setItem('pendingCred', JSON.stringify(error.credential)); - } else { - window.sessionStorage.removeItem('pendingCred'); - } - - throw new FirebaseUIError( - { - code: 'auth/account-exists-with-different-credential', - customData: { - email: error.customData?.email, - }, - }, - translations, - defaultLocale - ); - } - - // TODO: Debug why instanceof FirebaseError is not working - if (error?.name === 'FirebaseError') { - throw new FirebaseUIError(error, translations, defaultLocale); - } - throw new FirebaseUIError({ code: 'unknown' }, translations, defaultLocale); -} diff --git a/packages/firebaseui-core/src/index.ts b/packages/firebaseui-core/src/index.ts deleted file mode 100644 index 7975156b2..000000000 --- a/packages/firebaseui-core/src/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './auth'; -export * from './behaviors'; -export * from './config'; -export * from './errors'; -export * from './schemas'; -export * from './types'; -export * from './country-data'; -export * from './translations'; -export type { CountryData } from './types'; diff --git a/packages/firebaseui-core/src/schemas.ts b/packages/firebaseui-core/src/schemas.ts deleted file mode 100644 index d97796f8b..000000000 --- a/packages/firebaseui-core/src/schemas.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { z } from 'zod'; -import { RecaptchaVerifier } from 'firebase/auth'; -import { type TranslationsConfig, getTranslation } from '@firebase-ui/translations'; - -export const LoginTypes = ['email', 'phone', 'anonymous', 'emailLink', 'google'] as const; -export type LoginType = (typeof LoginTypes)[number]; -export type AuthMode = 'signIn' | 'signUp'; - -export function createEmailFormSchema(translations?: TranslationsConfig) { - return z.object({ - email: z.string().email({ message: getTranslation('errors', 'invalidEmail', translations) }), - password: z.string().min(8, { message: getTranslation('errors', 'weakPassword', translations) }), - }); -} - -export function createForgotPasswordFormSchema(translations?: TranslationsConfig) { - return z.object({ - email: z.string().email({ message: getTranslation('errors', 'invalidEmail', translations) }), - }); -} - -export function createEmailLinkFormSchema(translations?: TranslationsConfig) { - return z.object({ - email: z.string().email({ message: getTranslation('errors', 'invalidEmail', translations) }), - }); -} - -export function createPhoneFormSchema(translations?: TranslationsConfig) { - return z.object({ - phoneNumber: z - .string() - .min(1, { message: getTranslation('errors', 'missingPhoneNumber', translations) }) - .min(10, { message: getTranslation('errors', 'invalidPhoneNumber', translations) }), - verificationCode: z.string().refine((val) => !val || val.length >= 6, { - message: getTranslation('errors', 'invalidVerificationCode', translations), - }), - recaptchaVerifier: z.instanceof(RecaptchaVerifier), - }); -} - -export type EmailFormSchema = z.infer>; -export type ForgotPasswordFormSchema = z.infer>; -export type EmailLinkFormSchema = z.infer>; -export type PhoneFormSchema = z.infer>; diff --git a/packages/firebaseui-core/src/styles.css b/packages/firebaseui-core/src/styles.css deleted file mode 100644 index de4f9bb1c..000000000 --- a/packages/firebaseui-core/src/styles.css +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - \ No newline at end of file diff --git a/packages/firebaseui-core/tests/unit/auth.test.ts b/packages/firebaseui-core/tests/unit/auth.test.ts deleted file mode 100644 index 5168f5286..000000000 --- a/packages/firebaseui-core/tests/unit/auth.test.ts +++ /dev/null @@ -1,503 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/// -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - Auth, - EmailAuthProvider, - PhoneAuthProvider, - createUserWithEmailAndPassword as fbCreateUserWithEmailAndPassword, - getAuth, - isSignInWithEmailLink as fbIsSignInWithEmailLink, - linkWithCredential, - linkWithRedirect, - sendPasswordResetEmail as fbSendPasswordResetEmail, - sendSignInLinkToEmail as fbSendSignInLinkToEmail, - signInAnonymously as fbSignInAnonymously, - signInWithCredential, - signInWithPhoneNumber as fbSignInWithPhoneNumber, - signInWithRedirect, -} from 'firebase/auth'; -import { - signInWithEmailAndPassword, - createUserWithEmailAndPassword, - signInWithPhoneNumber, - confirmPhoneNumber, - sendPasswordResetEmail, - sendSignInLinkToEmail, - signInWithEmailLink, - signInAnonymously, - signInWithOAuth, - completeEmailLinkSignIn, -} from '../../src/auth'; -import { FirebaseUIConfiguration } from '../../src/config'; -import { english } from '@firebase-ui/translations'; - -// Mock all Firebase Auth functions -vi.mock('firebase/auth', async () => { - const actual = await vi.importActual('firebase/auth'); - return { - ...(actual as object), - getAuth: vi.fn(), - signInWithCredential: vi.fn(), - createUserWithEmailAndPassword: vi.fn(), - signInWithPhoneNumber: vi.fn(), - sendPasswordResetEmail: vi.fn(), - sendSignInLinkToEmail: vi.fn(), - isSignInWithEmailLink: vi.fn(), - signInAnonymously: vi.fn(), - linkWithCredential: vi.fn(), - linkWithRedirect: vi.fn(), - signInWithRedirect: vi.fn(), - EmailAuthProvider: { - credential: vi.fn(), - credentialWithLink: vi.fn(), - }, - PhoneAuthProvider: { - credential: vi.fn(), - }, - }; -}); - -describe('Firebase UI Auth', () => { - let mockAuth: Auth; - let mockUi: FirebaseUIConfiguration; - - const mockCredential = { type: 'password', token: 'mock-token' }; - const mockUserCredential = { user: { uid: 'mock-uid' } }; - const mockConfirmationResult = { verificationId: 'mock-verification-id' }; - const mockError = { name: 'FirebaseError', code: 'auth/user-not-found' }; - const mockProvider = { providerId: 'google.com' }; - - beforeEach(() => { - vi.clearAllMocks(); - mockAuth = { currentUser: null } as Auth; - window.localStorage.clear(); - window.sessionStorage.clear(); - (EmailAuthProvider.credential as any).mockReturnValue(mockCredential); - (EmailAuthProvider.credentialWithLink as any).mockReturnValue(mockCredential); - (PhoneAuthProvider.credential as any).mockReturnValue(mockCredential); - (getAuth as any).mockReturnValue(mockAuth); - - // Create a mock FirebaseUIConfiguration - mockUi = { - app: { name: 'test' } as any, - getAuth: () => mockAuth, - setLocale: vi.fn(), - state: 'idle', - setState: vi.fn(), - locale: 'en-US', - translations: { 'en-US': english.translations }, - behaviors: {}, - recaptchaMode: 'normal', - }; - }); - - describe('signInWithEmailAndPassword', () => { - it('should sign in with email and password', async () => { - (signInWithCredential as any).mockResolvedValue(mockUserCredential); - - const result = await signInWithEmailAndPassword(mockUi, 'test@test.com', 'password'); - - expect(EmailAuthProvider.credential).toHaveBeenCalledWith('test@test.com', 'password'); - expect(signInWithCredential).toHaveBeenCalledWith(mockAuth, mockCredential); - expect(result).toBe(mockUserCredential); - }); - - it('should upgrade anonymous user when enabled', async () => { - mockAuth = { currentUser: { isAnonymous: true } } as Auth; - (getAuth as any).mockReturnValue(mockAuth); - (linkWithCredential as any).mockResolvedValue(mockUserCredential); - - mockUi.behaviors.autoUpgradeAnonymousCredential = vi.fn().mockResolvedValue(mockUserCredential); - - const result = await signInWithEmailAndPassword(mockUi, 'test@test.com', 'password'); - - expect(mockUi.behaviors.autoUpgradeAnonymousCredential).toHaveBeenCalledWith(mockUi, mockCredential); - expect(result).toBe(mockUserCredential); - }); - }); - - describe('createUserWithEmailAndPassword', () => { - it('should create user with email and password', async () => { - (fbCreateUserWithEmailAndPassword as any).mockResolvedValue(mockUserCredential); - - const result = await createUserWithEmailAndPassword(mockUi, 'test@test.com', 'password'); - - expect(fbCreateUserWithEmailAndPassword).toHaveBeenCalledWith(mockAuth, 'test@test.com', 'password'); - expect(result).toBe(mockUserCredential); - }); - - it('should upgrade anonymous user when enabled', async () => { - mockAuth = { currentUser: { isAnonymous: true } } as Auth; - (getAuth as any).mockReturnValue(mockAuth); - (linkWithCredential as any).mockResolvedValue(mockUserCredential); - - mockUi.behaviors.autoUpgradeAnonymousCredential = vi.fn().mockResolvedValue(mockUserCredential); - - const result = await createUserWithEmailAndPassword(mockUi, 'test@test.com', 'password'); - - expect(mockUi.behaviors.autoUpgradeAnonymousCredential).toHaveBeenCalledWith(mockUi, mockCredential); - expect(result).toBe(mockUserCredential); - }); - }); - - describe('signInWithPhoneNumber', () => { - it('should initiate phone number sign in', async () => { - (fbSignInWithPhoneNumber as any).mockResolvedValue(mockConfirmationResult); - const mockRecaptcha = { type: 'recaptcha' }; - - const result = await signInWithPhoneNumber(mockUi, '+1234567890', mockRecaptcha as any); - - expect(fbSignInWithPhoneNumber).toHaveBeenCalledWith(mockAuth, '+1234567890', mockRecaptcha); - expect(result).toBe(mockConfirmationResult); - }); - }); - - describe('confirmPhoneNumber', () => { - it('should confirm phone number sign in', async () => { - (signInWithCredential as any).mockResolvedValue(mockUserCredential); - - const result = await confirmPhoneNumber(mockUi, { verificationId: 'mock-id' } as any, '123456'); - - expect(PhoneAuthProvider.credential).toHaveBeenCalledWith('mock-id', '123456'); - expect(signInWithCredential).toHaveBeenCalledWith(mockAuth, mockCredential); - expect(result).toBe(mockUserCredential); - }); - - it('should upgrade anonymous user when enabled', async () => { - mockAuth = { currentUser: { isAnonymous: true } } as Auth; - (getAuth as any).mockReturnValue(mockAuth); - (linkWithCredential as any).mockResolvedValue(mockUserCredential); - - mockUi.behaviors.autoUpgradeAnonymousCredential = vi.fn().mockResolvedValue(mockUserCredential); - - const result = await confirmPhoneNumber(mockUi, { verificationId: 'mock-id' } as any, '123456'); - - expect(mockUi.behaviors.autoUpgradeAnonymousCredential).toHaveBeenCalled(); - expect(result).toBe(mockUserCredential); - }); - }); - - describe('sendPasswordResetEmail', () => { - it('should send password reset email', async () => { - (fbSendPasswordResetEmail as any).mockResolvedValue(undefined); - - await sendPasswordResetEmail(mockUi, 'test@test.com'); - - expect(fbSendPasswordResetEmail).toHaveBeenCalledWith(mockAuth, 'test@test.com'); - }); - }); - - describe('sendSignInLinkToEmail', () => { - it('should send sign in link to email', async () => { - (fbSendSignInLinkToEmail as any).mockResolvedValue(undefined); - - const expectedActionCodeSettings = { - url: window.location.href, - handleCodeInApp: true, - }; - - await sendSignInLinkToEmail(mockUi, 'test@test.com'); - - expect(fbSendSignInLinkToEmail).toHaveBeenCalledWith(mockAuth, 'test@test.com', expectedActionCodeSettings); - expect(mockUi.setState).toHaveBeenCalledWith('sending-sign-in-link-to-email'); - expect(mockUi.setState).toHaveBeenCalledWith('idle'); - expect(window.localStorage.getItem('emailForSignIn')).toBe('test@test.com'); - }); - }); - - describe('signInWithEmailLink', () => { - it('should sign in with email link', async () => { - (signInWithCredential as any).mockResolvedValue(mockUserCredential); - - const result = await signInWithEmailLink(mockUi, 'test@test.com', 'mock-link'); - - expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith('test@test.com', 'mock-link'); - expect(signInWithCredential).toHaveBeenCalledWith(mockAuth, mockCredential); - expect(result).toBe(mockUserCredential); - }); - - it('should upgrade anonymous user when enabled', async () => { - mockAuth = { currentUser: { isAnonymous: true } } as Auth; - (getAuth as any).mockReturnValue(mockAuth); - window.localStorage.setItem('emailLinkAnonymousUpgrade', 'true'); - (linkWithCredential as any).mockResolvedValue(mockUserCredential); - - mockUi.behaviors.autoUpgradeAnonymousCredential = vi.fn().mockResolvedValue(mockUserCredential); - - const result = await signInWithEmailLink(mockUi, 'test@test.com', 'mock-link'); - - expect(mockUi.behaviors.autoUpgradeAnonymousCredential).toHaveBeenCalled(); - expect(result).toBe(mockUserCredential); - }); - }); - - describe('signInAnonymously', () => { - it('should sign in anonymously', async () => { - (fbSignInAnonymously as any).mockResolvedValue(mockUserCredential); - - const result = await signInAnonymously(mockUi); - - expect(fbSignInAnonymously).toHaveBeenCalledWith(mockAuth); - expect(result).toBe(mockUserCredential); - }); - - it('should handle operation not allowed error', async () => { - const operationNotAllowedError = { name: 'FirebaseError', code: 'auth/operation-not-allowed' }; - (fbSignInAnonymously as any).mockRejectedValue(operationNotAllowedError); - - await expect(signInAnonymously(mockUi)).rejects.toThrow(); - }); - - it('should handle admin restricted operation error', async () => { - const adminRestrictedError = { name: 'FirebaseError', code: 'auth/admin-restricted-operation' }; - (fbSignInAnonymously as any).mockRejectedValue(adminRestrictedError); - - await expect(signInAnonymously(mockUi)).rejects.toThrow(); - }); - }); - - describe('Anonymous User Upgrade', () => { - it('should handle upgrade with existing email', async () => { - mockAuth = { currentUser: { isAnonymous: true } } as Auth; - (getAuth as any).mockReturnValue(mockAuth); - const emailExistsError = { name: 'FirebaseError', code: 'auth/email-already-in-use' }; - (fbCreateUserWithEmailAndPassword as any).mockRejectedValue(emailExistsError); - - await expect(createUserWithEmailAndPassword(mockUi, 'existing@test.com', 'password')).rejects.toThrow(); - }); - - it('should handle upgrade of non-anonymous user', async () => { - mockAuth = { currentUser: { isAnonymous: false } } as Auth; - (getAuth as any).mockReturnValue(mockAuth); - (fbCreateUserWithEmailAndPassword as any).mockResolvedValue(mockUserCredential); - - const result = await createUserWithEmailAndPassword(mockUi, 'test@test.com', 'password'); - - expect(fbCreateUserWithEmailAndPassword).toHaveBeenCalledWith(mockAuth, 'test@test.com', 'password'); - expect(result).toBe(mockUserCredential); - }); - - it('should handle null user during upgrade', async () => { - mockAuth = { currentUser: null } as Auth; - (getAuth as any).mockReturnValue(mockAuth); - (fbCreateUserWithEmailAndPassword as any).mockResolvedValue(mockUserCredential); - - const result = await createUserWithEmailAndPassword(mockUi, 'test@test.com', 'password'); - - expect(fbCreateUserWithEmailAndPassword).toHaveBeenCalledWith(mockAuth, 'test@test.com', 'password'); - expect(result).toBe(mockUserCredential); - }); - }); - - describe('signInWithOAuth', () => { - it('should sign in with OAuth provider', async () => { - (signInWithRedirect as any).mockResolvedValue(undefined); - - await signInWithOAuth(mockUi, mockProvider as any); - - expect(signInWithRedirect).toHaveBeenCalledWith(mockAuth, mockProvider); - }); - - it('should upgrade anonymous user when enabled', async () => { - mockAuth = { currentUser: { isAnonymous: true } } as Auth; - (getAuth as any).mockReturnValue(mockAuth); - (linkWithRedirect as any).mockResolvedValue(undefined); - - mockUi.behaviors.autoUpgradeAnonymousProvider = vi.fn(); - - await signInWithOAuth(mockUi, mockProvider as any); - - expect(mockUi.behaviors.autoUpgradeAnonymousProvider).toHaveBeenCalledWith(mockUi, mockProvider); - }); - }); - - describe('completeEmailLinkSignIn', () => { - it('should complete email link sign in when valid', async () => { - (fbIsSignInWithEmailLink as any).mockReturnValue(true); - window.localStorage.setItem('emailForSignIn', 'test@test.com'); - (signInWithCredential as any).mockResolvedValue(mockUserCredential); - - const result = await completeEmailLinkSignIn(mockUi, 'https://example.com?oob=code'); - - expect(fbIsSignInWithEmailLink).toHaveBeenCalled(); - expect(result).toBe(mockUserCredential); - }); - - it('should clean up all storage items after sign in attempt', async () => { - (fbIsSignInWithEmailLink as any).mockReturnValue(true); - window.localStorage.setItem('emailForSignIn', 'test@test.com'); - (signInWithCredential as any).mockResolvedValue(mockUserCredential); - - await completeEmailLinkSignIn(mockUi, 'https://example.com?oob=code'); - - expect(window.localStorage.getItem('emailForSignIn')).toBeNull(); - }); - - it('should return null when not a valid sign in link', async () => { - (fbIsSignInWithEmailLink as any).mockReturnValue(false); - - const result = await completeEmailLinkSignIn(mockUi, 'https://example.com?invalidlink=true'); - - expect(result).toBeNull(); - }); - - it('should return null when no email in storage', async () => { - (fbIsSignInWithEmailLink as any).mockReturnValue(true); - window.localStorage.clear(); - - const result = await completeEmailLinkSignIn(mockUi, 'https://example.com?oob=code'); - - expect(result).toBeNull(); - }); - - it('should clean up storage even when sign in fails', async () => { - // Patch localStorage for testing - const mockLocalStorage = { - getItem: vi.fn().mockReturnValue('test@test.com'), - removeItem: vi.fn(), - setItem: vi.fn(), - clear: vi.fn(), - key: vi.fn(), - length: 0, - }; - Object.defineProperty(window, 'localStorage', { value: mockLocalStorage }); - - // Make isSignInWithEmailLink return true - (fbIsSignInWithEmailLink as any).mockReturnValue(true); - - // Make signInWithCredential throw an error - const error = new Error('Sign in failed'); - (signInWithCredential as any).mockRejectedValue(error); - - // Mock handleFirebaseError to throw our actual error instead - vi.mock('../../src/errors', async () => { - const actual = await vi.importActual('../../src/errors'); - return { - ...(actual as object), - handleFirebaseError: vi.fn().mockImplementation((ui, e) => { - throw e; - }), - }; - }); - - // Use rejects matcher with our specific error - await expect(completeEmailLinkSignIn(mockUi, 'https://example.com?oob=code')).rejects.toThrow('Sign in failed'); - - // Check localStorage was cleared - expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('emailForSignIn'); - }); - }); - - describe('Pending Credential Handling', () => { - it('should handle pending credential during email sign in', async () => { - (signInWithCredential as any).mockResolvedValue(mockUserCredential); - window.sessionStorage.setItem('pendingCred', JSON.stringify(mockCredential)); - (linkWithCredential as any).mockResolvedValue({ ...mockUserCredential, linked: true }); - - const result = await signInWithEmailAndPassword(mockUi, 'test@test.com', 'password'); - - expect(linkWithCredential).toHaveBeenCalledWith(mockUserCredential.user, mockCredential); - expect((result as any).linked).toBe(true); - expect(window.sessionStorage.getItem('pendingCred')).toBeNull(); - }); - - it('should handle invalid pending credential gracefully', async () => { - (signInWithCredential as any).mockResolvedValue(mockUserCredential); - window.sessionStorage.setItem('pendingCred', 'invalid-json'); - - const result = await signInWithEmailAndPassword(mockUi, 'test@test.com', 'password'); - - expect(result).toBe(mockUserCredential); - }); - - it('should handle linking failure gracefully', async () => { - (signInWithCredential as any).mockResolvedValue(mockUserCredential); - window.sessionStorage.setItem('pendingCred', JSON.stringify(mockCredential)); - (linkWithCredential as any).mockRejectedValue(new Error('Linking failed')); - - const result = await signInWithEmailAndPassword(mockUi, 'test@test.com', 'password'); - - expect(result).toBe(mockUserCredential); - expect(window.sessionStorage.getItem('pendingCred')).toBeNull(); - }); - }); - - describe('Storage Management', () => { - it('should clean up all storage items after successful email link sign in', async () => { - (fbIsSignInWithEmailLink as any).mockReturnValue(true); - - // Patch localStorage for testing - const mockLocalStorage = { - getItem: vi.fn().mockReturnValue('test@test.com'), - removeItem: vi.fn(), - setItem: vi.fn(), - clear: vi.fn(), - key: vi.fn(), - length: 0, - }; - Object.defineProperty(window, 'localStorage', { value: mockLocalStorage }); - - // Create mocks to ensure a successful sign in - (signInWithCredential as any).mockResolvedValue(mockUserCredential); - (EmailAuthProvider.credentialWithLink as any).mockReturnValue(mockCredential); - - const result = await completeEmailLinkSignIn(mockUi, 'https://example.com?oob=code'); - - expect(result).not.toBeNull(); - expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('emailForSignIn'); - }); - - it('should clean up storage even when sign in fails', async () => { - // Patch localStorage for testing - const mockLocalStorage = { - getItem: vi.fn().mockReturnValue('test@test.com'), - removeItem: vi.fn(), - setItem: vi.fn(), - clear: vi.fn(), - key: vi.fn(), - length: 0, - }; - Object.defineProperty(window, 'localStorage', { value: mockLocalStorage }); - - // Make isSignInWithEmailLink return true - (fbIsSignInWithEmailLink as any).mockReturnValue(true); - - // Make signInWithCredential throw an error - const error = new Error('Sign in failed'); - (signInWithCredential as any).mockRejectedValue(error); - - // Mock handleFirebaseError to throw our actual error instead - vi.mock('../../src/errors', async () => { - const actual = await vi.importActual('../../src/errors'); - return { - ...(actual as object), - handleFirebaseError: vi.fn().mockImplementation((ui, e) => { - throw e; - }), - }; - }); - - // Use rejects matcher with our specific error - await expect(completeEmailLinkSignIn(mockUi, 'https://example.com?oob=code')).rejects.toThrow('Sign in failed'); - - // Check localStorage was cleared - expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('emailForSignIn'); - }); - }); -}); diff --git a/packages/firebaseui-core/tests/unit/config.test.ts b/packages/firebaseui-core/tests/unit/config.test.ts deleted file mode 100644 index ffd2310f1..000000000 --- a/packages/firebaseui-core/tests/unit/config.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi } from 'vitest'; -import { initializeUI, $config } from '../../src/config'; -import { english } from '@firebase-ui/translations'; -import { onAuthStateChanged } from 'firebase/auth'; - -vi.mock('firebase/auth', () => ({ - getAuth: vi.fn(), - onAuthStateChanged: vi.fn(), -})); - -describe('Config', () => { - describe('initializeUI', () => { - it('should initialize config with default name', () => { - const config = { - app: { - name: 'test', - options: {}, - automaticDataCollectionEnabled: false, - }, - }; - - const store = initializeUI(config); - expect(store.get()).toEqual({ - app: config.app, - getAuth: expect.any(Function), - locale: 'en-US', - setLocale: expect.any(Function), - state: 'idle', - setState: expect.any(Function), - translations: { - 'en-US': english.translations, - }, - behaviors: {}, - recaptchaMode: 'normal', - }); - expect($config.get()['[DEFAULT]']).toBe(store); - }); - - it('should initialize config with custom name', () => { - const config = { - app: { - name: 'test', - options: {}, - automaticDataCollectionEnabled: false, - }, - }; - - const store = initializeUI(config, 'custom'); - expect(store.get()).toEqual({ - app: config.app, - getAuth: expect.any(Function), - locale: 'en-US', - setLocale: expect.any(Function), - state: 'idle', - setState: expect.any(Function), - translations: { - 'en-US': english.translations, - }, - behaviors: {}, - recaptchaMode: 'normal', - }); - expect($config.get()['custom']).toBe(store); - }); - - it('should setup auto anonymous login when enabled', () => { - const config = { - app: { - name: 'test', - options: {}, - automaticDataCollectionEnabled: false, - }, - behaviors: [ - { - autoAnonymousLogin: vi.fn().mockImplementation(async (ui) => { - ui.setState('idle'); - return {}; - }), - }, - ], - }; - - const store = initializeUI(config); - expect(store.get().behaviors.autoAnonymousLogin).toBeDefined(); - expect(store.get().behaviors.autoAnonymousLogin).toHaveBeenCalled(); - expect(store.get().state).toBe('idle'); - }); - - it('should not setup auto anonymous login when disabled', () => { - const config = { - app: { - name: 'test', - options: {}, - automaticDataCollectionEnabled: false, - }, - }; - - const store = initializeUI(config); - expect(store.get().behaviors.autoAnonymousLogin).toBeUndefined(); - }); - - it('should handle both auto features being enabled', () => { - const config = { - app: { - name: 'test', - options: {}, - automaticDataCollectionEnabled: false, - }, - behaviors: [ - { - autoAnonymousLogin: vi.fn().mockImplementation(async (ui) => { - ui.setState('idle'); - return {}; - }), - autoUpgradeAnonymousCredential: vi.fn(), - }, - ], - }; - - const store = initializeUI(config); - expect(store.get()).toEqual({ - app: config.app, - getAuth: expect.any(Function), - locale: 'en-US', - setLocale: expect.any(Function), - state: 'idle', - setState: expect.any(Function), - translations: { - 'en-US': english.translations, - }, - behaviors: { - autoAnonymousLogin: expect.any(Function), - autoUpgradeAnonymousCredential: expect.any(Function), - }, - recaptchaMode: 'normal', - }); - expect(store.get().behaviors.autoAnonymousLogin).toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/firebaseui-core/tests/unit/errors.test.ts b/packages/firebaseui-core/tests/unit/errors.test.ts deleted file mode 100644 index 90518eeb9..000000000 --- a/packages/firebaseui-core/tests/unit/errors.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi } from 'vitest'; -import { FirebaseUIError, handleFirebaseError } from '../../src/errors'; -import { english } from '@firebase-ui/translations'; - -describe('FirebaseUIError', () => { - describe('constructor', () => { - it('should extract error code from Firebase error message', () => { - const error = new FirebaseUIError({ - customData: { message: 'Firebase: Error (auth/wrong-password).' }, - }); - expect(error.code).toBe('auth/wrong-password'); - }); - - it('should use error code directly if available', () => { - const error = new FirebaseUIError({ code: 'auth/user-not-found' }); - expect(error.code).toBe('auth/user-not-found'); - }); - - it('should fallback to unknown if no code is found', () => { - const error = new FirebaseUIError({}); - expect(error.code).toBe('unknown'); - }); - - it('should use custom translations if provided', () => { - const translations = { - 'es-ES': { - errors: { - userNotFound: 'Usuario no encontrado', - }, - }, - }; - const error = new FirebaseUIError({ code: 'auth/user-not-found' }, translations, 'es-ES'); - expect(error.message).toBe('Usuario no encontrado'); - }); - - it('should fallback to default translation if language is not found', () => { - const error = new FirebaseUIError({ code: 'auth/user-not-found' }, undefined, 'fr-FR'); - expect(error.message).toBe('No account found with this email address'); - }); - - it('should handle malformed error objects gracefully', () => { - const error = new FirebaseUIError(null); - expect(error.code).toBe('unknown'); - expect(error.message).toBe('An unexpected error occurred'); - }); - - it('should set error name to FirebaseUIError', () => { - const error = new FirebaseUIError({}); - expect(error.name).toBe('FirebaseUIError'); - }); - }); - - describe('handleFirebaseError', () => { - const mockUi = { - translations: { - 'es-ES': { - errors: { - userNotFound: 'Usuario no encontrado', - }, - }, - }, - locale: 'es-ES', - }; - - it('should throw FirebaseUIError for Firebase errors', () => { - const firebaseError = { - name: 'FirebaseError', - code: 'auth/user-not-found', - }; - - expect(() => { - handleFirebaseError(mockUi as any, firebaseError); - }).toThrow(FirebaseUIError); - - try { - handleFirebaseError(mockUi as any, firebaseError); - } catch (e) { - expect(e).toBeInstanceOf(FirebaseUIError); - expect(e.code).toBe('auth/user-not-found'); - expect(e.message).toBe('Usuario no encontrado'); - } - }); - - it('should throw FirebaseUIError with unknown code for non-Firebase errors', () => { - const error = new Error('Random error'); - - expect(() => { - handleFirebaseError(mockUi as any, error); - }).toThrow(FirebaseUIError); - - try { - handleFirebaseError(mockUi as any, error); - } catch (e) { - expect(e).toBeInstanceOf(FirebaseUIError); - expect(e.code).toBe('unknown'); - } - }); - - it('should pass translations and language to FirebaseUIError', () => { - const firebaseError = { - name: 'FirebaseError', - code: 'auth/user-not-found', - }; - - expect(() => { - handleFirebaseError(mockUi as any, firebaseError); - }).toThrow(FirebaseUIError); - - try { - handleFirebaseError(mockUi as any, firebaseError); - } catch (e) { - expect(e).toBeInstanceOf(FirebaseUIError); - expect(e.message).toBe('Usuario no encontrado'); - } - }); - - it('should handle null/undefined errors', () => { - expect(() => { - handleFirebaseError(mockUi as any, null); - }).toThrow(FirebaseUIError); - - try { - handleFirebaseError(mockUi as any, null); - } catch (e) { - expect(e).toBeInstanceOf(FirebaseUIError); - expect(e.code).toBe('unknown'); - } - }); - - it('should preserve the error code in thrown error', () => { - const firebaseError = { - name: 'FirebaseError', - code: 'auth/wrong-password', - }; - - expect(() => { - handleFirebaseError(mockUi as any, firebaseError); - }).toThrow(FirebaseUIError); - - try { - handleFirebaseError(mockUi as any, firebaseError); - } catch (e) { - expect(e).toBeInstanceOf(FirebaseUIError); - expect(e.code).toBe('auth/wrong-password'); - } - }); - - describe('account exists with different credential handling', () => { - it('should store credential and throw error when enableHandleExistingCredential is true', () => { - const mockCredential = { type: 'google.com' }; - const error = { - code: 'auth/account-exists-with-different-credential', - credential: mockCredential, - customData: { email: 'test@test.com' }, - }; - - expect(() => { - handleFirebaseError(mockUi as any, error, { enableHandleExistingCredential: true }); - }).toThrow(FirebaseUIError); - - try { - handleFirebaseError(mockUi as any, error, { enableHandleExistingCredential: true }); - } catch (e) { - expect(e).toBeInstanceOf(FirebaseUIError); - expect(e.code).toBe('auth/account-exists-with-different-credential'); - expect(window.sessionStorage.getItem('pendingCred')).toBe(JSON.stringify(mockCredential)); - } - }); - - it('should not store credential when enableHandleExistingCredential is false', () => { - const mockCredential = { type: 'google.com' }; - const error = { - code: 'auth/account-exists-with-different-credential', - credential: mockCredential, - }; - - expect(() => { - handleFirebaseError(mockUi as any, error); - }).toThrow(FirebaseUIError); - - try { - handleFirebaseError(mockUi as any, error); - } catch (e) { - expect(window.sessionStorage.getItem('pendingCred')).toBeNull(); - } - }); - - it('should not store credential when no credential in error', () => { - const error = { - code: 'auth/account-exists-with-different-credential', - }; - - expect(() => { - handleFirebaseError(mockUi as any, error, { enableHandleExistingCredential: true }); - }).toThrow(FirebaseUIError); - - try { - handleFirebaseError(mockUi as any, error, { enableHandleExistingCredential: true }); - } catch (e) { - expect(window.sessionStorage.getItem('pendingCred')).toBeNull(); - } - }); - - it('should include email in error and use translations when provided', () => { - const error = { - code: 'auth/account-exists-with-different-credential', - customData: { email: 'test@test.com' }, - }; - - const customUi = { - translations: { - 'es-ES': { - errors: { - accountExistsWithDifferentCredential: 'La cuenta ya existe con otras credenciales', - }, - }, - }, - locale: 'es-ES', - }; - - expect(() => { - handleFirebaseError(customUi as any, error, { enableHandleExistingCredential: true }); - }).toThrow(FirebaseUIError); - - try { - handleFirebaseError(customUi as any, error, { enableHandleExistingCredential: true }); - } catch (e) { - expect(e).toBeInstanceOf(FirebaseUIError); - expect(e.code).toBe('auth/account-exists-with-different-credential'); - expect(e.message).toBe('La cuenta ya existe con otras credenciales'); - } - }); - }); - }); -}); diff --git a/packages/firebaseui-core/tests/unit/translations.test.ts b/packages/firebaseui-core/tests/unit/translations.test.ts deleted file mode 100644 index d7e26ad41..000000000 --- a/packages/firebaseui-core/tests/unit/translations.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi } from 'vitest'; -import { getTranslation } from '../../src/translations'; -import { english } from '@firebase-ui/translations'; - -describe('getTranslation', () => { - it('should return default English translation when no custom translations provided', () => { - const mockUi = { - translations: { 'en-US': english.translations }, - locale: 'en-US', - }; - - const translation = getTranslation(mockUi as any, 'errors', 'userNotFound'); - expect(translation).toBe('No account found with this email address'); - }); - - it('should use custom translation when provided', () => { - const mockUi = { - translations: { - 'es-ES': { - errors: { - userNotFound: 'Usuario no encontrado', - }, - }, - }, - locale: 'es-ES', - }; - - const translation = getTranslation(mockUi as any, 'errors', 'userNotFound'); - expect(translation).toBe('Usuario no encontrado'); - }); - - it('should use custom translation in specified language', () => { - const mockUi = { - translations: { - 'es-ES': { - errors: { - userNotFound: 'Usuario no encontrado', - }, - }, - 'en-US': english.translations, - }, - locale: 'es-ES', - }; - - const translation = getTranslation(mockUi as any, 'errors', 'userNotFound'); - expect(translation).toBe('Usuario no encontrado'); - }); - - it('should fallback to English when specified language is not available', () => { - const mockUi = { - translations: { - 'en-US': english.translations, - }, - locale: 'fr-FR', - }; - - const translation = getTranslation(mockUi as any, 'errors', 'userNotFound'); - expect(translation).toBe('No account found with this email address'); - }); - - it('should fallback to default English when no custom translations match', () => { - const mockUi = { - translations: { - 'es-ES': { - errors: {}, - }, - }, - locale: 'es-ES', - }; - - const translation = getTranslation(mockUi as any, 'errors', 'userNotFound'); - expect(translation).toBe('No account found with this email address'); - }); - - it('should work with different translation categories', () => { - const mockUi = { - translations: { - 'en-US': english.translations, - }, - locale: 'en-US', - }; - - const errorTranslation = getTranslation(mockUi as any, 'errors', 'userNotFound'); - const labelTranslation = getTranslation(mockUi as any, 'labels', 'signIn'); - - expect(errorTranslation).toBe('No account found with this email address'); - expect(labelTranslation).toBe('Sign In'); - }); - - it('should handle partial custom translations', () => { - const mockUi = { - translations: { - 'es-ES': { - errors: { - userNotFound: 'Usuario no encontrado', - }, - }, - 'en-US': english.translations, - }, - locale: 'es-ES', - }; - - const translation1 = getTranslation(mockUi as any, 'errors', 'userNotFound'); - const translation2 = getTranslation(mockUi as any, 'errors', 'unknownError'); - - expect(translation1).toBe('Usuario no encontrado'); - expect(translation2).toBe('An unexpected error occurred'); - }); - - it('should handle empty custom translations object', () => { - const mockUi = { - translations: {}, - locale: 'en-US', - }; - - const translation = getTranslation(mockUi as any, 'errors', 'userNotFound'); - expect(translation).toBe('No account found with this email address'); - }); - - it('should handle undefined custom translations', () => { - const mockUi = { - translations: undefined, - locale: 'en-US', - }; - - const translation = getTranslation(mockUi as any, 'errors', 'userNotFound'); - expect(translation).toBe('No account found with this email address'); - }); -}); diff --git a/packages/firebaseui-core/tsconfig.json b/packages/firebaseui-core/tsconfig.json deleted file mode 100644 index 64266b024..000000000 --- a/packages/firebaseui-core/tsconfig.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "lib": [ - "ES2020", - "DOM" - ], - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "noImplicitAny": true, - "strictNullChecks": true, - "strictFunctionTypes": true, - "strictBindCallApply": true, - "strictPropertyInitialization": true, - "noImplicitThis": true, - "useUnknownInCatchVariables": true, - "alwaysStrict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "exactOptionalPropertyTypes": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "noPropertyAccessFromIndexSignature": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "skipLibCheck": true, - "moduleResolution": "node" - }, - "include": [ - "src" - ], - "exclude": [ - "node_modules", - "dist" - ] -} \ No newline at end of file diff --git a/packages/firebaseui-react/package.json b/packages/firebaseui-react/package.json deleted file mode 100644 index cd2b7eb30..000000000 --- a/packages/firebaseui-react/package.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "name": "@firebase-ui/react", - "version": "0.0.1", - "type": "module", - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" - } - }, - "files": [ - "dist" - ], - "scripts": { - "prepare": "pnpm run build", - "build": "tsup", - "build:local": "pnpm run build && pnpm pack", - "dev": "tsup --watch", - "lint": "tsc --noEmit", - "format": "prettier --write \"src/**/*.ts\"", - "clean": "rimraf dist", - "test:unit": "vitest run tests/unit", - "test:unit:watch": "vitest tests/unit", - "test:integration": "vitest run tests/integration", - "test:integration:watch": "vitest tests/integration", - "publish:tags": "sh -c 'TAG=\"${npm_package_name}@${npm_package_version}\"; git tag --list \"$TAG\" | grep . || git tag \"$TAG\"; git push origin \"$TAG\"'", - "release": "pnpm run build && pnpm pack --pack-destination --pack-destination ../../releases/" - }, - "peerDependencies": { - "@firebase-ui/core": "workspace:*", - "@firebase-ui/styles": "workspace:*" - }, - "dependencies": { - "@nanostores/react": "^0.8.4", - "@tanstack/react-form": "^0.41.3", - "clsx": "^2.1.1", - "firebase": "^11.2.0", - "nanostores": "^0.11.3", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "tailwind-merge": "^3.0.1", - "zod": "^3.24.1" - }, - "devDependencies": { - "@testing-library/jest-dom": "^6.4.3", - "@testing-library/react": "^16.2.0", - "@types/jsdom": "^21.1.7", - "@types/node": "^22.13.8", - "@types/react": "^19.0.8", - "@types/react-dom": "^19.0.3", - "@vitejs/plugin-react": "^4.3.4", - "jsdom": "^26.0.0", - "tsup": "^8.3.6", - "typescript": "~5.6.2", - "vite": "^6.0.5", - "vitest": "^3.0.8", - "vitest-tsconfig-paths": "^3.4.1" - } -} diff --git a/packages/firebaseui-react/src/auth/forms/email-link-form.tsx b/packages/firebaseui-react/src/auth/forms/email-link-form.tsx deleted file mode 100644 index 965edb86e..000000000 --- a/packages/firebaseui-react/src/auth/forms/email-link-form.tsx +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -"use client"; - -import { - FirebaseUIError, - completeEmailLinkSignIn, - createEmailLinkFormSchema, - getTranslation, - sendSignInLinkToEmail, -} from "@firebase-ui/core"; -import { useForm } from "@tanstack/react-form"; -import { useEffect, useMemo, useState } from "react"; -import { useAuth, useUI } from "~/hooks"; -import { Button } from "../../components/button"; -import { FieldInfo } from "../../components/field-info"; -import { Policies } from "../../components/policies"; - -interface EmailLinkFormProps {} - -export function EmailLinkForm(_: EmailLinkFormProps) { - const ui = useUI(); - const auth = useAuth(ui); - - const [formError, setFormError] = useState(null); - const [emailSent, setEmailSent] = useState(false); - const [firstValidationOccured, setFirstValidationOccured] = useState(false); - - const emailLinkFormSchema = useMemo( - () => createEmailLinkFormSchema(ui.translations), - [ui.translations] - ); - - const form = useForm({ - defaultValues: { - email: "", - }, - validators: { - onBlur: emailLinkFormSchema, - onSubmit: emailLinkFormSchema, - }, - onSubmit: async ({ value }) => { - setFormError(null); - try { - await sendSignInLinkToEmail(ui, value.email); - setEmailSent(true); - } catch (error) { - if (error instanceof FirebaseUIError) { - setFormError(error.message); - return; - } - - console.error(error); - setFormError(getTranslation(ui, "errors", "unknownError")); - } - }, - }); - - // Handle email link sign-in if URL contains the link - useEffect(() => { - const completeSignIn = async () => { - try { - await completeEmailLinkSignIn(ui, window.location.href); - } catch (error) { - if (error instanceof FirebaseUIError) { - setFormError(error.message); - } - } - }; - - void completeSignIn(); - }, [auth, ui.translations]); - - if (emailSent) { - return
{getTranslation(ui, "messages", "signInLinkSent")}
; - } - - return ( -
{ - e.preventDefault(); - e.stopPropagation(); - await form.handleSubmit(); - }} - > -
- ( - <> - - - )} - /> -
- - - -
- - {formError &&
{formError}
} -
- - ); -} diff --git a/packages/firebaseui-react/src/auth/forms/email-password-form.tsx b/packages/firebaseui-react/src/auth/forms/email-password-form.tsx deleted file mode 100644 index 575745fce..000000000 --- a/packages/firebaseui-react/src/auth/forms/email-password-form.tsx +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -"use client"; - -import { - createEmailFormSchema, - FirebaseUIError, - getTranslation, - signInWithEmailAndPassword, - type EmailFormSchema, -} from "@firebase-ui/core"; -import { useForm } from "@tanstack/react-form"; -import { useMemo, useState } from "react"; -import { useUI } from "~/hooks"; -import { Button } from "../../components/button"; -import { FieldInfo } from "../../components/field-info"; -import { Policies } from "../../components/policies"; - -export interface EmailPasswordFormProps { - onForgotPasswordClick?: () => void; - onRegisterClick?: () => void; -} - -export function EmailPasswordForm({ - onForgotPasswordClick, - onRegisterClick, -}: EmailPasswordFormProps) { - const ui = useUI(); - - const [formError, setFormError] = useState(null); - const [firstValidationOccured, setFirstValidationOccured] = useState(false); - - // TODO: Do we need to memoize this? - const emailFormSchema = useMemo( - () => createEmailFormSchema(ui.translations), - [ui.translations] - ); - - const form = useForm({ - defaultValues: { - email: "", - password: "", - }, - validators: { - onBlur: emailFormSchema, - onSubmit: emailFormSchema, - }, - onSubmit: async ({ value }) => { - setFormError(null); - try { - await signInWithEmailAndPassword(ui, value.email, value.password); - } catch (error) { - if (error instanceof FirebaseUIError) { - setFormError(error.message); - return; - } - - console.error(error); - setFormError(getTranslation(ui, "errors", "unknownError")); - } - }, - }); - - return ( -
{ - e.preventDefault(); - e.stopPropagation(); - await form.handleSubmit(); - }} - > -
- ( - <> - - - )} - /> -
- -
- ( - <> - - - )} - /> -
- - - -
- - {formError &&
{formError}
} -
- - {onRegisterClick && ( -
- -
- )} - - ); -} diff --git a/packages/firebaseui-react/src/auth/forms/forgot-password-form.tsx b/packages/firebaseui-react/src/auth/forms/forgot-password-form.tsx deleted file mode 100644 index 03e6329a9..000000000 --- a/packages/firebaseui-react/src/auth/forms/forgot-password-form.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -"use client"; - -import { - createForgotPasswordFormSchema, - FirebaseUIError, - getTranslation, - sendPasswordResetEmail, - type ForgotPasswordFormSchema, -} from "@firebase-ui/core"; -import { useForm } from "@tanstack/react-form"; -import { useMemo, useState } from "react"; -import { useUI } from "~/hooks"; -import { Button } from "../../components/button"; -import { FieldInfo } from "../../components/field-info"; -import { Policies } from "../../components/policies"; - -interface ForgotPasswordFormProps { - onBackToSignInClick?: () => void; -} - -export function ForgotPasswordForm({ - onBackToSignInClick, -}: ForgotPasswordFormProps) { - const ui = useUI(); - - const [formError, setFormError] = useState(null); - const [emailSent, setEmailSent] = useState(false); - const [firstValidationOccured, setFirstValidationOccured] = useState(false); - const forgotPasswordFormSchema = useMemo( - () => createForgotPasswordFormSchema(ui.translations), - [ui.translations] - ); - - const form = useForm({ - defaultValues: { - email: "", - }, - validators: { - onBlur: forgotPasswordFormSchema, - onSubmit: forgotPasswordFormSchema, - }, - onSubmit: async ({ value }) => { - setFormError(null); - try { - await sendPasswordResetEmail(ui, value.email); - setEmailSent(true); - } catch (error) { - if (error instanceof FirebaseUIError) { - setFormError(error.message); - return; - } - - console.error(error); - setFormError(getTranslation(ui, "errors", "unknownError")); - } - }, - }); - - if (emailSent) { - return ( -
- {getTranslation(ui, "messages", "checkEmailForReset")} -
- ); - } - - return ( -
{ - e.preventDefault(); - e.stopPropagation(); - await form.handleSubmit(); - }} - > -
- ( - <> - - - )} - /> -
- - - -
- - {formError &&
{formError}
} -
- - {onBackToSignInClick && ( -
- -
- )} - - ); -} diff --git a/packages/firebaseui-react/src/auth/forms/phone-form.tsx b/packages/firebaseui-react/src/auth/forms/phone-form.tsx deleted file mode 100644 index 216a20efe..000000000 --- a/packages/firebaseui-react/src/auth/forms/phone-form.tsx +++ /dev/null @@ -1,452 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -"use client"; - -import { - confirmPhoneNumber, - CountryData, - countryData, - createPhoneFormSchema, - FirebaseUIError, - formatPhoneNumberWithCountry, - getTranslation, - signInWithPhoneNumber, -} from "@firebase-ui/core"; -import { useForm } from "@tanstack/react-form"; -import { ConfirmationResult, RecaptchaVerifier } from "firebase/auth"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { z } from "zod"; -import { useAuth, useUI } from "~/hooks"; -import { Button } from "../../components/button"; -import { CountrySelector } from "../../components/country-selector"; -import { FieldInfo } from "../../components/field-info"; -import { Policies } from "../../components/policies"; - -interface PhoneNumberFormProps { - onSubmit: (phoneNumber: string) => Promise; - formError: string | null; - recaptchaVerifier: RecaptchaVerifier | null; - recaptchaContainerRef: React.RefObject; -} - -function PhoneNumberForm({ - onSubmit, - formError, - recaptchaVerifier, - recaptchaContainerRef, -}: PhoneNumberFormProps) { - const ui = useUI(); - - const [selectedCountry, setSelectedCountry] = useState( - countryData[0] - ); - const [firstValidationOccured, setFirstValidationOccured] = useState(false); - - const phoneFormSchema = useMemo( - () => - createPhoneFormSchema(ui.translations).pick({ - phoneNumber: true, - }), - [ui.translations] - ); - - const phoneForm = useForm>({ - defaultValues: { - phoneNumber: "", - }, - validators: { - onBlur: phoneFormSchema, - onSubmit: phoneFormSchema, - }, - onSubmit: async ({ value }) => { - const formattedNumber = formatPhoneNumberWithCountry( - value.phoneNumber, - selectedCountry.dialCode - ); - await onSubmit(formattedNumber); - }, - }); - - return ( -
{ - e.preventDefault(); - e.stopPropagation(); - await phoneForm.handleSubmit(); - }} - > -
- ( - <> - - - )} - /> -
- -
-
-
- - - -
- - {formError &&
{formError}
} -
- - ); -} - -function useResendTimer(initialDelay: number) { - const [timeLeft, setTimeLeft] = useState(0); - const [isActive, setIsActive] = useState(false); - const timerRef = useRef(0); - - useEffect(() => { - return () => { - if (timerRef.current) { - clearInterval(timerRef.current); - } - }; - }, [initialDelay]); - - const startTimer = useCallback(() => { - if (timerRef.current) { - clearInterval(timerRef.current); - } - - setTimeLeft(initialDelay); - setIsActive(true); - - timerRef.current = window.setInterval(() => { - setTimeLeft((prev) => { - const next = prev <= 1 ? 0 : prev - 1; - if (prev <= 1) { - if (timerRef.current) { - clearInterval(timerRef.current); - } - setIsActive(false); - } - return next; - }); - }, 1000); - }, [initialDelay]); - - const canResend = !isActive && timeLeft === 0; - - return { timeLeft, canResend, startTimer }; -} - -interface VerificationFormProps { - onSubmit: (code: string) => Promise; - onResend: () => Promise; - formError: string | null; - isResending: boolean; - canResend: boolean; - timeLeft: number; - recaptchaContainerRef: React.RefObject; -} - -function VerificationForm({ - onSubmit, - onResend, - formError, - isResending, - canResend, - timeLeft, - recaptchaContainerRef, -}: VerificationFormProps) { - const ui = useUI(); - - const [firstValidationOccured, setFirstValidationOccured] = useState(false); - - const verificationFormSchema = useMemo( - () => - createPhoneFormSchema(ui.translations).pick({ - verificationCode: true, - }), - [ui.translations] - ); - - const verificationForm = useForm>({ - defaultValues: { - verificationCode: "", - }, - validators: { - onBlur: verificationFormSchema, - onSubmit: verificationFormSchema, - }, - onSubmit: async ({ value }) => { - await onSubmit(value.verificationCode); - }, - }); - - return ( -
{ - e.preventDefault(); - e.stopPropagation(); - await verificationForm.handleSubmit(); - }} - > -
- ( - <> - - - )} - /> -
- -
-
-
- - - -
- - - {formError &&
{formError}
} -
- - ); -} - -export interface PhoneFormProps { - resendDelay?: number; -} - -export function PhoneForm({ resendDelay = 30 }: PhoneFormProps) { - const ui = useUI(); - const auth = useAuth(ui); - - const [formError, setFormError] = useState(null); - const [confirmationResult, setConfirmationResult] = - useState(null); - const [recaptchaVerifier, setRecaptchaVerifier] = - useState(null); - const [phoneNumber, setPhoneNumber] = useState(""); - const [isResending, setIsResending] = useState(false); - const recaptchaContainerRef = useRef(null); - const { timeLeft, canResend, startTimer } = useResendTimer(resendDelay); - - useEffect(() => { - if (!recaptchaContainerRef.current) return; - - const verifier = new RecaptchaVerifier( - auth, - recaptchaContainerRef.current, - { - size: ui.recaptchaMode ?? "normal", - } - ); - - setRecaptchaVerifier(verifier); - - return () => { - verifier.clear(); - setRecaptchaVerifier(null); - }; - }, [auth, ui.recaptchaMode]); - - const handlePhoneSubmit = async (number: string) => { - setFormError(null); - try { - if (!recaptchaVerifier) { - throw new Error("ReCAPTCHA not initialized"); - } - - const result = await signInWithPhoneNumber(ui, number, recaptchaVerifier); - setPhoneNumber(number); - setConfirmationResult(result); - startTimer(); - } catch (error) { - if (error instanceof FirebaseUIError) { - setFormError(error.message); - return; - } - console.error(error); - setFormError(getTranslation(ui, "errors", "unknownError")); - } - }; - - const handleResend = async () => { - if ( - isResending || - !canResend || - !phoneNumber || - !recaptchaContainerRef.current - ) { - return; - } - - setIsResending(true); - setFormError(null); - - try { - if (recaptchaVerifier) { - recaptchaVerifier.clear(); - } - - const verifier = new RecaptchaVerifier( - auth, - recaptchaContainerRef.current, - { - size: ui.recaptchaMode ?? "normal", - } - ); - setRecaptchaVerifier(verifier); - - const result = await signInWithPhoneNumber(ui, phoneNumber, verifier); - setConfirmationResult(result); - startTimer(); - } catch (error) { - if (error instanceof FirebaseUIError) { - setFormError(error.message); - } else { - console.error(error); - setFormError(getTranslation(ui, "errors", "unknownError")); - } - } finally { - setIsResending(false); - } - }; - - const handleVerificationSubmit = async (code: string) => { - if (!confirmationResult) { - throw new Error("Confirmation result not initialized"); - } - - setFormError(null); - - try { - await confirmPhoneNumber(ui, confirmationResult, code); - } catch (error) { - if (error instanceof FirebaseUIError) { - setFormError(error.message); - return; - } - console.error(error); - setFormError(getTranslation(ui, "errors", "unknownError")); - } - }; - - return ( -
- {confirmationResult ? ( - - ) : ( - - )} -
- ); -} diff --git a/packages/firebaseui-react/src/auth/forms/register-form.tsx b/packages/firebaseui-react/src/auth/forms/register-form.tsx deleted file mode 100644 index a3b4a2bd7..000000000 --- a/packages/firebaseui-react/src/auth/forms/register-form.tsx +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -"use client"; - -import { - FirebaseUIError, - createEmailFormSchema, - createUserWithEmailAndPassword, - getTranslation, - type EmailFormSchema, -} from "@firebase-ui/core"; -import { useForm } from "@tanstack/react-form"; -import { useMemo, useState } from "react"; -import { useUI } from "~/hooks"; -import { Button } from "../../components/button"; -import { FieldInfo } from "../../components/field-info"; -import { Policies } from "../../components/policies"; - -export interface RegisterFormProps { - onBackToSignInClick?: () => void; -} - -export function RegisterForm({ onBackToSignInClick }: RegisterFormProps) { - const ui = useUI(); - - const [formError, setFormError] = useState(null); - const [firstValidationOccured, setFirstValidationOccured] = useState(false); - const emailFormSchema = useMemo( - () => createEmailFormSchema(ui.translations), - [ui.translations] - ); - - const form = useForm({ - defaultValues: { - email: "", - password: "", - }, - validators: { - onBlur: emailFormSchema, - onSubmit: emailFormSchema, - }, - onSubmit: async ({ value }) => { - setFormError(null); - try { - await createUserWithEmailAndPassword(ui, value.email, value.password); - } catch (error) { - if (error instanceof FirebaseUIError) { - setFormError(error.message); - return; - } - - console.error(error); - setFormError(getTranslation(ui, "errors", "unknownError")); - } - }, - }); - - return ( -
{ - e.preventDefault(); - e.stopPropagation(); - await form.handleSubmit(); - }} - > -
- ( - <> - - - )} - /> -
- -
- ( - <> - - - )} - /> -
- - - -
- - {formError &&
{formError}
} -
- - {onBackToSignInClick && ( -
- -
- )} - - ); -} diff --git a/packages/firebaseui-react/src/auth/index.ts b/packages/firebaseui-react/src/auth/index.ts deleted file mode 100644 index b97285662..000000000 --- a/packages/firebaseui-react/src/auth/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** Export screens */ -export { - EmailLinkAuthScreen, - type EmailLinkAuthScreenProps, -} from "./screens/email-link-auth-screen"; -export { - SignInAuthScreen, - type SignInAuthScreenProps, -} from "./screens/sign-in-auth-screen"; - -export { - PhoneAuthScreen, - type PhoneAuthScreenProps, -} from "./screens/phone-auth-screen"; - -export { - SignUpAuthScreen, - type SignUpAuthScreenProps, -} from "./screens/sign-up-auth-screen"; - -export { OAuthScreen, type OAuthScreenProps } from "./screens/oauth-screen"; - -export { - PasswordResetScreen, - type PasswordResetScreenProps, -} from "./screens/password-reset-screen"; - -/** Export forms */ -export { - EmailPasswordForm, - type EmailPasswordFormProps, -} from "./forms/email-password-form"; - -export { RegisterForm, type RegisterFormProps } from "./forms/register-form"; - -/** Export Buttons */ -export { GoogleSignInButton } from "./oauth/google-sign-in-button"; diff --git a/packages/firebaseui-react/src/auth/oauth/google-sign-in-button.tsx b/packages/firebaseui-react/src/auth/oauth/google-sign-in-button.tsx deleted file mode 100644 index bbd40bb17..000000000 --- a/packages/firebaseui-react/src/auth/oauth/google-sign-in-button.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -"use client"; - -import { getTranslation } from "@firebase-ui/core"; -import { GoogleAuthProvider } from "firebase/auth"; -import { useUI } from "~/hooks"; -import { OAuthButton } from "./oauth-button"; - -export function GoogleSignInButton() { - const ui = useUI(); - - return ( - - - - - - - - {getTranslation(ui, "labels", "signInWithGoogle")} - - ); -} diff --git a/packages/firebaseui-react/src/auth/oauth/oauth-button.tsx b/packages/firebaseui-react/src/auth/oauth/oauth-button.tsx deleted file mode 100644 index cf3ef5ce2..000000000 --- a/packages/firebaseui-react/src/auth/oauth/oauth-button.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -"use client"; - -import { - FirebaseUIError, - getTranslation, - signInWithOAuth, -} from "@firebase-ui/core"; -import type { AuthProvider } from "firebase/auth"; -import type { PropsWithChildren } from "react"; -import { useState } from "react"; -import { Button } from "~/components/button"; -import { useUI } from "~/hooks"; - -export type OAuthButtonProps = PropsWithChildren<{ - provider: AuthProvider; -}>; - -export function OAuthButton({ provider, children }: OAuthButtonProps) { - const ui = useUI(); - - const [error, setError] = useState(null); - - const handleOAuthSignIn = async () => { - setError(null); - try { - await signInWithOAuth(ui, provider); - } catch (error) { - if (error instanceof FirebaseUIError) { - setError(error.message); - return; - } - console.error(error); - setError(getTranslation(ui, "errors", "unknownError")); - } - }; - - return ( -
- - {error &&
{error}
} -
- ); -} diff --git a/packages/firebaseui-react/src/auth/screens/email-link-auth-screen.tsx b/packages/firebaseui-react/src/auth/screens/email-link-auth-screen.tsx deleted file mode 100644 index b7634e0ff..000000000 --- a/packages/firebaseui-react/src/auth/screens/email-link-auth-screen.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { PropsWithChildren } from "react"; -import { getTranslation } from "@firebase-ui/core"; -import { Divider } from "~/components/divider"; -import { useUI } from "~/hooks"; -import { - Card, - CardHeader, - CardSubtitle, - CardTitle, -} from "../../components/card"; -import { EmailLinkForm } from "../forms/email-link-form"; - -export type EmailLinkAuthScreenProps = PropsWithChildren; - -export function EmailLinkAuthScreen({ children }: EmailLinkAuthScreenProps) { - const ui = useUI(); - - const titleText = getTranslation(ui, "labels", "signIn"); - const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - - return ( -
- - - {titleText} - {subtitleText} - - - {children ? ( - <> - {getTranslation(ui, "messages", "dividerOr")} -
{children}
- - ) : null} -
-
- ); -} diff --git a/packages/firebaseui-react/src/auth/screens/oauth-screen.tsx b/packages/firebaseui-react/src/auth/screens/oauth-screen.tsx deleted file mode 100644 index c21063521..000000000 --- a/packages/firebaseui-react/src/auth/screens/oauth-screen.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { getTranslation } from "@firebase-ui/core"; -import { useUI } from "~/hooks"; -import { - Card, - CardHeader, - CardSubtitle, - CardTitle, -} from "../../components/card"; -import { PropsWithChildren } from "react"; -import { Policies } from "~/components/policies"; - -export type OAuthScreenProps = PropsWithChildren; - -export function OAuthScreen({ children }: OAuthScreenProps) { - const ui = useUI(); - - // TODO: Translations for oauth providers - const titleText = getTranslation(ui, "labels", "signIn"); - const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - - return ( -
- - - {titleText} - {subtitleText} - - {children} - - -
- ); -} diff --git a/packages/firebaseui-react/src/auth/screens/phone-auth-screen.tsx b/packages/firebaseui-react/src/auth/screens/phone-auth-screen.tsx deleted file mode 100644 index 460691c40..000000000 --- a/packages/firebaseui-react/src/auth/screens/phone-auth-screen.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { PropsWithChildren } from "react"; -import { getTranslation } from "@firebase-ui/core"; -import { Divider } from "~/components/divider"; -import { useUI } from "~/hooks"; -import { - Card, - CardHeader, - CardSubtitle, - CardTitle, -} from "../../components/card"; -import { PhoneForm } from "../forms/phone-form"; - -export type PhoneAuthScreenProps = PropsWithChildren<{ - resendDelay?: number; -}>; - -export function PhoneAuthScreen({ - children, - resendDelay, -}: PhoneAuthScreenProps) { - const ui = useUI(); - - const titleText = getTranslation(ui, "labels", "signIn"); - const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - - return ( -
- - - {titleText} - {subtitleText} - - - {children ? ( - <> - {getTranslation(ui, "messages", "dividerOr")} -
{children}
- - ) : null} -
-
- ); -} diff --git a/packages/firebaseui-react/src/auth/screens/sign-in-auth-screen.tsx b/packages/firebaseui-react/src/auth/screens/sign-in-auth-screen.tsx deleted file mode 100644 index ea32aff8f..000000000 --- a/packages/firebaseui-react/src/auth/screens/sign-in-auth-screen.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { PropsWithChildren } from "react"; -import { getTranslation } from "@firebase-ui/core"; -import { Divider } from "~/components/divider"; -import { useUI } from "~/hooks"; -import { - Card, - CardHeader, - CardSubtitle, - CardTitle, -} from "../../components/card"; -import { EmailPasswordForm } from "../forms/email-password-form"; - -export type SignInAuthScreenProps = PropsWithChildren<{ - onForgotPasswordClick?: () => void; - onRegisterClick?: () => void; -}>; - -export function SignInAuthScreen({ - onForgotPasswordClick, - onRegisterClick, - children, -}: SignInAuthScreenProps) { - const ui = useUI(); - - const titleText = getTranslation(ui, "labels", "signIn"); - const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); - - return ( -
- - - {titleText} - {subtitleText} - - - {children ? ( - <> - {getTranslation(ui, "messages", "dividerOr")} -
{children}
- - ) : null} -
-
- ); -} diff --git a/packages/firebaseui-react/src/auth/screens/sign-up-auth-screen.tsx b/packages/firebaseui-react/src/auth/screens/sign-up-auth-screen.tsx deleted file mode 100644 index 2579db449..000000000 --- a/packages/firebaseui-react/src/auth/screens/sign-up-auth-screen.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { PropsWithChildren } from "react"; -import { Divider } from "~/components/divider"; -import { useUI } from "~/hooks"; -import { - Card, - CardHeader, - CardSubtitle, - CardTitle, -} from "../../components/card"; -import { RegisterForm } from "../forms/register-form"; -import { getTranslation } from "@firebase-ui/core"; - -export type SignUpAuthScreenProps = PropsWithChildren<{ - onBackToSignInClick?: () => void; -}>; - -export function SignUpAuthScreen({ - onBackToSignInClick, - children, -}: SignUpAuthScreenProps) { - const ui = useUI(); - - const titleText = getTranslation(ui, "labels", "register"); - const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate"); - - return ( -
- - - {titleText} - {subtitleText} - - - {children ? ( - <> - {getTranslation(ui, "messages", "dividerOr")} -
{children}
- - ) : null} -
-
- ); -} diff --git a/packages/firebaseui-react/src/components/country-selector.tsx b/packages/firebaseui-react/src/components/country-selector.tsx deleted file mode 100644 index b10646247..000000000 --- a/packages/firebaseui-react/src/components/country-selector.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -"use client"; - -import { CountryData, countryData } from "@firebase-ui/core"; -import { cn } from "~/utils/cn"; - -interface CountrySelectorProps { - value: CountryData; - onChange: (country: CountryData) => void; - className?: string; -} - -export function CountrySelector({ - value, - onChange, - className, -}: CountrySelectorProps) { - return ( -
-
- {value.emoji} -
- {value.dialCode} - -
-
-
- ); -} diff --git a/packages/firebaseui-react/src/components/field-info.tsx b/packages/firebaseui-react/src/components/field-info.tsx deleted file mode 100644 index 2d7625bdb..000000000 --- a/packages/firebaseui-react/src/components/field-info.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { FieldApi } from "@tanstack/react-form"; -import { HTMLAttributes } from "react"; -import { cn } from "~/utils/cn"; - -interface FieldInfoProps extends HTMLAttributes { - field: FieldApi; -} - -export function FieldInfo({ - field, - className, - ...props -}: FieldInfoProps) { - return ( - <> - {field.state.meta.isTouched && field.state.meta.errors.length ? ( -
- {field.state.meta.errors[0]} -
- ) : null} - - ); -} diff --git a/packages/firebaseui-react/src/components/policies.tsx b/packages/firebaseui-react/src/components/policies.tsx deleted file mode 100644 index a26e7e317..000000000 --- a/packages/firebaseui-react/src/components/policies.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { getTranslation } from "@firebase-ui/core"; -import { createContext, useContext } from "react"; -import { useUI } from "~/hooks"; - -type Url = - | string - | URL - | (() => string | URL | void) - | Promise - | (() => Promise); - -export interface PolicyProps { - termsOfServiceUrl: Url; - privacyPolicyUrl: Url; -} - -const PolicyContext = createContext( - undefined -); - -export function PolicyProvider({ children, policies }: { children: React.ReactNode, policies?: PolicyProps }) { - return {children}; -} - -export function Policies() { - const ui = useUI(); - const policies = useContext(PolicyContext); - - if (!policies) { - return null; - } - - const { termsOfServiceUrl, privacyPolicyUrl } = policies; - - async function handleUrl(urlOrFunction: Url) { - let url: string | URL | void; - - if (typeof urlOrFunction === "function") { - const urlOrPromise = urlOrFunction(); - if (typeof urlOrPromise === "string" || urlOrPromise instanceof URL) { - url = urlOrPromise; - } else { - url = await urlOrPromise; - } - } else if (urlOrFunction instanceof Promise) { - url = await urlOrFunction; - } else { - url = urlOrFunction; - } - - if (url) { - window.open(url.toString(), "_blank"); - } - } - - const termsText = getTranslation(ui, "labels", "termsOfService"); - const privacyText = getTranslation(ui, "labels", "privacyPolicy"); - const termsAndPrivacyText = getTranslation(ui, "messages", "termsAndPrivacy"); - - const parts = termsAndPrivacyText.split(/(\{tos\}|\{privacy\})/); - - return ( - - ); -} diff --git a/packages/firebaseui-react/src/hooks.ts b/packages/firebaseui-react/src/hooks.ts deleted file mode 100644 index 544a18104..000000000 --- a/packages/firebaseui-react/src/hooks.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useContext, useMemo } from "react"; -import { getAuth } from "firebase/auth"; -import { FirebaseUIContext } from "./context"; -import { FirebaseUIConfiguration } from "@firebase-ui/core"; - -/** - * Get the UI configuration from the context. - */ -export function useUI() { - return useContext(FirebaseUIContext); -} - -/** - * Get the auth instance from the UI configuration. - * If no UI configuration is provided, use the auth instance from the context. - */ -export function useAuth(ui?: FirebaseUIConfiguration | undefined) { - const config = ui ?? useUI(); - const auth = useMemo( - () => ui?.getAuth() ?? getAuth(config.app), - [config.app], - ); - return auth; -} diff --git a/packages/firebaseui-react/tests/integration/auth/email-link-auth.integration.test.tsx b/packages/firebaseui-react/tests/integration/auth/email-link-auth.integration.test.tsx deleted file mode 100644 index 16d7112c7..000000000 --- a/packages/firebaseui-react/tests/integration/auth/email-link-auth.integration.test.tsx +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, afterAll } from "vitest"; -import { fireEvent, waitFor, act, render } from "@testing-library/react"; -import { EmailLinkForm } from "../../../src/auth/forms/email-link-form"; -import { initializeApp } from "firebase/app"; -import { getAuth, connectAuthEmulator, deleteUser } from "firebase/auth"; -import { initializeUI } from "@firebase-ui/core"; -import { FirebaseUIProvider } from "~/context"; - -// Prepare the test environment -const firebaseConfig = { - apiKey: "demo-api-key", - authDomain: "demo-firebaseui.firebaseapp.com", - projectId: "demo-firebaseui", -}; - -// Initialize app once for all tests -const app = initializeApp(firebaseConfig); -const auth = getAuth(app); -connectAuthEmulator(auth, "http://localhost:9099", { disableWarnings: true }); - -const ui = initializeUI({ - app, -}); - -describe("Email Link Authentication Integration", () => { - const testEmail = `test-${Date.now()}@example.com`; - - // Clean up after tests - afterAll(async () => { - try { - const currentUser = auth.currentUser; - if (currentUser) { - await deleteUser(currentUser); - } - } catch (error) { - // Ignore cleanup errors - } - }); - - it("should successfully initiate email link sign in", async () => { - // For integration tests with the Firebase emulator, we need to ensure localStorage is available - const emailForSignInKey = "emailForSignIn"; - - // Clear any existing values that might affect the test - window.localStorage.removeItem(emailForSignInKey); - - const { container } = render( - - - - ); - - // Get the email input - const emailInput = container.querySelector('input[type="email"]'); - expect(emailInput).not.toBeNull(); - - // Change the email input value - await act(async () => { - if (emailInput) { - fireEvent.change(emailInput, { target: { value: testEmail } }); - } - }); - - // Get the submit button - const submitButton = container.querySelector('button[type="submit"]')!; - expect(submitButton).not.toBeNull(); - - // Click the submit button - await act(async () => { - fireEvent.click(submitButton); - }); - - // In the Firebase emulator environment, we need to be more flexible - // The test passes if either: - // 1. The success message is displayed, or - // 2. There are no critical error messages (only validation errors are acceptable) - await waitFor( - () => { - // Check for success message - const successMessage = container.querySelector(".fui-form__success"); - - // If we have a success message, the test passes - if (successMessage) { - expect(successMessage).toBeTruthy(); - return; - } - - // Check for error messages - const errorElements = container.querySelectorAll(".fui-form__error"); - - // If there are error elements, check if they're just validation errors - if (errorElements.length > 0) { - let hasCriticalError = false; - let criticalErrorText = ""; - - errorElements.forEach((element) => { - const errorText = element.textContent?.toLowerCase() || ""; - - // Only fail if there's a critical error (not validation related) - if ( - !errorText.includes("email") && - !errorText.includes("valid") && - !errorText.includes("required") - ) { - hasCriticalError = true; - criticalErrorText = errorText; - } - }); - - // If we have critical errors, the test should fail with a descriptive message - if (hasCriticalError) { - expect( - criticalErrorText, - `Critical error found in email link test: ${criticalErrorText}` - ).toContain("email"); // This will fail with a descriptive message - } - } - }, - { timeout: 5000 } - ); - - // Clean up - window.localStorage.removeItem(emailForSignInKey); - }); - - it("should handle invalid email format", async () => { - const { container } = render( - - - - ); - - const emailInput = container.querySelector('input[type="email"]'); - expect(emailInput).not.toBeNull(); - - await act(async () => { - if (emailInput) { - fireEvent.change(emailInput, { target: { value: "invalid-email" } }); - // Trigger blur to show validation error - fireEvent.blur(emailInput); - } - }); - - const submitButton = container.querySelector('button[type="submit"]')!; - expect(submitButton).not.toBeNull(); - - await act(async () => { - fireEvent.click(submitButton); - }); - - await waitFor(() => { - expect(container.querySelector(".fui-form__error")).not.toBeNull(); - }); - }); -}); diff --git a/packages/firebaseui-react/tests/integration/auth/email-password-auth.integration.test.tsx b/packages/firebaseui-react/tests/integration/auth/email-password-auth.integration.test.tsx deleted file mode 100644 index 1fdf34f92..000000000 --- a/packages/firebaseui-react/tests/integration/auth/email-password-auth.integration.test.tsx +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { - screen, - fireEvent, - waitFor, - act, - render, -} from "@testing-library/react"; -import { EmailPasswordForm } from "../../../src/auth/forms/email-password-form"; -import { initializeApp } from "firebase/app"; -import { - getAuth, - connectAuthEmulator, - signInWithEmailAndPassword, - createUserWithEmailAndPassword, - deleteUser, -} from "firebase/auth"; -import { FirebaseUIProvider } from "~/context"; -import { initializeUI } from "@firebase-ui/core"; - -// Prepare the test environment -const firebaseConfig = { - apiKey: "test-api-key", - authDomain: "test-project.firebaseapp.com", - projectId: "test-project", -}; - -// Initialize app once for all tests -const app = initializeApp(firebaseConfig); -const auth = getAuth(app); - -const ui = initializeUI({ - app, -}); - -// Connect to the auth emulator -connectAuthEmulator(auth, "http://localhost:9099", { disableWarnings: true }); - -describe("Email Password Authentication Integration", () => { - // Test user we'll create for our tests - const testEmail = `test-${Date.now()}@example.com`; - const testPassword = "Test123!"; - - // Set up a test user before tests - beforeAll(async () => { - try { - await createUserWithEmailAndPassword(auth, testEmail, testPassword); - } catch (error) { - throw new Error( - `Failed to set up test user: ${error instanceof Error ? error.message : String(error)}` - ); - } - }); - - // Clean up after tests - afterAll(async () => { - try { - // First check if the user is already signed in - if (auth.currentUser && auth.currentUser.email === testEmail) { - await deleteUser(auth.currentUser); - } else { - // Try to sign in first - const userCredential = await signInWithEmailAndPassword( - auth, - testEmail, - testPassword - ); - await deleteUser(userCredential.user); - } - } catch (error) { - console.warn("Error in test cleanup process. Resuming, but this may indicate a problem.", error); - } - }); - - it("should successfully sign in with email and password using actual Firebase Auth", async () => { - const { container } = render( - - - - ); - - const emailInput = container.querySelector('input[type="email"]'); - const passwordInput = container.querySelector('input[type="password"]'); - - expect(emailInput).not.toBeNull(); - expect(passwordInput).not.toBeNull(); - - await act(async () => { - if (emailInput && passwordInput) { - fireEvent.change(emailInput, { target: { value: testEmail } }); - fireEvent.blur(emailInput); - fireEvent.change(passwordInput, { target: { value: testPassword } }); - fireEvent.blur(passwordInput); - } - }); - - const submitButton = await screen.findByRole("button", { - name: /sign in/i, - }); - - await act(async () => { - fireEvent.click(submitButton); - }); - - await waitFor( - () => { - expect(screen.queryByText(/invalid credentials/i)).toBeNull(); - }, - { timeout: 5000 } - ); - }); - - it("should fail when using invalid credentials", async () => { - const { container } = render( - - - - ); - - const emailInput = container.querySelector('input[type="email"]'); - const passwordInput = container.querySelector('input[type="password"]'); - - expect(emailInput).not.toBeNull(); - expect(passwordInput).not.toBeNull(); - - await act(async () => { - if (emailInput && passwordInput) { - fireEvent.change(emailInput, { target: { value: testEmail } }); - fireEvent.blur(emailInput); - fireEvent.change(passwordInput, { target: { value: "wrongpassword" } }); - fireEvent.blur(passwordInput); - } - }); - - const submitButton = await screen.findByRole("button", { - name: /sign in/i, - }); - - await act(async () => { - fireEvent.click(submitButton); - }); - - await waitFor( - () => { - expect(container.querySelector(".fui-form__error")).not.toBeNull(); - }, - { timeout: 5000 } - ); - }); - - it("should show an error message for invalid credentials", async () => { - const { container } = render( - - - - ); - - const emailInput = container.querySelector('input[type="email"]'); - const passwordInput = container.querySelector('input[type="password"]'); - - expect(emailInput).not.toBeNull(); - expect(passwordInput).not.toBeNull(); - - await act(async () => { - if (emailInput && passwordInput) { - fireEvent.change(emailInput, { target: { value: testEmail } }); - fireEvent.blur(emailInput); - fireEvent.change(passwordInput, { target: { value: "wrongpassword" } }); - fireEvent.blur(passwordInput); - } - }); - - const submitButton = await screen.findByRole("button", { - name: /sign in/i, - }); - - await act(async () => { - fireEvent.click(submitButton); - }); - - await waitFor( - () => { - expect(container.querySelector(".fui-form__error")).not.toBeNull(); - }, - { timeout: 5000 } - ); - }); -}); diff --git a/packages/firebaseui-react/tests/integration/auth/forgot-password.integration.test.tsx b/packages/firebaseui-react/tests/integration/auth/forgot-password.integration.test.tsx deleted file mode 100644 index 1623b3857..000000000 --- a/packages/firebaseui-react/tests/integration/auth/forgot-password.integration.test.tsx +++ /dev/null @@ -1,214 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, afterAll, beforeEach } from "vitest"; -import { fireEvent, waitFor, act, render } from "@testing-library/react"; -import { ForgotPasswordForm } from "../../../src/auth/forms/forgot-password-form"; -import { initializeApp } from "firebase/app"; -import { - getAuth, - connectAuthEmulator, - deleteUser, - signOut, - createUserWithEmailAndPassword, - signInWithEmailAndPassword, -} from "firebase/auth"; -import { initializeUI } from "@firebase-ui/core"; -import { FirebaseUIProvider } from "~/context"; - -// Prepare the test environment -const firebaseConfig = { - apiKey: "demo-api-key", - authDomain: "demo-firebaseui.firebaseapp.com", - projectId: "demo-firebaseui", -}; - -// Initialize app once for all tests -const app = initializeApp(firebaseConfig); -const auth = getAuth(app); - -// Connect to the auth emulator -connectAuthEmulator(auth, "http://localhost:9099", { disableWarnings: true }); - -const ui = initializeUI({ - app, -}); - -describe("Forgot Password Integration", () => { - const testEmail = `test-${Date.now()}@example.com`; - const testPassword = "Test123!"; - - // Clean up before each test - beforeEach(async () => { - // Try to sign in with the test email and delete the user if it exists - try { - await signInWithEmailAndPassword(auth, testEmail, testPassword); - if (auth.currentUser) { - await deleteUser(auth.currentUser); - } - } catch (error) { - // Ignore errors if user doesn't exist - } - await signOut(auth); - }); - - // Clean up after tests - afterAll(async () => { - try { - await signInWithEmailAndPassword(auth, testEmail, testPassword); - if (auth.currentUser) { - await deleteUser(auth.currentUser); - } - } catch (error) { - // Ignore errors if user doesn't exist - } - }); - - it("should successfully send password reset email", async () => { - // Create a user first - handle case where user might already exist - try { - await createUserWithEmailAndPassword(auth, testEmail, testPassword); - } catch (error) { - if (error instanceof Error) { - const firebaseError = error as { code?: string, message: string }; - // If the user already exists, that's fine for this test - if (firebaseError.code !== 'auth/email-already-in-use') { - // Skip non-relevant errors - } - } - } - await signOut(auth); - - // For integration tests, we want to test the actual implementation - - const { container } = render( - - - - ); - - // Wait for form to be rendered - await waitFor(() => { - expect(container.querySelector('input[type="email"]')).not.toBeNull(); - }); - - const emailInput = container.querySelector('input[type="email"]'); - expect(emailInput).not.toBeNull(); - - await act(async () => { - if (emailInput) { - fireEvent.change(emailInput, { target: { value: testEmail } }); - fireEvent.blur(emailInput); - } - }); - - const submitButton = container.querySelector('button[type="submit"]')!; - expect(submitButton).not.toBeNull(); - - await act(async () => { - fireEvent.click(submitButton); - }); - - // In the Firebase emulator environment, we need to be more flexible - // The test passes if either: - // 1. The success message is displayed, or - // 2. There are no critical error messages (only validation errors are acceptable) - await waitFor( - () => { - // Check for success message - const successMessage = container.querySelector(".fui-form__success"); - - // If we have a success message, the test passes - if (successMessage) { - expect(successMessage).toBeTruthy(); - return; - } - - // Check for error messages - const errorElements = container.querySelectorAll(".fui-form__error"); - - // If there are error elements, check if they're just validation errors - if (errorElements.length > 0) { - let hasCriticalError = false; - let criticalErrorText = ''; - - errorElements.forEach(element => { - const errorText = element.textContent?.toLowerCase() || ''; - // Only fail if there's a critical error (not validation related) - if (!errorText.includes('email') && - !errorText.includes('valid') && - !errorText.includes('required')) { - hasCriticalError = true; - criticalErrorText = errorText; - } - }); - - // If we have critical errors, the test should fail with a descriptive message - if (hasCriticalError) { - expect( - criticalErrorText, - `Critical error found in forgot password test: ${criticalErrorText}` - ).toContain('email'); // This will fail with a descriptive message - } - } - }, - { timeout: 10000 } - ); - }); - - it("should handle invalid email format", async () => { - const { container } = render( - - - - ); - - // Wait for form to be rendered - await waitFor(() => { - expect(container.querySelector('input[type="email"]')).not.toBeNull(); - }); - - const emailInput = container.querySelector('input[type="email"]'); - expect(emailInput).not.toBeNull(); - - await act(async () => { - if (emailInput) { - fireEvent.change(emailInput, { target: { value: "invalid-email" } }); - fireEvent.blur(emailInput); - } - }); - - const submitButton = container.querySelector('button[type="submit"]')!; - expect(submitButton).not.toBeNull(); - - await act(async () => { - fireEvent.click(submitButton); - }); - - await waitFor( - () => { - const errorElement = container.querySelector(".fui-form__error"); - expect(errorElement).not.toBeNull(); - if (errorElement) { - expect(errorElement.textContent).toBe( - "Please enter a valid email address" - ); - } - }, - { timeout: 10000 } - ); - }); -}); diff --git a/packages/firebaseui-react/tests/integration/auth/register.integration.test.tsx b/packages/firebaseui-react/tests/integration/auth/register.integration.test.tsx deleted file mode 100644 index ab204dd28..000000000 --- a/packages/firebaseui-react/tests/integration/auth/register.integration.test.tsx +++ /dev/null @@ -1,566 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, afterAll, beforeEach } from "vitest"; -import { screen, fireEvent, waitFor, act, render } from "@testing-library/react"; -import { RegisterForm } from "../../../src/auth/forms/register-form"; -import { initializeApp } from "firebase/app"; -import { - getAuth, - connectAuthEmulator, - deleteUser, - signOut, - signInWithEmailAndPassword, -} from "firebase/auth"; -import { initializeUI } from "@firebase-ui/core"; -import { FirebaseUIProvider } from "~/context"; - -// Prepare the test environment -const firebaseConfig = { - apiKey: "demo-api-key", - authDomain: "demo-firebaseui.firebaseapp.com", - projectId: "demo-firebaseui", -}; - -// Initialize app once for all tests -const app = initializeApp(firebaseConfig); -const auth = getAuth(app); - -// Connect to the auth emulator -connectAuthEmulator(auth, "http://localhost:9099", { disableWarnings: true }); - -const ui = initializeUI({ - app, -}); - -describe("Register Integration", () => { - // Ensure password is at least 8 characters to pass validation - const testPassword = "Test123456!"; - let testEmail: string; - - // Clean up before each test - beforeEach(async () => { - // Generate a unique email for each test with a valid format - // Ensure the email doesn't contain any special characters that might fail validation - testEmail = `test.${Date.now()}.${Math.floor( - Math.random() * 10000 - )}@example.com`; - - // Try to sign in with the test email and delete the user if it exists - try { - await signInWithEmailAndPassword(auth, testEmail, testPassword); - if (auth.currentUser) { - await deleteUser(auth.currentUser); - } - } catch (error) { - // Ignore errors if user doesn't exist - } - await signOut(auth); - }); - - // Clean up after tests - afterAll(async () => { - try { - // First check if the user is already signed in - if (auth.currentUser && auth.currentUser.email === testEmail) { - await deleteUser(auth.currentUser); - } else { - // Try to sign in first - try { - await signInWithEmailAndPassword(auth, testEmail, testPassword); - if (auth.currentUser) { - await deleteUser(auth.currentUser); - } - } catch (error) { - // If user not found, that's fine - it means it's already been deleted or never created - const firebaseError = error as { code?: string }; - if (firebaseError.code === "auth/user-not-found") { - } else { - } - } - } - } catch (error) { - // Throw error on cleanup failure - throw new Error( - `Cleanup process failed: ${ - error instanceof Error ? error.message : String(error) - }` - ); - } - }); - - it("should successfully register a new user", async () => { - const { container } = render( - - - - ); - - // Wait for form to be rendered - await waitFor(() => { - expect(container.querySelector('input[type="email"]')).not.toBeNull(); - }); - - // Get form elements - const emailInput = container.querySelector('input[type="email"]'); - const passwordInput = container.querySelector('input[type="password"]'); - expect(emailInput).not.toBeNull(); - expect(passwordInput).not.toBeNull(); - - // Use direct DOM manipulation for more reliable form interaction - await act(async () => { - if (emailInput && passwordInput) { - // Cast DOM elements to proper input types - const emailInputElement = emailInput as HTMLInputElement; - const passwordInputElement = passwordInput as HTMLInputElement; - - // Set values directly - emailInputElement.value = testEmail; - passwordInputElement.value = testPassword; - - // Trigger native browser events - const inputEvent = new Event("input", { bubbles: true }); - const changeEvent = new Event("change", { bubbles: true }); - const blurEvent = new Event("blur", { bubbles: true }); - - emailInputElement.dispatchEvent(inputEvent); - emailInputElement.dispatchEvent(changeEvent); - emailInputElement.dispatchEvent(blurEvent); - - passwordInputElement.dispatchEvent(inputEvent); - passwordInputElement.dispatchEvent(changeEvent); - passwordInputElement.dispatchEvent(blurEvent); - - // Wait for validation - await new Promise((resolve) => setTimeout(resolve, 300)); - } - }); - - // Submit form - const submitButton = container.querySelector('button[type="submit"]')!; - expect(submitButton).not.toBeNull(); - - await act(async () => { - // Use native click for more reliable behavior - fireEvent.click(submitButton); - }); - - // Wait for the form submission to complete - // We'll verify success by checking if we're signed in - await waitFor( - async () => { - // Check for critical error messages first - const errorElements = container.querySelectorAll(".fui-form__error"); - let hasCriticalError = false; - - errorElements.forEach((element) => { - const errorText = element.textContent?.toLowerCase() || ""; - // Only consider it a critical error if it's not a validation error - if ( - !errorText.includes("email") && - !errorText.includes("valid") && - !errorText.includes("required") && - !errorText.includes("password") - ) { - hasCriticalError = true; - } - }); - - if (hasCriticalError) { - throw new Error("Registration failed with critical error"); - } - - // Check if we're signed in - if (auth.currentUser) { - expect(auth.currentUser.email).toBe(testEmail); - return; - } - - // If we're not signed in yet, check if the user exists by trying to sign in - try { - const userCredential = await signInWithEmailAndPassword( - auth, - testEmail, - testPassword - ); - - expect(userCredential.user.email).toBe(testEmail); - } catch (error) { - // If we can't sign in, the test should fail - if (error instanceof Error) { - throw new Error( - `User creation verification failed: ${error.message}` - ); - } - } - }, - { timeout: 10000 } - ); - }); - - it("should handle invalid email format", async () => { - // This test verifies that the form validation prevents submission with an invalid email - const { container } = render( - - - - ); - - // Wait for form to be rendered - await waitFor(() => { - expect(container.querySelector('input[type="email"]')).not.toBeNull(); - }); - - // Get form elements - const emailInput = container.querySelector('input[type="email"]'); - const passwordInput = container.querySelector('input[type="password"]'); - expect(emailInput).not.toBeNull(); - expect(passwordInput).not.toBeNull(); - - // Use direct DOM manipulation for more reliable form interaction - await act(async () => { - if (emailInput && passwordInput) { - // Cast DOM elements to proper input types - const emailInputElement = emailInput as HTMLInputElement; - const passwordInputElement = passwordInput as HTMLInputElement; - - // Set invalid email value directly - emailInputElement.value = "invalid-email"; - passwordInputElement.value = testPassword; - - // Trigger native browser events - const inputEvent = new Event("input", { bubbles: true }); - const changeEvent = new Event("change", { bubbles: true }); - const blurEvent = new Event("blur", { bubbles: true }); - - emailInputElement.dispatchEvent(inputEvent); - emailInputElement.dispatchEvent(changeEvent); - emailInputElement.dispatchEvent(blurEvent); - - passwordInputElement.dispatchEvent(inputEvent); - passwordInputElement.dispatchEvent(changeEvent); - passwordInputElement.dispatchEvent(blurEvent); - - // Wait for validation - await new Promise((resolve) => setTimeout(resolve, 300)); - } - }); - - // Submit form - const submitButton = container.querySelector('button[type="submit"]')!; - expect(submitButton).not.toBeNull(); - - await act(async () => { - // Use native click for more reliable behavior - fireEvent.click(submitButton); - }); - - // Instead of checking for a specific error message, we'll verify that: - // 1. The form was not submitted successfully (no user was created) - // 2. The form is still visible (we haven't navigated away) - - // Wait a moment to allow any potential submission to complete - await new Promise((resolve) => setTimeout(resolve, 500)); - - // Verify the form is still visible - expect(container.querySelector("form")).not.toBeNull(); - - // Verify that no user was created with the invalid email - // We don't need to check Firebase directly - if the form is still visible, - // that means submission was prevented - - // This test is successful if the form is still visible after attempted submission - - // This test should NOT attempt to verify user creation since we expect validation to fail - }); - - it("should handle duplicate email", async () => { - // First register a user - const { container } = render( - - - - ); - - // Wait for form to be rendered - await waitFor(() => { - expect(container.querySelector('input[type="email"]')).not.toBeNull(); - }); - - // Fill in email - const emailInput = container.querySelector('input[type="email"]'); - const passwordInput = container.querySelector('input[type="password"]'); - const submitButton = container.querySelector('button[type="submit"]')!; - expect(submitButton).not.toBeNull(); - - // Use direct DOM manipulation to ensure values are set correctly - await act(async () => { - if (emailInput && passwordInput) { - // Cast DOM elements to proper input types - const emailInputElement = emailInput as HTMLInputElement; - const passwordInputElement = passwordInput as HTMLInputElement; - - // Directly set the input values using DOM properties - // This bypasses React's synthetic events which might not be working correctly in the test - emailInputElement.value = testEmail; - passwordInputElement.value = testPassword; - - // Trigger native browser events that React will detect - const inputEvent = new Event("input", { bubbles: true }); - const changeEvent = new Event("change", { bubbles: true }); - const blurEvent = new Event("blur", { bubbles: true }); - - emailInputElement.dispatchEvent(inputEvent); - emailInputElement.dispatchEvent(changeEvent); - emailInputElement.dispatchEvent(blurEvent); - - passwordInputElement.dispatchEvent(inputEvent); - passwordInputElement.dispatchEvent(changeEvent); - passwordInputElement.dispatchEvent(blurEvent); - - // Wait a moment to ensure validation has completed - await new Promise((resolve) => setTimeout(resolve, 300)); - - fireEvent.click(submitButton); - } - }); - - // Wait for first registration to complete - // We'll be more flexible here - we'll handle any errors that might occur - await waitFor( - () => { - const errorElement = container.querySelector(".fui-form__error"); - if (errorElement) { - // If there's an error, check if it's just a validation error or a real failure - const errorText = errorElement.textContent?.toLowerCase() || ""; - // We only care about non-validation errors - if ( - !errorText.includes("password") && - !errorText.includes("email") && - !errorText.includes("valid") && - !errorText.includes("required") - ) { - // For non-validation errors, we'll fail the test with a descriptive message - expect(errorText).toContain("either password or email"); // This will fail with a nice message - } - } - // No critical error means we can proceed with the test - }, - { timeout: 10000 } - ); - - // Wait for the form submission to complete - // The form submission is asynchronous and we need to ensure it finishes - - // Check for success indicators or validation errors in the UI - // We need to wait for the form submission to complete and check the result - await waitFor( - () => { - // Check for any success indicators in the UI - const successMessage = screen.queryByText( - (text) => - (text?.toLowerCase().includes("account") && - text?.toLowerCase().includes("created")) || - text?.toLowerCase().includes("success") || - text?.toLowerCase().includes("registered") - ); - - // Check for error messages that would indicate failure - const errorElements = container.querySelectorAll(".fui-form__error"); - let hasCriticalError = false; - - errorElements.forEach((element) => { - const errorText = element.textContent?.toLowerCase() || ""; - // Only consider it a critical error if it's not a validation error - if ( - !errorText.includes("email") && - !errorText.includes("valid") && - !errorText.includes("required") && - !errorText.includes("password") - ) { - hasCriticalError = true; - } - }); - - // If we have a success message or no critical errors, the test passes - if (successMessage || !hasCriticalError) { - expect(true).toBe(true); // Test passes - } - }, - { timeout: 5000 } - ); - - // Verify user creation by checking if the form submission was successful - // We'll use a combination of UI checks and direct Firebase authentication - - // First, check if the user is already signed in - if (auth.currentUser && auth.currentUser.email === testEmail) { - // User is already signed in, which means registration was successful - expect(auth.currentUser.email).toBe(testEmail); - } else { - // If not signed in automatically, we need to check if the user was created - // by looking for success indicators in the UI - - // Look for success messages or redirects that would indicate successful registration - const successElement = screen.queryByText( - (text) => - text?.toLowerCase().includes("success") || - text?.toLowerCase().includes("account created") || - text?.toLowerCase().includes("registered") - ); - - if (successElement) { - // Found success message, registration was successful - expect(successElement).toBeTruthy(); - } else { - // No success message found, try to sign in to verify user creation - try { - const userCredential = await signInWithEmailAndPassword( - auth, - testEmail, - testPassword - ); - - expect(userCredential.user.email).toBe(testEmail); - } catch (error) { - // If sign-in fails, the user might not have been created successfully - // This could indicate an actual issue with the registration process - if (error instanceof Error) { - const firebaseError = error as { code?: string; message: string }; - - // Check if there's an error message in the UI that explains the issue - const errorElements = - container.querySelectorAll(".fui-form__error"); - - const hasValidationError = Array.from(errorElements).some((el) => { - const text = el.textContent?.toLowerCase() || ""; - const isValidationError = - text.includes("email") || - text.includes("password") || - text.includes("required"); - - return isValidationError; - }); - - if (hasValidationError) { - // If there's a validation error, that explains why registration failed - expect(hasValidationError).toBe(true); - } else if (firebaseError.code === "auth/user-not-found") { - // This suggests the user wasn't created successfully - // Let's check if there are any error messages in the UI that might explain why - const anyErrorElement = - container.querySelector(".fui-form__error"); - - if (anyErrorElement) { - // There's an error message that might explain why registration failed - throw new Error( - `Registration failed with error: ${anyErrorElement.textContent}` - ); - } else { - // No error message found, this might indicate an issue with the test or implementation - throw new Error( - "User not found after registration attempt, but no error message displayed" - ); - } - } else { - // Some other error occurred during sign-in - throw new Error( - `Sign-in failed with error: ${firebaseError.code} - ${firebaseError.message}` - ); - } - } - } - } - } - - // Sign out to try registering again - await signOut(auth); - - // Try to register with same email - const newContainer = render( - - - - ); - - // Wait for form to be rendered - await waitFor(() => { - expect( - newContainer.container.querySelector('input[type="email"]') - ).not.toBeNull(); - }); - - // Fill in email - const newEmailInput = newContainer.container.querySelector( - 'input[type="email"]' - ); - const newPasswordInput = newContainer.container.querySelector( - 'input[type="password"]' - ); - const submitButtons = newContainer.container.querySelectorAll('button[type="submit"]')!; - const newSubmitButton = submitButtons[submitButtons.length - 1]; // Get the most recently added button - - await act(async () => { - if (newEmailInput && newPasswordInput) { - fireEvent.change(newEmailInput, { target: { value: testEmail } }); - fireEvent.blur(newEmailInput); - fireEvent.change(newPasswordInput, { target: { value: testPassword } }); - fireEvent.blur(newPasswordInput); - fireEvent.click(newSubmitButton); - } - }); - - // Wait for error message with longer timeout - await waitFor( - () => { - // Check for error message - const errorElement = - newContainer.container.querySelector(".fui-form__error"); - expect(errorElement).not.toBeNull(); - - if (errorElement) { - // The error message should indicate that the account already exists - // We're being flexible with the exact wording since it might vary - const errorText = errorElement.textContent?.toLowerCase() || ""; - - // In the test environment, we might not get the exact error message we expect - // So we'll also accept if there are validation errors - // This makes the test more robust against environment variations - if ( - !errorText.includes("already exists") && - !errorText.includes("already in use") && - !errorText.includes("already registered") - ) { - // If it's not a duplicate email error, make sure it's at least a validation error - // which is acceptable in our test environment - // Check if it's a validation error - const isValidationError = - errorText.includes("email") || - errorText.includes("valid") || - errorText.includes("required") || - errorText.includes("password"); - - expect(isValidationError).toBe(true); - } else { - // If we do have a duplicate email error, that's great! - expect(true).toBe(true); - } - } - }, - { timeout: 10000 } - ); - }); -}); diff --git a/packages/firebaseui-react/tests/tsconfig.json b/packages/firebaseui-react/tests/tsconfig.json deleted file mode 100644 index 298025e97..000000000 --- a/packages/firebaseui-react/tests/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "extends": "../tsconfig.test.json", - "include": [ - "./**/*.tsx", - "./**/*.ts" - ], - "compilerOptions": { - "jsx": "react-jsx", - "esModuleInterop": true, - "types": [ - "vitest/globals", - "node", - "@testing-library/jest-dom" - ], - "baseUrl": "..", - "paths": { - "@firebase-ui/core": [ - "../firebaseui-core/src/index.ts" - ], - "@firebase-ui/core/*": [ - "../firebaseui-core/src/*" - ], - "~/*": [ - "src/*" - ] - } - } -} \ No newline at end of file diff --git a/packages/firebaseui-react/tests/unit/auth/forms/email-link-form.test.tsx b/packages/firebaseui-react/tests/unit/auth/forms/email-link-form.test.tsx deleted file mode 100644 index 86d847b83..000000000 --- a/packages/firebaseui-react/tests/unit/auth/forms/email-link-form.test.tsx +++ /dev/null @@ -1,307 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, act } from "@testing-library/react"; -import { EmailLinkForm } from "../../../../src/auth/forms/email-link-form"; - -// Mock Firebase UI Core -vi.mock("@firebase-ui/core", async (importOriginal) => { - const mod = await importOriginal(); - const FirebaseUIError = vi.fn(); - FirebaseUIError.prototype.message = "Test error message"; - - return { - ...mod, - FirebaseUIError: class FirebaseUIError { - message: string; - code?: string; - - constructor({ code, message }: { code: string; message: string }) { - this.code = code; - this.message = message; - } - }, - completeEmailLinkSignIn: vi.fn(), - sendSignInLinkToEmail: vi.fn(), - createEmailLinkFormSchema: () => ({ - email: { - validate: (value: string) => { - if (!value) return "Email is required"; - return undefined; - }, - }, - }), - }; -}); - -import { - FirebaseUIError, - sendSignInLinkToEmail, - completeEmailLinkSignIn, -} from "@firebase-ui/core"; - -// Mock React's useState to control state for testing -const useStateMock = vi.fn(); -const setFormErrorMock = vi.fn(); -const setEmailSentMock = vi.fn(); - -// Mock hooks -vi.mock("../../../../src/hooks", () => ({ - useUI: vi.fn(() => ({ - locale: "en-US", - translations: { - "en-US": { - labels: { - emailAddress: "Email", - sendSignInLink: "sendSignInLink", - }, - }, - }, - })), - useAuth: vi.fn(() => ({})), -})); - -// Mock form -vi.mock("@tanstack/react-form", () => ({ - useForm: () => { - const formState = { - email: "test@example.com", - }; - - return { - Field: ({ name, children }: any) => { - // Create a mock field with the required methods and state management - const field = { - name, - handleBlur: vi.fn(), - handleChange: vi.fn((value: string) => { - formState[name as keyof typeof formState] = value; - }), - state: { - value: formState[name as keyof typeof formState] || "", - meta: { isTouched: false, errors: [] }, - }, - }; - - return children(field); - }, - handleSubmit: vi.fn().mockImplementation(async () => { - // Call the onSubmit handler with the form state - await (global as any).formOnSubmit?.({ value: formState }); - }), - }; - }, -})); - -// Mock components -vi.mock("../../../../src/components/field-info", () => ({ - FieldInfo: () =>
, -})); - -vi.mock("../../../../src/components/policies", () => ({ - Policies: () =>
Policies
, -})); - -vi.mock("../../../../src/components/button", () => ({ - Button: ({ - children, - onClick, - type, - ...rest - }: { - children: React.ReactNode; - onClick?: () => void; - type?: "submit" | "reset" | "button"; - [key: string]: any; - }) => ( - - ), -})); - -// Mock react useState to control state in tests -vi.mock("react", async () => { - const actual = (await vi.importActual("react")) as typeof import("react"); - return { - ...actual, - useState: vi.fn().mockImplementation((initialValue) => { - useStateMock(initialValue); - // For formError state - if (initialValue === null) { - return [null, setFormErrorMock]; - } - // For emailSent state - if (initialValue === false) { - return [false, setEmailSentMock]; - } - // Default behavior for other useState calls - return actual.useState(initialValue); - }), - }; -}); - -const mockSendSignInLink = vi.mocked(sendSignInLinkToEmail); -const mockCompleteEmailLink = vi.mocked(completeEmailLinkSignIn); - -describe("EmailLinkForm", () => { - beforeEach(() => { - vi.clearAllMocks(); - // Reset the global state - (global as any).formOnSubmit = null; - setFormErrorMock.mockReset(); - setEmailSentMock.mockReset(); - }); - - it("renders the email link form", () => { - render(); - - expect(screen.getByLabelText("Email")).toBeInTheDocument(); - expect(screen.getByText("sendSignInLink")).toBeInTheDocument(); - }); - - it("attempts to complete email link sign-in on load", () => { - mockCompleteEmailLink.mockResolvedValue(null); - - render(); - - expect(mockCompleteEmailLink).toHaveBeenCalled(); - }); - - it("submits the form and sends sign-in link to email", async () => { - mockSendSignInLink.mockResolvedValue(undefined); - - const { container } = render(); - - // Get the form element - const form = container.getElementsByClassName( - "fui-form" - )[0] as HTMLFormElement; - - // Set up the form submit handler - (global as any).formOnSubmit = async ({ - value, - }: { - value: { email: string }; - }) => { - await sendSignInLinkToEmail(expect.anything(), value.email); - }; - - // Submit the form - await act(async () => { - fireEvent.submit(form); - }); - - expect(mockSendSignInLink).toHaveBeenCalledWith( - expect.anything(), - "test@example.com", - ); - }); - - it("handles error when sending email link fails", async () => { - // Mock the error that will be thrown - const mockError = new FirebaseUIError({ - code: "auth/invalid-email", - message: "Invalid email", - }); - mockSendSignInLink.mockRejectedValue(mockError); - - const { container } = render(); - - // Get the form element - const form = container.getElementsByClassName( - "fui-form" - )[0] as HTMLFormElement; - - // Set up the form submit handler to simulate error - (global as any).formOnSubmit = async () => { - try { - // Simulate the action that would throw an error - await sendSignInLinkToEmail(expect.anything(), "invalid-email"); - } catch (error) { - // Simulate the error being caught and error state being set - setFormErrorMock("Invalid email"); - // Don't rethrow the error - we've handled it here - } - }; - - // Submit the form - await act(async () => { - fireEvent.submit(form); - }); - - // Verify that the error state was updated - expect(setFormErrorMock).toHaveBeenCalledWith("Invalid email"); - }); - - it("handles success when email is sent", async () => { - mockSendSignInLink.mockResolvedValue(undefined); - - const { container } = render(); - - // Get the form element - const form = container.getElementsByClassName( - "fui-form" - )[0] as HTMLFormElement; - - // Set up the form submit handler - (global as any).formOnSubmit = async () => { - // Simulate successful email send by setting emailSent to true - setEmailSentMock(true); - }; - - // Submit the form - await act(async () => { - fireEvent.submit(form); - }); - - // Verify that the success state was updated - expect(setEmailSentMock).toHaveBeenCalledWith(true); - }); - - it("validates on blur for the first time", async () => { - render(); - - const emailInput = screen.getByLabelText("Email"); - - await act(async () => { - fireEvent.blur(emailInput); - }); - - // Check that form validation is available - expect((global as any).formOnSubmit).toBeDefined(); - }); - - it("validates on input after first blur", async () => { - render(); - - const emailInput = screen.getByLabelText("Email"); - - // First validation on blur - await act(async () => { - fireEvent.blur(emailInput); - }); - - // Then validation should happen on input - await act(async () => { - fireEvent.input(emailInput, { target: { value: "test@example.com" } }); - }); - - // Check that form validation is available - expect((global as any).formOnSubmit).toBeDefined(); - }); -}); diff --git a/packages/firebaseui-react/tests/unit/auth/forms/email-password-form.test.tsx b/packages/firebaseui-react/tests/unit/auth/forms/email-password-form.test.tsx deleted file mode 100644 index c9f7f711a..000000000 --- a/packages/firebaseui-react/tests/unit/auth/forms/email-password-form.test.tsx +++ /dev/null @@ -1,224 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { EmailPasswordForm } from "../../../../src/auth/forms/email-password-form"; -import { act } from "react"; - -// Mock the dependencies -vi.mock("@firebase-ui/core", async (importOriginal) => { - const mod = await importOriginal(); - return { - ...mod, - signInWithEmailAndPassword: vi.fn().mockResolvedValue(undefined), - FirebaseUIError: class FirebaseUIError extends Error { - constructor(error: any) { - super(error.message || "Unknown error"); - this.name = "FirebaseUIError"; - } - }, - }; -}); - -// Mock @tanstack/react-form library -vi.mock("@tanstack/react-form", () => { - const handleSubmitMock = vi.fn().mockImplementation((callback) => { - // Store the callback to call it directly in tests - (global as any).formSubmitCallback = callback; - return Promise.resolve(); - }); - - return { - useForm: vi.fn().mockImplementation(({ onSubmit }) => { - // Save the onSubmit function to call it directly in tests - (global as any).formOnSubmit = onSubmit; - - return { - handleSubmit: handleSubmitMock, - Field: ({ children, name }: any) => { - const field = { - name, - state: { - value: name === "email" ? "test@example.com" : "password123", - meta: { - isTouched: false, - errors: [], - }, - }, - handleBlur: vi.fn(), - handleChange: vi.fn(), - }; - return children(field); - }, - }; - }), - }; -}); - -vi.mock("../../../../src/hooks", () => ({ - useAuth: vi.fn().mockReturnValue({}), - useUI: vi.fn().mockReturnValue({ - translations: { - "en-US": { - labels: { - emailAddress: "Email Address", - }, - }, - }, - }), -})); - -// Mock the components -vi.mock("../../../../src/components/field-info", () => ({ - FieldInfo: vi - .fn() - .mockImplementation(({ field }) => ( -
- {field.state.meta.errors.length > 0 && ( - {field.state.meta.errors[0]} - )} -
- )), -})); - -vi.mock("../../../../src/components/policies", () => ({ - Policies: vi - .fn() - .mockReturnValue(
), -})); - -vi.mock("../../../../src/components/button", () => ({ - Button: vi.fn().mockImplementation(({ children, type, onClick }) => ( - - )), -})); - -// Import the actual functions after mocking -import { signInWithEmailAndPassword } from "@firebase-ui/core"; - -describe("EmailPasswordForm", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("renders the form correctly", () => { - render(); - - expect( - screen.getByRole("textbox", { name: /email address/i }) - ).toBeInTheDocument(); - expect(screen.getByTestId("policies")).toBeInTheDocument(); - expect(screen.getByTestId("submit-button")).toBeInTheDocument(); - }); - - it("submits the form when the button is clicked", async () => { - render(); - - // Get the submit button - const submitButton = screen.getByTestId("submit-button"); - - // Trigger form submission - await act(async () => { - fireEvent.click(submitButton); - - // Directly call the onSubmit function with form values - if ((global as any).formOnSubmit) { - await (global as any).formOnSubmit({ - value: { - email: "test@example.com", - password: "password123", - }, - }); - } - }); - - // Check that the authentication function was called - expect(signInWithEmailAndPassword).toHaveBeenCalledWith( - expect.anything(), - "test@example.com", - "password123" - ); - }); - - it("displays error message when sign in fails", async () => { - // Mock the sign in function to reject with an error - const mockError = new Error("Invalid credentials"); - (signInWithEmailAndPassword as Mock).mockRejectedValueOnce(mockError); - - render(); - - // Get the submit button - const submitButton = screen.getByTestId("submit-button"); - - // Trigger form submission - await act(async () => { - fireEvent.click(submitButton); - - // Directly call the onSubmit function with form values - if ((global as any).formOnSubmit) { - await (global as any).formOnSubmit({ - value: { - email: "test@example.com", - password: "password123", - }, - }); - } - }); - - // Check that the authentication function was called - expect(signInWithEmailAndPassword).toHaveBeenCalled(); - }); - - it("validates on blur for the first time", async () => { - render(); - - const emailInput = screen.getByRole("textbox", { name: /email address/i }); - const passwordInput = screen.getByDisplayValue("password123"); - - await act(async () => { - fireEvent.blur(emailInput); - fireEvent.blur(passwordInput); - }); - - // Check that handleBlur was called - expect((global as any).formOnSubmit).toBeDefined(); - }); - - it("validates on input after first blur", async () => { - render(); - - const emailInput = screen.getByRole("textbox", { name: /email address/i }); - const passwordInput = screen.getByDisplayValue("password123"); - - // First validation on blur - await act(async () => { - fireEvent.blur(emailInput); - fireEvent.blur(passwordInput); - }); - - // Then validation should happen on input - await act(async () => { - fireEvent.input(emailInput, { target: { value: "test@example.com" } }); - fireEvent.input(passwordInput, { target: { value: "password123" } }); - }); - - // Check that handleBlur and form.update were called - expect((global as any).formOnSubmit).toBeDefined(); - }); -}); diff --git a/packages/firebaseui-react/tests/unit/auth/forms/forgot-password-form.test.tsx b/packages/firebaseui-react/tests/unit/auth/forms/forgot-password-form.test.tsx deleted file mode 100644 index efacc50c3..000000000 --- a/packages/firebaseui-react/tests/unit/auth/forms/forgot-password-form.test.tsx +++ /dev/null @@ -1,240 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { ForgotPasswordForm } from "../../../../src/auth/forms/forgot-password-form"; -import { act } from "react"; - -// Mock the dependencies -vi.mock("@firebase-ui/core", async (importOriginal) => { - const mod = await importOriginal(); - return { - ...mod, - sendPasswordResetEmail: vi.fn().mockImplementation(() => { - return Promise.resolve(); - }), - // FirebaseUIError: class FirebaseUIError extends Error { - // code: string; - // constructor(error: any) { - // super(error.message || "Unknown error"); - // this.name = "FirebaseUIError"; - // this.code = error.code || "unknown-error"; - // } - // }, - // createForgotPasswordFormSchema: vi.fn().mockReturnValue({ - // email: { required: "Email is required" }, - // }), - }; -}); - -// Mock @tanstack/react-form library -vi.mock("@tanstack/react-form", () => { - const handleSubmitMock = vi.fn().mockImplementation((callback) => { - // Store the callback to call it directly in tests - (global as any).formSubmitCallback = callback; - return Promise.resolve(); - }); - - return { - useForm: vi.fn().mockImplementation(({ onSubmit }) => { - // Save the onSubmit function to call it directly in tests - (global as any).formOnSubmit = onSubmit; - - return { - handleSubmit: handleSubmitMock, - Field: ({ children, name }: any) => { - const field = { - name, - state: { - value: name === "email" ? "test@example.com" : "", - meta: { - isTouched: false, - errors: [], - }, - }, - handleBlur: vi.fn(), - handleChange: vi.fn(), - }; - return children(field); - }, - }; - }), - }; -}); - -vi.mock("../../../../src/hooks", () => ({ - useAuth: vi.fn().mockReturnValue({}), - useUI: vi.fn().mockReturnValue({ - locale: "en-US", - translations: { - "en-US": { - labels: { - backToSignIn: "back button", - }, - }, - }, - }), -})); - -// Mock the components -vi.mock("../../../../src/components/field-info", () => ({ - FieldInfo: vi - .fn() - .mockImplementation(({ field }) => ( -
- {field.state.meta.errors.length > 0 && ( - {field.state.meta.errors[0]} - )} -
- )), -})); - -vi.mock("../../../../src/components/policies", () => ({ - Policies: vi.fn().mockReturnValue(
), -})); - -vi.mock("../../../../src/components/button", () => ({ - Button: vi.fn().mockImplementation(({ children, type, onClick }) => ( - - )), -})); - -// Import the actual functions after mocking -import { sendPasswordResetEmail } from "@firebase-ui/core"; - -describe("ForgotPasswordForm", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("renders the form correctly", () => { - render(); - - expect( - screen.getByRole("textbox", { name: /email address/i }) - ).toBeInTheDocument(); - expect(screen.getByTestId("submit-button")).toBeInTheDocument(); - }); - - it("submits the form when the button is clicked", async () => { - render(); - - // Get the submit button - const submitButton = screen.getByTestId("submit-button"); - - // Trigger form submission - await act(async () => { - fireEvent.click(submitButton); - - // Directly call the onSubmit function with form values - if ((global as any).formOnSubmit) { - await (global as any).formOnSubmit({ - value: { - email: "test@example.com", - }, - }); - } - }); - - // Check that the password reset function was called - expect(sendPasswordResetEmail).toHaveBeenCalledWith( - expect.anything(), - "test@example.com" - ); - }); - - it("displays error message when password reset fails", async () => { - // Mock the reset function to reject with an error - const mockError = new Error("Invalid email"); - (sendPasswordResetEmail as Mock).mockRejectedValueOnce(mockError); - - render(); - - // Get the submit button - const submitButton = screen.getByTestId("submit-button"); - - // Trigger form submission - await act(async () => { - fireEvent.click(submitButton); - - // Directly call the onSubmit function with form values - if ((global as any).formOnSubmit) { - await (global as any) - .formOnSubmit({ - value: { - email: "test@example.com", - }, - }) - .catch(() => { - // Catch the error here to prevent test from failing - }); - } - }); - - // Check that the password reset function was called - expect(sendPasswordResetEmail).toHaveBeenCalled(); - }); - - it("validates on blur for the first time", async () => { - render(); - - const emailInput = screen.getByRole("textbox", { name: /email address/i }); - - await act(async () => { - fireEvent.blur(emailInput); - }); - - // Check that handleBlur was called - expect((global as any).formOnSubmit).toBeDefined(); - }); - - it("validates on input after first blur", async () => { - render(); - - const emailInput = screen.getByRole("textbox", { name: /email address/i }); - - // First validation on blur - await act(async () => { - fireEvent.blur(emailInput); - }); - - // Then validation should happen on input - await act(async () => { - fireEvent.input(emailInput, { target: { value: "test@example.com" } }); - }); - - // Check that handleBlur and form.update were called - expect((global as any).formOnSubmit).toBeDefined(); - }); - - // TODO: Fix this test - it.skip("displays back to sign in button when provided", () => { - const onBackToSignInClickMock = vi.fn(); - render( - - ); - - const backButton = screen.getByText(/back button/i); - expect(backButton).toHaveClass("fui-form__action"); - expect(backButton).toBeInTheDocument(); - - fireEvent.click(backButton); - expect(onBackToSignInClickMock).toHaveBeenCalled(); - }); -}); diff --git a/packages/firebaseui-react/tests/unit/auth/forms/phone-form.test.tsx b/packages/firebaseui-react/tests/unit/auth/forms/phone-form.test.tsx deleted file mode 100644 index 05a1494bd..000000000 --- a/packages/firebaseui-react/tests/unit/auth/forms/phone-form.test.tsx +++ /dev/null @@ -1,277 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { PhoneForm } from "../../../../src/auth/forms/phone-form"; -import { act } from "react"; - -// Mock Firebase Auth -vi.mock("firebase/auth", () => ({ - RecaptchaVerifier: vi.fn().mockImplementation(() => ({ - render: vi.fn().mockResolvedValue(123), - clear: vi.fn(), - verify: vi.fn().mockResolvedValue("verification-token"), - })), - ConfirmationResult: vi.fn(), -})); - -// Mock the core dependencies -vi.mock("@firebase-ui/core", async (originalImport) => { - const mod = await originalImport(); - return { - ...mod, - signInWithPhoneNumber: vi.fn().mockResolvedValue({ - confirm: vi.fn().mockResolvedValue(undefined), - }), - confirmPhoneNumber: vi.fn().mockResolvedValue(undefined), - createPhoneFormSchema: vi.fn().mockReturnValue({ - phoneNumber: { required: "Phone number is required" }, - verificationCode: { required: "Verification code is required" }, - pick: vi.fn().mockReturnValue({ - phoneNumber: { required: "Phone number is required" }, - }), - }), - formatPhoneNumberWithCountry: vi.fn( - (phoneNumber, dialCode) => `${dialCode}${phoneNumber}` - ), - }; -}); - -// Mock @tanstack/react-form library -vi.mock("@tanstack/react-form", () => { - const handleSubmitMock = vi.fn().mockImplementation((callback) => { - // Store the callback to call it directly in tests - (global as any).formSubmitCallback = callback; - return Promise.resolve(); - }); - - return { - useForm: vi.fn().mockImplementation(({ onSubmit }) => { - // Save the onSubmit function to call it directly in tests - (global as any).formOnSubmit = onSubmit; - - return { - handleSubmit: handleSubmitMock, - Field: ({ children, name }: any) => { - const field = { - name, - state: { - value: name === "phoneNumber" ? "1234567890" : "123456", - meta: { - isTouched: false, - errors: [], - }, - }, - handleBlur: vi.fn(), - handleChange: vi.fn(), - }; - return children(field); - }, - }; - }), - }; -}); - -// Mock hooks -vi.mock("../../../../src/hooks", () => ({ - useAuth: vi.fn().mockReturnValue({}), - useUI: vi.fn().mockReturnValue({ - locale: "en-US", - translations: { - "en-US": { - labels: { - phoneNumber: "Phone Number", - verificationCode: "Verification Code", - sendVerificationCode: "Send Verification Code", - resendVerificationCode: "Resend Verification Code", - enterVerificationCode: "Enter Verification Code", - continue: "Continue", - backToSignIn: "Back to Sign In", - }, - errors: { - unknownError: "Unknown error", - }, - }, - }, - }), -})); - -// Mock the components -vi.mock("../../../../src/components/field-info", () => ({ - FieldInfo: vi - .fn() - .mockImplementation(({ field }) => ( -
- {field.state.meta.errors.length > 0 && ( - {field.state.meta.errors[0]} - )} -
- )), -})); - -vi.mock("../../../../src/components/policies", () => ({ - Policies: vi.fn().mockReturnValue(
), -})); - -vi.mock("../../../../src/components/button", () => ({ - Button: vi.fn().mockImplementation(({ children, type, onClick }) => ( - - )), -})); - -vi.mock("../../../../src/components/country-selector", () => ({ - CountrySelector: vi.fn().mockImplementation(({ value, onChange }) => ( -
- -
- )), -})); - -// Import the actual functions after mocking -import { signInWithPhoneNumber } from "@firebase-ui/core"; - -describe("PhoneForm", () => { - beforeEach(() => { - vi.clearAllMocks(); - // Reset the global state - (global as any).formOnSubmit = null; - (global as any).formSubmitCallback = null; - }); - - it("renders the phone number form initially", () => { - render(); - - expect( - screen.getByRole("textbox", { name: /phone number/i }) - ).toBeInTheDocument(); - expect(screen.getByTestId("country-selector")).toBeInTheDocument(); - expect(screen.getByTestId("policies")).toBeInTheDocument(); - expect(screen.getByTestId("submit-button")).toBeInTheDocument(); - }); - - it("attempts to send verification code when phone number is submitted", async () => { - render(); - - // Get the submit button - const submitButton = screen.getByTestId("submit-button"); - - // Trigger form submission - await act(async () => { - fireEvent.click(submitButton); - - // Directly call the onSubmit function with form values - if ((global as any).formOnSubmit) { - await (global as any).formOnSubmit({ - value: { - phoneNumber: "1234567890", - }, - }); - } - }); - - // Check that the phone verification function was called with any parameters - expect(signInWithPhoneNumber).toHaveBeenCalled(); - // Verify the phone number is in the second parameter - expect(signInWithPhoneNumber).toHaveBeenCalledWith( - expect.anything(), - expect.stringMatching(/1234567890/), - expect.anything() - ); - }); - - it("displays error message when phone verification fails", async () => { - const mockError = new Error("Invalid phone number"); - (mockError as any).code = "auth/invalid-phone-number"; - ( - signInWithPhoneNumber as unknown as ReturnType - ).mockRejectedValueOnce(mockError); - - render(); - - // Get the submit button - const submitButton = screen.getByTestId("submit-button"); - - // Trigger form submission - await act(async () => { - fireEvent.click(submitButton); - - // Directly call the onSubmit function with form values - if ((global as any).formOnSubmit) { - await (global as any) - .formOnSubmit({ - value: { - phoneNumber: "1234567890", - }, - }) - .catch(() => { - // Catch the error to prevent it from failing the test - }); - } - }); - - // The UI should show the error message in the form__error div - expect(screen.getByText("Unknown error")).toBeInTheDocument(); - }); - - it("validates on blur for the first time", async () => { - render(); - - const phoneInput = screen.getByRole("textbox", { name: /phone number/i }); - - await act(async () => { - fireEvent.blur(phoneInput); - }); - - // Check that handleBlur was called - expect((global as any).formOnSubmit).toBeDefined(); - }); - - it("validates on input after first blur", async () => { - render(); - - const phoneInput = screen.getByRole("textbox", { name: /phone number/i }); - - // First validation on blur - await act(async () => { - fireEvent.blur(phoneInput); - }); - - // Then validation should happen on input - await act(async () => { - fireEvent.input(phoneInput, { target: { value: "1234567890" } }); - }); - - // Check that handleBlur and form.update were called - expect((global as any).formOnSubmit).toBeDefined(); - }); -}); diff --git a/packages/firebaseui-react/tests/unit/auth/forms/register-form.test.tsx b/packages/firebaseui-react/tests/unit/auth/forms/register-form.test.tsx deleted file mode 100644 index f85f7b3f8..000000000 --- a/packages/firebaseui-react/tests/unit/auth/forms/register-form.test.tsx +++ /dev/null @@ -1,240 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { RegisterForm } from "../../../../src/auth/forms/register-form"; -import { act } from "react"; - -// Mock the dependencies -vi.mock("@firebase-ui/core", async (originalImport) => { - const mod = await originalImport(); - return { - ...mod, - createUserWithEmailAndPassword: vi.fn().mockResolvedValue(undefined), - }; -}); - -// Mock @tanstack/react-form library -vi.mock("@tanstack/react-form", () => { - const handleSubmitMock = vi.fn().mockImplementation((callback) => { - // Store the callback to call it directly in tests - (global as any).formSubmitCallback = callback; - return Promise.resolve(); - }); - - return { - useForm: vi.fn().mockImplementation(({ onSubmit }) => { - // Save the onSubmit function to call it directly in tests - (global as any).formOnSubmit = onSubmit; - - return { - handleSubmit: handleSubmitMock, - Field: ({ children, name }: any) => { - const field = { - name, - state: { - value: name === "email" ? "test@example.com" : "password123", - meta: { - isTouched: false, - errors: [], - }, - }, - handleBlur: vi.fn(), - handleChange: vi.fn(), - }; - return children(field); - }, - }; - }), - }; -}); - -vi.mock("../../../../src/hooks", () => ({ - useAuth: vi.fn().mockReturnValue({}), - useUI: vi.fn().mockReturnValue({ - locale: "en-US", - translations: { - "en-US": { - labels: { - emailAddress: "Email Address", - password: "Password", - }, - errors: { - unknownError: "Unknown error", - }, - }, - }, - }), -})); - -// Mock the components -vi.mock("../../../../src/components/field-info", () => ({ - FieldInfo: vi - .fn() - .mockImplementation(({ field }) => ( -
- {field.state.meta.errors.length > 0 && ( - {field.state.meta.errors[0]} - )} -
- )), -})); - -vi.mock("../../../../src/components/policies", () => ({ - Policies: vi.fn().mockReturnValue(
), -})); - -vi.mock("../../../../src/components/button", () => ({ - Button: vi.fn().mockImplementation(({ children, type, onClick }) => ( - - )), -})); - -// Import the actual functions after mocking -import { createUserWithEmailAndPassword } from "@firebase-ui/core"; - -describe("RegisterForm", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("renders the form correctly", () => { - render(); - - expect( - screen.getByRole("textbox", { name: /email address/i }) - ).toBeInTheDocument(); - expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); - expect(screen.getByTestId("policies")).toBeInTheDocument(); - expect(screen.getByTestId("submit-button")).toBeInTheDocument(); - }); - - it("submits the form when the button is clicked", async () => { - render(); - - // Get the submit button - const submitButton = screen.getByTestId("submit-button"); - - // Trigger form submission - await act(async () => { - fireEvent.click(submitButton); - - // Directly call the onSubmit function with form values - if ((global as any).formOnSubmit) { - await (global as any).formOnSubmit({ - value: { - email: "test@example.com", - password: "password123", - }, - }); - } - }); - - // Check that the registration function was called - expect(createUserWithEmailAndPassword).toHaveBeenCalledWith( - expect.anything(), - "test@example.com", - "password123" - ); - }); - - it("displays error message when registration fails", async () => { - // Mock the registration function to reject with an error - const mockError = new Error("Email already in use"); - (createUserWithEmailAndPassword as Mock).mockRejectedValueOnce( - mockError - ); - - render(); - - // Get the submit button - const submitButton = screen.getByTestId("submit-button"); - - // Trigger form submission - await act(async () => { - fireEvent.click(submitButton); - - // Directly call the onSubmit function with form values - if ((global as any).formOnSubmit) { - await (global as any) - .formOnSubmit({ - value: { - email: "test@example.com", - password: "password123", - }, - }) - .catch(() => { - // Catch the error here to prevent test from failing - }); - } - }); - - // Check that the registration function was called - expect(createUserWithEmailAndPassword).toHaveBeenCalled(); - }); - - it("validates on blur for the first time", async () => { - render(); - - const emailInput = screen.getByRole("textbox", { name: /email address/i }); - const passwordInput = screen.getByDisplayValue("password123"); - - await act(async () => { - fireEvent.blur(emailInput); - fireEvent.blur(passwordInput); - }); - - // Check that handleBlur was called - expect((global as any).formOnSubmit).toBeDefined(); - }); - - it("validates on input after first blur", async () => { - render(); - - const emailInput = screen.getByRole("textbox", { name: /email address/i }); - const passwordInput = screen.getByDisplayValue("password123"); - - // First validation on blur - await act(async () => { - fireEvent.blur(emailInput); - fireEvent.blur(passwordInput); - }); - - // Then validation should happen on input - await act(async () => { - fireEvent.input(emailInput, { target: { value: "test@example.com" } }); - fireEvent.input(passwordInput, { target: { value: "password123" } }); - }); - - // Check that handleBlur and form.update were called - expect((global as any).formOnSubmit).toBeDefined(); - }); - - // TODO: Fix this test - it.skip("displays back to sign in button when provided", () => { - const onBackToSignInClickMock = vi.fn(); - render(); - - const backButton = document.querySelector('.fui-form__action')!; - expect(backButton).toBeInTheDocument(); - - fireEvent.click(backButton); - expect(onBackToSignInClickMock).toHaveBeenCalled(); - }); -}); diff --git a/packages/firebaseui-react/tests/unit/auth/oauth/google-sign-in-button.test.tsx b/packages/firebaseui-react/tests/unit/auth/oauth/google-sign-in-button.test.tsx deleted file mode 100644 index 4784c0b91..000000000 --- a/packages/firebaseui-react/tests/unit/auth/oauth/google-sign-in-button.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { GoogleSignInButton } from "~/auth/oauth/google-sign-in-button"; - -// Mock hooks -vi.mock("~/hooks", () => ({ - useUI: () => ({ - locale: "en-US", - translations: { - "en-US": { labels: { signInWithGoogle: "foo bar" } }, - }, - }), -})); - -// Mock the OAuthButton component -vi.mock("~/auth/oauth/oauth-button", () => ({ - OAuthButton: ({ - children, - provider, - }: { - children: React.ReactNode; - provider: any; - }) => ( -
- {children} -
- ), -})); - -// Mock the GoogleAuthProvider -vi.mock("firebase/auth", () => ({ - GoogleAuthProvider: class GoogleAuthProvider { - constructor() { - // Empty constructor - } - }, -})); - -describe("GoogleSignInButton", () => { - it("renders with the correct provider", () => { - render(); - expect(screen.getByTestId("oauth-button")).toHaveAttribute( - "data-provider", - "GoogleAuthProvider" - ); - }); - - it("renders with the Google icon SVG", () => { - render(); - const svg = document.querySelector(".fui-provider__icon"); - expect(svg).toBeInTheDocument(); - expect(svg).toHaveClass("fui-provider__icon"); - }); - - it("renders with the correct text", () => { - render(); - expect(screen.getByText("foo bar")).toBeInTheDocument(); - }); -}); diff --git a/packages/firebaseui-react/tests/unit/auth/oauth/oauth-button.test.tsx b/packages/firebaseui-react/tests/unit/auth/oauth/oauth-button.test.tsx deleted file mode 100644 index e3feb082b..000000000 --- a/packages/firebaseui-react/tests/unit/auth/oauth/oauth-button.test.tsx +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { OAuthButton } from "../../../../src/auth/oauth/oauth-button"; -import type { AuthProvider } from "firebase/auth"; -import { signInWithOAuth } from "@firebase-ui/core"; - -// Mock signInWithOAuth function -vi.mock("@firebase-ui/core", async (importOriginal) => { - const mod = await importOriginal(); - return { - ...mod, - signInWithOAuth: vi.fn(), - }; -}); - - -// Create a mock provider that matches the AuthProvider interface -const mockGoogleProvider = { providerId: "google.com" } as AuthProvider; - -// Mock React hooks from the package -const useAuthMock = vi.fn(); - -vi.mock("../../../../src/hooks", () => ({ - useAuth: () => useAuthMock(), - useUI: () => vi.fn(), -})); - -// Mock the Button component -vi.mock("../../../../src/components/button", () => ({ - Button: ({ children, onClick, disabled }: any) => ( - - ), -})); - -describe("OAuthButton Component", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("renders a button with the provided children", () => { - render( - - Sign in with Google - - ); - - const button = screen.getByTestId("oauth-button"); - expect(button).toBeInTheDocument(); - expect(button).toHaveTextContent("Sign in with Google"); - }); - - // TODO: Fix this test - it.skip("calls signInWithOAuth when clicked", async () => { - // Mock the signInWithOAuth to resolve immediately - vi.mocked(signInWithOAuth).mockResolvedValueOnce(undefined); - - render( - - Sign in with Google - - ); - - const button = screen.getByTestId("oauth-button"); - fireEvent.click(button); - - await waitFor(() => { - expect(signInWithOAuth).toHaveBeenCalledTimes(1); - expect(signInWithOAuth).toHaveBeenCalledWith( - expect.anything(), - mockGoogleProvider - ); - }); - }); - - // TODO: Fix this test - it.skip("displays error message when non-Firebase error occurs", async () => { - // Mock console.error to prevent test output noise - const consoleErrorSpy = vi - .spyOn(console, "error") - .mockImplementation(() => {}); - - // Mock a non-Firebase error to trigger console.error - const regularError = new Error("Regular error"); - vi.mocked(signInWithOAuth).mockRejectedValueOnce(regularError); - - render( - - Sign in with Google - - ); - - const button = screen.getByTestId("oauth-button"); - - // Click the button to trigger the error - fireEvent.click(button); - - // Wait for the error message to be displayed - await waitFor(() => { - // Verify console.error was called with the regular error - expect(consoleErrorSpy).toHaveBeenCalledWith(regularError); - - // Verify the error message is displayed - const errorMessage = screen.getByText("An unknown error occurred"); - expect(errorMessage).toBeInTheDocument(); - expect(errorMessage).toHaveClass("fui-form__error"); - }); - - // Restore console.error - consoleErrorSpy.mockRestore(); - }); -}); diff --git a/packages/firebaseui-react/tests/unit/auth/screens/email-link-auth-screen.test.tsx b/packages/firebaseui-react/tests/unit/auth/screens/email-link-auth-screen.test.tsx deleted file mode 100644 index e021d4ff3..000000000 --- a/packages/firebaseui-react/tests/unit/auth/screens/email-link-auth-screen.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render } from "@testing-library/react"; -import { EmailLinkAuthScreen } from "~/auth/screens/email-link-auth-screen"; -import * as hooks from "~/hooks"; - -// Mock the hooks -vi.mock("~/hooks", () => ({ - useUI: vi.fn(() => ({ - locale: "en-US", - translations: { - "en-US": { - labels: { - signIn: "Sign In", - }, - prompts: { - signInToAccount: "Sign in to your account", - }, - messages: { - dividerOr: "or", - }, - }, - }, - })), -})); - -// Mock the EmailLinkForm component -vi.mock("~/auth/forms/email-link-form", () => ({ - EmailLinkForm: () =>
Email Link Form
, -})); - -describe("EmailLinkAuthScreen", () => { - beforeEach(() => { - // Setup default mock values - vi.mocked(hooks.useUI).mockReturnValue({ - locale: "en-US", - } as any); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("renders with correct title and subtitle", () => { - const { getByText } = render(); - - expect(getByText("Sign In")).toBeInTheDocument(); - expect(getByText("Sign in to your account")).toBeInTheDocument(); - }); - - it("calls useUI to get the locale", () => { - render(); - expect(hooks.useUI).toHaveBeenCalled(); - }); - - it("includes the EmailLinkForm component", () => { - const { getByTestId } = render(); - - expect(getByTestId("email-link-form")).toBeInTheDocument(); - }); - - it("does not render divider and children when no children are provided", () => { - const { queryByText } = render(); - - expect(queryByText("or")).not.toBeInTheDocument(); - }); - - it("renders divider and children when children are provided", () => { - const { getByText } = render( - -
Test Child
-
- ); - - expect(getByText("or")).toBeInTheDocument(); - expect(getByText("Test Child")).toBeInTheDocument(); - }); -}); diff --git a/packages/firebaseui-react/tests/unit/auth/screens/oauth-screen.test.tsx b/packages/firebaseui-react/tests/unit/auth/screens/oauth-screen.test.tsx deleted file mode 100644 index 0973269b2..000000000 --- a/packages/firebaseui-react/tests/unit/auth/screens/oauth-screen.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi } from "vitest"; -import { render } from "@testing-library/react"; -import { OAuthScreen } from "~/auth/screens/oauth-screen"; - -// Mock hooks -vi.mock("~/hooks", () => ({ - useUI: () => ({ - locale: "en-US", - translations: { - "en-US": { - labels: { - signIn: "Sign In", - signInToAccount: "Sign in to your account", - }, - }, - }, - }), -})); - -// Mock getTranslation -// vi.mock("@firebase-ui/core", () => ({ -// getTranslation: vi.fn((category, key) => { -// if (category === "labels" && key === "signIn") return "Sign In"; -// if (category === "prompts" && key === "signInToAccount") -// return "Sign in to your account"; -// return key; -// }), -// })); - -// Mock TermsAndPrivacy component -vi.mock("../../../../src/components/policies", () => ({ - Policies: () =>
Policies
, -})); - -describe("OAuthScreen", () => { - it("renders with correct title and subtitle", () => { - const { getByText } = render(OAuth Provider); - - expect(getByText("Sign In")).toBeInTheDocument(); - expect(getByText("Sign in to your account")).toBeInTheDocument(); - }); - - it("calls useConfig to get the language", () => { - render(OAuth Provider); - - // This test implicitly tests that useConfig is called through the mock - // If it hadn't been called, the title and subtitle wouldn't render correctly - }); - - it("renders children", () => { - const { getByText } = render(OAuth Provider); - - expect(getByText("OAuth Provider")).toBeInTheDocument(); - }); - - it("renders multiple children when provided", () => { - const { getByText } = render( - -
Provider 1
-
Provider 2
-
- ); - - expect(getByText("Provider 1")).toBeInTheDocument(); - expect(getByText("Provider 2")).toBeInTheDocument(); - }); - - it("includes the Policies component", () => { - const { getByTestId } = render(OAuth Provider); - - expect(getByTestId("policies")).toBeInTheDocument(); - }); -}); diff --git a/packages/firebaseui-react/tests/unit/auth/screens/password-reset-screen.test.tsx b/packages/firebaseui-react/tests/unit/auth/screens/password-reset-screen.test.tsx deleted file mode 100644 index 5362dc406..000000000 --- a/packages/firebaseui-react/tests/unit/auth/screens/password-reset-screen.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, fireEvent } from "@testing-library/react"; -import { PasswordResetScreen } from "~/auth/screens/password-reset-screen"; -import * as hooks from "~/hooks"; - -// Mock the hooks -vi.mock("~/hooks", () => ({ - useUI: vi.fn(() => ({ - locale: "en-US", - translations: { - "en-US": { - labels: { - resetPassword: "Reset Password", - }, - prompts: { - enterEmailToReset: "Enter your email to reset your password", - }, - }, - }, - })), -})); - -// Mock the ForgotPasswordForm component -vi.mock("~/auth/forms/forgot-password-form", () => ({ - ForgotPasswordForm: ({ - onBackToSignInClick, - }: { - onBackToSignInClick?: () => void; - }) => ( -
- -
- ), -})); - -describe("PasswordResetScreen", () => { - const mockOnBackToSignInClick = vi.fn(); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("renders with correct title and subtitle", () => { - const { getByText } = render(); - - expect(getByText("Reset Password")).toBeInTheDocument(); - expect( - getByText("Enter your email to reset your password") - ).toBeInTheDocument(); - }); - - it("calls useUI to get the locale", () => { - render(); - - expect(hooks.useUI).toHaveBeenCalled(); - }); - - it("includes the ForgotPasswordForm component", () => { - const { getByTestId } = render(); - - expect(getByTestId("forgot-password-form")).toBeInTheDocument(); - }); - - it("passes onBackToSignInClick to ForgotPasswordForm", () => { - const { getByTestId } = render( - - ); - - // Click the back button in the mocked form - fireEvent.click(getByTestId("back-button")); - - // Verify the callback was called - expect(mockOnBackToSignInClick).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/firebaseui-react/tests/unit/auth/screens/phone-auth-screen.test.tsx b/packages/firebaseui-react/tests/unit/auth/screens/phone-auth-screen.test.tsx deleted file mode 100644 index 18f18aa4e..000000000 --- a/packages/firebaseui-react/tests/unit/auth/screens/phone-auth-screen.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi } from "vitest"; -import { render } from "@testing-library/react"; -import { PhoneAuthScreen } from "~/auth/screens/phone-auth-screen"; - -// Mock the hooks -vi.mock("~/hooks", () => ({ - useUI: () => ({ - locale: "en-US", - translations: { - "en-US": { - labels: { - signIn: "Sign in", - dividerOr: "or", - }, - prompts: { - signInToAccount: "Sign in to your account", - }, - }, - }, - }), -})); - -// Mock the PhoneForm component -vi.mock("~/auth/forms/phone-form", () => ({ - PhoneForm: ({ resendDelay }: { resendDelay?: number }) => ( -
- Phone Form -
- ), -})); - -describe("PhoneAuthScreen", () => { - it("displays the correct title and subtitle", () => { - const { getByText } = render(); - - expect(getByText("Sign in")).toBeInTheDocument(); - expect(getByText("Sign in to your account")).toBeInTheDocument(); - }); - - it("calls useConfig to retrieve the language", () => { - const { getByText } = render(); - - expect(getByText("Sign in")).toBeInTheDocument(); - }); - - it("includes the PhoneForm with the correct resendDelay prop", () => { - const { getByTestId } = render(); - - const phoneForm = getByTestId("phone-form"); - expect(phoneForm).toBeInTheDocument(); - expect(phoneForm.getAttribute("data-resend-delay")).toBe("60"); - }); - - it("renders children when provided", () => { - const { getByText, getByTestId } = render( - - - - ); - - expect(getByTestId("test-button")).toBeInTheDocument(); - expect(getByText("or")).toBeInTheDocument(); - }); - - it("does not render children or divider when not provided", () => { - const { queryByText } = render(); - - expect(queryByText("or")).not.toBeInTheDocument(); - }); -}); diff --git a/packages/firebaseui-react/tests/unit/auth/screens/sign-in-auth-screen.test.tsx b/packages/firebaseui-react/tests/unit/auth/screens/sign-in-auth-screen.test.tsx deleted file mode 100644 index 9ef30550b..000000000 --- a/packages/firebaseui-react/tests/unit/auth/screens/sign-in-auth-screen.test.tsx +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi } from "vitest"; -import { render, fireEvent } from "@testing-library/react"; -import { SignInAuthScreen } from "~/auth/screens/sign-in-auth-screen"; - -// Mock the hooks -vi.mock("~/hooks", () => ({ - useUI: () => ({ - locale: "en-US", - translations: { - "en-US": { - labels: { - signIn: "Sign in", - dividerOr: "or", - }, - prompts: { - signInToAccount: "Sign in to your account", - }, - }, - }, - }), -})); - -// Mock the EmailPasswordForm component -vi.mock("~/auth/forms/email-password-form", () => ({ - EmailPasswordForm: ({ - onForgotPasswordClick, - onRegisterClick, - }: { - onForgotPasswordClick?: () => void; - onRegisterClick?: () => void; - }) => ( -
- - -
- ), -})); - -describe("SignInAuthScreen", () => { - it("displays the correct title and subtitle", () => { - const { getByText } = render(); - - expect(getByText("Sign in")).toBeInTheDocument(); - expect(getByText("Sign in to your account")).toBeInTheDocument(); - }); - - it("calls useConfig to retrieve the language", () => { - const { getByText } = render(); - - expect(getByText("Sign in")).toBeInTheDocument(); - }); - - it("includes the EmailPasswordForm component", () => { - const { getByTestId } = render(); - - expect(getByTestId("email-password-form")).toBeInTheDocument(); - }); - - it("passes onForgotPasswordClick to EmailPasswordForm", () => { - const mockOnForgotPasswordClick = vi.fn(); - const { getByTestId } = render( - - ); - - const forgotPasswordButton = getByTestId("forgot-password-button"); - fireEvent.click(forgotPasswordButton); - - expect(mockOnForgotPasswordClick).toHaveBeenCalledTimes(1); - }); - - it("passes onRegisterClick to EmailPasswordForm", () => { - const mockOnRegisterClick = vi.fn(); - const { getByTestId } = render( - - ); - - const registerButton = getByTestId("register-button"); - fireEvent.click(registerButton); - - expect(mockOnRegisterClick).toHaveBeenCalledTimes(1); - }); - - it("renders children when provided", () => { - const { getByText, getByTestId } = render( - - - - ); - - expect(getByTestId("test-button")).toBeInTheDocument(); - expect(getByText("or")).toBeInTheDocument(); - }); - - it("does not render children or divider when not provided", () => { - const { queryByText } = render(); - - expect(queryByText("or")).not.toBeInTheDocument(); - }); -}); diff --git a/packages/firebaseui-react/tests/unit/auth/screens/sign-up-auth-screen.test.tsx b/packages/firebaseui-react/tests/unit/auth/screens/sign-up-auth-screen.test.tsx deleted file mode 100644 index f9dcd17f8..000000000 --- a/packages/firebaseui-react/tests/unit/auth/screens/sign-up-auth-screen.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { SignUpAuthScreen } from "~/auth/screens/sign-up-auth-screen"; - -// Mock hooks -vi.mock("~/hooks", () => ({ - useUI: () => ({ - locale: "en-US", - translations: { - "en-US": { - labels: { - register: "Create Account", - dividerOr: "OR", - }, - prompts: { - enterDetailsToCreate: "Enter your details to create an account", - }, - }, - }, - }), -})); - -// Mock translations -// vi.mock("@firebase-ui/core", () => ({ -// getTranslation: vi.fn((category, key) => { -// if (category === "labels" && key === "register") return "Create Account"; -// if (category === "prompts" && key === "enterDetailsToCreate") -// return "Enter your details to create an account"; -// if (category === "messages" && key === "dividerOr") return "OR"; -// return `${category}.${key}`; -// }), -// })); - -// Mock RegisterForm component -vi.mock("~/auth/forms/register-form", () => ({ - RegisterForm: ({ - onBackToSignInClick, - }: { - onBackToSignInClick?: () => void; - }) => ( -
- -
- ), -})); - -describe("SignUpAuthScreen", () => { - it("renders the correct title and subtitle", () => { - render(); - - expect(screen.getByText("Create Account")).toBeInTheDocument(); - expect( - screen.getByText("Enter your details to create an account") - ).toBeInTheDocument(); - }); - - it("includes the RegisterForm component", () => { - render(); - - expect(screen.getByTestId("register-form")).toBeInTheDocument(); - }); - - it("passes the onBackToSignInClick prop to the RegisterForm", async () => { - const onBackToSignInClick = vi.fn(); - render(); - - const backButton = screen.getByTestId("back-to-sign-in-button"); - backButton.click(); - - expect(onBackToSignInClick).toHaveBeenCalled(); - }); - - it("renders children when provided", () => { - render( - -
Child element
-
- ); - - expect(screen.getByTestId("test-child")).toBeInTheDocument(); - expect(screen.getByText("or")).toBeInTheDocument(); - }); - - it("does not render divider or children container when no children are provided", () => { - render(); - - expect(screen.queryByText("or")).not.toBeInTheDocument(); - }); -}); diff --git a/packages/firebaseui-react/tests/unit/components/country-selector.test.tsx b/packages/firebaseui-react/tests/unit/components/country-selector.test.tsx deleted file mode 100644 index 14feb24cf..000000000 --- a/packages/firebaseui-react/tests/unit/components/country-selector.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { CountrySelector } from "../../../src/components/country-selector"; -import { countryData } from "@firebase-ui/core"; - -describe("CountrySelector Component", () => { - const mockOnChange = vi.fn(); - const defaultCountry = countryData[0]; // First country in the list - - beforeEach(() => { - mockOnChange.mockClear(); - }); - - it("renders with the selected country", () => { - render(); - - // Check if the country flag emoji is displayed - expect(screen.getByText(defaultCountry.emoji)).toBeInTheDocument(); - - // Check if the dial code is displayed - expect(screen.getByText(defaultCountry.dialCode)).toBeInTheDocument(); - - // Check if the select has the correct value - const select = screen.getByRole("combobox"); - expect(select).toHaveValue(defaultCountry.code); - }); - - it("applies custom className", () => { - render( - - ); - - const selector = screen - .getByRole("combobox") - .closest(".fui-country-selector"); - expect(selector).toHaveClass("fui-country-selector"); - expect(selector).toHaveClass("custom-class"); - }); - - it("calls onChange when a different country is selected", () => { - render(); - - const select = screen.getByRole("combobox"); - - // Find a different country to select - const newCountry = countryData.find( - (country) => country.code !== defaultCountry.code - ); - - if (newCountry) { - // Change the selection - fireEvent.change(select, { target: { value: newCountry.code } }); - - // Check if onChange was called with the new country - expect(mockOnChange).toHaveBeenCalledTimes(1); - expect(mockOnChange).toHaveBeenCalledWith(newCountry); - } else { - // Fail the test if no different country is found - expect.fail( - "No different country found in countryData. Test cannot proceed." - ); - } - }); - - it("renders all countries in the dropdown", () => { - render(); - - const select = screen.getByRole("combobox"); - const options = select.querySelectorAll("option"); - - // Check if all countries are in the dropdown - expect(options.length).toBe(countryData.length); - - // Check if a specific country exists in the dropdown - const usCountry = countryData.find((country) => country.code === "US"); - if (usCountry) { - const usOption = Array.from(options).find( - (option) => option.value === usCountry.code - ); - expect(usOption).toBeInTheDocument(); - expect(usOption?.textContent).toBe( - `${usCountry.dialCode} (${usCountry.name})` - ); - } else { - // Fail the test if US country is not found - expect.fail("US country not found in countryData. Test cannot proceed."); - } - }); -}); diff --git a/packages/firebaseui-react/tests/unit/components/field-info.test.tsx b/packages/firebaseui-react/tests/unit/components/field-info.test.tsx deleted file mode 100644 index 41bdb7321..000000000 --- a/packages/firebaseui-react/tests/unit/components/field-info.test.tsx +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { FieldInfo } from "../../../src/components/field-info"; -import { FieldApi } from "@tanstack/react-form"; - -describe("FieldInfo Component", () => { - // Create a mock FieldApi with errors - const createMockFieldWithErrors = (errors: string[]) => { - return { - state: { - meta: { - isTouched: true, - errors, - }, - }, - } as unknown as FieldApi; - }; - - // Create a mock FieldApi without errors - const createMockFieldWithoutErrors = () => { - return { - state: { - meta: { - isTouched: true, - errors: [], - }, - }, - } as unknown as FieldApi; - }; - - // Create a mock FieldApi that's not touched - const createMockFieldNotTouched = () => { - return { - state: { - meta: { - isTouched: false, - errors: ["This field is required"], - }, - }, - } as unknown as FieldApi; - }; - - it("renders error message when field is touched and has errors", () => { - const errorMessage = "This field is required"; - const field = createMockFieldWithErrors([errorMessage]); - - render(); - - const errorElement = screen.getByRole("alert"); - expect(errorElement).toBeInTheDocument(); - expect(errorElement).toHaveClass("fui-form__error"); - expect(errorElement).toHaveTextContent(errorMessage); - }); - - it("renders nothing when field is touched but has no errors", () => { - const field = createMockFieldWithoutErrors(); - - const { container } = render(); - - // The component should render nothing - expect(container).toBeEmptyDOMElement(); - }); - - it("renders nothing when field is not touched, even with errors", () => { - const field = createMockFieldNotTouched(); - - const { container } = render(); - - // The component should render nothing - expect(container).toBeEmptyDOMElement(); - }); - - it("applies custom className to the error message", () => { - const errorMessage = "This field is required"; - const field = createMockFieldWithErrors([errorMessage]); - - render(); - - const errorElement = screen.getByRole("alert"); - expect(errorElement).toHaveClass("fui-form__error"); - expect(errorElement).toHaveClass("custom-error"); - }); - - it("accepts and passes through additional props", () => { - const errorMessage = "This field is required"; - const field = createMockFieldWithErrors([errorMessage]); - - render( - - ); - - const errorElement = screen.getByTestId("error-message"); - expect(errorElement).toHaveAttribute("aria-labelledby", "form-field"); - }); - - it("displays only the first error when multiple errors exist", () => { - const errors = ["First error", "Second error"]; - const field = createMockFieldWithErrors(errors); - - render(); - - const errorElement = screen.getByRole("alert"); - expect(errorElement).toHaveTextContent(errors[0]); - expect(errorElement).not.toHaveTextContent(errors[1]); - }); -}); diff --git a/packages/firebaseui-react/tests/unit/components/terms-and-privacy.test.tsx b/packages/firebaseui-react/tests/unit/components/terms-and-privacy.test.tsx deleted file mode 100644 index 1ecce33df..000000000 --- a/packages/firebaseui-react/tests/unit/components/terms-and-privacy.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { Policies, PolicyProvider } from "../../../src/components/policies"; - -// Mock useUI hook -vi.mock("~/hooks", () => ({ - useUI: vi.fn(() => ({ - locale: "en-US", - translations: { - "en-US": { - labels: { - termsOfService: "Terms of Service", - privacyPolicy: "Privacy Policy", - }, - messages: { - termsAndPrivacy: "By continuing, you agree to our {tos} and {privacy}", - }, - }, - }, - })), -})); - -describe("TermsAndPrivacy Component", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("renders component with terms and privacy links", () => { - render( - - - - ); - - // Check that the text and links are rendered - expect( - screen.getByText(/By continuing, you agree to our/) - ).toBeInTheDocument(); - - const tosLink = screen.getByText("Terms of Service"); - expect(tosLink).toBeInTheDocument(); - expect(tosLink.tagName).toBe("A"); - expect(tosLink).toHaveAttribute("target", "_blank"); - expect(tosLink).toHaveAttribute("rel", "noopener noreferrer"); - - const privacyLink = screen.getByText("Privacy Policy"); - expect(privacyLink).toBeInTheDocument(); - expect(privacyLink.tagName).toBe("A"); - expect(privacyLink).toHaveAttribute("target", "_blank"); - expect(privacyLink).toHaveAttribute("rel", "noopener noreferrer"); - }); - - it("returns null when both tosUrl and privacyPolicyUrl are not provided", () => { - const { container } = render( - - - - ); - expect(container).toBeEmptyDOMElement(); - }); -}); diff --git a/packages/firebaseui-react/tests/unit/context/config-provider.test.tsx b/packages/firebaseui-react/tests/unit/context/config-provider.test.tsx deleted file mode 100644 index 44ae2b50f..000000000 --- a/packages/firebaseui-react/tests/unit/context/config-provider.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect } from "vitest"; -import { render, act } from "@testing-library/react"; -import { FirebaseUIProvider, FirebaseUIContext } from "../../../src/context"; -import { map } from "nanostores"; -import { useContext } from "react"; -import { FirebaseUI, FirebaseUIConfiguration } from "@firebase-ui/core"; - -// Mock component to test context value -function TestConsumer() { - const config = useContext(FirebaseUIContext); - return
{config.locale || "no-value"}
; -} - -describe("ConfigProvider", () => { - it("provides the config value to children", () => { - // Create a mock config store with the correct FUIConfig properties - const mockConfig = map>({ - locale: "en-US", - }) as FirebaseUI; - - const { getByTestId } = render( - - - - ); - - expect(getByTestId("test-value").textContent).toBe("en-US"); - }); - - it("updates when the config store changes", () => { - // Create a mock config store - const mockConfig = map>({ - locale: "en-US", - }) as FirebaseUI; - - const { getByTestId } = render( - - - - ); - - expect(getByTestId("test-value").textContent).toBe("en-US"); - - // Update the config store inside act() - act(() => { - mockConfig.setKey("locale", "fr-FR"); - }); - - // Check that the context value was updated - expect(getByTestId("test-value").textContent).toBe("fr-FR"); - }); -}); diff --git a/packages/firebaseui-react/tests/unit/hooks/hooks.test.tsx b/packages/firebaseui-react/tests/unit/hooks/hooks.test.tsx deleted file mode 100644 index dbb62c6f3..000000000 --- a/packages/firebaseui-react/tests/unit/hooks/hooks.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook } from "@testing-library/react"; -import { useUI, useAuth } from "../../../src/hooks"; -import { getAuth } from "firebase/auth"; -import { FirebaseUIContext } from "../../../src/context"; - -// Mock Firebase -vi.mock("firebase/auth", () => ({ - getAuth: vi.fn(() => ({ - currentUser: null, - /* other auth properties */ - })), -})); - -describe("Hooks", () => { - const mockApp = { name: "test-app" } as any; - const mockTranslations = { - en: { - labels: { - signIn: "Sign In", - email: "Email", - }, - }, - }; - - const mockConfig = { - app: mockApp, - getAuth: vi.fn(), - setLocale: vi.fn(), - state: 'idle', - setState: vi.fn(), - locale: 'en', - translations: mockTranslations, - behaviors: {}, - recaptchaMode: 'normal', - }; - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("useConfig", () => { - it("returns the config from context", () => { - const { result } = renderHook(() => useUI(), { wrapper }); - - expect(result.current).toEqual(mockConfig); - }); - }); - - describe("useAuth", () => { - it("returns the authentication instance from Firebase", () => { - const { result } = renderHook(() => useAuth(), { wrapper }); - - expect(getAuth).toHaveBeenCalledWith(mockApp); - expect(result.current).toBeDefined(); - }); - }); -}); diff --git a/packages/firebaseui-react/tsconfig.json b/packages/firebaseui-react/tsconfig.json deleted file mode 100644 index fcf5db7f8..000000000 --- a/packages/firebaseui-react/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "files": [], - "references": [ - { - "path": "./tsconfig.app.json" - }, - { - "path": "./tsconfig.node.json" - } - ], -} \ No newline at end of file diff --git a/packages/firebaseui-react/tsconfig.test.json b/packages/firebaseui-react/tsconfig.test.json deleted file mode 100644 index e08a25c21..000000000 --- a/packages/firebaseui-react/tsconfig.test.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "extends": "./tsconfig.app.json", - "compilerOptions": { - "jsx": "react-jsx", - "esModuleInterop": true, - "types": [ - "vitest/importMeta", - "node", - "@testing-library/jest-dom" - ], - "baseUrl": ".", - "paths": { - "~/*": [ - "./src/*" - ], - "@firebase-ui/core": [ - "../firebaseui-core/src/index.ts" - ], - "@firebase-ui/core/*": [ - "../firebaseui-core/src/*" - ] - } - }, - "include": [ - "src", - "tests" - ] -} \ No newline at end of file diff --git a/packages/firebaseui-styles/dist.css b/packages/firebaseui-styles/dist.css deleted file mode 100644 index 7bdd1f33d..000000000 --- a/packages/firebaseui-styles/dist.css +++ /dev/null @@ -1,2 +0,0 @@ -/*! tailwindcss v4.1.5 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-500:oklch(.637 .237 25.331);--color-gray-200:oklch(.928 .006 264.531);--color-gray-300:oklch(.872 .01 258.338);--color-gray-800:oklch(.278 .033 256.848);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--font-weight-medium:500;--font-weight-bold:700;--radius-sm:.25rem;--radius-xl:.75rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-font-feature-settings:var(--font-sans--font-feature-settings);--default-font-variation-settings:var(--font-sans--font-variation-settings);--default-mono-font-family:var(--font-mono);--default-mono-font-feature-settings:var(--font-mono--font-feature-settings);--default-mono-font-variation-settings:var(--font-mono--font-variation-settings);--radius:var(--fui-radius);--color-primary:var(--fui-primary);--color-primary-hover:var(--fui-primary-hover);--color-primary-surface:var(--fui-primary-surface);--color-text:var(--fui-text);--color-text-muted:var(--fui-text-muted);--color-background:var(--fui-background);--color-border:var(--fui-border);--color-input:var(--fui-input);--color-error:var(--fui-error);--radius-card:var(--fui-radius-card)}:root{--fui-primary:var(--color-black);--fui-primary-hover:var(--fui-primary);--fui-primary-surface:var(--color-white);--fui-text:var(--color-black);--fui-text-muted:var(--color-gray-800);--fui-background:var(--color-white);--fui-border:var(--color-gray-200);--fui-input:var(--color-gray-300);--fui-error:var(--color-red-500);--fui-radius:var(--radius-sm);--fui-radius-card:var(--radius-xl)}@supports (color:color-mix(in lab, red, red)){:root{--fui-primary-hover:color-mix(in oklab,var(--fui-primary)85%,transparent)}}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1;color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentColor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.fui-screen{max-width:var(--container-md);padding-top:calc(var(--spacing)*24);margin-inline:auto}.fui-card{border-radius:var(--radius-card);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-border);background-color:var(--color-background);padding:calc(var(--spacing)*10)}:where(.fui-card>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}.fui-card__header{text-align:center}:where(.fui-card__header>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}.fui-card__title{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold);color:var(--color-text)}.fui-card__subtitle{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-text-muted)}:where(.fui-form>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}.fui-form fieldset,.fui-form fieldset>label{gap:calc(var(--spacing)*2);color:var(--color-text);flex-direction:column;display:flex}.fui-form fieldset>label>span{gap:calc(var(--spacing)*3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);display:inline-flex}.fui-form .fui-form__action{padding-inline:calc(var(--spacing)*1);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--color-text-muted)}@media (hover:hover){.fui-form .fui-form__action:hover{text-decoration-line:underline}}.fui-form fieldset>label>input{border-radius:var(--radius);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-input);padding-inline:calc(var(--spacing)*2);padding-block:calc(var(--spacing)*2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);background-color:#0000}.fui-form fieldset>label>input:focus{outline-style:var(--tw-outline-style);outline-width:2px;outline-color:var(--color-primary)}.fui-form fieldset>label>input[aria-invalid=true]{outline-style:var(--tw-outline-style);outline-width:2px;outline-color:var(--color-error)}.fui-form .fui-form__error{text-align:center;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--color-error)}.fui-success{text-align:center;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.fui-button{justify-content:center;align-items:center;gap:calc(var(--spacing)*3);border-radius:var(--radius);background-color:var(--color-primary);width:100%;padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-surface);--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,visibility,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:flex}@media (hover:hover){.fui-button:hover{cursor:pointer;background-color:var(--color-primary-hover)}}.fui-button:disabled{cursor:not-allowed;opacity:.5}.fui-button--secondary{border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-input);color:var(--color-text);background-color:#0000}@media (hover:hover){.fui-button--secondary:hover{border-color:var(--color-primary);background-color:var(--color-background)}}.fui-provider__button>svg{height:calc(var(--spacing)*5);width:calc(var(--spacing)*5)}.fui-divider{align-items:center;gap:calc(var(--spacing)*3);display:flex}.fui-divider__line{background-color:var(--color-border);flex:1;height:1px}.fui-divider__text{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--color-text-muted)}.fui-phone-input{align-items:center;gap:calc(var(--spacing)*2);display:flex}.fui-phone-input__number-input{border-radius:var(--radius);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-input);padding-inline:calc(var(--spacing)*2);padding-block:calc(var(--spacing)*2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);background-color:#0000;flex:1}.fui-phone-input__number-input:focus{outline-style:var(--tw-outline-style);outline-width:2px;outline-color:var(--color-primary)}.fui-phone-input__number-input[aria-invalid=true]{outline-style:var(--tw-outline-style);outline-width:2px;outline-color:var(--color-error)}.fui-country-selector{width:80px;display:inline-block;position:relative}.fui-country-selector__wrapper{border-radius:var(--radius);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-input);background-color:#0000;align-items:center;display:flex;position:relative;overflow:hidden}.fui-country-selector__flag{pointer-events:none;left:calc(var(--spacing)*2);font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height));position:absolute}.fui-country-selector select{cursor:pointer;appearance:none;width:100%;padding-block:calc(var(--spacing)*2);padding-right:calc(var(--spacing)*2);padding-left:calc(var(--spacing)*8);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:#0000;--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);background-color:#0000}.fui-country-selector select:focus{outline-style:var(--tw-outline-style);outline-width:2px;outline-color:var(--color-primary)}.fui-country-selector__dial-code{pointer-events:none;top:50%;left:calc(var(--spacing)*8);--tw-translate-y:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-text);position:absolute}}@layer utilities;@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0} \ No newline at end of file diff --git a/packages/firebaseui-styles/package.json b/packages/firebaseui-styles/package.json deleted file mode 100644 index 05448ae72..000000000 --- a/packages/firebaseui-styles/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@firebase-ui/styles", - "version": "0.0.1", - "type": "module", - "files": [ - "dist.css", - "src" - ], - "scripts": { - "prepare": "pnpm run build", - "build": "npx -y @tailwindcss/cli -i ./src.css -o ./dist.css --minify", - "build:local": "pnpm run build && pnpm pack", - "publish:tags": "sh -c 'TAG=\"${npm_package_name}@${npm_package_version}\"; git tag --list \"$TAG\" | grep . || git tag \"$TAG\"; git push origin \"$TAG\"'", - "release": "pnpm run build && pnpm pack --pack-destination ../../releases/" - }, - "devDependencies": { - "tailwindcss": "^4.0.0" - } -} diff --git a/packages/firebaseui-styles/src/base.css b/packages/firebaseui-styles/src/base.css deleted file mode 100644 index 6c59377a2..000000000 --- a/packages/firebaseui-styles/src/base.css +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@theme { - --color-primary: var(--fui-primary); - --color-primary-hover: var(--fui-primary-hover); - --color-primary-surface: var(--fui-primary-surface); - --color-text: var(--fui-text); - --color-text-muted: var(--fui-text-muted); - --color-background: var(--fui-background); - --color-border: var(--fui-border); - --color-input: var(--fui-input); - --color-error: var(--fui-error); - --radius: var(--fui-radius); - --radius-card: var(--fui-radius-card); -} - -@layer theme { - :root { - /* The primary color is used for the button and link colors */ - --fui-primary: var(--color-black); - /* The primary hover color is used for the button and link colors when hovered */ - --fui-primary-hover: --alpha(var(--fui-primary) / 85%); - /* The primary surface color is used for the button text color */ - --fui-primary-surface: var(--color-white); - /* The text color used for body text */ - --fui-text: var(--color-black); - /* The muted text color used for body text, such as subtitles */ - --fui-text-muted: var(--color-gray-800); - /* The background color of the cards */ - --fui-background: var(--color-white); - /* The border color used for none input fields */ - --fui-border: var(--color-gray-200); - /* The input color used for input fields */ - --fui-input: var(--color-gray-300); - /* The error color used for error messages */ - --fui-error: var(--color-red-500); - /* The radius used for the input fields */ - --fui-radius: var(--radius-sm); - /* The radius used for the cards */ - --fui-radius-card: var(--radius-xl); - } -} - -@layer components { - .fui-screen { - @apply pt-24 max-w-md mx-auto; - } - - .fui-card { - @apply bg-background p-10 border border-border rounded-card space-y-6; - } - - .fui-card__header { - @apply text-center space-y-1; - } - - .fui-card__title { - @apply text-xl font-bold text-text; - } - - .fui-card__subtitle { - @apply text-sm text-text-muted; - } - - .fui-form { - @apply space-y-6; - } - - .fui-form fieldset, - .fui-form fieldset>label { - @apply flex flex-col gap-2 text-text; - } - - .fui-form fieldset>label>span { - @apply inline-flex gap-3 text-sm font-medium; - } - - .fui-form .fui-form__action { - @apply px-1 hover:underline text-xs text-text-muted; - } - - .fui-form fieldset>label>input { - @apply border-1 border-input rounded px-2 py-2 text-sm focus:outline-2 focus:outline-primary shadow-xs bg-transparent; - } - - .fui-form fieldset>label>input[aria-invalid="true"] { - @apply outline-error outline-2; - } - - .fui-form .fui-form__error { - @apply text-error text-center text-xs; - } - - .fui-success { - @apply text-center text-xs; - } - - .fui-button { - @apply w-full flex items-center justify-center gap-3 px-4 py-2 rounded text-sm font-medium shadow-xs bg-primary text-primary-surface transition hover:bg-primary-hover hover:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed; - } - - .fui-button--secondary { - @apply bg-transparent text-text border border-input hover:bg-background hover:border-primary; - } - - .fui-provider__button>svg { - @apply w-5 h-5; - } - - .fui-divider { - @apply flex items-center gap-3; - } - - .fui-divider__line { - @apply flex-1 h-px bg-border; - } - - .fui-divider__text { - @apply text-text-muted text-xs; - } - - .fui-phone-input { - @apply flex gap-2 items-center; - } - - .fui-phone-input__number-input { - @apply border-1 border-input rounded px-2 py-2 text-sm focus:outline-2 focus:outline-primary shadow-xs bg-transparent flex-1; - } - - .fui-phone-input__number-input[aria-invalid="true"] { - @apply outline-error outline-2; - } - - .fui-country-selector { - @apply relative inline-block w-[80px]; - } - - .fui-country-selector__wrapper { - @apply relative flex items-center border-1 border-input rounded bg-transparent overflow-hidden; - } - - .fui-country-selector__flag { - @apply absolute left-2 text-lg pointer-events-none; - } - - .fui-country-selector select { - @apply w-full pl-8 pr-2 py-2 text-sm focus:outline-2 focus:outline-primary shadow-xs bg-transparent appearance-none cursor-pointer text-transparent; - } - - .fui-country-selector__dial-code { - @apply absolute left-8 top-1/2 -translate-y-1/2 text-sm pointer-events-none text-text; - } -} \ No newline at end of file diff --git a/packages/firebaseui-translations/src/mapping.ts b/packages/firebaseui-translations/src/mapping.ts deleted file mode 100644 index a0ae157fd..000000000 --- a/packages/firebaseui-translations/src/mapping.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { enUS } from "./locales/en-us"; -import { Locale, english } from "."; -import type { - ErrorKey, - TranslationCategory, - TranslationKey, - TranslationsConfig, - TranslationSet, -} from "./types"; - -export const ERROR_CODE_MAP = { - "auth/user-not-found": "userNotFound", - "auth/wrong-password": "wrongPassword", - "auth/invalid-email": "invalidEmail", - "auth/user-disabled": "userDisabled", - "auth/network-request-failed": "networkRequestFailed", - "auth/too-many-requests": "tooManyRequests", - "auth/email-already-in-use": "emailAlreadyInUse", - "auth/weak-password": "weakPassword", - "auth/operation-not-allowed": "operationNotAllowed", - "auth/invalid-phone-number": "invalidPhoneNumber", - "auth/missing-phone-number": "missingPhoneNumber", - "auth/quota-exceeded": "quotaExceeded", - "auth/code-expired": "codeExpired", - "auth/captcha-check-failed": "captchaCheckFailed", - "auth/missing-verification-id": "missingVerificationId", - "auth/missing-email": "missingEmail", - "auth/invalid-action-code": "invalidActionCode", - "auth/credential-already-in-use": "credentialAlreadyInUse", - "auth/requires-recent-login": "requiresRecentLogin", - "auth/provider-already-linked": "providerAlreadyLinked", - "auth/invalid-verification-code": "invalidVerificationCode", - "auth/account-exists-with-different-credential": - "accountExistsWithDifferentCredential", -} satisfies Record; - -export type ErrorCode = keyof typeof ERROR_CODE_MAP; - -export function getTranslation( - category: T, - key: TranslationKey, - translations: TranslationsConfig | undefined, - locale: Locale | undefined = undefined -): string { - const userPreferredTranslationSet = translations?.[ - locale ?? english.locale - ]?.[category] as TranslationSet | undefined; - - // Try user's preferred language first - if (userPreferredTranslationSet && key in userPreferredTranslationSet) { - return userPreferredTranslationSet[key]; - } - - // Fall back to English translations if provided - const fallbackTranslationSet = translations?.["en"]?.[category] as - | TranslationSet - | undefined; - if (fallbackTranslationSet && key in fallbackTranslationSet) { - return fallbackTranslationSet[key]; - } - - // Default to built-in English translations - const defaultTranslationSet = enUS[category] as TranslationSet; - return defaultTranslationSet[key]; -} diff --git a/packages/firebaseui-translations/src/types.ts b/packages/firebaseui-translations/src/types.ts deleted file mode 100644 index 59337ec2b..000000000 --- a/packages/firebaseui-translations/src/types.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export type TranslationCategory = keyof Required; -export type TranslationKey = - keyof Required[T]; -export type TranslationSet = Record< - TranslationKey, - string ->; -export type ErrorKey = keyof Required["errors"]; -export type MessageKey = keyof Required["messages"]; -export type LabelKey = keyof Required["labels"]; -export type PromptKey = keyof Required["prompts"]; -export type TranslationsConfig = Partial>>; - -export type Translations = { - errors?: { - userNotFound?: string; - wrongPassword?: string; - invalidEmail?: string; - userDisabled?: string; - networkRequestFailed?: string; - tooManyRequests?: string; - emailAlreadyInUse?: string; - weakPassword?: string; - operationNotAllowed?: string; - invalidPhoneNumber?: string; - missingPhoneNumber?: string; - quotaExceeded?: string; - codeExpired?: string; - captchaCheckFailed?: string; - missingVerificationId?: string; - missingEmail?: string; - invalidActionCode?: string; - credentialAlreadyInUse?: string; - requiresRecentLogin?: string; - providerAlreadyLinked?: string; - invalidVerificationCode?: string; - unknownError?: string; - popupClosed?: string; - accountExistsWithDifferentCredential?: string; - }; - messages?: { - passwordResetEmailSent?: string; - signInLinkSent?: string; - verificationCodeFirst?: string; - checkEmailForReset?: string; - dividerOr?: string; - termsAndPrivacy?: string; - }; - labels?: { - emailAddress?: string; - password?: string; - forgotPassword?: string; - register?: string; - signIn?: string; - resetPassword?: string; - createAccount?: string; - backToSignIn?: string; - signInWithPhone?: string; - phoneNumber?: string; - verificationCode?: string; - sendCode?: string; - verifyCode?: string; - signInWithGoogle?: string; - signInWithEmailLink?: string; - sendSignInLink?: string; - termsOfService?: string; - privacyPolicy?: string; - resendCode?: string; - sending?: string; - }; - prompts?: { - noAccount?: string; - haveAccount?: string; - enterEmailToReset?: string; - signInToAccount?: string; - enterDetailsToCreate?: string; - enterPhoneNumber?: string; - enterVerificationCode?: string; - enterEmailForLink?: string; - }; -}; diff --git a/packages/firebaseui-translations/tsconfig.json b/packages/firebaseui-translations/tsconfig.json deleted file mode 100644 index 64266b024..000000000 --- a/packages/firebaseui-translations/tsconfig.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "lib": [ - "ES2020", - "DOM" - ], - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "noImplicitAny": true, - "strictNullChecks": true, - "strictFunctionTypes": true, - "strictBindCallApply": true, - "strictPropertyInitialization": true, - "noImplicitThis": true, - "useUnknownInCatchVariables": true, - "alwaysStrict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "exactOptionalPropertyTypes": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "noPropertyAccessFromIndexSignature": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "skipLibCheck": true, - "moduleResolution": "node" - }, - "include": [ - "src" - ], - "exclude": [ - "node_modules", - "dist" - ] -} \ No newline at end of file diff --git a/packages/react/.gitignore b/packages/react/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/packages/react/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/react/.prettierrc b/packages/react/.prettierrc new file mode 100644 index 000000000..37702140f --- /dev/null +++ b/packages/react/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "endOfLine": "auto" +} diff --git a/packages/react/GEMINI.md b/packages/react/GEMINI.md new file mode 100644 index 000000000..e273aa5d1 --- /dev/null +++ b/packages/react/GEMINI.md @@ -0,0 +1,96 @@ +# Firebase UI React + +This document provides context for the `@invertase/firebaseui-react` package. + +## Overview + +The `@invertase/firebaseui-react` package provides a set of React components and hooks to integrate Firebase UI for Web into a React application. It builds on top of `@invertase/firebaseui-core` and `@invertase/firebaseui-styles` to provide a seamless integration with the React ecosystem. + +The package offers two main ways to build your UI: + +1. **Pre-built Components**: A set of ready-to-use components for common authentication screens (e.g., Sign In, Register). +2. **Hooks**: A collection of React hooks that provide access to the underlying UI state and authentication logic, allowing you to build fully custom UIs. + +## Setup + +To use the React package, you must first initialize Firebase UI using `initializeUI` from the core package, and then wrap your application with the `FirebaseUIProvider`. + +```tsx +// In your main App.tsx or a similar entry point + +import { initializeUI } from "@invertase/firebaseui-core"; +import { enUs } from "@invertase/firebaseui-translations"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; +import { firebaseApp } from "./firebase"; // Your firebase config + +// 1. Initialize the UI +const ui = initializeUI({ + app: firebaseApp, + locale: enUs, + // ... other configurations +}); + +function App() { + // 2. Wrap your app in the provider + return ( + + {/* Your application components */} + + + ); +} +``` + +## Pre-built Components + +The package includes several pre-built "screen" components for a quick setup. These components render a full-page authentication form. + +**Example: Sign-In Screen** + +```tsx +import { SignInScreen } from "@invertase/firebaseui-react"; + +function MySignInPage() { + return ; +} +``` + +Other available components include `RegisterScreen`, `ForgotPasswordScreen`, etc. + +## Hooks + +Hooks are the recommended way to build a custom user interface. + +### `useUI()` + +The main hook is `useUI()`. It returns the entire UI state object from the underlying `nanostores` store. This gives you access to the current `state` (`idle`, `pending`, `error`), any `error` messages, and the `auth` instance. + +**Example: Custom Button** + +```tsx +import { useUI } from "@invertase/firebaseui-react"; +import { signInWithEmailAndPassword } from "@invertase/firebaseui-core"; + +function CustomSignInButton() { + const ui = useUI(); + + const handleClick = () => { + // Functions from @invertase/firebaseui-core require the `ui` instance + signInWithEmailAndPassword(ui, "user@example.com", "password"); + }; + + return ( + + ); +} +``` + +### Other Hooks + +The package also provides other specialized hooks: + +- `useSignInAuthFormSchema()`: Returns a Zod schema for sign-in form validation. +- `useSignUpAuthFormSchema()`: Returns a Zod schema for sign-up form validation. +- `useRecaptchaVerifier()`: A hook to easily integrate a reCAPTCHA verifier. diff --git a/packages/firebaseui-react/README.md b/packages/react/README.md similarity index 71% rename from packages/firebaseui-react/README.md rename to packages/react/README.md index 44188ed08..ceac7dcad 100644 --- a/packages/firebaseui-react/README.md +++ b/packages/react/README.md @@ -1,4 +1,4 @@ -# @firebase-ui/react +# @invertase/firebaseui-react This package contains the React components for the FirebaseUI. @@ -7,26 +7,26 @@ This package contains the React components for the FirebaseUI. Install the package from NPM: ```bash -npm install @firebase-ui/react +npm install @invertase/firebaseui-react ``` ## Usage ### Importing styles -To use the components, you need to import the styles from the `@firebase-ui/styles` package. +To use the components, you need to import the styles from the `@invertase/firebaseui-styles` package. If using Tailwind CSS, you can import the styles directly into your project. ```css @import "tailwindcss"; -@import "@firebase-ui/styles/src/base.css"; +@import "@invertase/firebaseui-styles/src/base.css"; ``` Alternatively, you can import the fully compiled CSS file into your project. ```tsx -import "@firebase-ui/styles/dist.css"; +import "@invertase/firebaseui-styles/dist.css"; ``` ### Initializing the UI @@ -42,7 +42,7 @@ const app = initializeApp({ ... }); Then, initialize the FirebaseUI with the configuration: ```tsx -import { initializeUI } from "@firebase-ui/react"; +import { initializeUI } from "@invertase/firebaseui-react"; const ui = initializeUI({ app, @@ -52,7 +52,7 @@ const ui = initializeUI({ Finally, wrap your app in the `ConfigProvider` component: ```tsx -import { ConfigProvider } from "@firebase-ui/react"; +import { ConfigProvider } from "@invertase/firebaseui-react"; createRoot(document.getElementById("root")!).render( @@ -65,10 +65,10 @@ createRoot(document.getElementById("root")!).render( ### Importing components -To use the components, you need to import the components from the `@firebase-ui/react` package. +To use the components, you need to import the components from the `@invertase/firebaseui-react` package. ```tsx -import { SignInAuthScreen, GoogleSignInButton } from "@firebase-ui/react"; +import { SignInAuthScreen, GoogleSignInButton } from "@invertase/firebaseui-react"; function App() { return ( diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 000000000..6afc75d0f --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,74 @@ +{ + "name": "@invertase/firebaseui-react", + "version": "0.0.10", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "prepare": "pnpm run build", + "build": "tsup --env.PROD=true && pnpm run build:logos", + "build:local": "pnpm run build && pnpm pack", + "build:logos": "pnpm dlx @svgr/cli --icon --typescript --no-index --jsx-runtime automatic --out-dir src/components/logos ../core/brands", + "dev": "tsup --watch", + "lint": "eslint . --ext .ts,.tsx", + "lint:fix": "eslint . --ext .ts,.tsx --fix", + "format": "prettier --write \"src/**/*.{ts,tsx}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx}\"", + "clean": "rimraf dist", + "test": "vitest run", + "test:unit": "vitest run tests/unit", + "test:unit:watch": "vitest tests/unit", + "test:integration": "vitest run tests/integration", + "test:integration:watch": "vitest tests/integration", + "version:bump": "pnpm version patch", + "publish:tags": "sh -c 'TAG=\"${npm_package_name}@${npm_package_version}\"; git tag --list \"$TAG\" | grep . || git tag \"$TAG\"; git push origin \"$TAG\"'", + "publish:npm": "pnpm publish --access public", + "release": "pnpm run build && pnpm pack --pack-destination --pack-destination ../../releases/" + }, + "peerDependencies": { + "firebase": "catalog:peerDependencies", + "react": "catalog:peerDependencies", + "react-dom": "catalog:peerDependencies" + }, + "dependencies": { + "@invertase/firebaseui-core": "workspace:*", + "@invertase/firebaseui-styles": "workspace:*", + "@nanostores/react": "^1.0.0", + "@radix-ui/react-slot": "^1.2.3", + "@tanstack/react-form": "1.20.0", + "clsx": "^2.1.1", + "tailwind-merge": "^3.0.1", + "zod": "catalog:" + }, + "devDependencies": { + "@invertase/firebaseui-translations": "workspace:*", + "@testing-library/jest-dom": "catalog:", + "@testing-library/react": "catalog:", + "@types/jsdom": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "firebase": "catalog:", + "jsdom": "catalog:", + "nanostores": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "tsup": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-svgr": "^4.5.0", + "vitest": "catalog:" + } +} diff --git a/examples/angular/src/app/app.component.css b/packages/react/setup-test.ts similarity index 93% rename from examples/angular/src/app/app.component.css rename to packages/react/setup-test.ts index e1ea07bd5..aa135cc62 100644 --- a/examples/angular/src/app/app.component.css +++ b/packages/react/setup-test.ts @@ -14,3 +14,4 @@ * limitations under the License. */ +import "@testing-library/jest-dom/vitest"; diff --git a/packages/react/src/auth/forms/email-link-auth-form.test.tsx b/packages/react/src/auth/forms/email-link-auth-form.test.tsx new file mode 100644 index 000000000..2eb86a535 --- /dev/null +++ b/packages/react/src/auth/forms/email-link-auth-form.test.tsx @@ -0,0 +1,315 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, renderHook, cleanup, waitFor } from "@testing-library/react"; +import { + EmailLinkAuthForm, + useEmailLinkAuthForm, + useEmailLinkAuthFormAction, + useEmailLinkAuthFormCompleteSignIn, +} from "./email-link-auth-form"; +import { act } from "react"; +import { sendSignInLinkToEmail, completeEmailLinkSignIn } from "@invertase/firebaseui-core"; +import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { FirebaseUIProvider } from "~/context"; +import type { UserCredential } from "firebase/auth"; + +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + getRedirectResult: vi.fn().mockResolvedValue(null), + }; +}); + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + sendSignInLinkToEmail: vi.fn(), + completeEmailLinkSignIn: vi.fn(), + }; +}); + +vi.mock("~/components/form", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + form: { + ...mod.form, + ErrorMessage: () =>
Error Message
, + }, + }; +}); + +describe("useEmailLinkAuthFormCompleteSignIn", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should call onSignIn when email link sign-in is completed successfully", async () => { + const mockCredential = { credential: true } as unknown as UserCredential; + const completeEmailLinkSignInMock = vi.mocked(completeEmailLinkSignIn).mockResolvedValue(mockCredential); + const onSignInMock = vi.fn(); + const mockUI = createMockUI(); + + renderHook(() => useEmailLinkAuthFormCompleteSignIn(onSignInMock), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await waitFor(() => { + expect(completeEmailLinkSignInMock).toHaveBeenCalledWith(mockUI.get(), window.location.href); + expect(onSignInMock).toHaveBeenCalledWith(mockCredential); + }); + }); + + it("should not call onSignIn when email link sign-in returns null", async () => { + const completeEmailLinkSignInMock = vi.mocked(completeEmailLinkSignIn).mockResolvedValue(null); + const onSignInMock = vi.fn(); + const mockUI = createMockUI(); + + renderHook(() => useEmailLinkAuthFormCompleteSignIn(onSignInMock), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await waitFor(() => { + expect(completeEmailLinkSignInMock).toHaveBeenCalledWith(mockUI.get(), window.location.href); + expect(onSignInMock).not.toHaveBeenCalled(); + }); + + expect(completeEmailLinkSignInMock).toHaveBeenCalledWith(mockUI.get(), window.location.href); + expect(onSignInMock).not.toHaveBeenCalled(); + }); + + it("should not call onSignIn when onSignIn is not provided", async () => { + const mockCredential = { credential: true } as unknown as UserCredential; + const completeEmailLinkSignInMock = vi.mocked(completeEmailLinkSignIn).mockResolvedValue(mockCredential); + const mockUI = createMockUI(); + + renderHook(() => useEmailLinkAuthFormCompleteSignIn(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await waitFor(() => { + expect(completeEmailLinkSignInMock).toHaveBeenCalledWith(mockUI.get(), window.location.href); + }); + }); +}); + +describe("useEmailLinkAuthFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a callback which accept an email", async () => { + const sendSignInLinkToEmailMock = vi.mocked(sendSignInLinkToEmail); + const mockUI = createMockUI(); + + const { result } = renderHook(() => useEmailLinkAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ email: "test@example.com" }); + }); + + expect(sendSignInLinkToEmailMock).toHaveBeenCalledWith(expect.any(Object), "test@example.com"); + }); + + it("should throw an unknown error when its not a FirebaseUIError", async () => { + const sendSignInLinkToEmailMock = vi.mocked(sendSignInLinkToEmail).mockRejectedValue(new Error("Unknown error")); + + const mockUI = createMockUI({ + locale: registerLocale("es-ES", { + errors: { + unknownError: "unknownError", + }, + }), + }); + + const { result } = renderHook(() => useEmailLinkAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await expect(async () => { + await act(async () => { + await result.current({ email: "test@example.com" }); + }); + }).rejects.toThrow("unknownError"); + + expect(sendSignInLinkToEmailMock).toHaveBeenCalledWith(mockUI.get(), "test@example.com"); + }); +}); + +describe("useEmailLinkAuthForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should allow the form to be submitted", async () => { + const mockUI = createMockUI(); + const sendSignInLinkToEmailMock = vi.mocked(sendSignInLinkToEmail); + + const { result } = renderHook(() => useEmailLinkAuthForm(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + result.current.setFieldValue("email", "test@example.com"); + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(sendSignInLinkToEmailMock).toHaveBeenCalledWith(mockUI.get(), "test@example.com"); + }); + + it("should not allow the form to be submitted if the form is invalid", async () => { + const mockUI = createMockUI(); + const sendSignInLinkToEmailMock = vi.mocked(sendSignInLinkToEmail); + + const { result } = renderHook(() => useEmailLinkAuthForm(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + result.current.setFieldValue("email", "123"); + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(result.current.getFieldMeta("email")!.errors[0].length).toBeGreaterThan(0); + expect(sendSignInLinkToEmailMock).not.toHaveBeenCalled(); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the form correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendSignInLink: "sendSignInLink", + }, + }), + }); + + const { container } = render( + + + + ); + + // There should be only one form + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + // Make sure we have an email input + expect(screen.getByRole("textbox", { name: /email/i })).toBeInTheDocument(); + + // Ensure the "Send Sign In Link" button is present and is a submit button + const sendSignInLinkButton = screen.getByRole("button", { name: "sendSignInLink" }); + expect(sendSignInLinkButton).toBeInTheDocument(); + expect(sendSignInLinkButton).toHaveAttribute("type", "submit"); + }); + + it("should attempt to complete email link sign-in on load", () => { + const completeEmailLinkSignInMock = vi.mocked(completeEmailLinkSignIn); + const mockUI = createMockUI(); + + render( + + + + ); + + expect(completeEmailLinkSignInMock).toHaveBeenCalled(); + }); + + it("should call onSignIn when email link sign-in is completed successfully", async () => { + const mockCredential = { credential: true } as unknown as UserCredential; + const completeEmailLinkSignInMock = vi.mocked(completeEmailLinkSignIn).mockResolvedValue(mockCredential); + const onSignInMock = vi.fn(); + const mockUI = createMockUI(); + + render( + + + + ); + + await act(async () => { + // Wait for the useEffect to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(completeEmailLinkSignInMock).toHaveBeenCalledWith(mockUI.get(), window.location.href); + expect(onSignInMock).toHaveBeenCalledWith(mockCredential); + }); + + it("should not call onSignIn when email link sign-in returns null", async () => { + const completeEmailLinkSignInMock = vi.mocked(completeEmailLinkSignIn).mockResolvedValue(null); + const onSignInMock = vi.fn(); + const mockUI = createMockUI(); + + render( + + + + ); + + await act(async () => { + // Wait for the useEffect to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(completeEmailLinkSignInMock).toHaveBeenCalledWith(mockUI.get(), window.location.href); + expect(onSignInMock).not.toHaveBeenCalled(); + }); + + it("should trigger validation errors when the form is blurred", () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + const form = container.querySelector("form.fui-form"); + expect(form).toBeInTheDocument(); + + const input = screen.getByRole("textbox", { name: /email/i }); + + act(() => { + fireEvent.blur(input); + }); + + expect(screen.getByText("Please enter a valid email address")).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/auth/forms/email-link-auth-form.tsx b/packages/react/src/auth/forms/email-link-auth-form.tsx new file mode 100644 index 000000000..c23bad858 --- /dev/null +++ b/packages/react/src/auth/forms/email-link-auth-form.tsx @@ -0,0 +1,161 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { + FirebaseUIError, + completeEmailLinkSignIn, + getTranslation, + sendSignInLinkToEmail, +} from "@invertase/firebaseui-core"; +import type { UserCredential } from "firebase/auth"; +import { useEmailLinkAuthFormSchema, useUI } from "~/hooks"; +import { form } from "~/components/form"; +import { Policies } from "~/components/policies"; +import { useCallback, useEffect, useState } from "react"; + +/** Props for the EmailLinkAuthForm component. */ +export type EmailLinkAuthFormProps = { + /** Callback function called when the sign-in link email is sent. */ + onEmailSent?: () => void; + /** Callback function called when sign-in is completed via the email link. */ + onSignIn?: (credential: UserCredential) => void; +}; + +/** + * Creates a memoized action function for sending a sign-in link to an email address. + * + * @returns A callback function that sends a sign-in link to the specified email address. + */ +export function useEmailLinkAuthFormAction() { + const ui = useUI(); + + return useCallback( + async ({ email }: { email: string }) => { + try { + return await sendSignInLinkToEmail(ui, email); + } catch (error) { + if (error instanceof FirebaseUIError) { + throw new Error(error.message); + } + + console.error(error); + throw new Error(getTranslation(ui, "errors", "unknownError")); + } + }, + [ui] + ); +} + +/** + * Creates a form hook for email link authentication. + * + * @param onSuccess - Optional callback function called when the sign-in link email is sent. + * @returns A form instance configured for email link authentication. + */ +export function useEmailLinkAuthForm(onSuccess?: EmailLinkAuthFormProps["onEmailSent"]) { + const schema = useEmailLinkAuthFormSchema(); + const action = useEmailLinkAuthFormAction(); + + return form.useAppForm({ + defaultValues: { + email: "", + }, + validators: { + onBlur: schema, + onSubmitAsync: async ({ value }) => { + try { + await action(value); + return onSuccess?.(); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }, + }); +} + +/** + * Hook that automatically completes the email link sign-in process when the component mounts. + * + * Checks if the current URL contains a valid email link sign-in link and completes the authentication. + * + * @param onSignIn - Optional callback function called when sign-in is completed. + */ +export function useEmailLinkAuthFormCompleteSignIn(onSignIn?: EmailLinkAuthFormProps["onSignIn"]) { + const ui = useUI(); + + useEffect(() => { + const completeSignIn = async () => { + const credential = await completeEmailLinkSignIn(ui, window.location.href); + if (credential) { + onSignIn?.(credential); + } + }; + + void completeSignIn(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- TODO(ehesp): ui triggers re-render + }, [onSignIn]); +} + +/** + * A form component for email link authentication. + * + * Sends a sign-in link to the user's email address and automatically completes sign-in + * if the user arrives via an email link. + * + * @returns The email link auth form component. + */ +export function EmailLinkAuthForm({ onEmailSent, onSignIn }: EmailLinkAuthFormProps) { + const ui = useUI(); + const [emailSent, setEmailSent] = useState(false); + + const form = useEmailLinkAuthForm(() => { + setEmailSent(true); + onEmailSent?.(); + }); + + useEmailLinkAuthFormCompleteSignIn(onSignIn); + + if (emailSent) { + return
{getTranslation(ui, "messages", "signInLinkSent")}
; + } + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
+ + {(field) => } + +
+ +
+ {getTranslation(ui, "labels", "sendSignInLink")} + +
+
+
+ ); +} diff --git a/packages/react/src/auth/forms/forgot-password-auth-form.test.tsx b/packages/react/src/auth/forms/forgot-password-auth-form.test.tsx new file mode 100644 index 000000000..e51320b4c --- /dev/null +++ b/packages/react/src/auth/forms/forgot-password-auth-form.test.tsx @@ -0,0 +1,221 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, renderHook, cleanup } from "@testing-library/react"; +import { + ForgotPasswordAuthForm, + useForgotPasswordAuthForm, + useForgotPasswordAuthFormAction, +} from "./forgot-password-auth-form"; +import { act } from "react"; +import { sendPasswordResetEmail } from "@invertase/firebaseui-core"; +import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { FirebaseUIProvider } from "~/context"; + +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + getRedirectResult: vi.fn().mockResolvedValue(null), + }; +}); + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + sendPasswordResetEmail: vi.fn(), + }; +}); + +describe("useForgotPasswordAuthFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a callback which accept an email", async () => { + const sendPasswordResetEmailMock = vi.mocked(sendPasswordResetEmail); + const mockUI = createMockUI(); + + const { result } = renderHook(() => useForgotPasswordAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ email: "test@example.com" }); + }); + + expect(sendPasswordResetEmailMock).toHaveBeenCalledWith(expect.any(Object), "test@example.com"); + }); + + it("should throw an unknown error when its not a FirebaseUIError", async () => { + const sendPasswordResetEmailMock = vi.mocked(sendPasswordResetEmail).mockRejectedValue(new Error("Unknown error")); + + const mockUI = createMockUI({ + locale: registerLocale("es-ES", { + errors: { + unknownError: "unknownError", + }, + }), + }); + + const { result } = renderHook(() => useForgotPasswordAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await expect(async () => { + await act(async () => { + await result.current({ email: "test@example.com" }); + }); + }).rejects.toThrow("unknownError"); + + expect(sendPasswordResetEmailMock).toHaveBeenCalledWith(mockUI.get(), "test@example.com"); + }); +}); + +describe("useForgotPasswordAuthForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should allow the form to be submitted", async () => { + const mockUI = createMockUI(); + const sendPasswordResetEmailMock = vi.mocked(sendPasswordResetEmail); + + const { result } = renderHook(() => useForgotPasswordAuthForm(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + result.current.setFieldValue("email", "test@example.com"); + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(sendPasswordResetEmailMock).toHaveBeenCalledWith(mockUI.get(), "test@example.com"); + }); + + it("should not allow the form to be submitted if the form is invalid", async () => { + const mockUI = createMockUI(); + const sendPasswordResetEmailMock = vi.mocked(sendPasswordResetEmail); + + const { result } = renderHook(() => useForgotPasswordAuthForm(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + result.current.setFieldValue("email", "123"); + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(result.current.getFieldMeta("email")!.errors[0].length).toBeGreaterThan(0); + expect(sendPasswordResetEmailMock).not.toHaveBeenCalled(); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the form correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + resetPassword: "resetPassword", + }, + }), + }); + + const { container } = render( + + + + ); + + // There should be only one form + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + // Make sure we have an email input + expect(screen.getByRole("textbox", { name: /email/i })).toBeInTheDocument(); + + // Ensure the "Reset Password" button is present and is a submit button + const resetPasswordButton = screen.getByRole("button", { name: "resetPassword" }); + expect(resetPasswordButton).toBeInTheDocument(); + expect(resetPasswordButton).toHaveAttribute("type", "submit"); + }); + + it("should render the back to sign in button callback when onBackToSignInClick is provided", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + backToSignIn: "backToSignIn", + }, + }), + }); + + const onBackToSignInClickMock = vi.fn(); + + render( + + + + ); + + const backToSignInButton = screen.getByRole("button", { name: "← backToSignIn" }); + expect(backToSignInButton).toBeInTheDocument(); + expect(backToSignInButton).toHaveTextContent("← backToSignIn"); + + // Make sure it's a button so it doesn't submit the form + expect(backToSignInButton).toHaveAttribute("type", "button"); + + fireEvent.click(backToSignInButton); + expect(onBackToSignInClickMock).toHaveBeenCalled(); + }); + + it("should trigger validation errors when the form is blurred", () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + const form = container.querySelector("form.fui-form"); + expect(form).toBeInTheDocument(); + + const input = screen.getByRole("textbox", { name: /email/i }); + + act(() => { + fireEvent.blur(input); + }); + + expect(screen.getByText("Please enter a valid email address")).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/auth/forms/forgot-password-auth-form.tsx b/packages/react/src/auth/forms/forgot-password-auth-form.tsx new file mode 100644 index 000000000..f6d84a78d --- /dev/null +++ b/packages/react/src/auth/forms/forgot-password-auth-form.tsx @@ -0,0 +1,131 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { FirebaseUIError, getTranslation, sendPasswordResetEmail } from "@invertase/firebaseui-core"; +import { useForgotPasswordAuthFormSchema, useUI } from "~/hooks"; +import { form } from "~/components/form"; +import { Policies } from "~/components/policies"; +import { useCallback, useState } from "react"; + +/** Props for the ForgotPasswordAuthForm component. */ +export type ForgotPasswordAuthFormProps = { + /** Callback function called when the password reset email is sent. */ + onPasswordSent?: () => void; + /** Callback function called when the back to sign in link is clicked. */ + onBackToSignInClick?: () => void; +}; + +/** + * Creates a memoized action function for sending a password reset email. + * + * @returns A callback function that sends a password reset email to the specified address. + */ +export function useForgotPasswordAuthFormAction() { + const ui = useUI(); + + return useCallback( + async ({ email }: { email: string }) => { + try { + return await sendPasswordResetEmail(ui, email); + } catch (error) { + if (error instanceof FirebaseUIError) { + throw new Error(error.message); + } + + console.error(error); + throw new Error(getTranslation(ui, "errors", "unknownError")); + } + }, + [ui] + ); +} + +/** + * Creates a form hook for forgot password authentication. + * + * @param onSuccess - Optional callback function called when the password reset email is sent. + * @returns A form instance configured for forgot password. + */ +export function useForgotPasswordAuthForm(onSuccess?: ForgotPasswordAuthFormProps["onPasswordSent"]) { + const schema = useForgotPasswordAuthFormSchema(); + const action = useForgotPasswordAuthFormAction(); + + return form.useAppForm({ + defaultValues: { + email: "", + }, + validators: { + onBlur: schema, + onSubmitAsync: async ({ value }) => { + try { + await action(value); + return onSuccess?.(); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }, + }); +} + +/** + * A form component for requesting a password reset email. + * + * Displays a success message after the email is sent. + * + * @returns The forgot password form component. + */ +export function ForgotPasswordAuthForm({ onBackToSignInClick, onPasswordSent }: ForgotPasswordAuthFormProps) { + const ui = useUI(); + const [emailSent, setEmailSent] = useState(false); + const form = useForgotPasswordAuthForm(() => { + setEmailSent(true); + onPasswordSent?.(); + }); + + if (emailSent) { + return
{getTranslation(ui, "messages", "checkEmailForReset")}
; + } + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
+ + {(field) => } + +
+ +
+ {getTranslation(ui, "labels", "resetPassword")} + +
+ {onBackToSignInClick ? ( + ← {getTranslation(ui, "labels", "backToSignIn")} + ) : null} +
+
+ ); +} diff --git a/packages/react/src/auth/forms/mfa/sms-multi-factor-assertion-form.test.tsx b/packages/react/src/auth/forms/mfa/sms-multi-factor-assertion-form.test.tsx new file mode 100644 index 000000000..3b9842317 --- /dev/null +++ b/packages/react/src/auth/forms/mfa/sms-multi-factor-assertion-form.test.tsx @@ -0,0 +1,359 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, renderHook, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { + SmsMultiFactorAssertionForm, + useSmsMultiFactorAssertionPhoneFormAction, + useSmsMultiFactorAssertionVerifyFormAction, +} from "./sms-multi-factor-assertion-form"; +import { act } from "react"; +import { verifyPhoneNumber, signInWithMultiFactorAssertion } from "@invertase/firebaseui-core"; +import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { PhoneAuthProvider, PhoneMultiFactorGenerator } from "firebase/auth"; + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + verifyPhoneNumber: vi.fn(), + signInWithMultiFactorAssertion: vi.fn(), + }; +}); + +vi.mock("firebase/auth", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + PhoneAuthProvider: { + credential: vi.fn(), + }, + PhoneMultiFactorGenerator: { + assertion: vi.fn(), + }, + }; +}); + +vi.mock("~/hooks", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useRecaptchaVerifier: vi.fn().mockReturnValue({ + render: vi.fn(), + clear: vi.fn(), + verify: vi.fn(), + }), + }; +}); + +describe("useSmsMultiFactorAssertionPhoneFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a function", () => { + const mockUI = createMockUI(); + const { result } = renderHook(() => useSmsMultiFactorAssertionPhoneFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(typeof result.current).toBe("function"); + }); + + it("should call verifyPhoneNumber with correct parameters", async () => { + const verifyPhoneNumberMock = vi.mocked(verifyPhoneNumber); + const mockUI = createMockUI(); + const mockRecaptchaVerifier = { render: vi.fn(), clear: vi.fn(), verify: vi.fn() }; + const mockHint = { + factorId: "phone" as const, + phoneNumber: "+1234567890", + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + const { result } = renderHook(() => useSmsMultiFactorAssertionPhoneFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ hint: mockHint, recaptchaVerifier: mockRecaptchaVerifier as any }); + }); + + expect(verifyPhoneNumberMock).toHaveBeenCalledWith( + expect.any(Object), // UI object + "", // empty phone number + mockRecaptchaVerifier, + undefined, // no mfaUser + mockHint // mfaHint + ); + }); +}); + +describe("useSmsMultiFactorAssertionVerifyFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a function", () => { + const mockUI = createMockUI(); + const { result } = renderHook(() => useSmsMultiFactorAssertionVerifyFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(typeof result.current).toBe("function"); + }); + + it("should call PhoneAuthProvider.credential and PhoneMultiFactorGenerator.assertion", async () => { + const mockUI = createMockUI(); + const mockCredential = { credential: true }; + const mockAssertion = { assertion: true }; + + vi.mocked(PhoneAuthProvider.credential).mockReturnValue(mockCredential as any); + vi.mocked(PhoneMultiFactorGenerator.assertion).mockReturnValue(mockAssertion as any); + + const { result } = renderHook(() => useSmsMultiFactorAssertionVerifyFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ verificationId: "test-verification-id", verificationCode: "123456" }); + }); + + expect(PhoneAuthProvider.credential).toHaveBeenCalledWith("test-verification-id", "123456"); + expect(PhoneMultiFactorGenerator.assertion).toHaveBeenCalledWith(mockCredential); + }); + + it("should call signInWithMultiFactorAssertion with correct parameters", async () => { + const signInWithMultiFactorAssertionMock = vi.mocked(signInWithMultiFactorAssertion); + const mockUI = createMockUI(); + const mockCredential = { credential: true }; + const mockAssertion = { assertion: true }; + + vi.mocked(PhoneAuthProvider.credential).mockReturnValue(mockCredential as any); + vi.mocked(PhoneMultiFactorGenerator.assertion).mockReturnValue(mockAssertion as any); + + const { result } = renderHook(() => useSmsMultiFactorAssertionVerifyFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ verificationId: "test-verification-id", verificationCode: "123456" }); + }); + + expect(signInWithMultiFactorAssertionMock).toHaveBeenCalledWith(expect.any(Object), mockAssertion); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the phone form initially", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "sendCode", + }, + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, + }), + }); + + const mockHint = { + factorId: "phone" as const, + phoneNumber: "+1234567890", + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + const { container } = render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + expect( + screen.getByText("A verification code will be sent to +1234567890 to complete the authentication process.") + ).toBeInTheDocument(); + + const sendCodeButton = screen.getByRole("button", { name: "sendCode" }); + expect(sendCodeButton).toBeInTheDocument(); + expect(sendCodeButton).toHaveAttribute("type", "submit"); + + expect(container.querySelector(".fui-recaptcha-container")).toBeInTheDocument(); + }); + + it("should display phone number from hint", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, + }), + }); + + const mockHint = { + factorId: "phone" as const, + phoneNumber: "+1234567890", + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + expect( + screen.getByText("A verification code will be sent to +1234567890 to complete the authentication process.") + ).toBeInTheDocument(); + }); + + it("should handle missing phone number in hint", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, + }), + }); + + const mockHint = { + factorId: "phone" as const, + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + // When phone number is missing, the placeholder remains because empty string is falsy in the replacement logic + expect( + screen.getByText("A verification code will be sent to {phoneNumber} to complete the authentication process.") + ).toBeInTheDocument(); + }); + + it("should accept onSuccess callback prop", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, + }), + }); + + const mockHint = { + factorId: "phone" as const, + phoneNumber: "+1234567890", + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + const onSuccessMock = vi.fn(); + + expect(() => { + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + }).not.toThrow(); + }); + + it("invokes onSuccess with credential after full SMS verification flow", async () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "sendCode", + verificationCode: "verificationCode", + verifyCode: "verifyCode", + }, + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, + }), + }); + + const mockHint = { + factorId: "phone" as const, + phoneNumber: "+123456789", // Max 10 chars for schema validation + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + vi.mocked(verifyPhoneNumber).mockResolvedValue("vid-123"); + const mockCredential = { user: { uid: "sms-cred-user" } } as any; + vi.mocked(signInWithMultiFactorAssertion).mockResolvedValue(mockCredential); + + const onSuccessMock = vi.fn(); + + const { container } = render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const sendCodeForm = screen.getByRole("button", { name: "sendCode" }).closest("form"); + await act(async () => { + fireEvent.submit(sendCodeForm!); + }); + + const codeInput = await waitFor(() => screen.findByRole("textbox", { name: /verificationCode/i })); + const form = codeInput.closest("form"); + + await act(async () => { + fireEvent.change(codeInput, { target: { value: "123456" } }); + }); + + await act(async () => { + fireEvent.submit(form!); + }); + + await waitFor(() => { + expect(verifyPhoneNumber).toHaveBeenCalled(); + expect(signInWithMultiFactorAssertion).toHaveBeenCalled(); + }); + + expect(onSuccessMock).toHaveBeenCalledTimes(1); + expect(onSuccessMock).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "sms-cred-user" }) }) + ); + }); +}); diff --git a/packages/react/src/auth/forms/mfa/sms-multi-factor-assertion-form.tsx b/packages/react/src/auth/forms/mfa/sms-multi-factor-assertion-form.tsx new file mode 100644 index 000000000..904cccd3d --- /dev/null +++ b/packages/react/src/auth/forms/mfa/sms-multi-factor-assertion-form.tsx @@ -0,0 +1,275 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useCallback, useRef, useState } from "react"; +import { + PhoneAuthProvider, + PhoneMultiFactorGenerator, + type UserCredential, + type MultiFactorInfo, + type RecaptchaVerifier, +} from "firebase/auth"; + +import { + signInWithMultiFactorAssertion, + FirebaseUIError, + getTranslation, + verifyPhoneNumber, +} from "@invertase/firebaseui-core"; +import { form } from "~/components/form"; +import { useMultiFactorPhoneAuthVerifyFormSchema, useRecaptchaVerifier, useUI } from "~/hooks"; + +type PhoneMultiFactorInfo = MultiFactorInfo & { + phoneNumber?: string; +}; + +/** + * Creates a memoized action function for verifying a phone number during SMS multi-factor assertion. + * + * @returns A callback function that verifies a phone number using the provided hint and reCAPTCHA verifier. + */ +export function useSmsMultiFactorAssertionPhoneFormAction() { + const ui = useUI(); + + return useCallback( + async ({ hint, recaptchaVerifier }: { hint: MultiFactorInfo; recaptchaVerifier: RecaptchaVerifier }) => { + return await verifyPhoneNumber(ui, "", recaptchaVerifier, undefined, hint); + }, + [ui] + ); +} + +/** Options for the SMS multi-factor assertion phone form hook. */ +type UseSmsMultiFactorAssertionPhoneForm = { + /** The multi-factor info hint containing phone number information. */ + hint: MultiFactorInfo; + /** The reCAPTCHA verifier instance. */ + recaptchaVerifier: RecaptchaVerifier; + /** Callback function called when phone verification is successful. */ + onSuccess: (verificationId: string) => void; +}; + +/** + * Creates a form hook for SMS multi-factor assertion phone number verification. + * + * @param options - The phone form options. + * @returns A form instance configured for phone number verification. + */ +export function useSmsMultiFactorAssertionPhoneForm({ + hint, + recaptchaVerifier, + onSuccess, +}: UseSmsMultiFactorAssertionPhoneForm) { + const action = useSmsMultiFactorAssertionPhoneFormAction(); + + return form.useAppForm({ + validators: { + onSubmitAsync: async () => { + try { + const verificationId = await action({ hint, recaptchaVerifier }); + return onSuccess(verificationId); + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); +} + +type SmsMultiFactorAssertionPhoneFormProps = { + hint: MultiFactorInfo; + onSubmit: (verificationId: string) => void; +}; + +function SmsMultiFactorAssertionPhoneForm(props: SmsMultiFactorAssertionPhoneFormProps) { + const ui = useUI(); + const recaptchaContainerRef = useRef(null); + const recaptchaVerifier = useRecaptchaVerifier(recaptchaContainerRef); + const form = useSmsMultiFactorAssertionPhoneForm({ + hint: props.hint, + recaptchaVerifier: recaptchaVerifier!, + onSuccess: props.onSubmit, + }); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
+ +
+
+
+
+
+ {getTranslation(ui, "labels", "sendCode")} + +
+
+
+ ); +} + +/** + * Creates a memoized action function for verifying the SMS verification code during multi-factor assertion. + * + * @returns A callback function that verifies the code and signs in with the multi-factor assertion. + */ +export function useSmsMultiFactorAssertionVerifyFormAction() { + const ui = useUI(); + + return useCallback( + async ({ verificationId, verificationCode }: { verificationId: string; verificationCode: string }) => { + const credential = PhoneAuthProvider.credential(verificationId, verificationCode); + const assertion = PhoneMultiFactorGenerator.assertion(credential); + return await signInWithMultiFactorAssertion(ui, assertion); + }, + [ui] + ); +} + +/** Options for the SMS multi-factor assertion verify form hook. */ +type UseSmsMultiFactorAssertionVerifyForm = { + /** The verification ID from the phone verification step. */ + verificationId: string; + /** Callback function called when verification is successful. */ + onSuccess: (credential: UserCredential) => void; +}; + +/** + * Creates a form hook for SMS multi-factor assertion verification code input. + * + * @param options - The verify form options. + * @returns A form instance configured for verification code input. + */ +export function useSmsMultiFactorAssertionVerifyForm({ + verificationId, + onSuccess, +}: UseSmsMultiFactorAssertionVerifyForm) { + const action = useSmsMultiFactorAssertionVerifyFormAction(); + const schema = useMultiFactorPhoneAuthVerifyFormSchema(); + + return form.useAppForm({ + defaultValues: { + verificationId, + verificationCode: "", + }, + validators: { + onBlur: schema, + onSubmitAsync: async ({ value }) => { + try { + const credential = await action(value); + return onSuccess(credential); + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); +} + +type SmsMultiFactorAssertionVerifyFormProps = { + verificationId: string; + onSuccess: (credential: UserCredential) => void; +}; + +function SmsMultiFactorAssertionVerifyForm(props: SmsMultiFactorAssertionVerifyFormProps) { + const ui = useUI(); + const form = useSmsMultiFactorAssertionVerifyForm({ + verificationId: props.verificationId, + onSuccess: props.onSuccess, + }); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
+ + {(field) => ( + + )} + +
+
+ {getTranslation(ui, "labels", "verifyCode")} + +
+
+
+ ); +} + +/** Props for the SmsMultiFactorAssertionForm component. */ +export type SmsMultiFactorAssertionFormProps = { + /** The multi-factor info hint containing phone number information. */ + hint: MultiFactorInfo; + /** Optional callback function called when multi-factor assertion is successful. */ + onSuccess?: (credential: UserCredential) => void; +}; + +/** + * A form component for SMS multi-factor authentication assertion. + * + * Handles the two-step process: first verifying the phone number, then verifying the SMS code. + * + * @returns The SMS multi-factor assertion form component. + */ +export function SmsMultiFactorAssertionForm(props: SmsMultiFactorAssertionFormProps) { + const [verification, setVerification] = useState<{ + verificationId: string; + } | null>(null); + + if (!verification) { + return ( + setVerification({ verificationId })} + /> + ); + } + + return ( + { + props.onSuccess?.(credential); + }} + /> + ); +} diff --git a/packages/react/src/auth/forms/mfa/sms-multi-factor-enrollment-form.test.tsx b/packages/react/src/auth/forms/mfa/sms-multi-factor-enrollment-form.test.tsx new file mode 100644 index 000000000..a1f4ab5cb --- /dev/null +++ b/packages/react/src/auth/forms/mfa/sms-multi-factor-enrollment-form.test.tsx @@ -0,0 +1,335 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, renderHook, cleanup } from "@testing-library/react"; +import { + SmsMultiFactorEnrollmentForm, + useSmsMultiFactorEnrollmentPhoneAuthFormAction, + useMultiFactorEnrollmentVerifyPhoneNumberFormAction, + MultiFactorEnrollmentVerifyPhoneNumberForm, +} from "./sms-multi-factor-enrollment-form"; +import { act } from "react"; +import { verifyPhoneNumber, enrollWithMultiFactorAssertion } from "@invertase/firebaseui-core"; +import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { PhoneAuthProvider, PhoneMultiFactorGenerator } from "firebase/auth"; + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + verifyPhoneNumber: vi.fn(), + enrollWithMultiFactorAssertion: vi.fn(), + formatPhoneNumber: vi.fn(), + }; +}); + +vi.mock("firebase/auth", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + PhoneAuthProvider: { + credential: vi.fn(), + }, + PhoneMultiFactorGenerator: { + assertion: vi.fn(), + }, + multiFactor: vi.fn(() => ({ + enroll: vi.fn(), + })), + }; +}); + +vi.mock("~/components/form", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + form: { + ...mod.form, + ErrorMessage: () =>
Error Message
, + }, + }; +}); + +vi.mock("~/components/country-selector", () => ({ + CountrySelector: ({ ref }: { ref: any }) => ( +
+ Country Selector +
+ ), +})); + +vi.mock("~/hooks", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useRecaptchaVerifier: () => ({ + render: vi.fn(), + verify: vi.fn(), + }), + }; +}); + +describe("useSmsMultiFactorEnrollmentPhoneAuthFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a callback which accepts phone number and recaptcha verifier", async () => { + const verifyPhoneNumberMock = vi.mocked(verifyPhoneNumber); + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + }); + + const { result } = renderHook(() => useSmsMultiFactorEnrollmentPhoneAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + const mockRecaptchaVerifier = {} as any; + + await act(async () => { + await result.current({ phoneNumber: "+1234567890", recaptchaVerifier: mockRecaptchaVerifier }); + }); + + expect(verifyPhoneNumberMock).toHaveBeenCalledWith( + expect.any(Object), + "+1234567890", + mockRecaptchaVerifier, + expect.any(Object) + ); + }); + + it("should throw an unknown error when its not a FirebaseUIError", async () => { + const verifyPhoneNumberMock = vi.mocked(verifyPhoneNumber).mockRejectedValue(new Error("Unknown error")); + + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + locale: registerLocale("es-ES", { + errors: { + unknownError: "unknownError", + }, + }), + }); + + const { result } = renderHook(() => useSmsMultiFactorEnrollmentPhoneAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await expect(async () => { + await act(async () => { + await result.current({ phoneNumber: "+1234567890", recaptchaVerifier: {} as any }); + }); + }).rejects.toThrow("Unknown error"); + + expect(verifyPhoneNumberMock).toHaveBeenCalledWith(expect.any(Object), "+1234567890", {}, expect.any(Object)); + }); +}); + +describe("useMultiFactorEnrollmentVerifyPhoneNumberFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a callback which accepts verification details", async () => { + const enrollWithMultiFactorAssertionMock = vi.mocked(enrollWithMultiFactorAssertion); + const PhoneAuthProviderCredentialMock = vi.mocked(PhoneAuthProvider.credential); + const PhoneMultiFactorGeneratorAssertionMock = vi.mocked(PhoneMultiFactorGenerator.assertion); + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + }); + + const { result } = renderHook(() => useMultiFactorEnrollmentVerifyPhoneNumberFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + const mockCredential = { credential: true }; + const mockAssertion = { assertion: true }; + PhoneAuthProviderCredentialMock.mockReturnValue(mockCredential as any); + PhoneMultiFactorGeneratorAssertionMock.mockReturnValue(mockAssertion as any); + + await act(async () => { + await result.current({ + verificationId: "verification-id-123", + verificationCode: "123456", + displayName: "Test User", + }); + }); + + expect(PhoneAuthProviderCredentialMock).toHaveBeenCalledWith("verification-id-123", "123456"); + expect(PhoneMultiFactorGeneratorAssertionMock).toHaveBeenCalledWith(mockCredential); + expect(enrollWithMultiFactorAssertionMock).toHaveBeenCalledWith(expect.any(Object), mockAssertion, "Test User"); + }); + + it("should throw an unknown error when its not a FirebaseUIError", async () => { + const enrollWithMultiFactorAssertionMock = vi + .mocked(enrollWithMultiFactorAssertion) + .mockRejectedValue(new Error("Unknown error")); + + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + locale: registerLocale("es-ES", { + errors: { + unknownError: "unknownError", + }, + }), + }); + + const { result } = renderHook(() => useMultiFactorEnrollmentVerifyPhoneNumberFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await expect(async () => { + await act(async () => { + await result.current({ + verificationId: "verification-id-123", + verificationCode: "123456", + displayName: "Test User", + }); + }); + }).rejects.toThrow("Unknown error"); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the form correctly", () => { + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + locale: registerLocale("test", { + labels: { + verificationCode: "verificationCode", + verifyCode: "verifyCode", + }, + prompts: { + smsVerificationPrompt: "smsVerificationPrompt", + }, + }), + }); + + const { container } = render( + createFirebaseUIProvider({ + children: ( + + ), + ui: mockUI, + }) + ); + + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + expect(screen.getByRole("textbox", { name: /verificationCode/i })).toBeInTheDocument(); + + const description = container.querySelector("[data-input-description]"); + expect(description).toBeInTheDocument(); + expect(description).toHaveTextContent("smsVerificationPrompt"); + + const verifyCodeButton = screen.getByRole("button", { name: "verifyCode" }); + expect(verifyCodeButton).toBeInTheDocument(); + expect(verifyCodeButton).toHaveAttribute("type", "submit"); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the phone number form initially", () => { + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + locale: registerLocale("test", { + labels: { + displayName: "displayName", + phoneNumber: "phoneNumber", + sendCode: "sendCode", + }, + }), + }); + + const { container } = render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + expect(screen.getByRole("textbox", { name: /displayName/i })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /phoneNumber/i })).toBeInTheDocument(); + + const sendCodeButton = screen.getByRole("button", { name: "sendCode" }); + expect(sendCodeButton).toBeInTheDocument(); + expect(sendCodeButton).toHaveAttribute("type", "submit"); + + expect(screen.getByTestId("country-selector")).toBeInTheDocument(); + expect(container.querySelector(".fui-recaptcha-container")).toBeInTheDocument(); + }); + + it("should throw error when user is not authenticated", () => { + const mockUI = createMockUI({ + auth: { currentUser: null } as any, + }); + + expect(() => { + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + }).toThrow("User must be authenticated to enroll with multi-factor authentication"); + }); + + it("should render form elements correctly", () => { + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + locale: registerLocale("test", { + labels: { + displayName: "displayName", + phoneNumber: "phoneNumber", + sendCode: "sendCode", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + expect(screen.getByRole("textbox", { name: /displayName/i })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /phoneNumber/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "sendCode" })).toBeInTheDocument(); + expect(screen.getByTestId("country-selector")).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/auth/forms/mfa/sms-multi-factor-enrollment-form.tsx b/packages/react/src/auth/forms/mfa/sms-multi-factor-enrollment-form.tsx new file mode 100644 index 000000000..4c9d5157c --- /dev/null +++ b/packages/react/src/auth/forms/mfa/sms-multi-factor-enrollment-form.tsx @@ -0,0 +1,311 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useCallback, useRef, useState } from "react"; +import { multiFactor, PhoneAuthProvider, PhoneMultiFactorGenerator, type RecaptchaVerifier } from "firebase/auth"; +import { + enrollWithMultiFactorAssertion, + FirebaseUIError, + formatPhoneNumber, + getTranslation, + verifyPhoneNumber, +} from "@invertase/firebaseui-core"; +import { CountrySelector, type CountrySelectorRef } from "~/components/country-selector"; +import { form } from "~/components/form"; +import { + useMultiFactorPhoneAuthNumberFormSchema, + useMultiFactorPhoneAuthVerifyFormSchema, + useRecaptchaVerifier, + useUI, +} from "~/hooks"; + +/** + * Creates a memoized action function for verifying a phone number during SMS multi-factor enrollment. + * + * @returns A callback function that verifies a phone number for MFA enrollment using the provided reCAPTCHA verifier. + */ +export function useSmsMultiFactorEnrollmentPhoneAuthFormAction() { + const ui = useUI(); + + return useCallback( + async ({ phoneNumber, recaptchaVerifier }: { phoneNumber: string; recaptchaVerifier: RecaptchaVerifier }) => { + const mfaUser = multiFactor(ui.auth.currentUser!); + return await verifyPhoneNumber(ui, phoneNumber, recaptchaVerifier, mfaUser); + }, + [ui] + ); +} + +/** Options for the SMS multi-factor enrollment phone number form hook. */ +export type UseSmsMultiFactorEnrollmentPhoneNumberForm = { + /** The reCAPTCHA verifier instance. */ + recaptchaVerifier: RecaptchaVerifier; + /** Callback function called when phone verification is successful. */ + onSuccess: (verificationId: string, displayName?: string) => void; + /** Optional function to format the phone number before verification. */ + formatPhoneNumber?: (phoneNumber: string) => string; +}; + +/** + * Creates a form hook for SMS multi-factor enrollment phone number verification. + * + * @param options - The phone number form options. + * @returns A form instance configured for phone number input and verification for MFA enrollment. + */ +export function useSmsMultiFactorEnrollmentPhoneNumberForm({ + recaptchaVerifier, + onSuccess, + formatPhoneNumber, +}: UseSmsMultiFactorEnrollmentPhoneNumberForm) { + const action = useSmsMultiFactorEnrollmentPhoneAuthFormAction(); + const schema = useMultiFactorPhoneAuthNumberFormSchema(); + + return form.useAppForm({ + defaultValues: { + displayName: "", + phoneNumber: "", + }, + validators: { + onBlur: schema, + onSubmitAsync: async ({ value }) => { + try { + const formatted = formatPhoneNumber ? formatPhoneNumber(value.phoneNumber) : value.phoneNumber; + const confirmationResult = await action({ phoneNumber: formatted, recaptchaVerifier }); + return onSuccess(confirmationResult, value.displayName); + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); +} + +type MultiFactorEnrollmentPhoneNumberFormProps = { + onSubmit: (verificationId: string, displayName?: string) => void; +}; + +function MultiFactorEnrollmentPhoneNumberForm(props: MultiFactorEnrollmentPhoneNumberFormProps) { + const ui = useUI(); + const recaptchaContainerRef = useRef(null); + const recaptchaVerifier = useRecaptchaVerifier(recaptchaContainerRef); + const countrySelector = useRef(null); + const form = useSmsMultiFactorEnrollmentPhoneNumberForm({ + recaptchaVerifier: recaptchaVerifier!, + onSuccess: props.onSubmit, + formatPhoneNumber: (phoneNumber) => formatPhoneNumber(phoneNumber, countrySelector.current!.getCountry()), + }); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
+ + {(field) => } + +
+
+ + {(field) => ( + } + /> + )} + +
+
+
+
+
+ {getTranslation(ui, "labels", "sendCode")} + +
+
+
+ ); +} + +/** + * Creates a memoized action function for verifying the SMS verification code during multi-factor enrollment. + * + * @returns A callback function that verifies the code and enrolls the phone number as a multi-factor authentication method. + */ +export function useMultiFactorEnrollmentVerifyPhoneNumberFormAction() { + const ui = useUI(); + return useCallback( + async ({ + verificationId, + verificationCode, + displayName, + }: { + verificationId: string; + verificationCode: string; + displayName?: string; + }) => { + const credential = PhoneAuthProvider.credential(verificationId, verificationCode); + const assertion = PhoneMultiFactorGenerator.assertion(credential); + return await enrollWithMultiFactorAssertion(ui, assertion, displayName); + }, + [ui] + ); +} + +/** Options for the multi-factor enrollment verify phone number form hook. */ +type UseMultiFactorEnrollmentVerifyPhoneNumberForm = { + /** The verification ID from the phone verification step. */ + verificationId: string; + /** Optional display name for the enrolled MFA method. */ + displayName?: string; + /** Callback function called when enrollment is successful. */ + onSuccess: () => void; +}; + +/** + * Creates a form hook for SMS multi-factor enrollment verification code input. + * + * @param options - The verify phone number form options. + * @returns A form instance configured for verification code input during MFA enrollment. + */ +export function useMultiFactorEnrollmentVerifyPhoneNumberForm({ + verificationId, + displayName, + onSuccess, +}: UseMultiFactorEnrollmentVerifyPhoneNumberForm) { + const schema = useMultiFactorPhoneAuthVerifyFormSchema(); + const action = useMultiFactorEnrollmentVerifyPhoneNumberFormAction(); + + return form.useAppForm({ + defaultValues: { + verificationId, + verificationCode: "", + }, + validators: { + onBlur: schema, + onSubmitAsync: async ({ value }) => { + try { + await action({ ...value, displayName }); + return onSuccess(); + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); +} + +/** Props for the MultiFactorEnrollmentVerifyPhoneNumberForm component. */ +type MultiFactorEnrollmentVerifyPhoneNumberFormProps = { + /** The verification ID from the phone verification step. */ + verificationId: string; + /** Optional display name for the enrolled MFA method. */ + displayName?: string; + /** Callback function called when enrollment is successful. */ + onSuccess: () => void; +}; + +/** + * A form component for verifying the SMS code during multi-factor enrollment. + * + * @returns The verify phone number form component. + */ +export function MultiFactorEnrollmentVerifyPhoneNumberForm(props: MultiFactorEnrollmentVerifyPhoneNumberFormProps) { + const ui = useUI(); + const form = useMultiFactorEnrollmentVerifyPhoneNumberForm({ + ...props, + onSuccess: props.onSuccess, + }); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
+ + {(field) => ( + + )} + +
+
+ {getTranslation(ui, "labels", "verifyCode")} + +
+
+
+ ); +} + +/** Props for the SmsMultiFactorEnrollmentForm component. */ +export type SmsMultiFactorEnrollmentFormProps = { + /** Optional callback function called when enrollment is successful. */ + onSuccess?: () => void; +}; + +/** + * A form component for SMS multi-factor authentication enrollment. + * + * Handles the two-step process: first entering the phone number and display name, then verifying the SMS code. + * + * @returns The SMS multi-factor enrollment form component. + * @throws {Error} Throws an error if the user is not authenticated. + */ +export function SmsMultiFactorEnrollmentForm(props: SmsMultiFactorEnrollmentFormProps) { + const ui = useUI(); + + const [verification, setVerification] = useState<{ + verificationId: string; + displayName?: string; + } | null>(null); + + if (!ui.auth.currentUser) { + throw new Error("User must be authenticated to enroll with multi-factor authentication"); + } + + if (!verification) { + return ( + setVerification({ verificationId, displayName })} + /> + ); + } + + return ( + { + props.onSuccess?.(); + }} + /> + ); +} diff --git a/packages/react/src/auth/forms/mfa/totp-multi-factor-assertion-form.test.tsx b/packages/react/src/auth/forms/mfa/totp-multi-factor-assertion-form.test.tsx new file mode 100644 index 000000000..de7a5d145 --- /dev/null +++ b/packages/react/src/auth/forms/mfa/totp-multi-factor-assertion-form.test.tsx @@ -0,0 +1,272 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @license + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, renderHook, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { + TotpMultiFactorAssertionForm, + useTotpMultiFactorAssertionFormAction, +} from "./totp-multi-factor-assertion-form"; +import { act } from "react"; +import { signInWithMultiFactorAssertion } from "@invertase/firebaseui-core"; +import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { TotpMultiFactorGenerator } from "firebase/auth"; + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + signInWithMultiFactorAssertion: vi.fn(), + }; +}); + +vi.mock("firebase/auth", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + TotpMultiFactorGenerator: { + assertionForSignIn: vi.fn(), + }, + }; +}); + +describe("useTotpMultiFactorAssertionFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a function", () => { + const mockUI = createMockUI(); + const { result } = renderHook(() => useTotpMultiFactorAssertionFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(typeof result.current).toBe("function"); + }); + + it("should call TotpMultiFactorGenerator.assertionForSignIn and signInWithMultiFactorAssertion", async () => { + const mockUI = createMockUI(); + const mockAssertion = { assertion: true }; + const signInWithMultiFactorAssertionMock = vi.mocked(signInWithMultiFactorAssertion); + const mockHint = { + factorId: "totp" as const, + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + vi.mocked(TotpMultiFactorGenerator.assertionForSignIn).mockReturnValue(mockAssertion as any); + + const { result } = renderHook(() => useTotpMultiFactorAssertionFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ verificationCode: "123456", hint: mockHint }); + }); + + expect(TotpMultiFactorGenerator.assertionForSignIn).toHaveBeenCalledWith("test-uid", "123456"); + expect(signInWithMultiFactorAssertionMock).toHaveBeenCalledWith(expect.any(Object), mockAssertion); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the form correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + verificationCode: "verificationCode", + verifyCode: "verifyCode", + }, + }), + }); + + const mockHint = { + factorId: "totp" as const, + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + const { container } = render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + expect(screen.getByRole("textbox", { name: /verificationCode/i })).toBeInTheDocument(); + + const verifyCodeButton = screen.getByRole("button", { name: "verifyCode" }); + expect(verifyCodeButton).toBeInTheDocument(); + expect(verifyCodeButton).toHaveAttribute("type", "submit"); + }); + + it("should accept onSuccess callback prop", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + verificationCode: "verificationCode", + }, + }), + }); + + const mockHint = { + factorId: "totp" as const, + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + const onSuccessMock = vi.fn(); + + expect(() => { + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + }).not.toThrow(); + }); + + it("should render form elements correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + verificationCode: "verificationCode", + verifyCode: "verifyCode", + }, + }), + }); + + const mockHint = { + factorId: "totp" as const, + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + expect(screen.getByRole("textbox", { name: /verificationCode/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "verifyCode" })).toBeInTheDocument(); + }); + + it("should render input field for TOTP code", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + verificationCode: "verificationCode", + }, + }), + }); + + const mockHint = { + factorId: "totp" as const, + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const input = screen.getByRole("textbox", { name: /verificationCode/i }); + expect(input).toBeInTheDocument(); + }); + + it("invokes onSuccess with credential after successful verification", async () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + verificationCode: "verificationCode", + verifyCode: "verifyCode", + }, + }), + }); + + const mockHint = { + factorId: "totp" as const, + uid: "test-uid", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + const mockCredential = { user: { uid: "totp-cred-user" } } as any; + vi.mocked(signInWithMultiFactorAssertion).mockResolvedValue(mockCredential); + + const onSuccessMock = vi.fn(); + + const { container } = render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const input = screen.getByRole("textbox", { name: /verificationCode/i }); + const form = input.closest("form"); + + await act(async () => { + fireEvent.change(input, { target: { value: "123456" } }); + }); + + await act(async () => { + fireEvent.submit(form!); + }); + + await waitFor(() => { + expect(signInWithMultiFactorAssertion).toHaveBeenCalled(); + }); + + expect(onSuccessMock).toHaveBeenCalledTimes(1); + expect(onSuccessMock).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "totp-cred-user" }) }) + ); + }); +}); diff --git a/packages/react/src/auth/forms/mfa/totp-multi-factor-assertion-form.tsx b/packages/react/src/auth/forms/mfa/totp-multi-factor-assertion-form.tsx new file mode 100644 index 000000000..ad9896982 --- /dev/null +++ b/packages/react/src/auth/forms/mfa/totp-multi-factor-assertion-form.tsx @@ -0,0 +1,130 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useCallback } from "react"; +import { TotpMultiFactorGenerator, type MultiFactorInfo, type UserCredential } from "firebase/auth"; +import { signInWithMultiFactorAssertion, FirebaseUIError, getTranslation } from "@invertase/firebaseui-core"; +import { form } from "~/components/form"; +import { useMultiFactorTotpAuthVerifyFormSchema, useUI } from "~/hooks"; + +/** + * Creates a memoized action function for verifying a TOTP code during multi-factor assertion. + * + * @returns A callback function that verifies the TOTP code and signs in with the multi-factor assertion. + */ +export function useTotpMultiFactorAssertionFormAction() { + const ui = useUI(); + + return useCallback( + async ({ verificationCode, hint }: { verificationCode: string; hint: MultiFactorInfo }) => { + const assertion = TotpMultiFactorGenerator.assertionForSignIn(hint.uid, verificationCode); + return await signInWithMultiFactorAssertion(ui, assertion); + }, + [ui] + ); +} + +/** Options for the TOTP multi-factor assertion form hook. */ +export type UseTotpMultiFactorAssertionForm = { + /** The multi-factor info hint containing TOTP information. */ + hint: MultiFactorInfo; + /** Callback function called when verification is successful. */ + onSuccess: (credential: UserCredential) => void; +}; + +/** + * Creates a form hook for TOTP multi-factor assertion verification code input. + * + * @param options - The TOTP assertion form options. + * @returns A form instance configured for TOTP verification code input. + */ +export function useTotpMultiFactorAssertionForm({ hint, onSuccess }: UseTotpMultiFactorAssertionForm) { + const action = useTotpMultiFactorAssertionFormAction(); + const schema = useMultiFactorTotpAuthVerifyFormSchema(); + + return form.useAppForm({ + defaultValues: { + verificationCode: "", + }, + validators: { + onBlur: schema, + onSubmitAsync: async ({ value }) => { + try { + const credential = await action({ verificationCode: value.verificationCode, hint }); + return onSuccess(credential); + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); +} + +/** Props for the TotpMultiFactorAssertionForm component. */ +export type TotpMultiFactorAssertionFormProps = { + /** The multi-factor info hint containing TOTP information. */ + hint: MultiFactorInfo; + /** Optional callback function called when multi-factor assertion is successful. */ + onSuccess?: (credential: UserCredential) => void; +}; + +/** + * A form component for TOTP multi-factor authentication assertion. + * + * Allows users to enter a 6-digit TOTP code from their authenticator app. + * + * @returns The TOTP multi-factor assertion form component. + */ +export function TotpMultiFactorAssertionForm(props: TotpMultiFactorAssertionFormProps) { + const ui = useUI(); + const form = useTotpMultiFactorAssertionForm({ + hint: props.hint, + onSuccess: (credential) => { + props.onSuccess?.(credential); + }, + }); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
+ + {(field) => ( + + )} + +
+
+ {getTranslation(ui, "labels", "verifyCode")} + +
+
+
+ ); +} diff --git a/packages/react/src/auth/forms/mfa/totp-multi-factor-enrollment-form.test.tsx b/packages/react/src/auth/forms/mfa/totp-multi-factor-enrollment-form.test.tsx new file mode 100644 index 000000000..b92be46d0 --- /dev/null +++ b/packages/react/src/auth/forms/mfa/totp-multi-factor-enrollment-form.test.tsx @@ -0,0 +1,280 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, renderHook, cleanup } from "@testing-library/react"; +import { + TotpMultiFactorEnrollmentForm, + useTotpMultiFactorSecretGenerationFormAction, + useMultiFactorEnrollmentVerifyTotpFormAction, + MultiFactorEnrollmentVerifyTotpForm, +} from "./totp-multi-factor-enrollment-form"; +import { act } from "react"; +import { generateTotpSecret, generateTotpQrCode, enrollWithMultiFactorAssertion } from "@invertase/firebaseui-core"; +import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { TotpMultiFactorGenerator } from "firebase/auth"; + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + generateTotpSecret: vi.fn(), + generateTotpQrCode: vi.fn(), + enrollWithMultiFactorAssertion: vi.fn(), + }; +}); + +vi.mock("firebase/auth", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + TotpMultiFactorGenerator: { + ...mod.TotpMultiFactorGenerator, + assertionForEnrollment: vi.fn(), + }, + }; +}); + +describe("useTotpMultiFactorSecretGenerationFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a callback which generates a TOTP secret", async () => { + const generateTotpSecretMock = vi.mocked(generateTotpSecret); + const mockSecret = { secretKey: "test-secret" } as any; + generateTotpSecretMock.mockResolvedValue(mockSecret); + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + }); + + const { result } = renderHook(() => useTotpMultiFactorSecretGenerationFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + const secret = await result.current(); + expect(secret).toBe(mockSecret); + }); + + expect(generateTotpSecretMock).toHaveBeenCalledWith(expect.any(Object)); + }); + + it("should throw an unknown error when its not a FirebaseUIError", async () => { + const generateTotpSecretMock = vi.mocked(generateTotpSecret).mockRejectedValue(new Error("Unknown error")); + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + }); + + const { result } = renderHook(() => useTotpMultiFactorSecretGenerationFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await expect(async () => { + await act(async () => { + await result.current(); + }); + }).rejects.toThrow("Unknown error"); + + expect(generateTotpSecretMock).toHaveBeenCalledWith(expect.any(Object)); + }); +}); + +describe("useMultiFactorEnrollmentVerifyTotpFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a callback which accepts verification details", async () => { + const enrollWithMultiFactorAssertionMock = vi.mocked(enrollWithMultiFactorAssertion); + const TotpMultiFactorGeneratorAssertionMock = vi.mocked(TotpMultiFactorGenerator.assertionForEnrollment); + const mockAssertion = { assertion: true } as any; + const mockSecret = { secretKey: "test-secret" } as any; + TotpMultiFactorGeneratorAssertionMock.mockReturnValue(mockAssertion); + enrollWithMultiFactorAssertionMock.mockResolvedValue(undefined); + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + }); + + const { result } = renderHook(() => useMultiFactorEnrollmentVerifyTotpFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ + secret: mockSecret, + verificationCode: "123456", + displayName: "Test User", + }); + }); + + expect(TotpMultiFactorGeneratorAssertionMock).toHaveBeenCalledWith(mockSecret, "123456"); + expect(enrollWithMultiFactorAssertionMock).toHaveBeenCalledWith(expect.any(Object), mockAssertion, "Test User"); + }); + + it("should throw an unknown error when its not a FirebaseUIError", async () => { + vi.mocked(enrollWithMultiFactorAssertion).mockRejectedValue(new Error("Unknown error")); + + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + }); + + const { result } = renderHook(() => useMultiFactorEnrollmentVerifyTotpFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await expect(async () => { + await act(async () => { + await result.current({ + secret: { secretKey: "test-secret" } as any, + verificationCode: "123456", + displayName: "Test User", + }); + }); + }).rejects.toThrow("Unknown error"); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the form correctly", () => { + const generateTotpQrCodeMock = vi.mocked(generateTotpQrCode); + generateTotpQrCodeMock.mockReturnValue("data:image/png;base64,test-qr-code"); + const mockSecret = { secretKey: "test-secret" } as any; + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + locale: registerLocale("test", { + labels: { + verificationCode: "verificationCode", + verifyCode: "verifyCode", + }, + prompts: { + mfaTotpQrCodePrompt: "Scan this QR code with your authenticator app", + mfaTotpEnrollmentVerificationPrompt: "Add the code generated by your authenticator app", + }, + }), + }); + + const { container } = render( + createFirebaseUIProvider({ + children: ( + + ), + ui: mockUI, + }) + ); + + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + expect(screen.getByRole("textbox", { name: /verificationCode/i })).toBeInTheDocument(); + + const description = container.querySelector("[data-input-description]"); + expect(description).toBeInTheDocument(); + expect(description).toHaveTextContent("Add the code generated by your authenticator app"); + + const verifyCodeButton = screen.getByRole("button", { name: "verifyCode" }); + expect(verifyCodeButton).toBeInTheDocument(); + expect(verifyCodeButton).toHaveAttribute("type", "submit"); + + expect(container.querySelector(".fui-qr-code-container")).toBeInTheDocument(); + expect(container.querySelector("img[alt='TOTP QR Code']")).toBeInTheDocument(); + expect(screen.getByText("Scan this QR code with your authenticator app")).toBeInTheDocument(); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the secret generation form initially", () => { + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + locale: registerLocale("test", { + labels: { + displayName: "displayName", + generateQrCode: "generateQrCode", + }, + }), + }); + + const { container } = render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + expect(screen.getByRole("textbox", { name: /displayName/i })).toBeInTheDocument(); + + const generateQrCodeButton = screen.getByRole("button", { name: "generateQrCode" }); + expect(generateQrCodeButton).toBeInTheDocument(); + expect(generateQrCodeButton).toHaveAttribute("type", "submit"); + }); + + it("should throw error when user is not authenticated", () => { + const mockUI = createMockUI({ + auth: { currentUser: null } as any, + }); + + expect(() => { + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + }).toThrow("User must be authenticated to enroll with multi-factor authentication"); + }); + + it("should render form elements correctly", () => { + const mockUI = createMockUI({ + auth: { currentUser: { uid: "test-user", _onReload: vi.fn() } } as any, + locale: registerLocale("test", { + labels: { + displayName: "displayName", + generateQrCode: "generateQrCode", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + expect(screen.getByRole("textbox", { name: /displayName/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "generateQrCode" })).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/auth/forms/mfa/totp-multi-factor-enrollment-form.tsx b/packages/react/src/auth/forms/mfa/totp-multi-factor-enrollment-form.tsx new file mode 100644 index 000000000..d800d48d5 --- /dev/null +++ b/packages/react/src/auth/forms/mfa/totp-multi-factor-enrollment-form.tsx @@ -0,0 +1,278 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useCallback, useState } from "react"; +import { TotpMultiFactorGenerator, type TotpSecret } from "firebase/auth"; +import { + enrollWithMultiFactorAssertion, + FirebaseUIError, + generateTotpQrCode, + generateTotpSecret, + getTranslation, +} from "@invertase/firebaseui-core"; +import { form } from "~/components/form"; +import { useMultiFactorTotpAuthNumberFormSchema, useMultiFactorTotpAuthVerifyFormSchema, useUI } from "~/hooks"; + +/** + * Creates a memoized action function for generating a TOTP secret for multi-factor enrollment. + * + * @returns A callback function that generates a TOTP secret. + */ +export function useTotpMultiFactorSecretGenerationFormAction() { + const ui = useUI(); + + return useCallback(async () => { + return await generateTotpSecret(ui); + }, [ui]); +} + +/** Options for the TOTP multi-factor enrollment form hook. */ +export type UseTotpMultiFactorEnrollmentForm = { + /** Callback function called when the TOTP secret is generated. */ + onSuccess: (secret: TotpSecret, displayName: string) => void; +}; + +/** + * Creates a form hook for TOTP multi-factor enrollment secret generation. + * + * @param options - The TOTP enrollment form options. + * @returns A form instance configured for display name input and secret generation. + */ +export function useTotpMultiFactorSecretGenerationForm({ onSuccess }: UseTotpMultiFactorEnrollmentForm) { + const action = useTotpMultiFactorSecretGenerationFormAction(); + const schema = useMultiFactorTotpAuthNumberFormSchema(); + + return form.useAppForm({ + defaultValues: { + displayName: "", + }, + validators: { + onBlur: schema, + onSubmitAsync: async ({ value }) => { + try { + const secret = await action(); + return onSuccess(secret, value.displayName); + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); +} + +type TotpMultiFactorSecretGenerationFormProps = { + onSubmit: (secret: TotpSecret, displayName: string) => void; +}; + +function TotpMultiFactorSecretGenerationForm(props: TotpMultiFactorSecretGenerationFormProps) { + const ui = useUI(); + const form = useTotpMultiFactorSecretGenerationForm({ + onSuccess: props.onSubmit, + }); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
+ + {(field) => } + +
+
+ {getTranslation(ui, "labels", "generateQrCode")} + +
+
+
+ ); +} + +/** + * Creates a memoized action function for verifying the TOTP code during multi-factor enrollment. + * + * @returns A callback function that verifies the TOTP code and enrolls it as a multi-factor authentication method. + */ +export function useMultiFactorEnrollmentVerifyTotpFormAction() { + const ui = useUI(); + return useCallback( + async ({ + secret, + verificationCode, + displayName, + }: { + secret: TotpSecret; + verificationCode: string; + displayName: string; + }) => { + const assertion = TotpMultiFactorGenerator.assertionForEnrollment(secret, verificationCode); + return await enrollWithMultiFactorAssertion(ui, assertion, displayName); + }, + [ui] + ); +} + +/** Options for the multi-factor enrollment verify TOTP form hook. */ +type UseMultiFactorEnrollmentVerifyTotpForm = { + /** The TOTP secret generated in the previous step. */ + secret: TotpSecret; + /** The display name for the enrolled MFA method. */ + displayName: string; + /** Callback function called when enrollment is successful. */ + onSuccess: () => void; +}; + +/** + * Creates a form hook for TOTP multi-factor enrollment verification code input. + * + * @param options - The verify TOTP form options. + * @returns A form instance configured for TOTP verification code input during MFA enrollment. + */ +export function useMultiFactorEnrollmentVerifyTotpForm({ + secret, + displayName, + onSuccess, +}: UseMultiFactorEnrollmentVerifyTotpForm) { + const schema = useMultiFactorTotpAuthVerifyFormSchema(); + const action = useMultiFactorEnrollmentVerifyTotpFormAction(); + + return form.useAppForm({ + defaultValues: { + verificationCode: "", + }, + validators: { + onBlur: schema, + onSubmitAsync: async ({ value }) => { + try { + await action({ secret, verificationCode: value.verificationCode, displayName }); + return onSuccess(); + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); +} + +/** Props for the MultiFactorEnrollmentVerifyTotpForm component. */ +type MultiFactorEnrollmentVerifyTotpFormProps = { + /** The TOTP secret generated in the previous step. */ + secret: TotpSecret; + /** The display name for the enrolled MFA method. */ + displayName: string; + /** Callback function called when enrollment is successful. */ + onSuccess: () => void; +}; + +/** + * A form component for verifying the TOTP code during multi-factor enrollment. + * + * Displays a QR code and secret key for the user to scan with their authenticator app, + * then allows them to verify the enrollment with a TOTP code. + * + * @returns The verify TOTP form component. + */ +export function MultiFactorEnrollmentVerifyTotpForm(props: MultiFactorEnrollmentVerifyTotpFormProps) { + const ui = useUI(); + const form = useMultiFactorEnrollmentVerifyTotpForm({ + ...props, + onSuccess: props.onSuccess, + }); + + const qrCodeDataUrl = generateTotpQrCode(ui, props.secret, props.displayName); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > +
+ TOTP QR Code + {props.secret.secretKey.toString()} +

{getTranslation(ui, "prompts", "mfaTotpQrCodePrompt")}

+
+ +
+ + {(field) => ( + + )} + +
+
+ {getTranslation(ui, "labels", "verifyCode")} + +
+
+
+ ); +} + +/** Props for the TotpMultiFactorEnrollmentForm component. */ +export type TotpMultiFactorEnrollmentFormProps = { + /** Optional callback function called when enrollment is successful. */ + onSuccess?: () => void; +}; + +/** + * A form component for TOTP multi-factor authentication enrollment. + * + * Handles the two-step process: first generating a TOTP secret and QR code, then verifying the TOTP code. + * + * @returns The TOTP multi-factor enrollment form component. + * @throws {Error} Throws an error if the user is not authenticated. + */ +export function TotpMultiFactorEnrollmentForm(props: TotpMultiFactorEnrollmentFormProps) { + const ui = useUI(); + + const [enrollment, setEnrollment] = useState<{ + secret: TotpSecret; + displayName: string; + } | null>(null); + + if (!ui.auth.currentUser) { + throw new Error("User must be authenticated to enroll with multi-factor authentication"); + } + + if (!enrollment) { + return ( + setEnrollment({ secret, displayName })} /> + ); + } + + return ( + { + props.onSuccess?.(); + }} + /> + ); +} diff --git a/packages/react/src/auth/forms/multi-factor-auth-assertion-form.test.tsx b/packages/react/src/auth/forms/multi-factor-auth-assertion-form.test.tsx new file mode 100644 index 000000000..178120043 --- /dev/null +++ b/packages/react/src/auth/forms/multi-factor-auth-assertion-form.test.tsx @@ -0,0 +1,470 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, renderHook } from "@testing-library/react"; +import { + MultiFactorAuthAssertionForm, + useMultiFactorAssertionCleanup, +} from "~/auth/forms/multi-factor-auth-assertion-form"; +import { CreateFirebaseUIProvider, createMockUI, createFirebaseUIProvider } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { FactorId, MultiFactorResolver, PhoneMultiFactorGenerator, TotpMultiFactorGenerator } from "firebase/auth"; + +vi.mock("~/auth/forms/mfa/sms-multi-factor-assertion-form", () => ({ + SmsMultiFactorAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+
SMS Assertion Form
+ +
+ ), +})); + +vi.mock("~/auth/forms/mfa/totp-multi-factor-assertion-form", () => ({ + TotpMultiFactorAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+
TOTP Assertion Form
+ +
+ ), +})); + +vi.mock("~/components/button", () => ({ + Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( + + ), +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("useMultiFactorAssertionCleanup", () => { + it("calls setMultiFactorResolver on unmount", () => { + const ui = createMockUI(); + const setMultiFactorResolverSpy = vi.spyOn(ui.get(), "setMultiFactorResolver"); + + const { unmount } = renderHook(() => useMultiFactorAssertionCleanup(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui }), + }); + + expect(setMultiFactorResolverSpy).not.toHaveBeenCalled(); + + unmount(); + + expect(setMultiFactorResolverSpy).toHaveBeenCalledTimes(1); + }); + + it("clears multiFactorResolver when component unmounts", () => { + const ui = createMockUI(); + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + } as unknown as MultiFactorResolver; + ui.get().setMultiFactorResolver(mockResolver); + + const setMultiFactorResolverSpy = vi.spyOn(ui.get(), "setMultiFactorResolver"); + + const { unmount } = renderHook(() => useMultiFactorAssertionCleanup(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui }), + }); + + expect(ui.get().multiFactorResolver).toBe(mockResolver); + + unmount(); + + expect(setMultiFactorResolverSpy).toHaveBeenCalledTimes(1); + expect(ui.get().multiFactorResolver).toBeUndefined(); + }); +}); + +describe("", () => { + it("throws error when no multiFactorResolver is present", () => { + const ui = createMockUI(); + + expect(() => { + render( + + + + ); + }).toThrow("MultiFactorAuthAssertionForm requires a multi-factor resolver"); + }); + + it("auto-selects single factor when only one hint exists", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + uid: "test-uid", + displayName: "Test Phone", + }, + ], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByTestId("sms-assertion-form")).toBeDefined(); + expect(screen.queryByTestId("mfa-button")).toBeNull(); + }); + + it("invokes onSuccess with credential from SMS assertion child", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + uid: "test-uid", + displayName: "Test Phone", + }, + ], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSuccess = vi.fn(); + + render( + + + + ); + + fireEvent.click(screen.getByTestId("sms-on-success")); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "sms-user" }) }) + ); + }); + + it("invokes onSuccess with credential from TOTP assertion child", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [ + { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + uid: "test-uid", + displayName: "Test TOTP", + }, + ], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSuccess = vi.fn(); + + render( + + + + ); + + fireEvent.click(screen.getByTestId("totp-on-success")); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "totp-user" }) }) + ); + }); + + it("auto-selects TOTP factor when only one TOTP hint exists", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [ + { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + uid: "test-uid", + displayName: "Test TOTP", + }, + ], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByTestId("totp-assertion-form")).toBeDefined(); + expect(screen.queryByTestId("mfa-button")).toBeNull(); + }); + + it("displays factor selection UI when multiple hints exist", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-1", + displayName: "Test Phone", + }, + { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-2", + displayName: "Test TOTP", + }, + ], + }; + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "TOTP Verification", + mfaSmsVerification: "SMS Verification", + }, + prompts: { + mfaAssertionFactorPrompt: "Please choose a multi-factor authentication method", + }, + }), + }); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByText("Please choose a multi-factor authentication method")).toBeDefined(); + expect(screen.getAllByTestId("mfa-button")).toHaveLength(2); + expect(screen.getByText("TOTP Verification")).toBeDefined(); + expect(screen.getByText("SMS Verification")).toBeDefined(); + }); + + it("renders SmsMultiFactorAssertionForm when SMS factor is selected", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-1", + displayName: "Test Phone", + }, + { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-2", + displayName: "Test TOTP", + }, + ], + }; + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "TOTP Verification", + mfaSmsVerification: "SMS Verification", + }, + }), + }); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + const smsButton = screen.getByText("SMS Verification"); + fireEvent.click(smsButton); + + expect(screen.getByTestId("sms-assertion-form")).toBeDefined(); + expect(screen.queryByTestId("totp-assertion-form")).toBeNull(); + }); + + it("renders TotpMultiFactorAssertionForm when TOTP factor is selected", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-1", + displayName: "Test Phone", + }, + { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-2", + displayName: "Test TOTP", + }, + ], + }; + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "TOTP Verification", + mfaSmsVerification: "SMS Verification", + }, + }), + }); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + const totpButton = screen.getByText("TOTP Verification"); + fireEvent.click(totpButton); + + expect(screen.getByTestId("totp-assertion-form")).toBeDefined(); + expect(screen.queryByTestId("sms-assertion-form")).toBeNull(); + }); + + it("buttons display correct translated labels", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-1", + displayName: "Test Phone", + }, + { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-2", + displayName: "Test TOTP", + }, + ], + }; + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Custom TOTP Label", + mfaSmsVerification: "Custom SMS Label", + }, + }), + }); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByText("Custom TOTP Label")).toBeDefined(); + expect(screen.getByText("Custom SMS Label")).toBeDefined(); + }); + + it("factor selection triggers correct form rendering", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [ + { + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-1", + displayName: "Test Phone", + }, + { + factorId: TotpMultiFactorGenerator.FACTOR_ID, + uid: "test-uid-2", + displayName: "Test TOTP", + }, + ], + }; + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "TOTP Verification", + mfaSmsVerification: "SMS Verification", + }, + prompts: { + mfaAssertionFactorPrompt: "Please choose a multi-factor authentication method", + }, + }), + }); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const { rerender } = render( + + + + ); + + expect(screen.getByText("Please choose a multi-factor authentication method")).toBeDefined(); + expect(screen.queryByTestId("sms-assertion-form")).toBeNull(); + expect(screen.queryByTestId("totp-assertion-form")).toBeNull(); + + const smsButton = screen.getByText("SMS Verification"); + fireEvent.click(smsButton); + + rerender( + + + + ); + + // Should now show SMS form + expect(screen.getByTestId("sms-assertion-form")).toBeDefined(); + expect(screen.queryByTestId("totp-assertion-form")).toBeNull(); + expect(screen.queryByText("Please choose a multi-factor authentication method")).toBeNull(); + }); + + it("handles unknown factor types gracefully", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [ + { + factorId: "unknown-factor" as any, + uid: "test-uid", + displayName: "Unknown Factor", + }, + ], + }; + const ui = createMockUI({ + locale: registerLocale("test", { + prompts: { + mfaAssertionFactorPrompt: "Please choose a multi-factor authentication method", + }, + }), + }); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + // Should show selection UI for unknown factor + expect(screen.getByText("Please choose a multi-factor authentication method")).toBeDefined(); + expect(screen.queryByTestId("sms-assertion-form")).toBeNull(); + expect(screen.queryByTestId("totp-assertion-form")).toBeNull(); + }); +}); diff --git a/packages/react/src/auth/forms/multi-factor-auth-assertion-form.tsx b/packages/react/src/auth/forms/multi-factor-auth-assertion-form.tsx new file mode 100644 index 000000000..7135f1991 --- /dev/null +++ b/packages/react/src/auth/forms/multi-factor-auth-assertion-form.tsx @@ -0,0 +1,129 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + PhoneMultiFactorGenerator, + TotpMultiFactorGenerator, + type UserCredential, + type MultiFactorInfo, +} from "firebase/auth"; +import { type ComponentProps, useEffect, useState } from "react"; +import { useUI } from "~/hooks"; +import { TotpMultiFactorAssertionForm } from "../forms/mfa/totp-multi-factor-assertion-form"; +import { SmsMultiFactorAssertionForm } from "../forms/mfa/sms-multi-factor-assertion-form"; +import { Button } from "~/components/button"; +import { getTranslation } from "@invertase/firebaseui-core"; + +/** Props for the MultiFactorAuthAssertionForm component. */ +export type MultiFactorAuthAssertionFormProps = { + /** Optional callback function called when multi-factor assertion is successful. */ + onSuccess?: (credential: UserCredential) => void; +}; + +/** + * Hook that cleans up the multi-factor resolver when the component unmounts. + * + * Ensures the resolver is cleared from the UI state to prevent stale state. + */ +export function useMultiFactorAssertionCleanup() { + const ui = useUI(); + + useEffect(() => { + return () => { + ui.setMultiFactorResolver(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- UI isn't stable enough to be a dependency here. Could we use useEffectEvent here instead once we depend on 19.2? + }, []); +} + +/** + * A form component for multi-factor authentication assertion. + * + * Displays the appropriate MFA form (SMS or TOTP) based on the available hints. + * If only one hint is available, it is automatically selected. + * + * @returns The multi-factor auth assertion form component. + * @throws {Error} Throws an error if no multi-factor resolver is available. + */ +export function MultiFactorAuthAssertionForm(props: MultiFactorAuthAssertionFormProps) { + const ui = useUI(); + const resolver = ui.multiFactorResolver; + const mfaAssertionFactorPrompt = getTranslation(ui, "prompts", "mfaAssertionFactorPrompt"); + + useMultiFactorAssertionCleanup(); + + if (!resolver) { + throw new Error("MultiFactorAuthAssertionForm requires a multi-factor resolver"); + } + + // If only a single hint is provided, select it by default to improve UX. + const [hint, setHint] = useState( + resolver.hints.length === 1 ? resolver.hints[0] : undefined + ); + + if (hint) { + if (hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID) { + return ( + { + props.onSuccess?.(credential); + }} + /> + ); + } + + if (hint.factorId === TotpMultiFactorGenerator.FACTOR_ID) { + return ( + { + props.onSuccess?.(credential); + }} + /> + ); + } + } + + return ( +
+

{mfaAssertionFactorPrompt}

+ {resolver.hints.map((hint) => { + if (hint.factorId === TotpMultiFactorGenerator.FACTOR_ID) { + return setHint(hint)} />; + } + + if (hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID) { + return setHint(hint)} />; + } + + return null; + })} +
+ ); +} + +function TotpButton(props: ComponentProps) { + const ui = useUI(); + const labelText = getTranslation(ui, "labels", "mfaTotpVerification"); + return ; +} + +function SmsButton(props: ComponentProps) { + const ui = useUI(); + const labelText = getTranslation(ui, "labels", "mfaSmsVerification"); + return ; +} diff --git a/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.test.tsx b/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.test.tsx new file mode 100644 index 000000000..e73644171 --- /dev/null +++ b/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.test.tsx @@ -0,0 +1,335 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { MultiFactorAuthEnrollmentForm } from "./multi-factor-auth-enrollment-form"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { FactorId } from "firebase/auth"; + +vi.mock("./mfa/sms-multi-factor-enrollment-form", () => ({ + SmsMultiFactorEnrollmentForm: ({ onSuccess }: { onSuccess?: () => void }) => ( +
+
{onSuccess &&
onSuccess
}
+
+ ), +})); + +vi.mock("./mfa/totp-multi-factor-enrollment-form", () => ({ + TotpMultiFactorEnrollmentForm: ({ onSuccess }: { onSuccess?: () => void }) => ( +
+
{onSuccess &&
onSuccess
}
+
+ ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders with default hints (TOTP and PHONE) when no hints provided", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + render( + + + + ); + + // Should show both buttons since we have multiple hints (since no prop) + expect(screen.getByRole("button", { name: "Set up TOTP" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Set up SMS" })).toBeInTheDocument(); + }); + + it("renders with custom hints when provided", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument(); + }); + + it("auto-selects single hint and renders corresponding form", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument(); + }); + + it("auto-selects SMS hint and renders corresponding form", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("sms-multi-factor-enrollment-form")).toBeInTheDocument(); + }); + + it("shows buttons for multiple hints and allows selection", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "Set up TOTP" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Set up SMS" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Set up TOTP" })); + + expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument(); + }); + + it("shows buttons for multiple hints and allows SMS selection", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "Set up TOTP" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Set up SMS" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Set up SMS" })); + + expect(screen.getByTestId("sms-multi-factor-enrollment-form")).toBeInTheDocument(); + }); + + it("passes onEnrollment prop to TOTP form when auto-selected", () => { + const mockOnEnrollment = vi.fn(); + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("totp-on-success")).toBeInTheDocument(); + }); + + it("passes onEnrollment prop to SMS form when auto-selected", () => { + const mockOnEnrollment = vi.fn(); + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("sms-on-enrollment")).toBeInTheDocument(); + }); + + it("passes onEnrollment prop to TOTP form when selected via button", () => { + const mockOnEnrollment = vi.fn(); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Set up TOTP" })); + + expect(screen.getByTestId("totp-on-success")).toBeInTheDocument(); + }); + + it("passes onEnrollment prop to SMS form when selected via button", () => { + const mockOnEnrollment = vi.fn(); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Set up SMS" })); + + expect(screen.getByTestId("sms-on-enrollment")).toBeInTheDocument(); + }); + + it("throws error when hints array is empty", () => { + const ui = createMockUI(); + + expect(() => { + render( + + + + ); + }).toThrow("MultiFactorAuthEnrollmentForm must have at least one hint"); + }); + + it("throws error for unknown hint type", () => { + const ui = createMockUI(); + + const unknownHint = "unknown" as any; + + expect(() => { + render( + + + + ); + }).toThrow("Unknown multi-factor enrollment type: unknown"); + }); + + it("uses correct translation keys for buttons", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Configure TOTP Authentication", + mfaSmsVerification: "Configure SMS Authentication", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "Configure TOTP Authentication" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Configure SMS Authentication" })).toBeInTheDocument(); + }); + + it("renders with correct CSS classes", () => { + const ui = createMockUI(); + + const { container } = render( + + + + ); + + const contentDiv = container.querySelector(".fui-content"); + expect(contentDiv).toBeInTheDocument(); + }); + + it("handles mixed hint types correctly", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "Set up TOTP" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Set up SMS" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Set up TOTP" })); + + expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument(); + expect(screen.queryByTestId("sms-multi-factor-enrollment-form")).not.toBeInTheDocument(); + }); + + it("maintains state correctly when switching between hints", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + const { rerender } = render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Set up TOTP" })); + expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument(); + + rerender( + + + + ); + + expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.tsx b/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.tsx new file mode 100644 index 000000000..6b9ec9ce2 --- /dev/null +++ b/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.tsx @@ -0,0 +1,104 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FactorId } from "firebase/auth"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { type ComponentProps, useState } from "react"; + +import { SmsMultiFactorEnrollmentForm } from "./mfa/sms-multi-factor-enrollment-form"; +import { TotpMultiFactorEnrollmentForm } from "./mfa/totp-multi-factor-enrollment-form"; +import { Button } from "~/components/button"; +import { useUI } from "~/hooks"; + +type Hint = (typeof FactorId)[keyof typeof FactorId]; + +/** Props for the MultiFactorAuthEnrollmentForm component. */ +export type MultiFactorAuthEnrollmentFormProps = { + /** Optional callback function called when enrollment is successful. */ + onEnrollment?: () => void; + /** Optional array of factor IDs to allow enrollment for. Defaults to TOTP and PHONE. */ + hints?: Hint[]; +}; + +const DEFAULT_HINTS = [FactorId.TOTP, FactorId.PHONE] as const; + +/** + * A form component for multi-factor authentication enrollment. + * + * Displays the appropriate MFA enrollment form (SMS or TOTP) based on the provided hints. + * If only one hint is provided, it is automatically selected. + * + * @returns The multi-factor auth enrollment form component. + * @throws {Error} Throws an error if no hints are provided or if an unknown hint type is encountered. + */ +export function MultiFactorAuthEnrollmentForm(props: MultiFactorAuthEnrollmentFormProps) { + const hints = props.hints ?? DEFAULT_HINTS; + + if (hints.length === 0) { + throw new Error("MultiFactorAuthEnrollmentForm must have at least one hint"); + } + + // If only a single hint is provided, select it by default to improve UX. + const [hint, setHint] = useState(hints.length === 1 ? hints[0] : undefined); + + if (hint) { + if (hint === FactorId.TOTP) { + return ; + } + + if (hint === FactorId.PHONE) { + return ; + } + + throw new Error(`Unknown multi-factor enrollment type: ${hint}`); + } + + return ( +
+ {hints.map((hint) => { + if (hint === FactorId.TOTP) { + return setHint(hint)} />; + } + + if (hint === FactorId.PHONE) { + return setHint(hint)} />; + } + + return null; + })} +
+ ); +} + +function TotpButton(props: ComponentProps) { + const ui = useUI(); + const labelText = getTranslation(ui, "labels", "mfaTotpVerification"); + return ( + + ); +} + +function SmsButton(props: ComponentProps) { + const ui = useUI(); + const labelText = getTranslation(ui, "labels", "mfaSmsVerification"); + return ( + + ); +} diff --git a/packages/react/src/auth/forms/phone-auth-form.test.tsx b/packages/react/src/auth/forms/phone-auth-form.test.tsx new file mode 100644 index 000000000..619f7a171 --- /dev/null +++ b/packages/react/src/auth/forms/phone-auth-form.test.tsx @@ -0,0 +1,606 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, renderHook, cleanup, waitFor } from "@testing-library/react"; +import { + PhoneAuthForm, + usePhoneNumberFormAction, + usePhoneNumberForm, + useVerifyPhoneNumberFormAction, + useVerifyPhoneNumberForm, + PhoneNumberForm, +} from "./phone-auth-form"; +import { act } from "react"; +import type { UserCredential } from "firebase/auth"; + +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + RecaptchaVerifier: vi.fn().mockImplementation(() => ({ + render: vi.fn().mockResolvedValue(123), + clear: vi.fn(), + verify: vi.fn().mockResolvedValue("verification-token"), + })), + ConfirmationResult: vi.fn(), + getRedirectResult: vi.fn().mockResolvedValue(null), + }; +}); + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + verifyPhoneNumber: vi.fn(), + confirmPhoneNumber: vi.fn(), + formatPhoneNumberWithCountry: vi.fn((phoneNumber, dialCode) => `${dialCode}${phoneNumber}`), + }; +}); + +vi.mock("~/components/form", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + form: { + ...mod.form, + ErrorMessage: () =>
Error Message
, + }, + }; +}); + +vi.mock("~/hooks", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useRecaptchaVerifier: vi.fn().mockReturnValue({ + render: vi.fn(), + clear: vi.fn(), + verify: vi.fn(), + }), + }; +}); + +import { verifyPhoneNumber, confirmPhoneNumber } from "@invertase/firebaseui-core"; +import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { FirebaseUIProvider } from "~/context"; + +vi.mock("~/components/country-selector", () => ({ + CountrySelector: vi.fn().mockImplementation(({ value, onChange, ref }: any) => { + if (ref && typeof ref === "object" && "current" in ref) { + ref.current = { + getCountry: () => ({ + code: "US", + name: "United States", + dialCode: "+1", + emoji: "🇺🇸", + }), + setCountry: () => {}, + }; + } + + return ( +
+ +
+ ); + }), +})); + +describe("usePhoneNumberFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a callback which accepts phone number and recaptcha verifier", async () => { + const verifyPhoneNumberMock = vi.mocked(verifyPhoneNumber); + const mockUI = createMockUI(); + const mockRecaptchaVerifier = { render: vi.fn(), clear: vi.fn(), verify: vi.fn() }; + + const { result } = renderHook(() => usePhoneNumberFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ phoneNumber: "1234567890", recaptchaVerifier: mockRecaptchaVerifier as any }); + }); + + expect(verifyPhoneNumberMock).toHaveBeenCalledWith(expect.any(Object), "1234567890", mockRecaptchaVerifier); + }); + + it("should return a verification ID on success", async () => { + const mockVerificationId = "test-verification-id"; + const verifyPhoneNumberMock = vi.mocked(verifyPhoneNumber).mockResolvedValue(mockVerificationId); + const mockUI = createMockUI(); + const mockRecaptchaVerifier = { render: vi.fn(), clear: vi.fn(), verify: vi.fn() }; + + const { result } = renderHook(() => usePhoneNumberFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + const verificationId = await result.current({ + phoneNumber: "1234567890", + recaptchaVerifier: mockRecaptchaVerifier as any, + }); + expect(verificationId).toBe(mockVerificationId); + }); + + expect(verifyPhoneNumberMock).toHaveBeenCalledWith(expect.any(Object), "1234567890", mockRecaptchaVerifier); + }); + + it("should throw an unknown error when its not a FirebaseUIError", async () => { + const verifyPhoneNumberMock = vi.mocked(verifyPhoneNumber).mockRejectedValue(new Error("Unknown error")); + + const mockUI = createMockUI({ + locale: registerLocale("es-ES", { + errors: { + unknownError: "unknownError", + }, + }), + }); + + const mockRecaptchaVerifier = { render: vi.fn(), clear: vi.fn(), verify: vi.fn() }; + + const { result } = renderHook(() => usePhoneNumberFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await expect(async () => { + await act(async () => { + await result.current({ phoneNumber: "1234567890", recaptchaVerifier: mockRecaptchaVerifier as any }); + }); + }).rejects.toThrow("Unknown error"); + + expect(verifyPhoneNumberMock).toHaveBeenCalledWith(mockUI.get(), "1234567890", mockRecaptchaVerifier); + }); +}); + +describe("usePhoneNumberForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should allow the form to be submitted with valid phone number", async () => { + const mockUI = createMockUI(); + const mockVerificationId = "test-verification-id"; + const verifyPhoneNumberMock = vi.mocked(verifyPhoneNumber).mockResolvedValue(mockVerificationId); + const mockRecaptchaVerifier = { render: vi.fn(), clear: vi.fn(), verify: vi.fn() }; + + const { result } = renderHook( + () => + usePhoneNumberForm({ + recaptchaVerifier: mockRecaptchaVerifier as any, + onSuccess: vi.fn(), + }), + { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + } + ); + + act(() => { + result.current.setFieldValue("phoneNumber", "1234567890"); + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(verifyPhoneNumberMock).toHaveBeenCalledWith(mockUI.get(), "1234567890", mockRecaptchaVerifier); + }); + + it("should not allow the form to be submitted if the form is invalid", async () => { + const mockUI = createMockUI(); + const verifyPhoneNumberMock = vi.mocked(verifyPhoneNumber); + const mockRecaptchaVerifier = { render: vi.fn(), clear: vi.fn(), verify: vi.fn() }; + + const { result } = renderHook( + () => + usePhoneNumberForm({ + recaptchaVerifier: mockRecaptchaVerifier as any, + onSuccess: vi.fn(), + }), + { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + } + ); + + act(() => { + result.current.setFieldValue("phoneNumber", "12345678901"); // too long + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + const fieldMeta = result.current.getFieldMeta("phoneNumber"); + expect(fieldMeta?.errors).toBeDefined(); + expect(fieldMeta?.errors.length).toBeGreaterThan(0); + expect(verifyPhoneNumberMock).not.toHaveBeenCalled(); + }); + + it("should call onSuccess callback when form submission succeeds", async () => { + const mockUI = createMockUI(); + const mockRecaptchaVerifier = { render: vi.fn(), clear: vi.fn(), verify: vi.fn() }; + const mockVerificationId = "test-verification-id"; + const onSuccessMock = vi.fn(); + + vi.mocked(verifyPhoneNumber).mockResolvedValue(mockVerificationId); + + const { result } = renderHook( + () => + usePhoneNumberForm({ + recaptchaVerifier: mockRecaptchaVerifier as any, + onSuccess: onSuccessMock, + }), + { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + } + ); + + act(() => { + result.current.setFieldValue("phoneNumber", "1234567890"); + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(onSuccessMock).toHaveBeenCalledWith(mockVerificationId); + }); +}); + +describe("useVerifyPhoneNumberFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a callback which accepts verification ID and code", async () => { + const confirmPhoneNumberMock = vi.mocked(confirmPhoneNumber); + const mockUI = createMockUI(); + const mockVerificationId = "test-verification-id"; + + const { result } = renderHook(() => useVerifyPhoneNumberFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ verificationId: mockVerificationId, verificationCode: "123456" }); + }); + + expect(confirmPhoneNumberMock).toHaveBeenCalledWith(expect.any(Object), mockVerificationId, "123456"); + }); + + it("should return a credential on success", async () => { + const mockCredential = { credential: true } as unknown as UserCredential; + const confirmPhoneNumberMock = vi.mocked(confirmPhoneNumber).mockResolvedValue(mockCredential); + const mockUI = createMockUI(); + const mockVerificationId = "test-verification-id"; + + const { result } = renderHook(() => useVerifyPhoneNumberFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + const credential = await result.current({ verificationId: mockVerificationId, verificationCode: "123456" }); + expect(credential).toBe(mockCredential); + }); + + expect(confirmPhoneNumberMock).toHaveBeenCalledWith(expect.any(Object), mockVerificationId, "123456"); + }); + + it("should throw an unknown error when its not a FirebaseUIError", async () => { + const confirmPhoneNumberMock = vi.mocked(confirmPhoneNumber).mockRejectedValue(new Error("Unknown error")); + + const mockUI = createMockUI({ + locale: registerLocale("es-ES", { + errors: { + unknownError: "unknownError", + }, + }), + }); + + const mockVerificationId = "test-verification-id"; + + const { result } = renderHook(() => useVerifyPhoneNumberFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await expect(async () => { + await act(async () => { + await result.current({ verificationId: mockVerificationId, verificationCode: "123456" }); + }); + }).rejects.toThrow("Unknown error"); + + expect(confirmPhoneNumberMock).toHaveBeenCalledWith(mockUI.get(), mockVerificationId, "123456"); + }); +}); + +describe("useVerifyPhoneNumberForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should allow the form to be submitted with valid verification code", async () => { + const mockUI = createMockUI(); + const confirmPhoneNumberMock = vi.mocked(confirmPhoneNumber); + const mockVerificationId = "test-verification-id"; + + const { result } = renderHook( + () => + useVerifyPhoneNumberForm({ + verificationId: mockVerificationId, + onSuccess: vi.fn(), + }), + { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + } + ); + + act(() => { + result.current.setFieldValue("verificationCode", "123456"); + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(confirmPhoneNumberMock).toHaveBeenCalledWith(mockUI.get(), mockVerificationId, "123456"); + }); + + it("should not allow the form to be submitted if the form is invalid", async () => { + const mockUI = createMockUI(); + const confirmPhoneNumberMock = vi.mocked(confirmPhoneNumber); + const mockVerificationId = "test-verification-id"; + + const { result } = renderHook( + () => + useVerifyPhoneNumberForm({ + verificationId: mockVerificationId, + onSuccess: vi.fn(), + }), + { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + } + ); + + act(() => { + result.current.setFieldValue("verificationCode", "123"); + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(result.current.getFieldMeta("verificationCode")!.errors[0].length).toBeGreaterThan(0); + expect(confirmPhoneNumberMock).not.toHaveBeenCalled(); + }); + + it("should call onSuccess callback when form submission succeeds", async () => { + const mockUI = createMockUI(); + const mockVerificationId = "test-verification-id"; + const mockCredential = { credential: true } as unknown as UserCredential; + const onSuccessMock = vi.fn(); + + vi.mocked(confirmPhoneNumber).mockResolvedValue(mockCredential); + + const { result } = renderHook( + () => + useVerifyPhoneNumberForm({ + verificationId: mockVerificationId, + onSuccess: onSuccessMock, + }), + { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + } + ); + + act(() => { + result.current.setFieldValue("verificationCode", "123456"); + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(onSuccessMock).toHaveBeenCalledWith(mockCredential); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the phone number form correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "sendCode", + phoneNumber: "phoneNumber", + }, + }), + }); + + const { container } = render( + + + + ); + + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + expect(screen.getByRole("textbox", { name: /phoneNumber/i })).toBeInTheDocument(); + expect(screen.getByTestId("country-selector")).toBeInTheDocument(); + + const sendCodeButton = screen.getByRole("button", { name: "sendCode" }); + expect(sendCodeButton).toBeInTheDocument(); + expect(sendCodeButton).toHaveAttribute("type", "submit"); + }); + + it("should trigger validation errors when the form is blurred", () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + const form = container.querySelector("form.fui-form"); + expect(form).toBeInTheDocument(); + + const input = screen.getByRole("textbox", { name: /phone number/i }); + + act(() => { + fireEvent.blur(input); + }); + + expect(screen.getByText("Please provide a phone number")).toBeInTheDocument(); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the phone number form initially", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "sendCode", + phoneNumber: "phoneNumber", + }, + }), + }); + + const { container } = render( + + + + ); + + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + expect(screen.getByRole("textbox", { name: /phoneNumber/i })).toBeInTheDocument(); + expect(screen.getByTestId("country-selector")).toBeInTheDocument(); + + const sendCodeButton = screen.getByRole("button", { name: "sendCode" }); + expect(sendCodeButton).toBeInTheDocument(); + expect(sendCodeButton).toHaveAttribute("type", "submit"); + }); + + it("should render phone number form initially and handle form submission", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "sendCode", + phoneNumber: "phoneNumber", + }, + }), + }); + + const onSignInMock = vi.fn(); + + render( + + + + ); + + expect(screen.getByRole("textbox", { name: /phoneNumber/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "sendCode" })).toBeInTheDocument(); + expect(screen.getByTestId("country-selector")).toBeInTheDocument(); + }); + + it("should render the verification code form with description after phone number submission", async () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + verificationCode: "verificationCode", + verifyCode: "verifyCode", + }, + prompts: { + smsVerificationPrompt: "Enter the verification code sent to your phone number", + }, + }), + }); + + const mockVerificationId = "test-verification-id"; + vi.mocked(verifyPhoneNumber).mockResolvedValue(mockVerificationId); + + const { container } = render( + + + + ); + + const phoneInput = screen.getByRole("textbox", { name: /phone number/i }); + expect(phoneInput).toBeInTheDocument(); + + const sendCodeButton = screen.getByRole("button", { name: /send code/i }); + + await act(async () => { + fireEvent.change(phoneInput, { target: { value: "1234567890" } }); + fireEvent.click(sendCodeButton); + }); + + const verificationInput = await waitFor(() => { + return screen.getByRole("textbox", { name: /verificationCode/i }); + }); + expect(verificationInput).toBeInTheDocument(); + + const description = container.querySelector("[data-input-description]"); + expect(description).toBeInTheDocument(); + expect(description).toHaveTextContent("Enter the verification code sent to your phone number"); + }); +}); diff --git a/packages/react/src/auth/forms/phone-auth-form.tsx b/packages/react/src/auth/forms/phone-auth-form.tsx new file mode 100644 index 000000000..b6c29cbc6 --- /dev/null +++ b/packages/react/src/auth/forms/phone-auth-form.tsx @@ -0,0 +1,266 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { + FirebaseUIError, + formatPhoneNumber, + getTranslation, + verifyPhoneNumber, + confirmPhoneNumber, +} from "@invertase/firebaseui-core"; +import { type RecaptchaVerifier, type UserCredential } from "firebase/auth"; +import { useCallback, useRef, useState } from "react"; +import { usePhoneAuthNumberFormSchema, usePhoneAuthVerifyFormSchema, useRecaptchaVerifier, useUI } from "~/hooks"; +import { form } from "~/components/form"; +import { Policies } from "~/components/policies"; +import { CountrySelector, type CountrySelectorRef } from "~/components/country-selector"; + +/** + * Creates a memoized action function for verifying a phone number. + * + * @returns A callback function that verifies a phone number using the provided reCAPTCHA verifier. + */ +export function usePhoneNumberFormAction() { + const ui = useUI(); + + return useCallback( + async ({ phoneNumber, recaptchaVerifier }: { phoneNumber: string; recaptchaVerifier: RecaptchaVerifier }) => { + return await verifyPhoneNumber(ui, phoneNumber, recaptchaVerifier); + }, + [ui] + ); +} + +/** Options for the phone number form hook. */ +type UsePhoneNumberForm = { + /** The reCAPTCHA verifier instance. */ + recaptchaVerifier: RecaptchaVerifier; + /** Callback function called when phone verification is successful. */ + onSuccess: (verificationId: string) => void; + /** Optional function to format the phone number before verification. */ + formatPhoneNumber?: (phoneNumber: string) => string; +}; + +/** + * Creates a form hook for phone number verification. + * + * @param options - The phone number form options. + * @returns A form instance configured for phone number input and verification. + */ +export function usePhoneNumberForm({ recaptchaVerifier, onSuccess, formatPhoneNumber }: UsePhoneNumberForm) { + const action = usePhoneNumberFormAction(); + const schema = usePhoneAuthNumberFormSchema(); + + return form.useAppForm({ + defaultValues: { + phoneNumber: "", + }, + validators: { + onBlur: schema, + onSubmitAsync: async ({ value }) => { + try { + const formatted = formatPhoneNumber ? formatPhoneNumber(value.phoneNumber) : value.phoneNumber; + const confirmationResult = await action({ phoneNumber: formatted, recaptchaVerifier }); + return onSuccess(confirmationResult); + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); +} + +/** Props for the PhoneNumberForm component. */ +type PhoneNumberFormProps = { + /** Callback function called when phone verification is successful. */ + onSubmit: (verificationId: string) => void; +}; + +/** + * A form component for entering and verifying a phone number. + * + * Includes a country selector and reCAPTCHA verification. + * + * @returns The phone number form component. + */ +export function PhoneNumberForm(props: PhoneNumberFormProps) { + const ui = useUI(); + const recaptchaContainerRef = useRef(null); + const recaptchaVerifier = useRecaptchaVerifier(recaptchaContainerRef); + const countrySelector = useRef(null); + const form = usePhoneNumberForm({ + recaptchaVerifier: recaptchaVerifier!, + onSuccess: props.onSubmit, + formatPhoneNumber: (phoneNumber) => formatPhoneNumber(phoneNumber, countrySelector.current!.getCountry()), + }); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
+ + {(field) => ( + } + /> + )} + +
+
+
+
+ +
+ {getTranslation(ui, "labels", "sendCode")} + +
+
+
+ ); +} + +/** + * Creates a memoized action function for verifying a phone verification code. + * + * @returns A callback function that confirms the phone number using the verification ID and code. + */ +export function useVerifyPhoneNumberFormAction() { + const ui = useUI(); + + return useCallback( + async ({ verificationId, verificationCode }: { verificationId: string; verificationCode: string }) => { + return await confirmPhoneNumber(ui, verificationId, verificationCode); + }, + [ui] + ); +} + +/** Options for the verify phone number form hook. */ +type UseVerifyPhoneNumberForm = { + /** The verification ID from the phone verification step. */ + verificationId: string; + /** Callback function called when verification is successful. */ + onSuccess: (credential: UserCredential) => void; +}; + +/** + * Creates a form hook for phone verification code input. + * + * @param options - The verify phone number form options. + * @returns A form instance configured for verification code input. + */ +export function useVerifyPhoneNumberForm({ verificationId, onSuccess }: UseVerifyPhoneNumberForm) { + const schema = usePhoneAuthVerifyFormSchema(); + const action = useVerifyPhoneNumberFormAction(); + + return form.useAppForm({ + defaultValues: { + verificationId, + verificationCode: "", + }, + validators: { + onBlur: schema, + onSubmitAsync: async ({ value }) => { + try { + const credential = await action(value); + return onSuccess(credential); + } catch (error) { + return error instanceof FirebaseUIError ? error.message : String(error); + } + }, + }, + }); +} + +type VerifyPhoneNumberFormProps = { + onSuccess: (credential: UserCredential) => void; + verificationId: string; +}; + +function VerifyPhoneNumberForm(props: VerifyPhoneNumberFormProps) { + const ui = useUI(); + const form = useVerifyPhoneNumberForm({ verificationId: props.verificationId, onSuccess: props.onSuccess }); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
+ + {(field) => ( + + )} + +
+
+ {getTranslation(ui, "labels", "verifyCode")} + +
+
+
+ ); +} + +/** Props for the PhoneAuthForm component. */ +export type PhoneAuthFormProps = { + /** Optional callback function called when sign-in is successful. */ + onSignIn?: (credential: UserCredential) => void; +}; + +/** + * A form component for phone authentication. + * + * Handles the two-step process: first entering the phone number, then verifying the SMS code. + * + * @returns The phone auth form component. + */ +export function PhoneAuthForm(props: PhoneAuthFormProps) { + const [verificationId, setVerificationId] = useState(null); + + if (!verificationId) { + return ; + } + + return ( + { + props.onSignIn?.(credential); + }} + /> + ); +} diff --git a/packages/react/src/auth/forms/sign-in-auth-form.test.tsx b/packages/react/src/auth/forms/sign-in-auth-form.test.tsx new file mode 100644 index 000000000..de9ffa535 --- /dev/null +++ b/packages/react/src/auth/forms/sign-in-auth-form.test.tsx @@ -0,0 +1,285 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, renderHook, cleanup } from "@testing-library/react"; +import { SignInAuthForm, useSignInAuthForm, useSignInAuthFormAction } from "./sign-in-auth-form"; +import { act } from "react"; +import { signInWithEmailAndPassword } from "@invertase/firebaseui-core"; +import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import type { UserCredential } from "firebase/auth"; +import { FirebaseUIProvider } from "~/context"; + +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + getRedirectResult: vi.fn().mockResolvedValue(null), + }; +}); + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + signInWithEmailAndPassword: vi.fn(), + }; +}); + +vi.mock("~/components/form", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + form: { + ...mod.form, + ErrorMessage: () =>
Error Message
, + }, + }; +}); + +describe("useSignInAuthFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a callback which accept an email and password", async () => { + const signInWithEmailAndPasswordMock = vi.mocked(signInWithEmailAndPassword); + const mockUI = createMockUI(); + + const { result } = renderHook(() => useSignInAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ email: "test@example.com", password: "password123" }); + }); + + expect(signInWithEmailAndPasswordMock).toHaveBeenCalledWith(expect.any(Object), "test@example.com", "password123"); + }); + + it("should return a credential on success", async () => { + const mockCredential = { credential: true } as unknown as UserCredential; + + const signInWithEmailAndPasswordMock = vi.mocked(signInWithEmailAndPassword).mockResolvedValue(mockCredential); + + const mockUI = createMockUI(); + + const { result } = renderHook(() => useSignInAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + const credential = await result.current({ email: "test@example.com", password: "password123" }); + expect(credential).toBe(mockCredential); + }); + + expect(signInWithEmailAndPasswordMock).toHaveBeenCalledWith(expect.any(Object), "test@example.com", "password123"); + }); + + it("should throw an unknown error when its not a FirebaseUIError", async () => { + const signInWithEmailAndPasswordMock = vi + .mocked(signInWithEmailAndPassword) + .mockRejectedValue(new Error("Unknown error")); + + const mockUI = createMockUI({ + locale: registerLocale("es-ES", { + errors: { + unknownError: "unknownError", + }, + }), + }); + + const { result } = renderHook(() => useSignInAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await expect(async () => { + await act(async () => { + await result.current({ email: "test@example.com", password: "password123" }); + }); + }).rejects.toThrow("unknownError"); + + expect(signInWithEmailAndPasswordMock).toHaveBeenCalledWith(mockUI.get(), "test@example.com", "password123"); + }); +}); + +describe("useSignInAuthForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should allow the form to be submitted", async () => { + const mockUI = createMockUI(); + const signInWithEmailAndPasswordMock = vi.mocked(signInWithEmailAndPassword); + + const { result } = renderHook(() => useSignInAuthForm(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + result.current.setFieldValue("email", "test@example.com"); + result.current.setFieldValue("password", "password123"); + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(signInWithEmailAndPasswordMock).toHaveBeenCalledWith(mockUI.get(), "test@example.com", "password123"); + }); + + it("should not allow the form to be submitted if the form is invalid", async () => { + const mockUI = createMockUI(); + const signInWithEmailAndPasswordMock = vi.mocked(signInWithEmailAndPassword); + + const { result } = renderHook(() => useSignInAuthForm(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + result.current.setFieldValue("email", "123"); + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(result.current.getFieldMeta("email")!.errors[0].length).toBeGreaterThan(0); + expect(signInWithEmailAndPasswordMock).not.toHaveBeenCalled(); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the form correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "signIn", + }, + }), + }); + + const { container } = render( + + + + ); + + // There should be only one form + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + // Make sure we have an email and password input + expect(screen.getByRole("textbox", { name: /email/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + + // Ensure the "Sign In" button is present and is a submit button + const signInButton = screen.getByRole("button", { name: "signIn" }); + expect(signInButton).toBeInTheDocument(); + expect(signInButton).toHaveAttribute("type", "submit"); + }); + + it("should render the forgot password button callback when onForgotPasswordClick is provided", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + forgotPassword: "forgotPassword", + }, + }), + }); + + const onForgotPasswordClickMock = vi.fn(); + + render( + + + + ); + + const forgotPasswordButton = screen.getByRole("button", { name: "forgotPassword" }); + expect(forgotPasswordButton).toBeInTheDocument(); + expect(forgotPasswordButton).toHaveTextContent("forgotPassword"); + + // Make sure it's a button so it doesn't submit the form + expect(forgotPasswordButton).toHaveAttribute("type", "button"); + + fireEvent.click(forgotPasswordButton); + expect(onForgotPasswordClickMock).toHaveBeenCalled(); + }); + + it("should render the register button callback when onSignUpClick is provided", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + prompts: { + noAccount: "foo", + }, + labels: { + signUp: "bar", + }, + }), + }); + + const onSignUpClick = vi.fn(); + + render( + + + + ); + + const name = "foo bar"; + + const registerButton = screen.getByRole("button", { name }); + expect(registerButton).toBeInTheDocument(); + expect(registerButton).toHaveTextContent(name); + + // Make sure it's a button so it doesn't submit the form + expect(registerButton).toHaveAttribute("type", "button"); + + fireEvent.click(registerButton); + expect(onSignUpClick).toHaveBeenCalled(); + }); + + it("should trigger validation errors when the form is blurred", () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + const form = container.querySelector("form.fui-form"); + expect(form).toBeInTheDocument(); + + const input = screen.getByRole("textbox", { name: /email/i }); + + act(() => { + fireEvent.blur(input); + }); + + expect(screen.getByText("Please enter a valid email address")).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/auth/forms/sign-in-auth-form.tsx b/packages/react/src/auth/forms/sign-in-auth-form.tsx new file mode 100644 index 000000000..1812ccfd5 --- /dev/null +++ b/packages/react/src/auth/forms/sign-in-auth-form.tsx @@ -0,0 +1,144 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { FirebaseUIError, getTranslation, signInWithEmailAndPassword } from "@invertase/firebaseui-core"; +import type { UserCredential } from "firebase/auth"; +import { useSignInAuthFormSchema, useUI } from "~/hooks"; +import { form } from "~/components/form"; +import { Policies } from "~/components/policies"; +import { useCallback } from "react"; + +/** Props for the SignInAuthForm component. */ +export type SignInAuthFormProps = { + /** Callback function called when sign-in is successful. */ + onSignIn?: (credential: UserCredential) => void; + /** Callback function called when the forgot password link is clicked. */ + onForgotPasswordClick?: () => void; + /** Callback function called when the sign up link is clicked. */ + onSignUpClick?: () => void; +}; + +/** + * Creates a memoized action function for signing in with email and password. + * + * @returns A callback function that signs in a user with email and password. + */ +export function useSignInAuthFormAction() { + const ui = useUI(); + + return useCallback( + async ({ email, password }: { email: string; password: string }) => { + try { + return await signInWithEmailAndPassword(ui, email, password); + } catch (error) { + if (error instanceof FirebaseUIError) { + throw new Error(error.message); + } + + console.error(error); + throw new Error(getTranslation(ui, "errors", "unknownError")); + } + }, + [ui] + ); +} + +/** + * Creates a form hook for sign-in authentication. + * + * @param onSuccess - Optional callback function called when sign-in is successful. + * @returns A form instance configured for sign-in. + */ +export function useSignInAuthForm(onSuccess?: SignInAuthFormProps["onSignIn"]) { + const schema = useSignInAuthFormSchema(); + const action = useSignInAuthFormAction(); + + return form.useAppForm({ + defaultValues: { + email: "", + password: "", + }, + validators: { + onBlur: schema, + onSubmitAsync: async ({ value }) => { + try { + const credential = await action(value); + return onSuccess?.(credential); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }, + }); +} + +/** + * A form component for signing in with email and password. + * + * @returns The sign-in form component. + */ +export function SignInAuthForm({ onSignIn, onForgotPasswordClick, onSignUpClick }: SignInAuthFormProps) { + const ui = useUI(); + const form = useSignInAuthForm(onSignIn); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + +
+ + {(field) => } + +
+
+ + {(field) => ( + + {getTranslation(ui, "labels", "forgotPassword")} + + ) : null + } + /> + )} + +
+ +
+ {getTranslation(ui, "labels", "signIn")} + +
+ {onSignUpClick ? ( + + {getTranslation(ui, "prompts", "noAccount")} {getTranslation(ui, "labels", "signUp")} + + ) : null} +
+
+ ); +} diff --git a/packages/react/src/auth/forms/sign-up-auth-form.test.tsx b/packages/react/src/auth/forms/sign-up-auth-form.test.tsx new file mode 100644 index 000000000..4a2df616c --- /dev/null +++ b/packages/react/src/auth/forms/sign-up-auth-form.test.tsx @@ -0,0 +1,512 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, renderHook, cleanup } from "@testing-library/react"; +import { SignUpAuthForm, useSignUpAuthForm, useSignUpAuthFormAction, useRequireDisplayName } from "./sign-up-auth-form"; +import { act } from "react"; +import { createUserWithEmailAndPassword } from "@invertase/firebaseui-core"; +import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import type { UserCredential } from "firebase/auth"; +import { FirebaseUIProvider } from "~/context"; + +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + getRedirectResult: vi.fn().mockResolvedValue(null), + }; +}); + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + createUserWithEmailAndPassword: vi.fn(), + }; +}); + +vi.mock("~/components/form", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + form: { + ...mod.form, + ErrorMessage: () =>
Error Message
, + }, + }; +}); + +describe("useSignUpAuthFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a callback which accept an email and password", async () => { + const createUserWithEmailAndPasswordMock = vi.mocked(createUserWithEmailAndPassword); + const mockUI = createMockUI(); + + const { result } = renderHook(() => useSignUpAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ email: "test@example.com", password: "password123" }); + }); + + expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith( + expect.any(Object), + "test@example.com", + "password123", + undefined + ); + }); + + it("should return a credential on success", async () => { + const mockCredential = { credential: true } as unknown as UserCredential; + + const createUserWithEmailAndPasswordMock = vi + .mocked(createUserWithEmailAndPassword) + .mockResolvedValue(mockCredential); + + const mockUI = createMockUI(); + + const { result } = renderHook(() => useSignUpAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + const credential = await result.current({ email: "test@example.com", password: "password123" }); + expect(credential).toBe(mockCredential); + }); + + expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith( + expect.any(Object), + "test@example.com", + "password123", + undefined + ); + }); + + it("should throw an unknown error when its not a FirebaseUIError", async () => { + const createUserWithEmailAndPasswordMock = vi + .mocked(createUserWithEmailAndPassword) + .mockRejectedValue(new Error("Unknown error")); + + const mockUI = createMockUI({ + locale: registerLocale("es-ES", { + errors: { + unknownError: "unknownError", + }, + }), + }); + + const { result } = renderHook(() => useSignUpAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await expect(async () => { + await act(async () => { + await result.current({ email: "test@example.com", password: "password123" }); + }); + }).rejects.toThrow("unknownError"); + + expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith( + mockUI.get(), + "test@example.com", + "password123", + undefined + ); + }); + + it("should return a callback which accepts email, password, and displayName", async () => { + const mockCredential = { credential: true } as unknown as UserCredential; + const createUserWithEmailAndPasswordMock = vi + .mocked(createUserWithEmailAndPassword) + .mockResolvedValue(mockCredential); + const mockUI = createMockUI(); + + const { result } = renderHook(() => useSignUpAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ email: "test@example.com", password: "password123", displayName: "John Doe" }); + }); + + expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith( + expect.any(Object), + "test@example.com", + "password123", + "John Doe" + ); + }); +}); + +describe("useSignUpAuthForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should allow the form to be submitted", async () => { + const mockCredential = { credential: true } as unknown as UserCredential; + const mockUI = createMockUI(); + const createUserWithEmailAndPasswordMock = vi + .mocked(createUserWithEmailAndPassword) + .mockResolvedValue(mockCredential); + + const { result } = renderHook(() => useSignUpAuthForm(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + result.current.setFieldValue("email", "test@example.com"); + result.current.setFieldValue("password", "password123"); + // Don't set displayName - let it be undefined (optional) + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith( + mockUI.get(), + "test@example.com", + "password123", + undefined + ); + }); + + it("should not allow the form to be submitted if the form is invalid", async () => { + const mockUI = createMockUI(); + const createUserWithEmailAndPasswordMock = vi.mocked(createUserWithEmailAndPassword); + + const { result } = renderHook(() => useSignUpAuthForm(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + result.current.setFieldValue("email", "123"); + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(result.current.getFieldMeta("email")!.errors[0].length).toBeGreaterThan(0); + expect(createUserWithEmailAndPasswordMock).not.toHaveBeenCalled(); + }); + + it("should allow the form to be submitted with displayName", async () => { + const mockUI = createMockUI(); + const createUserWithEmailAndPasswordMock = vi.mocked(createUserWithEmailAndPassword); + + const { result } = renderHook(() => useSignUpAuthForm(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + result.current.setFieldValue("email", "test@example.com"); + result.current.setFieldValue("password", "password123"); + result.current.setFieldValue("displayName", "John Doe"); + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith( + mockUI.get(), + "test@example.com", + "password123", + "John Doe" + ); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the form correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + createAccount: "createAccount", + emailAddress: "emailAddress", + password: "password", + }, + }), + }); + + const { container } = render( + + + + ); + + // There should be only one form + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + // Make sure we have an email and password input with translated labels + expect(screen.getByRole("textbox", { name: /emailAddress/ })).toBeInTheDocument(); + expect(screen.getByLabelText(/password/)).toBeInTheDocument(); + + // Ensure the "Create Account" button is present and is a submit button + const createAccountButton = screen.getByRole("button", { name: "createAccount" }); + expect(createAccountButton).toBeInTheDocument(); + expect(createAccountButton).toHaveAttribute("type", "submit"); + }); + + it("should render the back to sign in button callback when onSignInClick is provided", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + prompts: { + haveAccount: "foo", + }, + labels: { + signIn: "bar", + }, + }), + }); + + const onSignInClickMock = vi.fn(); + + render( + + + + ); + + const name = "foo bar"; + + const backToSignInButton = screen.getByRole("button", { name }); + expect(backToSignInButton).toBeInTheDocument(); + expect(backToSignInButton).toHaveTextContent(name); + + // Make sure it's a button so it doesn't submit the form + expect(backToSignInButton).toHaveAttribute("type", "button"); + + fireEvent.click(backToSignInButton); + expect(onSignInClickMock).toHaveBeenCalled(); + }); + + it("should trigger validation errors when the form is blurred", () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + const form = container.querySelector("form.fui-form"); + expect(form).toBeInTheDocument(); + + const input = screen.getByRole("textbox", { name: /email/i }); + + act(() => { + fireEvent.blur(input); + }); + + expect(screen.getByText("Please enter a valid email address")).toBeInTheDocument(); + }); + + it("should render displayName field when requireDisplayName behavior is enabled", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + createAccount: "createAccount", + emailAddress: "emailAddress", + password: "password", + displayName: "displayName", + }, + }), + behaviors: [ + { + requireDisplayName: { type: "callable" as const, handler: vi.fn() }, + }, + ], + }); + + const { container } = render( + + + + ); + + // There should be only one form + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + // Make sure we have all three inputs with translated labels + expect(screen.getByRole("textbox", { name: /emailAddress/ })).toBeInTheDocument(); + expect(screen.getByLabelText(/password/)).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /displayName/ })).toBeInTheDocument(); + + // Ensure the "Create Account" button is present and is a submit button + const createAccountButton = screen.getByRole("button", { name: "createAccount" }); + expect(createAccountButton).toBeInTheDocument(); + expect(createAccountButton).toHaveAttribute("type", "submit"); + }); + + it("should not render displayName field when requireDisplayName behavior is not enabled", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + createAccount: "createAccount", + emailAddress: "emailAddress", + password: "password", + displayName: "displayName", + }, + }), + behaviors: [], // Explicitly set empty behaviors array + }); + + const { container } = render( + + + + ); + + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + expect(screen.getByRole("textbox", { name: /email/ })).toBeInTheDocument(); + expect(screen.getByLabelText(/password/)).toBeInTheDocument(); + expect(screen.queryByRole("textbox", { name: /displayName/ })).not.toBeInTheDocument(); + }); + + it("should trigger displayName validation errors when the form is blurred and requireDisplayName is enabled", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + errors: { + displayNameRequired: "Please provide a display name", + }, + labels: { + displayName: "displayName", + }, + }), + behaviors: [ + { + requireDisplayName: { type: "callable" as const, handler: vi.fn() }, + }, + ], + }); + + const { container } = render( + + + + ); + + const form = container.querySelector("form.fui-form"); + expect(form).toBeInTheDocument(); + + const displayNameInput = screen.getByRole("textbox", { name: /displayName/ }); + expect(displayNameInput).toBeInTheDocument(); + + act(() => { + fireEvent.blur(displayNameInput); + }); + + expect(screen.getByText("Please provide a display name")).toBeInTheDocument(); + }); + + it("should not trigger displayName validation when requireDisplayName is not enabled", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + errors: { + displayNameRequired: "Please provide a display name", + }, + labels: { + displayName: "displayName", + }, + }), + }); + + const { container } = render( + + + + ); + + const form = container.querySelector("form.fui-form"); + expect(form).toBeInTheDocument(); + + // Display name field should not be present + expect(screen.queryByRole("textbox", { name: "displayName" })).not.toBeInTheDocument(); + }); +}); + +describe("useRequireDisplayName", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should return true when requireDisplayName behavior is enabled", () => { + const mockUI = createMockUI({ + behaviors: [ + { + requireDisplayName: { type: "callable" as const, handler: vi.fn() }, + }, + ], + }); + + const { result } = renderHook(() => useRequireDisplayName(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current).toBe(true); + }); + + it("should return false when requireDisplayName behavior is not enabled", () => { + const mockUI = createMockUI({ + behaviors: [], + }); + + const { result } = renderHook(() => useRequireDisplayName(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current).toBe(false); + }); + + it("should return false when behaviors array is empty", () => { + const mockUI = createMockUI(); + + const { result } = renderHook(() => useRequireDisplayName(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current).toBe(false); + }); +}); diff --git a/packages/react/src/auth/forms/sign-up-auth-form.tsx b/packages/react/src/auth/forms/sign-up-auth-form.tsx new file mode 100644 index 000000000..f40c4e382 --- /dev/null +++ b/packages/react/src/auth/forms/sign-up-auth-form.tsx @@ -0,0 +1,158 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { + FirebaseUIError, + getTranslation, + createUserWithEmailAndPassword, + hasBehavior, +} from "@invertase/firebaseui-core"; +import type { UserCredential } from "firebase/auth"; +import { useSignUpAuthFormSchema, useUI } from "~/hooks"; +import { form } from "~/components/form"; +import { Policies } from "~/components/policies"; +import { useCallback } from "react"; +import { type z } from "zod"; + +/** + * Checks if the requireDisplayName behavior is enabled. + * + * @returns True if display name is required, false otherwise. + */ +export function useRequireDisplayName() { + const ui = useUI(); + return hasBehavior(ui, "requireDisplayName"); +} + +/** Props for the SignUpAuthForm component. */ +export type SignUpAuthFormProps = { + /** Callback function called when sign-up is successful. */ + onSignUp?: (credential: UserCredential) => void; + /** Callback function called when the sign in link is clicked. */ + onSignInClick?: () => void; +}; + +/** + * Creates a memoized action function for signing up with email and password. + * + * @returns A callback function that creates a new user account with email and password. + */ +export function useSignUpAuthFormAction() { + const ui = useUI(); + + return useCallback( + async ({ email, password, displayName }: { email: string; password: string; displayName?: string }) => { + try { + return await createUserWithEmailAndPassword(ui, email, password, displayName); + } catch (error) { + if (error instanceof FirebaseUIError) { + throw new Error(error.message); + } + + console.error(error); + throw new Error(getTranslation(ui, "errors", "unknownError")); + } + }, + [ui] + ); +} + +/** + * Creates a form hook for sign-up authentication. + * + * @param onSuccess - Optional callback function called when sign-up is successful. + * @returns A form instance configured for sign-up. + */ +export function useSignUpAuthForm(onSuccess?: SignUpAuthFormProps["onSignUp"]) { + const schema = useSignUpAuthFormSchema(); + const action = useSignUpAuthFormAction(); + const requireDisplayName = useRequireDisplayName(); + + return form.useAppForm({ + defaultValues: { + email: "", + password: "", + displayName: requireDisplayName ? "" : undefined, + } as z.infer, + validators: { + onBlur: schema, + onSubmitAsync: async ({ value }) => { + try { + const credential = await action(value); + return onSuccess?.(credential); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }, + }); +} + +/** + * A form component for signing up with email and password. + * + * Optionally includes a display name field if the requireDisplayName behavior is enabled. + * + * @returns The sign-up form component. + */ +export function SignUpAuthForm({ onSignInClick, onSignUp }: SignUpAuthFormProps) { + const ui = useUI(); + const form = useSignUpAuthForm(onSignUp); + const requireDisplayName = useRequireDisplayName(); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + await form.handleSubmit(); + }} + > + + {requireDisplayName ? ( +
+ + {(field) => } + +
+ ) : null} +
+ + {(field) => } + +
+
+ + {(field) => } + +
+ +
+ {getTranslation(ui, "labels", "createAccount")} + +
+ {onSignInClick ? ( + + {getTranslation(ui, "prompts", "haveAccount")} {getTranslation(ui, "labels", "signIn")} + + ) : null} +
+
+ ); +} diff --git a/packages/react/src/auth/index.ts b/packages/react/src/auth/index.ts new file mode 100644 index 000000000..846f012c6 --- /dev/null +++ b/packages/react/src/auth/index.ts @@ -0,0 +1,131 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Forms + */ + +export { + EmailLinkAuthForm, + type EmailLinkAuthFormProps, + useEmailLinkAuthFormAction, + useEmailLinkAuthForm, + useEmailLinkAuthFormCompleteSignIn, +} from "./forms/email-link-auth-form"; +export { + ForgotPasswordAuthForm, + type ForgotPasswordAuthFormProps, + useForgotPasswordAuthFormAction, + useForgotPasswordAuthForm, +} from "./forms/forgot-password-auth-form"; +export { + MultiFactorAuthAssertionForm, + useMultiFactorAssertionCleanup, + type MultiFactorAuthAssertionFormProps, +} from "./forms/multi-factor-auth-assertion-form"; +export { + MultiFactorAuthEnrollmentForm, + type MultiFactorAuthEnrollmentFormProps, +} from "./forms/multi-factor-auth-enrollment-form"; +export { + PhoneAuthForm, + type PhoneAuthFormProps, + usePhoneNumberForm, + usePhoneNumberFormAction, + useVerifyPhoneNumberForm, + useVerifyPhoneNumberFormAction, +} from "./forms/phone-auth-form"; +export { + SignInAuthForm, + type SignInAuthFormProps, + useSignInAuthForm, + useSignInAuthFormAction, +} from "./forms/sign-in-auth-form"; +export { + SignUpAuthForm, + type SignUpAuthFormProps, + useSignUpAuthForm, + useSignUpAuthFormAction, + useRequireDisplayName, +} from "./forms/sign-up-auth-form"; + +export { + useSmsMultiFactorAssertionPhoneFormAction, + useSmsMultiFactorAssertionVerifyFormAction, + SmsMultiFactorAssertionForm, + type SmsMultiFactorAssertionFormProps, +} from "./forms/mfa/sms-multi-factor-assertion-form"; +export { + useSmsMultiFactorEnrollmentPhoneAuthFormAction, + useSmsMultiFactorEnrollmentPhoneNumberForm, + useMultiFactorEnrollmentVerifyPhoneNumberFormAction, + useMultiFactorEnrollmentVerifyPhoneNumberForm, + SmsMultiFactorEnrollmentForm, + MultiFactorEnrollmentVerifyPhoneNumberForm, + type UseSmsMultiFactorEnrollmentPhoneNumberForm, + type SmsMultiFactorEnrollmentFormProps, +} from "./forms/mfa/sms-multi-factor-enrollment-form"; +export { + useTotpMultiFactorAssertionFormAction, + useTotpMultiFactorAssertionForm, + TotpMultiFactorAssertionForm, + type UseTotpMultiFactorAssertionForm, + type TotpMultiFactorAssertionFormProps, +} from "./forms/mfa/totp-multi-factor-assertion-form"; +export { + useTotpMultiFactorSecretGenerationFormAction, + useTotpMultiFactorSecretGenerationForm, + useMultiFactorEnrollmentVerifyTotpFormAction, + useMultiFactorEnrollmentVerifyTotpForm, + MultiFactorEnrollmentVerifyTotpForm, + TotpMultiFactorEnrollmentForm, + type UseTotpMultiFactorEnrollmentForm, + type TotpMultiFactorEnrollmentFormProps, +} from "./forms/mfa/totp-multi-factor-enrollment-form"; +/** + * Screens + */ + +export { EmailLinkAuthScreen, type EmailLinkAuthScreenProps } from "./screens/email-link-auth-screen"; +export { ForgotPasswordAuthScreen, type ForgotPasswordAuthScreenProps } from "./screens/forgot-password-auth-screen"; +export { + MultiFactorAuthAssertionScreen, + type MultiFactorAuthAssertionScreenProps, +} from "./screens/multi-factor-auth-assertion-screen"; +export { + MultiFactorAuthEnrollmentScreen, + type MultiFactorAuthEnrollmentScreenProps, +} from "./screens/multi-factor-auth-enrollment-screen"; +export { OAuthScreen, type OAuthScreenProps } from "./screens/oauth-screen"; +export { PhoneAuthScreen, type PhoneAuthScreenProps } from "./screens/phone-auth-screen"; +export { SignInAuthScreen, type SignInAuthScreenProps } from "./screens/sign-in-auth-screen"; +export { SignUpAuthScreen, type SignUpAuthScreenProps } from "./screens/sign-up-auth-screen"; + +/** + * OAuth + */ + +export { AppleSignInButton, AppleLogo, type AppleSignInButtonProps } from "./oauth/apple-sign-in-button"; +export { FacebookSignInButton, FacebookLogo, type FacebookSignInButtonProps } from "./oauth/facebook-sign-in-button"; +export { GitHubSignInButton, GitHubLogo, type GitHubSignInButtonProps } from "./oauth/github-sign-in-button"; +export { GoogleSignInButton, GoogleLogo, type GoogleSignInButtonProps } from "./oauth/google-sign-in-button"; +export { + MicrosoftSignInButton, + MicrosoftLogo, + type MicrosoftSignInButtonProps, +} from "./oauth/microsoft-sign-in-button"; +export { TwitterSignInButton, TwitterLogo, type TwitterSignInButtonProps } from "./oauth/twitter-sign-in-button"; +export { OAuthButton, useSignInWithProvider, type OAuthButtonProps } from "./oauth/oauth-button"; diff --git a/packages/react/src/auth/oauth/apple-sign-in-button.test.tsx b/packages/react/src/auth/oauth/apple-sign-in-button.test.tsx new file mode 100644 index 000000000..6165c5d96 --- /dev/null +++ b/packages/react/src/auth/oauth/apple-sign-in-button.test.tsx @@ -0,0 +1,229 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { AppleLogo, AppleSignInButton } from "./apple-sign-in-button"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { OAuthProvider } from "firebase/auth"; +import { signInWithProvider } from "@invertase/firebaseui-core"; +import type { UserCredential } from "firebase/auth"; + +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + OAuthProvider: class OAuthProvider { + constructor(providerId: string) { + this.providerId = providerId; + } + providerId: string; + }, + }; +}); + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...(mod as object), + signInWithProvider: vi.fn(), + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with the correct provider", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithApple: "Sign in with Apple", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toBeDefined(); + expect(button.getAttribute("data-provider")).toBe("apple.com"); + }); + + it("renders with custom provider when provided", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithApple: "Sign in with Apple", + }, + }), + }); + + const customProvider = new OAuthProvider("custom.apple.com"); + + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toBeDefined(); + expect(button.getAttribute("data-provider")).toBe("custom.apple.com"); + }); + + it("renders with the Apple icon", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithApple: "Sign in with Apple", + }, + }), + }); + + render( + + + + ); + + const svg = document.querySelector(".fui-provider__icon"); + expect(svg).toBeDefined(); + expect(svg).toHaveClass("fui-provider__icon"); + expect(svg?.tagName.toLowerCase()).toBe("svg"); + }); + + it("renders with the correct translated text", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithApple: "Sign in with Apple", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Sign in with Apple")).toBeDefined(); + }); + + it("renders with different translated text for different locales", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithApple: "Iniciar sesión con Apple", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Iniciar sesión con Apple")).toBeDefined(); + }); + + it("renders as a button with correct classes", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithApple: "Sign in with Apple", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toHaveClass("fui-provider__button"); + expect(button.getAttribute("type")).toBe("button"); + }); + + it("calls onSignIn callback when sign-in is successful", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + const onSignIn = vi.fn(); + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithApple: "Sign in with Apple", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockCredential); + }); + }); +}); + +describe("", () => { + it("renders as an SVG element", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg).toBeDefined(); + expect(svg?.tagName.toLowerCase()).toBe("svg"); + }); + + it("has the correct CSS class", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg).toHaveClass("fui-provider__icon"); + }); + + it("forwards custom SVG props", () => { + const { container } = render(); + const svg = container.querySelector('svg[data-testid="custom-svg"]'); + + expect(svg).toBeDefined(); + expect(svg!.getAttribute("width")).toBe("32"); + expect(svg).toHaveClass("fui-provider__icon"); + expect(svg).toHaveClass("foo"); + }); +}); diff --git a/packages/react/src/auth/oauth/apple-sign-in-button.tsx b/packages/react/src/auth/oauth/apple-sign-in-button.tsx new file mode 100644 index 000000000..18c970d3b --- /dev/null +++ b/packages/react/src/auth/oauth/apple-sign-in-button.tsx @@ -0,0 +1,59 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { OAuthProvider, type UserCredential } from "firebase/auth"; +import { useUI } from "~/hooks"; +import { OAuthButton } from "./oauth-button"; +import AppleSvgLogo from "~/components/logos/apple/Logo"; +import { cn } from "~/utils/cn"; + +/** Props for the AppleSignInButton component. */ +export type AppleSignInButtonProps = { + /** Optional OAuth provider instance. Defaults to Apple provider. */ + provider?: OAuthProvider; + /** Whether to apply themed styling. */ + themed?: boolean; + /** Callback function called when sign-in is successful. */ + onSignIn?: (credential: UserCredential) => void; +}; + +/** + * A button component for signing in with Apple. + * + * @returns The Apple sign-in button component. + */ +export function AppleSignInButton({ provider, ...props }: AppleSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithApple")} + + ); +} + +/** + * The Apple logo SVG component. + * + * @returns The Apple logo component. + */ +export function AppleLogo({ className, ...props }: React.SVGProps) { + return ; +} diff --git a/packages/react/src/auth/oauth/facebook-sign-in-button.test.tsx b/packages/react/src/auth/oauth/facebook-sign-in-button.test.tsx new file mode 100644 index 000000000..595042cbb --- /dev/null +++ b/packages/react/src/auth/oauth/facebook-sign-in-button.test.tsx @@ -0,0 +1,230 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { FacebookLogo, FacebookSignInButton } from "./facebook-sign-in-button"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { signInWithProvider } from "@invertase/firebaseui-core"; +import type { UserCredential } from "firebase/auth"; + +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + FacebookAuthProvider: class FacebookAuthProvider { + constructor() { + this.providerId = "facebook.com"; + } + providerId: string; + }, + }; +}); + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...(mod as object), + signInWithProvider: vi.fn(), + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with the correct provider", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toBeDefined(); + expect(button.getAttribute("data-provider")).toBe("facebook.com"); + }); + + it("renders with custom provider when provided", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + const customProvider = new (class CustomFacebookProvider { + providerId = "custom.facebook.com"; + })() as any; + + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toBeDefined(); + expect(button.getAttribute("data-provider")).toBe("custom.facebook.com"); + }); + + it("renders with the Facebook icon", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + const svg = document.querySelector(".fui-provider__icon"); + expect(svg).toBeDefined(); + expect(svg).toHaveClass("fui-provider__icon"); + expect(svg?.tagName.toLowerCase()).toBe("svg"); + }); + + it("renders with the correct translated text", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Sign in with Facebook")).toBeDefined(); + }); + + it("renders with different translated text for different locales", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Iniciar sesión con Facebook", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Iniciar sesión con Facebook")).toBeDefined(); + }); + + it("renders as a button with correct classes", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toHaveClass("fui-provider__button"); + expect(button.getAttribute("type")).toBe("button"); + }); + + it("calls onSignIn callback when sign-in is successful", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + const onSignIn = vi.fn(); + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockCredential); + }); + }); +}); + +describe("", () => { + it("renders as an SVG element", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg).toBeDefined(); + expect(svg?.tagName.toLowerCase()).toBe("svg"); + }); + + it("has the correct CSS class", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg).toHaveClass("fui-provider__icon"); + }); + + it("forwards custom SVG props", () => { + const { container } = render(); + const svg = container.querySelector('svg[data-testid="custom-svg"]'); + + expect(svg).toBeDefined(); + expect(svg!.getAttribute("width")).toBe("32"); + expect(svg).toHaveClass("fui-provider__icon"); + expect(svg).toHaveClass("foo"); + }); +}); diff --git a/packages/react/src/auth/oauth/facebook-sign-in-button.tsx b/packages/react/src/auth/oauth/facebook-sign-in-button.tsx new file mode 100644 index 000000000..6e5d50841 --- /dev/null +++ b/packages/react/src/auth/oauth/facebook-sign-in-button.tsx @@ -0,0 +1,59 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { FacebookAuthProvider, type UserCredential } from "firebase/auth"; +import { useUI } from "~/hooks"; +import { OAuthButton } from "./oauth-button"; +import FacebookSvgLogo from "~/components/logos/facebook/Logo"; +import { cn } from "~/utils/cn"; + +/** Props for the FacebookSignInButton component. */ +export type FacebookSignInButtonProps = { + /** Optional OAuth provider instance. Defaults to Facebook provider. */ + provider?: FacebookAuthProvider; + /** Whether to apply themed styling. */ + themed?: boolean; + /** Callback function called when sign-in is successful. */ + onSignIn?: (credential: UserCredential) => void; +}; + +/** + * A button component for signing in with Facebook. + * + * @returns The Facebook sign-in button component. + */ +export function FacebookSignInButton({ provider, ...props }: FacebookSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithFacebook")} + + ); +} + +/** + * The Facebook logo SVG component. + * + * @returns The Facebook logo component. + */ +export function FacebookLogo({ className, ...props }: React.SVGProps) { + return ; +} diff --git a/packages/react/src/auth/oauth/github-sign-in-button.test.tsx b/packages/react/src/auth/oauth/github-sign-in-button.test.tsx new file mode 100644 index 000000000..7fe80b333 --- /dev/null +++ b/packages/react/src/auth/oauth/github-sign-in-button.test.tsx @@ -0,0 +1,230 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { GitHubLogo, GitHubSignInButton } from "./github-sign-in-button"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { signInWithProvider } from "@invertase/firebaseui-core"; +import type { UserCredential } from "firebase/auth"; + +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + GithubAuthProvider: class GithubAuthProvider { + constructor() { + this.providerId = "github.com"; + } + providerId: string; + }, + }; +}); + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...(mod as object), + signInWithProvider: vi.fn(), + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with the correct provider", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toBeDefined(); + expect(button.getAttribute("data-provider")).toBe("github.com"); + }); + + it("renders with custom provider when provided", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + const customProvider = new (class CustomGitHubProvider { + providerId = "custom.github.com"; + })() as any; + + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toBeDefined(); + expect(button.getAttribute("data-provider")).toBe("custom.github.com"); + }); + + it("renders with the GitHub icon", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + const svg = document.querySelector(".fui-provider__icon"); + expect(svg).toBeDefined(); + expect(svg).toHaveClass("fui-provider__icon"); + expect(svg?.tagName.toLowerCase()).toBe("svg"); + }); + + it("renders with the correct translated text", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Sign in with GitHub")).toBeDefined(); + }); + + it("renders with different translated text for different locales", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Iniciar sesión con GitHub", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Iniciar sesión con GitHub")).toBeDefined(); + }); + + it("renders as a button with correct classes", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toHaveClass("fui-provider__button"); + expect(button.getAttribute("type")).toBe("button"); + }); + + it("calls onSignIn callback when sign-in is successful", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + const onSignIn = vi.fn(); + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockCredential); + }); + }); +}); + +describe("", () => { + it("renders as an SVG element", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg).toBeDefined(); + expect(svg?.tagName.toLowerCase()).toBe("svg"); + }); + + it("has the correct CSS class", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg).toHaveClass("fui-provider__icon"); + }); + + it("forwards custom SVG props", () => { + const { container } = render(); + const svg = container.querySelector('svg[data-testid="custom-svg"]'); + + expect(svg).toBeDefined(); + expect(svg!.getAttribute("width")).toBe("32"); + expect(svg).toHaveClass("fui-provider__icon"); + expect(svg).toHaveClass("foo"); + }); +}); diff --git a/packages/react/src/auth/oauth/github-sign-in-button.tsx b/packages/react/src/auth/oauth/github-sign-in-button.tsx new file mode 100644 index 000000000..41ae37a2a --- /dev/null +++ b/packages/react/src/auth/oauth/github-sign-in-button.tsx @@ -0,0 +1,59 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { GithubAuthProvider, type UserCredential } from "firebase/auth"; +import { useUI } from "~/hooks"; +import { OAuthButton } from "./oauth-button"; +import GitHubSvgLogo from "~/components/logos/github/Logo"; +import { cn } from "~/utils/cn"; + +/** Props for the GitHubSignInButton component. */ +export type GitHubSignInButtonProps = { + /** Optional OAuth provider instance. Defaults to GitHub provider. */ + provider?: GithubAuthProvider; + /** Whether to apply themed styling. */ + themed?: boolean; + /** Callback function called when sign-in is successful. */ + onSignIn?: (credential: UserCredential) => void; +}; + +/** + * A button component for signing in with GitHub. + * + * @returns The GitHub sign-in button component. + */ +export function GitHubSignInButton({ provider, ...props }: GitHubSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithGitHub")} + + ); +} + +/** + * The GitHub logo SVG component. + * + * @returns The GitHub logo component. + */ +export function GitHubLogo({ className, ...props }: React.SVGProps) { + return ; +} diff --git a/packages/react/src/auth/oauth/google-sign-in-button.test.tsx b/packages/react/src/auth/oauth/google-sign-in-button.test.tsx new file mode 100644 index 000000000..c9d1f18a5 --- /dev/null +++ b/packages/react/src/auth/oauth/google-sign-in-button.test.tsx @@ -0,0 +1,237 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { GoogleLogo, GoogleSignInButton } from "./google-sign-in-button"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { signInWithProvider } from "@invertase/firebaseui-core"; +import type { UserCredential } from "firebase/auth"; + +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + GoogleAuthProvider: class GoogleAuthProvider { + constructor() { + this.providerId = "google.com"; + } + providerId: string; + }, + }; +}); + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...(mod as object), + signInWithProvider: vi.fn(), + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with the correct provider", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toBeDefined(); + expect(button.getAttribute("data-provider")).toBe("google.com"); + }); + + it("renders with custom provider when provided", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + const customProvider = new (class CustomGoogleProvider { + providerId = "custom.google.com"; + })() as any; + + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toBeDefined(); + expect(button.getAttribute("data-provider")).toBe("custom.google.com"); + }); + + it("renders with the Google icon", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + const svg = document.querySelector(".fui-provider__icon"); + expect(svg).toBeDefined(); + expect(svg).toHaveClass("fui-provider__icon"); + expect(svg?.tagName.toLowerCase()).toBe("svg"); + }); + + it("renders with the correct translated text", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Sign in with Google")).toBeDefined(); + }); + + it("renders with different translated text for different locales", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Iniciar sesión con Google", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Iniciar sesión con Google")).toBeDefined(); + }); + + it("renders as a button with correct classes", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toHaveClass("fui-provider__button"); + expect(button.getAttribute("type")).toBe("button"); + }); + + it("calls onSignIn callback when sign-in is successful", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + const onSignIn = vi.fn(); + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockCredential); + }); + }); +}); + +describe("", () => { + it("renders as an SVG element", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg).toBeDefined(); + expect(svg?.tagName.toLowerCase()).toBe("svg"); + }); + + it("has the correct CSS class", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg).toHaveClass("fui-provider__icon"); + }); + + it("has the correct viewBox attribute", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg?.getAttribute("viewBox")).toBe("0 0 48 48"); + }); + + it("forwards custom SVG props", () => { + const { container } = render(); + const svg = container.querySelector('svg[data-testid="custom-svg"]'); + + expect(svg).toBeDefined(); + expect(svg!.getAttribute("width")).toBe("32"); + expect(svg).toHaveClass("fui-provider__icon"); + expect(svg).toHaveClass("foo"); + }); +}); diff --git a/packages/react/src/auth/oauth/google-sign-in-button.tsx b/packages/react/src/auth/oauth/google-sign-in-button.tsx new file mode 100644 index 000000000..37eaa2c51 --- /dev/null +++ b/packages/react/src/auth/oauth/google-sign-in-button.tsx @@ -0,0 +1,59 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { GoogleAuthProvider, type UserCredential } from "firebase/auth"; +import { useUI } from "~/hooks"; +import { OAuthButton } from "./oauth-button"; +import GoogleSvgLogo from "~/components/logos/google/Logo"; +import { cn } from "~/utils/cn"; + +/** Props for the GoogleSignInButton component. */ +export type GoogleSignInButtonProps = { + /** Optional OAuth provider instance. Defaults to Google provider. */ + provider?: GoogleAuthProvider; + /** Whether to apply themed styling. Can be true, false, or "neutral". */ + themed?: boolean | "neutral"; + /** Callback function called when sign-in is successful. */ + onSignIn?: (credential: UserCredential) => void; +}; + +/** + * A button component for signing in with Google. + * + * @returns The Google sign-in button component. + */ +export function GoogleSignInButton({ provider, ...props }: GoogleSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithGoogle")} + + ); +} + +/** + * The Google logo SVG component. + * + * @returns The Google logo component. + */ +export function GoogleLogo({ className, ...props }: React.SVGProps) { + return ; +} diff --git a/packages/react/src/auth/oauth/microsoft-sign-in-button.test.tsx b/packages/react/src/auth/oauth/microsoft-sign-in-button.test.tsx new file mode 100644 index 000000000..3ed992392 --- /dev/null +++ b/packages/react/src/auth/oauth/microsoft-sign-in-button.test.tsx @@ -0,0 +1,229 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { MicrosoftLogo, MicrosoftSignInButton } from "./microsoft-sign-in-button"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { OAuthProvider } from "firebase/auth"; +import { signInWithProvider } from "@invertase/firebaseui-core"; +import type { UserCredential } from "firebase/auth"; + +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + OAuthProvider: class OAuthProvider { + constructor(providerId: string) { + this.providerId = providerId; + } + providerId: string; + }, + }; +}); + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...(mod as object), + signInWithProvider: vi.fn(), + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with the correct provider", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toBeDefined(); + expect(button.getAttribute("data-provider")).toBe("microsoft.com"); + }); + + it("renders with custom provider when provided", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + const customProvider = new OAuthProvider("custom.microsoft.com"); + + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toBeDefined(); + expect(button.getAttribute("data-provider")).toBe("custom.microsoft.com"); + }); + + it("renders with the Microsoft icon", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + const svg = document.querySelector(".fui-provider__icon"); + expect(svg).toBeDefined(); + expect(svg).toHaveClass("fui-provider__icon"); + expect(svg?.tagName.toLowerCase()).toBe("svg"); + }); + + it("renders with the correct translated text", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Sign in with Microsoft")).toBeDefined(); + }); + + it("renders with different translated text for different locales", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Iniciar sesión con Microsoft", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Iniciar sesión con Microsoft")).toBeDefined(); + }); + + it("renders as a button with correct classes", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toHaveClass("fui-provider__button"); + expect(button.getAttribute("type")).toBe("button"); + }); + + it("calls onSignIn callback when sign-in is successful", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + const onSignIn = vi.fn(); + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockCredential); + }); + }); +}); + +describe("", () => { + it("renders as an SVG element", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg).toBeDefined(); + expect(svg?.tagName.toLowerCase()).toBe("svg"); + }); + + it("has the correct CSS class", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg).toHaveClass("fui-provider__icon"); + }); + + it("forwards custom SVG props", () => { + const { container } = render(); + const svg = container.querySelector('svg[data-testid="custom-svg"]'); + + expect(svg).toBeDefined(); + expect(svg!.getAttribute("width")).toBe("32"); + expect(svg).toHaveClass("fui-provider__icon"); + expect(svg).toHaveClass("foo"); + }); +}); diff --git a/packages/react/src/auth/oauth/microsoft-sign-in-button.tsx b/packages/react/src/auth/oauth/microsoft-sign-in-button.tsx new file mode 100644 index 000000000..7342a6843 --- /dev/null +++ b/packages/react/src/auth/oauth/microsoft-sign-in-button.tsx @@ -0,0 +1,59 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { OAuthProvider, type UserCredential } from "firebase/auth"; +import { useUI } from "~/hooks"; +import { OAuthButton } from "./oauth-button"; +import MicrosoftSvgLogo from "~/components/logos/microsoft/Logo"; +import { cn } from "~/utils/cn"; + +/** Props for the MicrosoftSignInButton component. */ +export type MicrosoftSignInButtonProps = { + /** Optional OAuth provider instance. Defaults to Microsoft provider. */ + provider?: OAuthProvider; + /** Whether to apply themed styling. */ + themed?: boolean; + /** Callback function called when sign-in is successful. */ + onSignIn?: (credential: UserCredential) => void; +}; + +/** + * A button component for signing in with Microsoft. + * + * @returns The Microsoft sign-in button component. + */ +export function MicrosoftSignInButton({ provider, ...props }: MicrosoftSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithMicrosoft")} + + ); +} + +/** + * The Microsoft logo SVG component. + * + * @returns The Microsoft logo component. + */ +export function MicrosoftLogo({ className, ...props }: React.SVGProps) { + return ; +} diff --git a/packages/react/src/auth/oauth/oauth-button.test.tsx b/packages/react/src/auth/oauth/oauth-button.test.tsx new file mode 100644 index 000000000..209c85ae4 --- /dev/null +++ b/packages/react/src/auth/oauth/oauth-button.test.tsx @@ -0,0 +1,476 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, fireEvent, cleanup, renderHook, act, waitFor } from "@testing-library/react"; +import { OAuthButton, useSignInWithProvider } from "./oauth-button"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { enUs, registerLocale } from "@invertase/firebaseui-translations"; +import type { AuthProvider, UserCredential } from "firebase/auth"; +import { ComponentProps } from "react"; + +import { signInWithProvider } from "@invertase/firebaseui-core"; +import { FirebaseError } from "firebase/app"; + +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + getRedirectResult: vi.fn().mockResolvedValue(null), + }; +}); + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...(mod as object), + signInWithProvider: vi.fn(), + }; +}); + +vi.mock("~/components/button", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + Button: (props: ComponentProps<"button">) => , + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + const mockGoogleProvider = { providerId: "google.com" } as AuthProvider; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders a button with the provided children", () => { + const ui = createMockUI(); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + expect(button).toBeDefined(); + expect(button.textContent).toBe("Sign in with Google"); + }); + + it("applies correct CSS classes", () => { + const ui = createMockUI(); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + expect(button).toHaveClass("fui-provider__button"); + expect(button.getAttribute("type")).toBe("button"); + }); + + it("is disabled when UI state is not idle", () => { + const ui = createMockUI(); + ui.setKey("state", "pending"); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + expect(button).toHaveAttribute("disabled"); + }); + + it("is enabled when UI state is idle", () => { + const ui = createMockUI(); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + expect(button).not.toHaveAttribute("disabled"); + }); + + it("calls signInWithProvider when clicked", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + + const ui = createMockUI(); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + fireEvent.click(button); + + expect(mockSignInWithProvider).toHaveBeenCalledTimes(1); + expect(mockSignInWithProvider).toHaveBeenCalledWith(expect.anything(), mockGoogleProvider); + }); + + it("calls onSignIn callback when sign-in is successful", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + const onSignIn = vi.fn(); + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const ui = createMockUI(); + + render( + + + Sign in with Google + + + ); + + const button = screen.getByTestId("oauth-button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockCredential); + }); + }); + + it("does not call onSignIn callback when sign-in fails", async () => { + const { FirebaseUIError } = await import("@invertase/firebaseui-core"); + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const onSignIn = vi.fn(); + const ui = createMockUI(); + const mockError = new FirebaseUIError( + ui.get(), + new FirebaseError("auth/user-not-found", "No account found with this email address") + ); + mockSignInWithProvider.mockRejectedValue(mockError); + + render( + + + Sign in with Google + + + ); + + const button = screen.getByTestId("oauth-button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).not.toHaveBeenCalled(); + }); + }); + + it("displays FirebaseUIError message when FirebaseUIError occurs", async () => { + const { FirebaseUIError } = await import("@invertase/firebaseui-core"); + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const ui = createMockUI(); + const mockError = new FirebaseUIError( + ui.get(), + new FirebaseError("auth/user-not-found", "No account found with this email address") + ); + mockSignInWithProvider.mockRejectedValue(mockError); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + fireEvent.click(button); + + await waitFor(() => { + const errorMessage = screen.getByText("No account found with this email address"); + expect(errorMessage).toBeDefined(); + expect(errorMessage.className).toContain("fui-error"); + }); + }); + + it("displays unknown error message when non-Firebase error occurs", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const regularError = new Error("Regular error"); + mockSignInWithProvider.mockRejectedValue(regularError); + + // Mock console.error to prevent test output noise + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const ui = createMockUI({ + locale: registerLocale("test", { + errors: { + unknownError: "unknownError", + }, + }), + }); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + fireEvent.click(button); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith(regularError); + + const errorMessage = screen.getByText("unknownError"); + expect(errorMessage).toBeDefined(); + expect(errorMessage.className).toContain("fui-error"); + }); + + // Restore console.error + consoleErrorSpy.mockRestore(); + }); + + it("clears error when button is clicked again", async () => { + const { FirebaseUIError } = await import("@invertase/firebaseui-core"); + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const ui = createMockUI(); + + // First call fails, second call succeeds + mockSignInWithProvider + .mockRejectedValueOnce(new FirebaseUIError(ui.get(), new FirebaseError("auth/wrong-password", "..."))) + .mockResolvedValueOnce({} as UserCredential); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + + // First click - should show error + fireEvent.click(button); + const expectedError = enUs.translations.errors!.wrongPassword!; + + await waitFor(() => { + // The error message will be the translated message for auth/wrong-password + const errorMessage = screen.getByText(expectedError); + expect(errorMessage).toBeDefined(); + }); + + // Second click - should clear error + fireEvent.click(button); + await waitFor(() => { + expect(screen.queryByText(expectedError)).toBeNull(); + }); + }); +}); + +describe("useSignInWithProvider", () => { + const mockGoogleProvider = { providerId: "google.com" } as AuthProvider; + const mockFacebookProvider = { providerId: "facebook.com" } as AuthProvider; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns error and callback", () => { + const ui = createMockUI(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useSignInWithProvider(mockGoogleProvider), { wrapper }); + + expect(result.current.error).toBeNull(); + expect(typeof result.current.callback).toBe("function"); + }); + + it("calls signInWithProvider when callback is executed", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const ui = createMockUI(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useSignInWithProvider(mockGoogleProvider), { wrapper }); + + await act(async () => { + await result.current.callback(); + }); + + expect(mockSignInWithProvider).toHaveBeenCalledTimes(1); + expect(mockSignInWithProvider).toHaveBeenCalledWith(ui.get(), mockGoogleProvider); + }); + + it("calls onSignIn callback when sign-in is successful", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + const onSignIn = vi.fn(); + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const ui = createMockUI(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useSignInWithProvider(mockGoogleProvider, onSignIn), { wrapper }); + + await act(async () => { + await result.current.callback(); + }); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockCredential); + }); + + it("does not call onSignIn callback when sign-in fails", async () => { + const { FirebaseUIError } = await import("@invertase/firebaseui-core"); + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const onSignIn = vi.fn(); + const ui = createMockUI(); + const mockError = new FirebaseUIError( + ui.get(), + new FirebaseError("auth/user-not-found", "No account found with this email address") + ); + mockSignInWithProvider.mockRejectedValue(mockError); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useSignInWithProvider(mockGoogleProvider, onSignIn), { wrapper }); + + await act(async () => { + await result.current.callback(); + }); + + expect(onSignIn).not.toHaveBeenCalled(); + }); + + it("sets error state when FirebaseUIError occurs", async () => { + const { FirebaseUIError } = await import("@invertase/firebaseui-core"); + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const ui = createMockUI(); + const mockError = new FirebaseUIError( + ui.get(), + new FirebaseError("auth/user-not-found", "No account found with this email address") + ); + mockSignInWithProvider.mockRejectedValue(mockError); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useSignInWithProvider(mockGoogleProvider), { wrapper }); + + await act(async () => { + await result.current.callback(); + }); + + expect(result.current.error).toBe("No account found with this email address"); + }); + + it("sets unknown error message when non-Firebase error occurs", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const regularError = new Error("Regular error"); + mockSignInWithProvider.mockRejectedValue(regularError); + + // Mock console.error to prevent test output noise + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const ui = createMockUI({ + locale: registerLocale("test", { + errors: { + unknownError: "unknownError", + }, + }), + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useSignInWithProvider(mockGoogleProvider), { wrapper }); + + await act(async () => { + await result.current.callback(); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith(regularError); + expect(result.current.error).toBe("unknownError"); + + // Restore console.error + consoleErrorSpy.mockRestore(); + }); + + it("clears error when callback is called again", async () => { + const { FirebaseUIError } = await import("@invertase/firebaseui-core"); + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const ui = createMockUI(); + + // First call fails, second call succeeds + mockSignInWithProvider + .mockRejectedValueOnce( + new FirebaseUIError(ui.get(), new FirebaseError("auth/wrong-password", "Incorrect password")) + ) + .mockResolvedValueOnce({} as UserCredential); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useSignInWithProvider(mockGoogleProvider), { wrapper }); + + // First call - should set error + await act(async () => { + await result.current.callback(); + }); + + expect(result.current.error).toBe("Incorrect password"); + + // Second call - should clear error + await act(async () => { + await result.current.callback(); + }); + + expect(result.current.error).toBeNull(); + }); + + it("maintains stable callback reference when provider changes", () => { + const ui = createMockUI(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result, rerender } = renderHook(({ provider }) => useSignInWithProvider(provider), { + wrapper, + initialProps: { provider: mockGoogleProvider }, + }); + + const firstCallback = result.current.callback; + + // Change provider + rerender({ provider: mockFacebookProvider }); + + // Callback should be different due to dependency change + expect(result.current.callback).not.toBe(firstCallback); + }); +}); diff --git a/packages/react/src/auth/oauth/oauth-button.tsx b/packages/react/src/auth/oauth/oauth-button.tsx new file mode 100644 index 000000000..83427e35d --- /dev/null +++ b/packages/react/src/auth/oauth/oauth-button.tsx @@ -0,0 +1,91 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { FirebaseUIError, getTranslation, signInWithProvider } from "@invertase/firebaseui-core"; +import type { AuthProvider, UserCredential } from "firebase/auth"; +import type { PropsWithChildren } from "react"; +import { useCallback, useState } from "react"; +import { Button } from "~/components/button"; +import { useUI } from "~/hooks"; + +/** Props for the OAuthButton component. */ +export type OAuthButtonProps = PropsWithChildren<{ + /** The authentication provider to sign in with. */ + provider: AuthProvider; + /** Whether to use themed styling for the button. */ + themed?: boolean | string; + /** Callback function called when sign-in is successful. */ + onSignIn?: (credential: UserCredential) => void; +}>; + +/** + * Hook for signing in with an OAuth provider. + * + * @param provider - The authentication provider to sign in with. + * @param onSignIn - Optional callback function called when sign-in is successful. + * @returns An object containing the error state and a callback function to trigger sign-in. + */ +export function useSignInWithProvider(provider: AuthProvider, onSignIn?: (credential: UserCredential) => void) { + const ui = useUI(); + const [error, setError] = useState(null); + + const callback = useCallback(async () => { + setError(null); + try { + const credential = await signInWithProvider(ui, provider); + onSignIn?.(credential); + } catch (error) { + if (error instanceof FirebaseUIError) { + setError(error.message); + return; + } + console.error(error); + setError(getTranslation(ui, "errors", "unknownError")); + } + }, [ui, provider, setError, onSignIn]); + + return { error, callback }; +} + +/** + * A button component for signing in with an OAuth provider. + * + * @returns The OAuth button component. + */ +export function OAuthButton({ provider, children, themed, onSignIn }: OAuthButtonProps) { + const ui = useUI(); + + const { error, callback } = useSignInWithProvider(provider, onSignIn); + + return ( +
+ + {error &&
{error}
} +
+ ); +} diff --git a/packages/react/src/auth/oauth/twitter-sign-in-button.test.tsx b/packages/react/src/auth/oauth/twitter-sign-in-button.test.tsx new file mode 100644 index 000000000..3d940db6b --- /dev/null +++ b/packages/react/src/auth/oauth/twitter-sign-in-button.test.tsx @@ -0,0 +1,230 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { TwitterLogo, TwitterSignInButton } from "./twitter-sign-in-button"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { signInWithProvider } from "@invertase/firebaseui-core"; +import type { UserCredential } from "firebase/auth"; + +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + TwitterAuthProvider: class TwitterAuthProvider { + constructor() { + this.providerId = "twitter.com"; + } + providerId: string; + }, + }; +}); + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...(mod as object), + signInWithProvider: vi.fn(), + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with the correct provider", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toBeDefined(); + expect(button.getAttribute("data-provider")).toBe("twitter.com"); + }); + + it("renders with custom provider when provided", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + const customProvider = new (class CustomTwitterProvider { + providerId = "custom.twitter.com"; + })() as any; + + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toBeDefined(); + expect(button.getAttribute("data-provider")).toBe("custom.twitter.com"); + }); + + it("renders with the Twitter icon", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + const svg = document.querySelector(".fui-provider__icon"); + expect(svg).toBeDefined(); + expect(svg).toHaveClass("fui-provider__icon"); + expect(svg?.tagName.toLowerCase()).toBe("svg"); + }); + + it("renders with the correct translated text", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Sign in with Twitter")).toBeDefined(); + }); + + it("renders with different translated text for different locales", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Iniciar sesión con Twitter", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Iniciar sesión con Twitter")).toBeDefined(); + }); + + it("renders as a button with correct classes", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toHaveClass("fui-provider__button"); + expect(button.getAttribute("type")).toBe("button"); + }); + + it("calls onSignIn callback when sign-in is successful", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + const onSignIn = vi.fn(); + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockCredential); + }); + }); +}); + +describe("", () => { + it("renders as an SVG element", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg).toBeDefined(); + expect(svg?.tagName.toLowerCase()).toBe("svg"); + }); + + it("has the correct CSS class", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg).toHaveClass("fui-provider__icon"); + }); + + it("forwards custom SVG props", () => { + const { container } = render(); + const svg = container.querySelector('svg[data-testid="custom-svg"]'); + + expect(svg).toBeDefined(); + expect(svg!.getAttribute("width")).toBe("32"); + expect(svg).toHaveClass("fui-provider__icon"); + expect(svg).toHaveClass("foo"); + }); +}); diff --git a/packages/react/src/auth/oauth/twitter-sign-in-button.tsx b/packages/react/src/auth/oauth/twitter-sign-in-button.tsx new file mode 100644 index 000000000..7f14db802 --- /dev/null +++ b/packages/react/src/auth/oauth/twitter-sign-in-button.tsx @@ -0,0 +1,59 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { TwitterAuthProvider, type UserCredential } from "firebase/auth"; +import { useUI } from "~/hooks"; +import { OAuthButton } from "./oauth-button"; +import TwitterSvgLogo from "~/components/logos/twitter/Logo"; +import { cn } from "~/utils/cn"; + +/** Props for the TwitterSignInButton component. */ +export type TwitterSignInButtonProps = { + /** Optional OAuth provider instance. Defaults to Twitter provider. */ + provider?: TwitterAuthProvider; + /** Whether to apply themed styling. */ + themed?: boolean; + /** Callback function called when sign-in is successful. */ + onSignIn?: (credential: UserCredential) => void; +}; + +/** + * A button component for signing in with Twitter. + * + * @returns The Twitter sign-in button component. + */ +export function TwitterSignInButton({ provider, ...props }: TwitterSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithTwitter")} + + ); +} + +/** + * The Twitter logo SVG component. + * + * @returns The Twitter logo component. + */ +export function TwitterLogo({ className, ...props }: React.SVGProps) { + return ; +} 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 new file mode 100644 index 000000000..52734863d --- /dev/null +++ b/packages/react/src/auth/screens/email-link-auth-screen.test.tsx @@ -0,0 +1,307 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +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, User } from "firebase/auth"; + +vi.mock("~/auth/forms/email-link-auth-form", () => ({ + EmailLinkAuthForm: () =>
Email Link Form
, +})); + +vi.mock("~/components/divider", () => ({ + Divider: () =>
Divider
, +})); + +vi.mock("~/components/redirect-error", () => ({ + RedirectError: () =>
Redirect Error
, +})); + +vi.mock("~/auth/forms/multi-factor-auth-assertion-form", () => ({ + MultiFactorAuthAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+
MFA Assertion Form
+ +
+ ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders with correct title and subtitle", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "signIn", + }, + prompts: { + signInToAccount: "signInToAccount", + }, + }), + }); + + render( + + + + ); + + const title = screen.getByText("signIn"); + expect(title).toBeInTheDocument(); + expect(title).toHaveClass("fui-card__title"); + + const subtitle = screen.getByText("signInToAccount"); + expect(subtitle).toBeInTheDocument(); + expect(subtitle).toHaveClass("fui-card__subtitle"); + }); + + it("renders the component", () => { + const ui = createMockUI(); + + render( + + + + ); + + // Mocked so only has as test id + expect(screen.getByTestId("email-link-auth-form")).toBeInTheDocument(); + }); + + it("renders the a divider with children when present", () => { + const ui = createMockUI(); + + render( + + +
Test Child
+
+
+ ); + + expect(screen.getByTestId("divider")).toBeInTheDocument(); + expect(screen.getByTestId("test-child")).toBeInTheDocument(); + }); + + it("renders RedirectError component in children section", () => { + const ui = createMockUI(); + + render( + + +
Test Child
+
+
+ ); + + expect(screen.getByTestId("redirect-error")).toBeInTheDocument(); + expect(screen.getByTestId("test-child")).toBeInTheDocument(); + }); + + it("does not render RedirectError when no children are provided", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.queryByTestId("redirect-error")).toBeNull(); + }); + + it("renders MFA assertion form when MFA resolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByTestId("mfa-assertion-form")).toBeDefined(); + }); + + it("renders RedirectError component in children section when no MFA resolver", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
Test Child
+
+
+ ); + + expect(screen.getByTestId("redirect-error")).toBeDefined(); + expect(screen.getByTestId("test-child")).toBeDefined(); + }); + + it("does not render RedirectError when MFA resolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + +
Test Child
+
+
+ ); + + expect(screen.queryByTestId("redirect-error")).toBeNull(); + expect(screen.getByTestId("mfa-assertion-form")).toBeDefined(); + }); + + it("calls onSignIn with credential when MFA flow succeeds", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + 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(); + + render( + + + + ); + + 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(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 new file mode 100644 index 000000000..268a79988 --- /dev/null +++ b/packages/react/src/auth/screens/email-link-auth-screen.tsx @@ -0,0 +1,77 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { PropsWithChildren } from "react"; +import type { User } from "firebase/auth"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { Divider } from "~/components/divider"; +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"; + +/** Props for the EmailLinkAuthScreen component. */ +export type EmailLinkAuthScreenProps = PropsWithChildren< + Pick & { + /** Callback function called when sign-in is successful. */ + onSignIn?: (user: User) => void; + } +>; + +/** + * A screen component for email link authentication. + * + * Displays a card with the email link auth form and handles multi-factor authentication if required. + * + * @returns The email link auth screen component. + */ +export function EmailLinkAuthScreen({ children, onEmailSent, onSignIn }: EmailLinkAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signIn"); + const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + const mfaResolver = ui.multiFactorResolver; + + useOnUserAuthenticated(onSignIn); + + if (mfaResolver) { + return ; + } + + return ( +
+ + + {titleText} + {subtitleText} + + + + {children ? ( + <> + {getTranslation(ui, "messages", "dividerOr")} +
+ {children} + +
+ + ) : null} +
+
+
+ ); +} diff --git a/packages/react/src/auth/screens/forgot-password-auth-screen.test.tsx b/packages/react/src/auth/screens/forgot-password-auth-screen.test.tsx new file mode 100644 index 000000000..8e126eb9d --- /dev/null +++ b/packages/react/src/auth/screens/forgot-password-auth-screen.test.tsx @@ -0,0 +1,97 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { ForgotPasswordAuthScreen } from "~/auth/screens/forgot-password-auth-screen"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; + +vi.mock("~/auth/forms/forgot-password-auth-form", () => ({ + ForgotPasswordAuthForm: ({ onBackToSignInClick }: { onBackToSignInClick?: () => void }) => ( +
+ +
+ ), +})); + +describe("", () => { + afterEach(() => { + cleanup(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders with correct title and subtitle", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + resetPassword: "resetPassword", + }, + prompts: { + enterEmailToReset: "enterEmailToReset", + }, + }), + }); + + render( + + + + ); + + const title = screen.getByText("resetPassword"); + expect(title).toBeDefined(); + expect(title.className).toContain("fui-card__title"); + + const subtitle = screen.getByText("enterEmailToReset"); + expect(subtitle).toBeDefined(); + expect(subtitle.className).toContain("fui-card__subtitle"); + }); + + it("renders the component", () => { + const ui = createMockUI(); + + render( + + + + ); + + // Mocked so only has as test id + expect(screen.getByTestId("forgot-password-auth-form")).toBeDefined(); + }); + + it("passes onBackToSignInClick to ForgotPasswordAuthForm", () => { + const mockOnBackToSignInClick = vi.fn(); + const ui = createMockUI(); + + render( + + + + ); + + // Click the back button in the mocked form + fireEvent.click(screen.getByTestId("back-button")); + + // Verify the callback was called + expect(mockOnBackToSignInClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/firebaseui-react/src/auth/screens/password-reset-screen.tsx b/packages/react/src/auth/screens/forgot-password-auth-screen.tsx similarity index 59% rename from packages/firebaseui-react/src/auth/screens/password-reset-screen.tsx rename to packages/react/src/auth/screens/forgot-password-auth-screen.tsx index 0226ce09d..944fbea12 100644 --- a/packages/firebaseui-react/src/auth/screens/password-reset-screen.tsx +++ b/packages/react/src/auth/screens/forgot-password-auth-screen.tsx @@ -14,23 +14,22 @@ * limitations under the License. */ -import { getTranslation } from "@firebase-ui/core"; +import { getTranslation } from "@invertase/firebaseui-core"; import { useUI } from "~/hooks"; -import { - Card, - CardHeader, - CardSubtitle, - CardTitle, -} from "../../components/card"; -import { ForgotPasswordForm } from "../forms/forgot-password-form"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; +import { ForgotPasswordAuthForm, type ForgotPasswordAuthFormProps } from "../forms/forgot-password-auth-form"; -export type PasswordResetScreenProps = { - onBackToSignInClick?: () => void; -}; +/** Props for the ForgotPasswordAuthScreen component. */ +export type ForgotPasswordAuthScreenProps = ForgotPasswordAuthFormProps; -export function PasswordResetScreen({ - onBackToSignInClick, -}: PasswordResetScreenProps) { +/** + * A screen component for requesting a password reset. + * + * Displays a card with the forgot password form. + * + * @returns The forgot password screen component. + */ +export function ForgotPasswordAuthScreen(props: ForgotPasswordAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "resetPassword"); @@ -43,7 +42,9 @@ export function PasswordResetScreen({ {titleText} {subtitleText} - + + +
); diff --git a/packages/react/src/auth/screens/multi-factor-auth-assertion-screen.test.tsx b/packages/react/src/auth/screens/multi-factor-auth-assertion-screen.test.tsx new file mode 100644 index 000000000..8a0b194e3 --- /dev/null +++ b/packages/react/src/auth/screens/multi-factor-auth-assertion-screen.test.tsx @@ -0,0 +1,165 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { registerLocale } from "@invertase/firebaseui-translations"; +import { cleanup, render, screen } from "@testing-library/react"; +import { type UserCredential } from "firebase/auth"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MultiFactorAuthAssertionScreen } from "~/auth/screens/multi-factor-auth-assertion-screen"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; + +vi.mock("~/auth/forms/multi-factor-auth-assertion-form", () => ({ + MultiFactorAuthAssertionForm: ({ onSuccess }: { onSuccess?: (credential: UserCredential) => void }) => ( +
+
+ {onSuccess ?
onSuccess
: null} +
+
+ ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders with correct title and subtitle", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + multiFactorAssertion: "multiFactorAssertion", + }, + prompts: { + mfaAssertionPrompt: "mfaAssertionPrompt", + }, + }), + }); + + render( + + + + ); + + const title = screen.getByText("multiFactorAssertion"); + expect(title).toBeInTheDocument(); + expect(title).toHaveClass("fui-card__title"); + + const subtitle = screen.getByText("mfaAssertionPrompt"); + expect(subtitle).toBeInTheDocument(); + expect(subtitle).toHaveClass("fui-card__subtitle"); + }); + + it("renders the component", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("multi-factor-auth-assertion-form")).toBeInTheDocument(); + }); + + it("passes onSuccess prop to MultiFactorAuthAssertionForm", () => { + const mockOnSuccess = vi.fn(); + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("on-success-prop")).toBeInTheDocument(); + }); + + it("renders with default props when no props are provided", () => { + const ui = createMockUI(); + + render( + + + + ); + + // Should render the form without onSuccess prop + expect(screen.queryByTestId("on-success-prop")).not.toBeInTheDocument(); + }); + + it("renders with correct screen structure", () => { + const ui = createMockUI(); + + render( + + + + ); + + const screenContainer = screen.getByTestId("multi-factor-auth-assertion-form").closest(".fui-screen"); + expect(screenContainer).toBeInTheDocument(); + expect(screenContainer).toHaveClass("fui-screen"); + + const card = screenContainer?.querySelector(".fui-card"); + expect(card).toBeInTheDocument(); + + const cardHeader = screenContainer?.querySelector(".fui-card__header"); + expect(cardHeader).toBeInTheDocument(); + + const cardContent = screenContainer?.querySelector(".fui-card__content"); + expect(cardContent).toBeInTheDocument(); + }); + + it("uses correct translation keys", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + multiFactorAssertion: "Multi-factor Authentication", + }, + prompts: { + mfaAssertionPrompt: "Please complete the multi-factor authentication process", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Multi-factor Authentication")).toBeInTheDocument(); + expect(screen.getByText("Please complete the multi-factor authentication process")).toBeInTheDocument(); + }); + + it("passes through all props correctly", () => { + const mockOnSuccess = vi.fn(); + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("on-success-prop")).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/auth/screens/multi-factor-auth-assertion-screen.tsx b/packages/react/src/auth/screens/multi-factor-auth-assertion-screen.tsx new file mode 100644 index 000000000..79c5c3e69 --- /dev/null +++ b/packages/react/src/auth/screens/multi-factor-auth-assertion-screen.tsx @@ -0,0 +1,54 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getTranslation } from "@invertase/firebaseui-core"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card"; +import { useUI } from "~/hooks"; +import { + MultiFactorAuthAssertionForm, + type MultiFactorAuthAssertionFormProps, +} from "../forms/multi-factor-auth-assertion-form"; + +/** Props for the MultiFactorAuthAssertionScreen component. */ +export type MultiFactorAuthAssertionScreenProps = MultiFactorAuthAssertionFormProps; + +/** + * A screen component for multi-factor authentication assertion. + * + * Displays a card with the multi-factor assertion form. + * + * @returns The multi-factor auth assertion screen component. + */ +export function MultiFactorAuthAssertionScreen(props: MultiFactorAuthAssertionScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "multiFactorAssertion"); + const subtitleText = getTranslation(ui, "prompts", "mfaAssertionPrompt"); + + return ( +
+ + + {titleText} + {subtitleText} + + + + + +
+ ); +} diff --git a/packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.test.tsx b/packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.test.tsx new file mode 100644 index 000000000..702b21bc0 --- /dev/null +++ b/packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.test.tsx @@ -0,0 +1,199 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { MultiFactorAuthEnrollmentScreen } from "~/auth/screens/multi-factor-auth-enrollment-screen"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { FactorId } from "firebase/auth"; + +vi.mock("~/auth/forms/multi-factor-auth-enrollment-form", () => ({ + MultiFactorAuthEnrollmentForm: ({ onEnrollment, hints }: { onEnrollment?: () => void; hints?: string[] }) => ( +
+
+ {onEnrollment ?
onEnrollment
: null} + {hints ?
{hints.join(",")}
: null} +
+
+ ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders with correct title and subtitle", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + multiFactorEnrollment: "multiFactorEnrollment", + }, + prompts: { + mfaEnrollmentPrompt: "mfaEnrollmentPrompt", + }, + }), + }); + + render( + + + + ); + + const title = screen.getByText("multiFactorEnrollment"); + expect(title).toBeInTheDocument(); + expect(title).toHaveClass("fui-card__title"); + + const subtitle = screen.getByText("mfaEnrollmentPrompt"); + expect(subtitle).toBeInTheDocument(); + expect(subtitle).toHaveClass("fui-card__subtitle"); + }); + + it("renders the component", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("multi-factor-auth-enrollment-form")).toBeInTheDocument(); + }); + + it("passes onEnrollment prop to MultiFactorAuthEnrollmentForm", () => { + const mockOnEnrollment = vi.fn(); + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("on-enrollment-prop")).toBeInTheDocument(); + }); + + it("passes hints prop to MultiFactorAuthEnrollmentForm", () => { + const mockHints = [FactorId.TOTP, FactorId.PHONE]; + const ui = createMockUI(); + + render( + + + + ); + + const hintsElement = screen.getByTestId("hints-prop"); + expect(hintsElement).toBeInTheDocument(); + expect(hintsElement.textContent).toBe("totp,phone"); + }); + + it("renders with default props when no props are provided", () => { + const ui = createMockUI(); + + render( + + + + ); + + // Should render the form without onEnrollment prop + expect(screen.queryByTestId("on-enrollment-prop")).not.toBeInTheDocument(); + expect(screen.queryByTestId("hints-prop")).not.toBeInTheDocument(); + }); + + it("renders with correct screen structure", () => { + const ui = createMockUI(); + + render( + + + + ); + + const screenContainer = screen.getByTestId("multi-factor-auth-enrollment-form").closest(".fui-screen"); + expect(screenContainer).toBeInTheDocument(); + expect(screenContainer).toHaveClass("fui-screen"); + + const card = screenContainer?.querySelector(".fui-card"); + expect(card).toBeInTheDocument(); + + const cardHeader = screenContainer?.querySelector(".fui-card__header"); + expect(cardHeader).toBeInTheDocument(); + + const cardContent = screenContainer?.querySelector(".fui-card__content"); + expect(cardContent).toBeInTheDocument(); + }); + + it("uses correct translation keys", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + multiFactorEnrollment: "Set up Multi-Factor Authentication", + }, + prompts: { + mfaEnrollmentPrompt: "Choose a method to secure your account", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Set up Multi-Factor Authentication")).toBeInTheDocument(); + expect(screen.getByText("Choose a method to secure your account")).toBeInTheDocument(); + }); + + it("handles all supported factor IDs", () => { + const allHints = [FactorId.TOTP, FactorId.PHONE]; + const ui = createMockUI(); + + render( + + + + ); + + const hintsElement = screen.getByTestId("hints-prop"); + expect(hintsElement.textContent).toBe("totp,phone"); + }); + + it("passes through all props correctly", () => { + const mockOnEnrollment = vi.fn(); + const mockHints = [FactorId.TOTP]; + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("on-enrollment-prop")).toBeInTheDocument(); + expect(screen.getByTestId("hints-prop")).toBeInTheDocument(); + expect(screen.getByTestId("hints-prop").textContent).toBe("totp"); + }); +}); diff --git a/packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.tsx b/packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.tsx new file mode 100644 index 000000000..f76716b41 --- /dev/null +++ b/packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.tsx @@ -0,0 +1,54 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getTranslation } from "@invertase/firebaseui-core"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card"; +import { useUI } from "~/hooks"; +import { + MultiFactorAuthEnrollmentForm, + type MultiFactorAuthEnrollmentFormProps, +} from "../forms/multi-factor-auth-enrollment-form"; + +/** Props for the MultiFactorAuthEnrollmentScreen component. */ +export type MultiFactorAuthEnrollmentScreenProps = MultiFactorAuthEnrollmentFormProps; + +/** + * A screen component for multi-factor authentication enrollment. + * + * Displays a card with the multi-factor enrollment form. + * + * @returns The multi-factor auth enrollment screen component. + */ +export function MultiFactorAuthEnrollmentScreen(props: MultiFactorAuthEnrollmentScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "multiFactorEnrollment"); + const subtitleText = getTranslation(ui, "prompts", "mfaEnrollmentPrompt"); + + return ( +
+ + + {titleText} + {subtitleText} + + + + + +
+ ); +} diff --git a/packages/react/src/auth/screens/oauth-screen.test.tsx b/packages/react/src/auth/screens/oauth-screen.test.tsx new file mode 100644 index 000000000..4afcedab1 --- /dev/null +++ b/packages/react/src/auth/screens/oauth-screen.test.tsx @@ -0,0 +1,336 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +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, type User } from "firebase/auth"; + +vi.mock("~/components/policies", async (originalModule) => { + const module = await originalModule(); + return { + ...(module as object), + Policies: () =>
Policies
, + }; +}); + +vi.mock("~/components/redirect-error", () => ({ + RedirectError: () =>
Redirect Error
, +})); + +vi.mock("~/auth/screens/multi-factor-auth-assertion-screen", () => ({ + MultiFactorAuthAssertionScreen: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+ +
+ ), +})); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders with correct title and subtitle", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "signIn", + }, + prompts: { + signInToAccount: "signInToAccount", + }, + }), + }); + + render( + + OAuth Provider + + ); + + const title = screen.getByText("signIn"); + expect(title).toBeDefined(); + expect(title.className).toContain("fui-card__title"); + + const subtitle = screen.getByText("signInToAccount"); + expect(subtitle).toBeDefined(); + expect(subtitle.className).toContain("fui-card__subtitle"); + }); + + it("renders children", () => { + const ui = createMockUI(); + + render( + + OAuth Provider + + ); + + expect(screen.getByText("OAuth Provider")).toBeDefined(); + }); + + it("renders multiple children when provided", () => { + const ui = createMockUI(); + + render( + + +
Provider 1
+
Provider 2
+
+
+ ); + + expect(screen.getByText("Provider 1")).toBeDefined(); + expect(screen.getByText("Provider 2")).toBeDefined(); + }); + + it("includes the Policies component", () => { + const ui = createMockUI(); + + render( + + OAuth Provider + + ); + + expect(screen.getByTestId("policies")).toBeDefined(); + }); + + it("renders children before the Policies component", () => { + const ui = createMockUI(); + + render( + + +
OAuth Provider
+
+
+ ); + + const oauthProvider = screen.getByTestId("oauth-provider"); + const policies = screen.getByTestId("policies"); + + // Both should be present + expect(oauthProvider).toBeDefined(); + expect(policies).toBeDefined(); + + // OAuth provider should come before policies + const cardContent = oauthProvider.parentElement; + const children = Array.from(cardContent?.children || []); + const oauthIndex = children.indexOf(oauthProvider); + const policiesIndex = children.indexOf(policies); + + expect(oauthIndex).toBeLessThan(policiesIndex); + }); + + it("renders MultiFactorAuthAssertionScreen when multiFactorResolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + OAuth Provider + + ); + + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + expect(screen.queryByText("OAuth Provider")).toBeNull(); + expect(screen.queryByTestId("policies")).toBeNull(); + }); + + it("does not render children or Policies when MFA resolver exists", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + +
OAuth Provider
+
+
+ ); + + expect(screen.queryByTestId("oauth-provider")).toBeNull(); + expect(screen.queryByTestId("policies")).toBeNull(); + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + }); + + it("renders RedirectError component with children when no MFA resolver", () => { + const ui = createMockUI(); + + render( + + +
OAuth Provider
+
+
+ ); + + expect(screen.getByTestId("redirect-error")).toBeDefined(); + expect(screen.getByTestId("oauth-provider")).toBeDefined(); + expect(screen.getByTestId("policies")).toBeDefined(); + }); + + it("does not render RedirectError when MFA resolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + +
OAuth Provider
+
+
+ ); + + expect(screen.queryByTestId("redirect-error")).toBeNull(); + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + }); + + it("calls onSignIn with credential when MFA flow succeeds", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + 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(); + + render( + + OAuth Provider + + ); + + 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(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 new file mode 100644 index 000000000..86d551002 --- /dev/null +++ b/packages/react/src/auth/screens/oauth-screen.tsx @@ -0,0 +1,68 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getTranslation } from "@invertase/firebaseui-core"; +import { type User } from "firebase/auth"; +import { type PropsWithChildren } from "react"; +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"; + +/** Props for the OAuthScreen component. */ +export type OAuthScreenProps = PropsWithChildren<{ + /** Callback function called when sign-in is successful. */ + onSignIn?: (user: User) => void; +}>; + +/** + * A screen component for OAuth provider authentication. + * + * Displays a card that should contain OAuth sign-in buttons as children. + * Handles multi-factor authentication if required. + * + * @returns The OAuth screen component. + */ +export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signIn"); + const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + const mfaResolver = ui.multiFactorResolver; + + useOnUserAuthenticated(onSignIn); + + if (mfaResolver) { + return ; + } + + return ( +
+ + + {titleText} + {subtitleText} + + + {children} + + + + +
+ ); +} diff --git a/packages/react/src/auth/screens/phone-auth-screen.test.tsx b/packages/react/src/auth/screens/phone-auth-screen.test.tsx new file mode 100644 index 000000000..3159aec39 --- /dev/null +++ b/packages/react/src/auth/screens/phone-auth-screen.test.tsx @@ -0,0 +1,349 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +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, type User } from "firebase/auth"; + +vi.mock("~/auth/forms/phone-auth-form", () => ({ + PhoneAuthForm: ({ resendDelay }: { resendDelay?: number }) => ( +
+ Phone Auth Form +
+ ), +})); + +vi.mock("~/components/divider", async (originalModule) => { + const module = await originalModule(); + return { + ...(module as object), + Divider: ({ children }: { children: React.ReactNode }) =>
{children}
, + }; +}); + +vi.mock("~/components/redirect-error", () => ({ + RedirectError: () =>
Redirect Error
, +})); + +vi.mock("~/auth/screens/multi-factor-auth-assertion-screen", () => ({ + MultiFactorAuthAssertionScreen: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+ +
+ ), +})); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders with correct title and subtitle", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "signIn", + }, + prompts: { + signInToAccount: "signInToAccount", + }, + }), + }); + + render( + + + + ); + + const title = screen.getByText("signIn"); + expect(title).toBeDefined(); + expect(title.className).toContain("fui-card__title"); + + const subtitle = screen.getByText("signInToAccount"); + expect(subtitle).toBeDefined(); + expect(subtitle.className).toContain("fui-card__subtitle"); + }); + + it("renders the component", () => { + const ui = createMockUI(); + + render( + + + + ); + + // Mocked so only has as test id + expect(screen.getByTestId("phone-auth-form")).toBeDefined(); + }); + + it("renders a divider with children when present", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
Test Child
+
+
+ ); + + expect(screen.getByTestId("divider")).toBeDefined(); + expect(screen.getByText("dividerOr")).toBeDefined(); + expect(screen.getByTestId("test-child")).toBeDefined(); + }); + + it("does not render divider and children when no children are provided", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.queryByTestId("divider")).toBeNull(); + }); + + it("renders multiple children when provided", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
Child 1
+
Child 2
+
+
+ ); + + expect(screen.getByTestId("divider")).toBeDefined(); + expect(screen.getByTestId("child-1")).toBeDefined(); + expect(screen.getByTestId("child-2")).toBeDefined(); + }); + + it("renders MultiFactorAuthAssertionScreen when multiFactorResolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + expect(screen.queryByTestId("phone-auth-form")).toBeNull(); + }); + + it("does not render PhoneAuthForm when MFA resolver exists", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.queryByTestId("phone-auth-form")).toBeNull(); + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + }); + + it("renders RedirectError component in children section when no MFA resolver", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
Test Child
+
+
+ ); + + expect(screen.getByTestId("redirect-error")).toBeDefined(); + expect(screen.getByTestId("test-child")).toBeDefined(); + }); + + it("does not render RedirectError when MFA resolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + +
Test Child
+
+
+ ); + + expect(screen.queryByTestId("redirect-error")).toBeNull(); + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + }); + + it("calls onSignIn with credential when MFA flow succeeds", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + 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(); + + render( + + + + ); + + // 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(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 new file mode 100644 index 000000000..095e6558b --- /dev/null +++ b/packages/react/src/auth/screens/phone-auth-screen.tsx @@ -0,0 +1,75 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { PropsWithChildren } from "react"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { Divider } from "~/components/divider"; +import { useOnUserAuthenticated, useUI } from "~/hooks"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card"; +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"; + +/** Props for the PhoneAuthScreen component. */ +export type PhoneAuthScreenProps = PropsWithChildren<{ + /** Callback function called when sign-in is successful. */ + onSignIn?: (user: User) => void; +}>; + +/** + * A screen component for phone authentication. + * + * Displays a card with the phone auth form and handles multi-factor authentication if required. + * + * @returns The phone auth screen component. + */ +export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signIn"); + const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + const mfaResolver = ui.multiFactorResolver; + + useOnUserAuthenticated(props.onSignIn); + + if (mfaResolver) { + return ; + } + + return ( +
+ + + {titleText} + {subtitleText} + + + + {children ? ( + <> + {getTranslation(ui, "messages", "dividerOr")} +
+ {children} + +
+ + ) : null} +
+
+
+ ); +} 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 new file mode 100644 index 000000000..4a9b176ed --- /dev/null +++ b/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx @@ -0,0 +1,392 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +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, type User } from "firebase/auth"; + +vi.mock("~/auth/forms/sign-in-auth-form", () => ({ + SignInAuthForm: ({ + onForgotPasswordClick, + onRegisterClick, + }: { + onForgotPasswordClick?: () => void; + onRegisterClick?: () => void; + }) => ( +
+ + +
+ ), +})); + +vi.mock("~/components/divider", async (originalModule) => { + const module = await originalModule(); + return { + ...(module as object), + Divider: ({ children }: { children: React.ReactNode }) =>
{children}
, + }; +}); + +vi.mock("~/components/redirect-error", () => ({ + RedirectError: () =>
Redirect Error
, +})); + +vi.mock("~/auth/screens/multi-factor-auth-assertion-screen", () => ({ + MultiFactorAuthAssertionScreen: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+ +
+ ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders with correct title and subtitle", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "signIn", + }, + prompts: { + signInToAccount: "signInToAccount", + }, + }), + }); + + render( + + + + ); + + const title = screen.getByText("signIn"); + expect(title).toBeDefined(); + expect(title.className).toContain("fui-card__title"); + + const subtitle = screen.getByText("signInToAccount"); + expect(subtitle).toBeDefined(); + expect(subtitle.className).toContain("fui-card__subtitle"); + }); + + it("renders the component", () => { + const ui = createMockUI(); + + render( + + + + ); + + // Mocked so only has as test id + expect(screen.getByTestId("sign-in-auth-form")).toBeDefined(); + }); + + it("passes onForgotPasswordClick to SignInAuthForm", () => { + const mockOnForgotPasswordClick = vi.fn(); + const ui = createMockUI(); + + render( + + + + ); + + const forgotPasswordButton = screen.getByTestId("forgot-password-button"); + fireEvent.click(forgotPasswordButton); + + expect(mockOnForgotPasswordClick).toHaveBeenCalledTimes(1); + }); + + it("passes onRegisterClick to SignInAuthForm", () => { + const mockOnRegisterClick = vi.fn(); + const ui = createMockUI(); + + render( + + + + ); + + const registerButton = screen.getByTestId("register-button"); + fireEvent.click(registerButton); + + expect(mockOnRegisterClick).toHaveBeenCalledTimes(1); + }); + + it("renders a divider with children when present", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
Test Child
+
+
+ ); + + expect(screen.getByTestId("divider")).toBeDefined(); + expect(screen.getByText("dividerOr")).toBeDefined(); + expect(screen.getByTestId("test-child")).toBeDefined(); + }); + + it("does not render divider and children when no children are provided", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.queryByTestId("divider")).toBeNull(); + }); + + it("renders multiple children when provided", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
Child 1
+
Child 2
+
+
+ ); + + expect(screen.getByTestId("divider")).toBeDefined(); + expect(screen.getByTestId("child-1")).toBeDefined(); + expect(screen.getByTestId("child-2")).toBeDefined(); + }); + + it("renders MultiFactorAuthAssertionScreen when multiFactorResolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + expect(screen.queryByTestId("sign-in-auth-form")).toBeNull(); + }); + + it("does not render SignInAuthForm when MFA resolver exists", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.queryByTestId("sign-in-auth-form")).toBeNull(); + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + }); + + it("renders RedirectError component in children section when no MFA resolver", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
Test Child
+
+
+ ); + + expect(screen.getByTestId("redirect-error")).toBeDefined(); + expect(screen.getByTestId("test-child")).toBeDefined(); + }); + + it("does not render RedirectError when MFA resolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + +
Test Child
+
+
+ ); + + expect(screen.queryByTestId("redirect-error")).toBeNull(); + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + }); + + it("calls onSignIn with credential when MFA flow succeeds", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + 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(); + + render( + + + + ); + + // 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(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 new file mode 100644 index 000000000..a56af1a2d --- /dev/null +++ b/packages/react/src/auth/screens/sign-in-auth-screen.tsx @@ -0,0 +1,74 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { PropsWithChildren } from "react"; +import type { User } from "firebase/auth"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { Divider } from "~/components/divider"; +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"; + +/** Props for the SignInAuthScreen component. */ +export type SignInAuthScreenProps = PropsWithChildren> & { + /** Callback function called when sign-in is successful. */ + onSignIn?: (user: User) => void; +}; + +/** + * A screen component for signing in with email and password. + * + * Displays a card with the sign-in form and handles multi-factor authentication if required. + * + * @returns The sign-in screen component. + */ +export function SignInAuthScreen({ children, onSignIn, ...props }: SignInAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signIn"); + const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; + } + + return ( +
+ + + {titleText} + {subtitleText} + + + + {children ? ( + <> + {getTranslation(ui, "messages", "dividerOr")} +
+ {children} + +
+ + ) : null} +
+
+
+ ); +} 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 new file mode 100644 index 000000000..2c7d21b56 --- /dev/null +++ b/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx @@ -0,0 +1,365 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +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, type User } from "firebase/auth"; + +vi.mock("~/auth/forms/sign-up-auth-form", () => ({ + SignUpAuthForm: ({ onSignInClick }: { onSignInClick?: () => void }) => ( +
+ +
+ ), +})); + +vi.mock("~/components/divider", async (originalModule) => { + const module = await originalModule(); + return { + ...(module as object), + Divider: ({ children }: { children: React.ReactNode }) =>
{children}
, + }; +}); + +vi.mock("~/components/redirect-error", () => ({ + RedirectError: () =>
Redirect Error
, +})); + +vi.mock("~/auth/screens/multi-factor-auth-assertion-screen", () => ({ + MultiFactorAuthAssertionScreen: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+ +
+ ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders with correct title and subtitle", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signUp: "register", + }, + prompts: { + enterDetailsToCreate: "enterDetailsToCreate", + }, + }), + }); + + render( + + + + ); + + const title = screen.getByText("register"); + expect(title).toBeDefined(); + expect(title.className).toContain("fui-card__title"); + + const subtitle = screen.getByText("enterDetailsToCreate"); + expect(subtitle).toBeDefined(); + expect(subtitle.className).toContain("fui-card__subtitle"); + }); + + it("renders the component", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("sign-up-auth-form")).toBeDefined(); + }); + + it("passes onSignInClick to SignUpAuthForm", () => { + const mockOnSignInClick = vi.fn(); + const ui = createMockUI(); + + render( + + + + ); + + const backButton = screen.getByTestId("back-to-sign-in-button"); + fireEvent.click(backButton); + + expect(mockOnSignInClick).toHaveBeenCalledTimes(1); + }); + + it("renders a divider with children when present", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
Test Child
+
+
+ ); + + expect(screen.getByTestId("divider")).toBeDefined(); + expect(screen.getByText("dividerOr")).toBeDefined(); + expect(screen.getByTestId("test-child")).toBeDefined(); + }); + + it("does not render divider and children when no children are provided", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.queryByTestId("divider")).toBeNull(); + }); + + it("renders multiple children when provided", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
Child 1
+
Child 2
+
+
+ ); + + expect(screen.getByTestId("divider")).toBeDefined(); + expect(screen.getByTestId("child-1")).toBeDefined(); + expect(screen.getByTestId("child-2")).toBeDefined(); + }); + + it("renders MultiFactorAuthAssertionScreen when multiFactorResolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + expect(screen.queryByTestId("sign-up-auth-form")).toBeNull(); + }); + + it("does not render SignUpAuthForm when MFA resolver exists", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.queryByTestId("sign-up-auth-form")).toBeNull(); + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + }); + + it("renders RedirectError component in children section when no MFA resolver", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
Test Child
+
+
+ ); + + expect(screen.getByTestId("redirect-error")).toBeDefined(); + expect(screen.getByTestId("test-child")).toBeDefined(); + }); + + it("does not render RedirectError when MFA resolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + +
Test Child
+
+
+ ); + + expect(screen.queryByTestId("redirect-error")).toBeNull(); + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + }); + + it("calls onSignUp with credential when MFA flow succeeds", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + 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(); + + render( + + + + ); + + 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(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 new file mode 100644 index 000000000..09a6115cf --- /dev/null +++ b/packages/react/src/auth/screens/sign-up-auth-screen.tsx @@ -0,0 +1,74 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { PropsWithChildren } from "react"; +import type { User } from "firebase/auth"; +import { Divider } from "~/components/divider"; +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"; + +/** Props for the SignUpAuthScreen component. */ +export type SignUpAuthScreenProps = PropsWithChildren> & { + /** Callback function called when sign-up is successful. */ + onSignUp?: (user: User) => void; +}; + +/** + * A screen component for signing up with email and password. + * + * Displays a card with the sign-up form and handles multi-factor authentication if required. + * + * @returns The sign-up screen component. + */ +export function SignUpAuthScreen({ children, onSignUp, ...props }: SignUpAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signUp"); + const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate"); + + useOnUserAuthenticated(onSignUp); + + if (ui.multiFactorResolver) { + return ; + } + + return ( +
+ + + {titleText} + {subtitleText} + + + + {children ? ( + <> + {getTranslation(ui, "messages", "dividerOr")} +
+ {children} + +
+ + ) : null} +
+
+
+ ); +} diff --git a/packages/firebaseui-react/tests/unit/components/button.test.tsx b/packages/react/src/components/button.test.tsx similarity index 62% rename from packages/firebaseui-react/tests/unit/components/button.test.tsx rename to packages/react/src/components/button.test.tsx index f9d8335c1..4ffafad20 100644 --- a/packages/firebaseui-react/tests/unit/components/button.test.tsx +++ b/packages/react/src/components/button.test.tsx @@ -14,16 +14,19 @@ * limitations under the License. */ -import { describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { Button } from "../../../src/components/button"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { Button } from "./button"; -describe("Button Component", () => { +afterEach(() => { + cleanup(); +}); + +describe("); const button = screen.getByRole("button", { name: /click me/i }); - expect(button).toBeInTheDocument(); + expect(button).toBeDefined(); expect(button).toHaveClass("fui-button"); expect(button).not.toHaveClass("fui-button--secondary"); }); @@ -60,6 +63,30 @@ describe("Button Component", () => { ); const button = screen.getByTestId("test-button"); - expect(button).toBeDisabled(); + expect(button).toHaveAttribute("disabled"); + }); + + it("renders as a Slot component when asChild is true", () => { + render( + + ); + const link = screen.getByRole("link", { name: /link button/i }); + + expect(link).toBeDefined(); + expect(link).toHaveClass("fui-button"); + expect(link.tagName).toBe("A"); + expect(link).toHaveAttribute("href", "/test"); + }); + + it("renders as a button element when asChild is false or undefined", () => { + const { rerender } = render(); + let button = screen.getByRole("button", { name: /regular button/i }); + expect(button.tagName).toBe("BUTTON"); + + rerender(); + button = screen.getByRole("button", { name: /regular button/i }); + expect(button.tagName).toBe("BUTTON"); }); }); diff --git a/packages/react/src/components/button.tsx b/packages/react/src/components/button.tsx new file mode 100644 index 000000000..3e6813155 --- /dev/null +++ b/packages/react/src/components/button.tsx @@ -0,0 +1,38 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { type ComponentProps } from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { buttonVariant, type ButtonVariant } from "@invertase/firebaseui-styles"; +import { cn } from "~/utils/cn"; + +/** Props for the Button component. */ +export type ButtonProps = ComponentProps<"button"> & { + /** The visual variant of the button. */ + variant?: ButtonVariant; + /** If true, the button will render as a child component using Radix UI's Slot. */ + asChild?: boolean; +}; + +/** + * A customizable button component with multiple variants. + * + * @returns The button component. + */ +export function Button({ className, variant = "primary", asChild, ...props }: ButtonProps) { + const Comp = asChild ? Slot : "button"; + return ; +} diff --git a/packages/firebaseui-react/tests/unit/components/card.test.tsx b/packages/react/src/components/card.test.tsx similarity index 62% rename from packages/firebaseui-react/tests/unit/components/card.test.tsx rename to packages/react/src/components/card.test.tsx index 1d3157144..2967c4b62 100644 --- a/packages/firebaseui-react/tests/unit/components/card.test.tsx +++ b/packages/react/src/components/card.test.tsx @@ -14,52 +14,80 @@ * limitations under the License. */ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { - Card, - CardHeader, - CardTitle, - CardSubtitle, -} from "../../../src/components/card"; - -describe("Card Components", () => { - describe("Card", () => { - it("renders a card with children", () => { - render(Card content); - const card = screen.getByTestId("test-card"); - - expect(card).toHaveClass("fui-card"); - expect(card).toHaveTextContent("Card content"); - }); +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { Card, CardHeader, CardTitle, CardSubtitle, CardContent } from "./card"; - it("applies custom className", () => { - render( - - Card content - - ); - const card = screen.getByTestId("test-card"); +afterEach(() => { + cleanup(); +}); - expect(card).toHaveClass("fui-card"); - expect(card).toHaveClass("custom-class"); - }); +describe("", () => { + it("renders a card with children", () => { + render(Card content); + const card = screen.getByTestId("test-card"); - it("passes other props to the div element", () => { - render( - - Card content - - ); - const card = screen.getByTestId("test-card"); + expect(card).toHaveClass("fui-card"); + expect(card).toHaveTextContent("Card content"); + }); - expect(card).toHaveClass("fui-card"); - expect(card).toHaveAttribute("aria-label", "card"); - }); + it("applies custom className", () => { + render( + + Card content + + ); + const card = screen.getByTestId("test-card"); + + expect(card).toHaveClass("fui-card"); + expect(card).toHaveClass("custom-class"); + }); + + it("passes other props to the div element", () => { + render( + + Card content + + ); + const card = screen.getByTestId("test-card"); + + expect(card).toHaveClass("fui-card"); + expect(card).toHaveAttribute("aria-label", "card"); + }); + + it("renders a complete card with all subcomponents", () => { + render( + + + Card Title + Card Subtitle + + +
Card Body Content
+
+
+ ); + + const card = screen.getByTestId("complete-card"); + const header = screen.getByTestId("complete-header"); + const title = screen.getByRole("heading", { name: "Card Title" }); + const subtitle = screen.getByText("Card Subtitle"); + const content = screen.getByText("Card Body Content"); + + expect(card).toHaveClass("fui-card"); + expect(title).toHaveClass("fui-card__title"); + expect(subtitle).toHaveClass("fui-card__subtitle"); + expect(header).toHaveClass("fui-card__header"); + expect(content).toBeInTheDocument(); + + // Check structure + expect(header).toContainElement(title); + expect(header).toContainElement(subtitle); + expect(card).toContainElement(header); + expect(card).toContainElement(content); }); - describe("CardHeader", () => { + describe("", () => { it("renders a card header with children", () => { render(Header content); const header = screen.getByTestId("test-header"); @@ -81,12 +109,12 @@ describe("Card Components", () => { }); }); - describe("CardTitle", () => { + describe("", () => { it("renders a card title with children", () => { render(Title content); const title = screen.getByRole("heading", { name: "Title content" }); - expect(title).toHaveClass("fui-card__title"); + expect(title.className).toContain("fui-card__title"); expect(title.tagName).toBe("H2"); }); @@ -99,7 +127,7 @@ describe("Card Components", () => { }); }); - describe("CardSubtitle", () => { + describe("", () => { it("renders a card subtitle with children", () => { render(Subtitle content); const subtitle = screen.getByText("Subtitle content"); @@ -109,11 +137,7 @@ describe("Card Components", () => { }); it("applies custom className", () => { - render( - - Subtitle content - - ); + render(Subtitle content); const subtitle = screen.getByText("Subtitle content"); expect(subtitle).toHaveClass("fui-card__subtitle"); @@ -121,33 +145,21 @@ describe("Card Components", () => { }); }); - it("renders a complete card with all subcomponents", () => { - render( - - - Card Title - Card Subtitle - -
Card Body Content
-
- ); + describe("", () => { + it("renders a card content with children", () => { + render(Content content); + const content = screen.getByText("Content content"); - const card = screen.getByTestId("complete-card"); - const header = screen.getByTestId("complete-header"); - const title = screen.getByRole("heading", { name: "Card Title" }); - const subtitle = screen.getByText("Card Subtitle"); - const content = screen.getByText("Card Body Content"); + expect(content).toHaveClass("fui-card__content"); + expect(content.tagName).toBe("DIV"); + }); - expect(card).toHaveClass("fui-card"); - expect(title).toHaveClass("fui-card__title"); - expect(subtitle).toHaveClass("fui-card__subtitle"); - expect(header).toHaveClass("fui-card__header"); - expect(content).toBeInTheDocument(); + it("applies custom className", () => { + render(Content); + const content = screen.getByText("Content"); - // Check structure - expect(header).toContainElement(title); - expect(header).toContainElement(subtitle); - expect(card).toContainElement(header); - expect(card).toContainElement(content); + expect(content).toHaveClass("fui-card__content"); + expect(content).toHaveClass("custom-content"); + }); }); }); diff --git a/packages/firebaseui-react/src/components/card.tsx b/packages/react/src/components/card.tsx similarity index 55% rename from packages/firebaseui-react/src/components/card.tsx rename to packages/react/src/components/card.tsx index fd8d3954a..6f485a08e 100644 --- a/packages/firebaseui-react/src/components/card.tsx +++ b/packages/react/src/components/card.tsx @@ -14,11 +14,17 @@ * limitations under the License. */ -import type { HTMLAttributes, PropsWithChildren } from "react"; +import type { ComponentProps, PropsWithChildren } from "react"; import { cn } from "~/utils/cn"; -type CardProps = PropsWithChildren>; +/** Props for the Card component. */ +export type CardProps = PropsWithChildren>; +/** + * A card container component for grouping related content. + * + * @returns The card component. + */ export function Card({ children, className, ...props }: CardProps) { return (
@@ -27,6 +33,11 @@ export function Card({ children, className, ...props }: CardProps) { ); } +/** + * The header section of a card. + * + * @returns The card header component. + */ export function CardHeader({ children, className, ...props }: CardProps) { return (
@@ -35,11 +46,12 @@ export function CardHeader({ children, className, ...props }: CardProps) { ); } -export function CardTitle({ - children, - className, - ...props -}: HTMLAttributes) { +/** + * The title of a card. + * + * @returns The card title component. + */ +export function CardTitle({ children, className, ...props }: ComponentProps<"h2">) { return (

{children} @@ -47,14 +59,28 @@ export function CardTitle({ ); } -export function CardSubtitle({ - children, - className, - ...props -}: HTMLAttributes) { +/** + * The subtitle of a card. + * + * @returns The card subtitle component. + */ +export function CardSubtitle({ children, className, ...props }: ComponentProps<"p">) { return (

{children}

); } + +/** + * The content section of a card. + * + * @returns The card content component. + */ +export function CardContent({ children, className, ...props }: ComponentProps<"div">) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/react/src/components/country-selector.test.tsx b/packages/react/src/components/country-selector.test.tsx new file mode 100644 index 000000000..089af7fca --- /dev/null +++ b/packages/react/src/components/country-selector.test.tsx @@ -0,0 +1,209 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { countryData, countryCodes } from "@invertase/firebaseui-core"; +import { CountrySelector, CountrySelectorRef, useCountries, useDefaultCountry } from "./country-selector"; +import { createMockUI, createFirebaseUIProvider } from "~/tests/utils"; +import { RefObject } from "react"; + +describe("useCountries", () => { + it("should return allowed countries from behavior", () => { + const mockUI = createMockUI({ + behaviors: [countryCodes({ allowedCountries: ["US", "GB", "CA"] })], + }); + + const { result } = renderHook(() => useCountries(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current).toHaveLength(3); + expect(result.current.map((c) => c.code)).toEqual(["CA", "GB", "US"]); + }); + + it("should return all countries when no behavior is set", () => { + const mockUI = createMockUI({ + behaviors: [], + }); + + const { result } = renderHook(() => useCountries(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current).toEqual(countryData); + }); +}); + +describe("useDefaultCountry", () => { + it("should return default country from behavior", () => { + const mockUI = createMockUI({ + behaviors: [countryCodes({ allowedCountries: ["US", "GB", "CA"] })], + }); + + const { result } = renderHook(() => useDefaultCountry(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current.code).toBe("US"); + expect(result.current.name).toBe("United States"); + }); + + it("should return US when no behavior is set", () => { + const mockUI = createMockUI({ + behaviors: [], + }); + + const { result } = renderHook(() => useDefaultCountry(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current.code).toBe("US"); + }); +}); + +describe("", () => { + const mockUI = createMockUI({ + behaviors: [countryCodes({ allowedCountries: ["US", "GB", "CA"] })], + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders with the default country", () => { + render(createFirebaseUIProvider({ children: , ui: mockUI })); + + expect(screen.getByText("🇺🇸")).toBeInTheDocument(); + expect(screen.getByText("+1")).toBeInTheDocument(); + + const select = screen.getByRole("combobox"); + expect(select).toHaveValue("US"); + }); + + it("applies custom className", () => { + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const rootDiv = screen.getByRole("combobox").closest("div.fui-country-selector"); + expect(rootDiv).toHaveClass("custom-class"); + }); + + it("changes selection when a different country is selected", () => { + render(createFirebaseUIProvider({ children: , ui: mockUI })); + + const select = screen.getByRole("combobox"); + + // Change to GB + fireEvent.change(select, { target: { value: "GB" } }); + + expect(screen.getByText("🇬🇧")).toBeInTheDocument(); + expect(screen.getByText("+44")).toBeInTheDocument(); + expect(select).toHaveValue("GB"); + }); + + it("renders only allowed countries in the dropdown", () => { + render(createFirebaseUIProvider({ children: , ui: mockUI })); + + const select = screen.getByRole("combobox"); + const options = select.querySelectorAll("option"); + + expect(options).toHaveLength(3); + expect(Array.from(options).map((option) => option.value)).toEqual(["CA", "GB", "US"]); + }); + + it("displays country information correctly", () => { + render(createFirebaseUIProvider({ children: , ui: mockUI })); + + // Check that all countries show dial code and name + const options = screen.getAllByRole("option"); + options.forEach((option) => { + const text = option.textContent; + expect(text).toMatch(/^\+\d+ \([^)]+\)$/); // Format: +123 (Country Name) + }); + }); +}); + +describe("CountrySelector ref", () => { + const mockUI = createMockUI({ + behaviors: [countryCodes({ allowedCountries: ["US", "GB", "CA"] })], + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should expose getCountry and setCountry methods", () => { + const ref: RefObject = { current: undefined as unknown as CountrySelectorRef }; + + render(createFirebaseUIProvider({ children: , ui: mockUI })); + + expect(ref.current).toBeDefined(); + expect(typeof ref.current?.getCountry).toBe("function"); + expect(typeof ref.current?.setCountry).toBe("function"); + }); + + it("should return current selected country via getCountry", () => { + const ref: RefObject = { current: undefined as unknown as CountrySelectorRef }; + + render(createFirebaseUIProvider({ children: , ui: mockUI })); + + const currentCountry = ref.current?.getCountry(); + expect(currentCountry?.code).toBe("US"); + expect(currentCountry?.name).toBe("United States"); + }); + + it("should set country via setCountry", async () => { + const ref: RefObject = { current: undefined as unknown as CountrySelectorRef }; + + render(createFirebaseUIProvider({ children: , ui: mockUI })); + + ref.current?.setCountry("GB"); + + await waitFor(() => { + const select = screen.getByRole("combobox"); + expect(select).toHaveValue("GB"); + }); + }); + + it("should update getCountry after setCountry", async () => { + const ref: RefObject = { current: undefined as unknown as CountrySelectorRef }; + + render(createFirebaseUIProvider({ children: , ui: mockUI })); + + ref.current?.setCountry("CA"); + + await waitFor(() => { + const currentCountry = ref.current?.getCountry(); + expect(currentCountry?.code).toBe("CA"); + }); + + const currentCountry = ref.current?.getCountry(); + expect(currentCountry?.name).toBe("Canada"); + }); +}); diff --git a/packages/react/src/components/country-selector.tsx b/packages/react/src/components/country-selector.tsx new file mode 100644 index 000000000..bfa4bfbdc --- /dev/null +++ b/packages/react/src/components/country-selector.tsx @@ -0,0 +1,114 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { type CountryCode, type CountryData, getBehavior } from "@invertase/firebaseui-core"; +import { type ComponentProps, forwardRef, useImperativeHandle, useState, useCallback } from "react"; +import { useUI } from "~/hooks"; +import { cn } from "~/utils/cn"; + +/** Ref methods for the CountrySelector component. */ +export interface CountrySelectorRef { + /** Gets the currently selected country. */ + getCountry: () => CountryData; + /** Sets the selected country by country code. */ + setCountry: (code: CountryCode) => void; +} + +/** Props for the CountrySelector component. */ +export type CountrySelectorProps = ComponentProps<"div">; + +/** + * Gets the list of allowed countries from the country codes behavior. + * + * @returns The list of allowed countries. + */ +export function useCountries() { + const ui = useUI(); + return getBehavior(ui, "countryCodes")().allowedCountries; +} + +/** + * Gets the default country from the country codes behavior. + * + * @returns The default country data. + */ +export function useDefaultCountry() { + const ui = useUI(); + return getBehavior(ui, "countryCodes")().defaultCountry; +} + +/** + * A country selector component for phone number input. + * + * Displays a dropdown with country flags, dial codes, and names for selecting a country. + * + * @param ref - A ref to access the country selector methods. + * @returns The country selector component. + */ +export const CountrySelector = forwardRef(({ className, ...props }, ref) => { + const countries = useCountries(); + const defaultCountry = useDefaultCountry(); + const [selected, setSelected] = useState(defaultCountry); + + const setCountry = useCallback( + (code: CountryCode) => { + const foundCountry = countries.find((country) => country.code === code); + setSelected(foundCountry!); + }, + [countries] + ); + + useImperativeHandle( + ref, + () => ({ + getCountry: () => selected, + setCountry, + }), + [selected, setCountry] + ); + + return ( +
+
+ {selected.emoji} +
+ {selected.dialCode} + +
+
+
+ ); +}); + +CountrySelector.displayName = "CountrySelector"; diff --git a/packages/firebaseui-react/tests/unit/components/divider.test.tsx b/packages/react/src/components/divider.test.tsx similarity index 90% rename from packages/firebaseui-react/tests/unit/components/divider.test.tsx rename to packages/react/src/components/divider.test.tsx index 778418065..4e744244e 100644 --- a/packages/firebaseui-react/tests/unit/components/divider.test.tsx +++ b/packages/react/src/components/divider.test.tsx @@ -16,10 +16,9 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { Divider } from "../../../src/components/divider"; +import { Divider } from "./divider"; -describe("Divider Component", () => { +describe("", () => { it("renders a divider with no text", () => { render(); const divider = screen.getByTestId("divider-no-text"); @@ -44,9 +43,7 @@ describe("Divider Component", () => { }); it("applies custom className", () => { - render( - - ); + render(); const divider = screen.getByTestId("divider-custom-class"); expect(divider).toHaveClass("fui-divider"); diff --git a/packages/firebaseui-react/src/components/divider.tsx b/packages/react/src/components/divider.tsx similarity index 78% rename from packages/firebaseui-react/src/components/divider.tsx rename to packages/react/src/components/divider.tsx index 171031f8c..03f030f07 100644 --- a/packages/firebaseui-react/src/components/divider.tsx +++ b/packages/react/src/components/divider.tsx @@ -14,11 +14,17 @@ * limitations under the License. */ -import { HTMLAttributes } from "react"; +import { type ComponentProps, type PropsWithChildren } from "react"; import { cn } from "~/utils/cn"; -type DividerProps = HTMLAttributes; +/** Props for the Divider component. */ +export type DividerProps = PropsWithChildren>; +/** + * A divider component that can display a line or a line with text in the middle. + * + * @returns The divider component. + */ export function Divider({ className, children, ...props }: DividerProps) { if (!children) { return ( diff --git a/packages/react/src/components/form.test.tsx b/packages/react/src/components/form.test.tsx new file mode 100644 index 000000000..c609c80ae --- /dev/null +++ b/packages/react/src/components/form.test.tsx @@ -0,0 +1,322 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, afterEach, vi, beforeEach } from "vitest"; +import { render, screen, cleanup, renderHook, act, waitFor } from "@testing-library/react"; +import { form } from "./form"; +import { ComponentProps } from "react"; + +vi.mock("~/components/button", () => { + return { + Button: (props: ComponentProps<"button">) => + } + /> + )} + + + ); + + expect(screen.getByTestId("test-action")).toBeInTheDocument(); + expect(screen.getByTestId("test-action")).toHaveTextContent("Action"); + }); + + it("should render the Input description prop when provided", () => { + const { result } = renderHook(() => { + return form.useAppForm({ + defaultValues: { foo: "bar" }, + }); + }); + + const hook = result.current; + + const { container } = render( + + + {(field) => } + + + ); + + const description = container.querySelector("[data-input-description]"); + expect(description).toBeInTheDocument(); + expect(description).toHaveTextContent("This is a description"); + }); + + it("should not render the Input description when not provided", () => { + const { result } = renderHook(() => { + return form.useAppForm({ + defaultValues: { foo: "bar" }, + }); + }); + + const hook = result.current; + + const { container } = render( + + {(field) => } + + ); + + const description = container.querySelector("[data-input-description]"); + expect(description).not.toBeInTheDocument(); + }); + + it("should render the Input metadata when available", async () => { + const { result } = renderHook(() => { + return form.useAppForm({ + defaultValues: { foo: "" }, + }); + }); + + const hook = result.current; + + render( + + { + return "error!"; + }, + }} + name="foo" + > + {(field) => } + + + ); + + await act(async () => { + await hook.handleSubmit(); + }); + + const error = screen.getByRole("alert"); + expect(error).toBeInTheDocument(); + expect(error).toHaveClass("fui-error"); + }); + }); + + describe("", () => { + it("should render the Action component", () => { + const { result } = renderHook(() => { + return form.useAppForm({}); + }); + + const hook = result.current; + + render( + + Action + + ); + + expect(screen.getByRole("button", { name: "Action" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Action" })).toHaveClass("fui-form__action"); + expect(screen.getByRole("button", { name: "Action" })).toHaveTextContent("Action"); + expect(screen.getByRole("button", { name: "Action" })).toHaveAttribute("type", "button"); + }); + }); + + describe("", () => { + it("should render the SubmitButton component", () => { + const { result } = renderHook(() => { + return form.useAppForm({}); + }); + + const hook = result.current; + + render( + + Submit + + ); + + expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Submit" })).toHaveTextContent("Submit"); + expect(screen.getByRole("button", { name: "Submit" })).toHaveAttribute("type", "submit"); + expect(screen.getByTestId("submit-button")).toBeInTheDocument(); + }); + + it("should subscribe to the isSubmitting state", async () => { + const { result } = renderHook(() => { + return form.useAppForm({ + validators: { + onSubmitAsync: async () => { + // Simulate a slow async operation + await new Promise((resolve) => setTimeout(resolve, 100)); + return undefined; + }, + }, + }); + }); + + const hook = result.current; + + render( + + Submit + + ); + + const submitButton = screen.getByTestId("submit-button"); + + expect(submitButton).toBeInTheDocument(); + expect(submitButton).not.toHaveAttribute("disabled"); + + act(() => { + hook.handleSubmit(); + }); + + await waitFor(() => { + expect(submitButton).toHaveAttribute("disabled"); + }); + }); + }); + + describe("", () => { + it("should render the ErrorMessage if the onSubmit error is set", async () => { + const { result } = renderHook(() => { + return form.useAppForm({ + validators: { + onSubmitAsync: async () => { + return "error!"; + }, + }, + }); + }); + + const hook = result.current; + + const { container } = render( + + + + ); + + act(async () => { + await hook.handleSubmit(); + }); + + await waitFor(() => { + const error = container.querySelector(".fui-error"); + expect(error).toBeInTheDocument(); + expect(error).toHaveTextContent("error!"); + }); + }); + }); +}); diff --git a/packages/react/src/components/form.tsx b/packages/react/src/components/form.tsx new file mode 100644 index 000000000..897cf01ee --- /dev/null +++ b/packages/react/src/components/form.tsx @@ -0,0 +1,127 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { type ComponentProps, type PropsWithChildren, type ReactNode } from "react"; +import { type AnyFieldApi, createFormHook, createFormHookContexts } from "@tanstack/react-form"; +import { Button } from "./button"; +import { cn } from "~/utils/cn"; + +const { fieldContext, useFieldContext, formContext, useFormContext } = createFormHookContexts(); + +function FieldMetadata({ className, ...props }: ComponentProps<"div"> & { field: AnyFieldApi }) { + if (!props.field.state.meta.isTouched || !props.field.state.meta.errors.length) { + return null; + } + + return ( +
+
+ {props.field.state.meta.errors.map((error) => error.message).join(", ")} +
+
+ ); +} + +function Input({ + children, + before, + label, + action, + description, + ...props +}: PropsWithChildren< + ComponentProps<"input"> & { label: string; before?: ReactNode; action?: ReactNode; description?: ReactNode } +>) { + const field = useFieldContext(); + + return ( + + ); +} + +function Action({ className, ...props }: ComponentProps<"button">) { + return + ), + SelectValue: ({ children }: any) => {children}, + SelectContent: ({ children }: any) => ( +
+ {children} +
+ ), + SelectItem: ({ children, value }: any) => ( +
+ {children} +
+ ), +})); + +describe("useCountries", () => { + it("should return allowed countries from behavior", () => { + const mockUI = createMockUI({ + behaviors: [countryCodes({ allowedCountries: ["US", "GB", "CA"] })], + }); + + const { result } = renderHook(() => useCountries(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current).toHaveLength(3); + expect(result.current.map((c) => c.code)).toEqual(["CA", "GB", "US"]); + }); + + it("should return all countries when no behavior is set", () => { + const mockUI = createMockUI({ + behaviors: [], + }); + + const { result } = renderHook(() => useCountries(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current.length).toBeGreaterThan(100); // Should have many countries + }); +}); + +describe("useDefaultCountry", () => { + it("should return US as default country", () => { + const mockUI = createMockUI(); + + const { result } = renderHook(() => useDefaultCountry(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current.code).toBe("US"); + }); +}); + +describe("", () => { + const mockUI = createMockUI({ + behaviors: [countryCodes({ allowedCountries: ["US", "GB", "CA"] })], + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders with the default country", () => { + render( + + + + ); + + expect(screen.getByTestId("select")).toHaveAttribute("data-value", "US"); + expect(screen.getByTestId("select-value")).toHaveTextContent("🇺🇸 +1"); + }); + + it("renders country options in the dropdown", () => { + render( + + + + ); + + const selectItems = screen.getAllByTestId("select-item"); + expect(selectItems).toHaveLength(3); + + // Check that items have correct values + expect(selectItems[0]).toHaveAttribute("data-value", "CA"); + expect(selectItems[1]).toHaveAttribute("data-value", "GB"); + expect(selectItems[2]).toHaveAttribute("data-value", "US"); + }); + + it("displays country information correctly in options", () => { + render( + + + + ); + + const selectItems = screen.getAllByTestId("select-item"); + + // Check that each option shows dial code and country name + selectItems.forEach((item) => { + const text = item.textContent; + expect(text).toMatch(/^\+\d+ \([^)]+\)$/); // Format: +123 (Country Name) + }); + }); + + it("changes selection when a different country is selected", async () => { + const ref: RefObject = { current: null as unknown as CountrySelectorRef }; + + render( + + + + ); + + // Use the ref to change the country + ref.current?.setCountry("GB"); + + await waitFor(() => { + expect(screen.getByTestId("select")).toHaveAttribute("data-value", "GB"); + }); + }); + + it("renders only allowed countries in the dropdown", () => { + render( + + + + ); + + const selectItems = screen.getAllByTestId("select-item"); + expect(selectItems).toHaveLength(3); + + const values = selectItems.map((item) => item.getAttribute("data-value")); + expect(values).toEqual(["CA", "GB", "US"]); + }); + + it("handles country selection with setCountry callback", async () => { + const ref: RefObject = { current: null as unknown as CountrySelectorRef }; + + render( + + + + ); + + // Use the ref to change the country + ref.current?.setCountry("CA"); + + await waitFor(() => { + expect(screen.getByTestId("select")).toHaveAttribute("data-value", "CA"); + }); + }); + it("should work with all countries when no behavior is set", () => { + const mockUI = createMockUI({ + behaviors: [], + }); + + render( + + + + ); + + const selectItems = screen.getAllByTestId("select-item"); + expect(selectItems.length).toBeGreaterThan(100); // Should have many countries + }); + + it("should display correct emoji and dial code in trigger", () => { + const mockUI = createMockUI({ + behaviors: [countryCodes({ allowedCountries: ["US", "GB", "CA"] })], + }); + + render( + + + + ); + + const selectValues = screen.getAllByTestId("select-value"); + const triggerValue = selectValues.find((el) => el.closest('[data-testid="select-trigger"]')); + expect(triggerValue).toHaveTextContent("🇺🇸 +1"); + }); + + it("should update display when country changes", async () => { + const mockUI = createMockUI({ + behaviors: [countryCodes({ allowedCountries: ["US", "GB", "CA"] })], + }); + const ref: RefObject = { current: null as unknown as CountrySelectorRef }; + + render( + + + + ); + + // Change to Canada + ref.current?.setCountry("CA"); + + await waitFor(() => { + // Verify that the select component receives the updated value + const selects = screen.getAllByTestId("select"); + const selectWithCA = selects.find((el) => el.getAttribute("data-value") === "CA"); + expect(selectWithCA).toBeDefined(); + }); + }); +}); + +describe("CountrySelectorRef", () => { + const mockUI = createMockUI({ + behaviors: [countryCodes({ allowedCountries: ["US", "GB", "CA"] })], + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should expose getCountry and setCountry methods", () => { + const ref: RefObject = { current: null as unknown as CountrySelectorRef }; + + render( + + + + ); + + expect(ref.current).toBeDefined(); + expect(typeof ref.current?.getCountry).toBe("function"); + expect(typeof ref.current?.setCountry).toBe("function"); + }); + + it("should return current selected country via getCountry", () => { + const ref: RefObject = { current: null as unknown as CountrySelectorRef }; + + render( + + + + ); + + const currentCountry = ref.current?.getCountry(); + expect(currentCountry?.code).toBe("US"); + expect(currentCountry?.name).toBe("United States"); + }); + + it("should set country via setCountry", async () => { + const ref: RefObject = { current: null as unknown as CountrySelectorRef }; + + render( + + + + ); + + ref.current?.setCountry("GB"); + + await waitFor(() => { + const select = screen.getByTestId("select"); + expect(select).toHaveAttribute("data-value", "GB"); + }); + }); + + it("should update getCountry after setCountry", async () => { + const ref: RefObject = { current: null as unknown as CountrySelectorRef }; + + render( + + + + ); + + ref.current?.setCountry("CA"); + + await waitFor(() => { + const currentCountry = ref.current?.getCountry(); + expect(currentCountry?.code).toBe("CA"); + expect(currentCountry?.name).toBe("Canada"); + }); + }); +}); diff --git a/packages/shadcn/src/components/country-selector.tsx b/packages/shadcn/src/components/country-selector.tsx new file mode 100644 index 000000000..aa9c38e39 --- /dev/null +++ b/packages/shadcn/src/components/country-selector.tsx @@ -0,0 +1,72 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { forwardRef, useCallback, useImperativeHandle, useState } from "react"; +import type { CountryCode, CountryData } from "@invertase/firebaseui-core"; +import { + type CountrySelectorRef, + type CountrySelectorProps, + useCountries, + useDefaultCountry, +} from "@invertase/firebaseui-react"; + +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +export type { CountrySelectorRef }; + +export const CountrySelector = forwardRef((_props, ref) => { + const countries = useCountries(); + const defaultCountry = useDefaultCountry(); + const [selected, setSelected] = useState(defaultCountry); + + const setCountry = useCallback( + (code: CountryCode) => { + const foundCountry = countries.find((country) => country.code === code); + setSelected(foundCountry!); + }, + [countries] + ); + + useImperativeHandle( + ref, + () => ({ + getCountry: () => selected, + setCountry, + }), + [selected, setCountry] + ); + + return ( + + ); +}); + +CountrySelector.displayName = "CountrySelector"; diff --git a/packages/shadcn/src/components/email-link-auth-form.test.tsx b/packages/shadcn/src/components/email-link-auth-form.test.tsx new file mode 100644 index 000000000..ae0396ab2 --- /dev/null +++ b/packages/shadcn/src/components/email-link-auth-form.test.tsx @@ -0,0 +1,236 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { EmailLinkAuthForm } from "./email-link-auth-form"; +import { act } from "react"; +import { useEmailLinkAuthFormAction } from "@invertase/firebaseui-react"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; +import { UserCredential } from "firebase/auth"; +import { completeEmailLinkSignIn } from "@invertase/firebaseui-core"; + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + sendSignInLinkToEmail: vi.fn(), + completeEmailLinkSignIn: vi.fn(), + }; +}); + +vi.mock("@invertase/firebaseui-react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useEmailLinkAuthFormAction: vi.fn(), + }; +}); + +vi.mock("./policies", () => ({ + Policies: () =>
Policies
, +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the form correctly", () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + expect(container.querySelector("input[name='email']")).toBeInTheDocument(); + expect(container.querySelector("button[type='submit']")).toBeInTheDocument(); + }); + + it("should call the onEmailSent callback when the form is submitted successfully", async () => { + const mockAction = vi.fn().mockResolvedValue(undefined); + vi.mocked(useEmailLinkAuthFormAction).mockReturnValue(mockAction); + const onEmailSentMock = vi.fn(); + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + sendSignInLink: "Send Sign In Link", + }, + errors: { + invalidEmail: "Invalid email", + }, + }), + }); + + const { container } = render( + + + + ); + + const form = container.querySelector("form"); + expect(form).toBeInTheDocument(); + + const emailInput = container.querySelector("input[name='email']"); + + act(() => { + fireEvent.change(emailInput!, { target: { value: "test@example.com" } }); + }); + + await act(async () => { + fireEvent.submit(form!); + }); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalled(); + }); + + expect(mockAction).toHaveBeenCalledWith({ email: "test@example.com" }); + expect(onEmailSentMock).toHaveBeenCalled(); + }); + + it("should display error message when form submission fails", async () => { + const mockAction = vi.fn().mockRejectedValue(new Error("foo")); + + vi.mocked(useEmailLinkAuthFormAction).mockReturnValue(mockAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + sendSignInLink: "Send Sign In Link", + }, + }), + }); + + const { container } = render( + + + + ); + + const emailInput = container.querySelector("input[name='email']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(await screen.findByText("Error: foo")).toBeInTheDocument(); + }); + + it("should show success message after successful submission", async () => { + const mockAction = vi.fn().mockResolvedValue(undefined); + vi.mocked(useEmailLinkAuthFormAction).mockReturnValue(mockAction); + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + sendSignInLink: "Send Sign In Link", + }, + messages: { + signInLinkSent: "Sign in link sent to your email", + }, + }), + }); + + const { container } = render( + + + + ); + + const form = container.querySelector("form"); + const emailInput = container.querySelector("input[name='email']"); + + act(() => { + fireEvent.change(emailInput!, { target: { value: "test@example.com" } }); + }); + + await act(async () => { + fireEvent.submit(form!); + }); + + await waitFor(() => { + expect(screen.getByText("Sign in link sent to your email")).toBeInTheDocument(); + }); + + // Form should no longer be visible + expect(container.querySelector("form")).not.toBeInTheDocument(); + }); + + it("should not show success message initially", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + messages: { + signInLinkSent: "Sign in link sent to your email", + }, + }), + }); + + const { container } = render( + + + + ); + + expect(screen.queryByText("Sign in link sent to your email")).not.toBeInTheDocument(); + expect(container.querySelector("form")).toBeInTheDocument(); + }); + + it("should attempt to complete email link sign-in on mount", () => { + const completeEmailLinkSignInMock = vi.mocked(completeEmailLinkSignIn); + const mockUI = createMockUI(); + + render( + + + + ); + + expect(completeEmailLinkSignInMock).toHaveBeenCalled(); + }); + + it("should call onSignIn when email link sign-in is completed successfully", async () => { + const mockCredential = { credential: true } as unknown as UserCredential; + const completeEmailLinkSignInMock = vi.mocked(completeEmailLinkSignIn).mockResolvedValue(mockCredential); + const onSignInMock = vi.fn(); + const mockUI = createMockUI(); + + render( + + + + ); + + await waitFor(() => { + expect(completeEmailLinkSignInMock).toHaveBeenCalled(); + }); + + expect(onSignInMock).toHaveBeenCalledWith(mockCredential); + }); +}); diff --git a/packages/shadcn/src/components/email-link-auth-form.tsx b/packages/shadcn/src/components/email-link-auth-form.tsx new file mode 100644 index 000000000..f34c7a304 --- /dev/null +++ b/packages/shadcn/src/components/email-link-auth-form.tsx @@ -0,0 +1,99 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import type { EmailLinkAuthFormSchema } from "@invertase/firebaseui-core"; +import { FirebaseUIError, getTranslation } from "@invertase/firebaseui-core"; +import { + useEmailLinkAuthFormAction, + useEmailLinkAuthFormCompleteSignIn, + useEmailLinkAuthFormSchema, + useUI, + type EmailLinkAuthFormProps, +} from "@invertase/firebaseui-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +import { Policies } from "@/components/policies"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +export type { EmailLinkAuthFormProps }; + +export function EmailLinkAuthForm(props: EmailLinkAuthFormProps) { + const { onEmailSent, onSignIn } = props; + const ui = useUI(); + const schema = useEmailLinkAuthFormSchema(); + const action = useEmailLinkAuthFormAction(); + const [emailSent, setEmailSent] = useState(false); + + const form = useForm({ + resolver: standardSchemaResolver(schema), + defaultValues: { + email: "", + }, + }); + + useEmailLinkAuthFormCompleteSignIn(onSignIn); + + async function onSubmit(values: EmailLinkAuthFormSchema) { + try { + await action(values); + setEmailSent(true); + onEmailSent?.(); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + } + + if (emailSent) { + return ( + + {getTranslation(ui, "messages", "signInLinkSent")} + + ); + } + + return ( +
+ + ( + + {getTranslation(ui, "labels", "emailAddress")} + + + + + + )} + /> + + + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} diff --git a/packages/shadcn/src/components/email-link-auth-screen.test.tsx b/packages/shadcn/src/components/email-link-auth-screen.test.tsx new file mode 100644 index 000000000..3bc4493b5 --- /dev/null +++ b/packages/shadcn/src/components/email-link-auth-screen.test.tsx @@ -0,0 +1,350 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +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, type User } from "firebase/auth"; + +vi.mock("./email-link-auth-form", () => ({ + EmailLinkAuthForm: ({ onEmailSent, onSignIn }: any) => ( +
+
EmailLinkAuthForm
+ {onEmailSent &&
onEmailSent provided
} + {onSignIn &&
onSignIn provided
} +
+ ), +})); + +vi.mock("@/components/multi-factor-auth-assertion-screen", () => ({ + MultiFactorAuthAssertionScreen: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+ +
+ ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the screen correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "Sign In", + }, + prompts: { + signInToAccount: "Sign in to your account", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Sign In")).toBeInTheDocument(); + expect(screen.getByText("Sign in to your account")).toBeInTheDocument(); + expect(screen.getByTestId("email-link-auth-form")).toBeInTheDocument(); + }); + + it("should render with children", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "Sign In", + }, + prompts: { + signInToAccount: "Sign in to your account", + }, + messages: { + dividerOr: "or", + }, + }), + }); + + render( + + +
Child Component
+
+
+ ); + + expect(screen.getByText("Sign In")).toBeInTheDocument(); + expect(screen.getByText("Sign in to your account")).toBeInTheDocument(); + expect(screen.getByTestId("email-link-auth-form")).toBeInTheDocument(); + expect(screen.getByTestId("child-component")).toBeInTheDocument(); + }); + + it("should pass props to EmailLinkAuthForm", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "Sign In", + }, + prompts: { + signInToAccount: "Sign in to your account", + }, + }), + }); + + const onEmailSentMock = vi.fn(); + + render( + + + + ); + + expect(screen.getByTestId("onEmailSent-prop")).toBeInTheDocument(); + }); + + it("should not render separator when no children", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "Sign In", + }, + prompts: { + signInToAccount: "Sign in to your account", + }, + messages: { + dividerOr: "or", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Sign In")).toBeInTheDocument(); + expect(screen.getByText("Sign in to your account")).toBeInTheDocument(); + expect(screen.getByTestId("email-link-auth-form")).toBeInTheDocument(); + expect(screen.queryByText("or")).not.toBeInTheDocument(); + }); + + it("should render MultiFactorAuthAssertionScreen when multiFactorResolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "Sign In", + }, + prompts: { + signInToAccount: "Sign in to your account", + }, + }), + }); + mockUI.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("email-link-auth-form")).not.toBeInTheDocument(); + }); + + it("should not render EmailLinkAuthForm when MFA resolver exists", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "Sign In", + }, + prompts: { + signInToAccount: "Sign in to your account", + }, + messages: { + dividerOr: "or", + }, + }), + }); + mockUI.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + +
Child Component
+
+
+ ); + + expect(screen.queryByTestId("email-link-auth-form")).not.toBeInTheDocument(); + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeInTheDocument(); + expect(screen.queryByText("or")).not.toBeInTheDocument(); + expect(screen.queryByTestId("child-component")).not.toBeInTheDocument(); + }); + + it("should render EmailLinkAuthForm when MFA resolver is not present", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "Sign In", + }, + prompts: { + signInToAccount: "Sign in to your account", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("email-link-auth-form")).toBeInTheDocument(); + expect(screen.queryByTestId("multi-factor-auth-assertion-screen")).not.toBeInTheDocument(); + }); + + it("calls onSignIn with credential when MFA flow succeeds", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + 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(); + + render( + + + + ); + + const mockUser = { + uid: "email-link-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 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 new file mode 100644 index 000000000..dcb962dfa --- /dev/null +++ b/packages/shadcn/src/components/email-link-auth-screen.tsx @@ -0,0 +1,64 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +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"; +import { EmailLinkAuthForm } from "@/components/email-link-auth-form"; +import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen"; +import { RedirectError } from "@/components/redirect-error"; + +export type { EmailLinkAuthScreenProps }; + +export function EmailLinkAuthScreen({ children, onSignIn, ...props }: EmailLinkAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signIn"); + const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; + } + + return ( +
+ + + {titleText} + {subtitleText} + + + + {children ? ( + <> + +
+ {children} + +
+ + ) : null} +
+
+
+ ); +} diff --git a/packages/shadcn/src/components/facebook-sign-in-button.test.tsx b/packages/shadcn/src/components/facebook-sign-in-button.test.tsx new file mode 100644 index 000000000..daebce5fb --- /dev/null +++ b/packages/shadcn/src/components/facebook-sign-in-button.test.tsx @@ -0,0 +1,215 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { FacebookSignInButton } from "./facebook-sign-in-button"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { FacebookAuthProvider } from "firebase/auth"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; + +vi.mock("./oauth-button", () => ({ + OAuthButton: ({ provider, children, themed, onSignIn }: any) => ( +
+
{provider.providerId}
+
{String(themed)}
+
{onSignIn ? "present" : "absent"}
+
{children}
+
+ ), +})); + +vi.mock("@invertase/firebaseui-react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + FacebookLogo: ({ className, ...props }: any) => ( + + Facebook Logo + + ), + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with default Facebook provider", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("facebook.com"); + expect(screen.getByTestId("facebook-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with Facebook")).toBeInTheDocument(); + }); + + it("renders with custom Facebook provider", () => { + const customProvider = new FacebookAuthProvider(); + customProvider.addScope("email"); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("facebook.com"); + expect(screen.getByTestId("facebook-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with Facebook")).toBeInTheDocument(); + }); + + it("passes themed prop to OAuthButton", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).toHaveTextContent("true"); + }); + + it("renders Facebook logo with correct props", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + const facebookLogo = screen.getByTestId("facebook-logo"); + expect(facebookLogo).toBeInTheDocument(); + }); + + it("uses correct translation for button text", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Custom Facebook Sign In Text", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Custom Facebook Sign In Text")).toBeInTheDocument(); + }); + + it("renders children correctly", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + const childrenContainer = screen.getByTestId("children"); + expect(childrenContainer).toBeInTheDocument(); + + // Should contain both the Facebook logo and the text + expect(childrenContainer.querySelector('[data-testid="facebook-logo"]')).toBeInTheDocument(); + expect(childrenContainer).toHaveTextContent("Sign in with Facebook"); + }); + + it("handles missing themed prop", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).not.toHaveTextContent("true"); + }); + + it("passes onSignIn prop to OAuthButton", () => { + const onSignIn = vi.fn(); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("onSignIn")).toHaveTextContent("present"); + }); +}); diff --git a/packages/shadcn/src/components/facebook-sign-in-button.tsx b/packages/shadcn/src/components/facebook-sign-in-button.tsx new file mode 100644 index 000000000..0afdc44a8 --- /dev/null +++ b/packages/shadcn/src/components/facebook-sign-in-button.tsx @@ -0,0 +1,36 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { FacebookAuthProvider } from "firebase/auth"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type FacebookSignInButtonProps, FacebookLogo } from "@invertase/firebaseui-react"; + +import { OAuthButton } from "@/components/oauth-button"; + +export type { FacebookSignInButtonProps }; + +export function FacebookSignInButton({ provider, ...props }: FacebookSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithFacebook")} + + ); +} diff --git a/packages/shadcn/src/components/forgot-password-auth-form.test.tsx b/packages/shadcn/src/components/forgot-password-auth-form.test.tsx new file mode 100644 index 000000000..3a9a4efa3 --- /dev/null +++ b/packages/shadcn/src/components/forgot-password-auth-form.test.tsx @@ -0,0 +1,228 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { ForgotPasswordAuthForm } from "./forgot-password-auth-form"; +import { act } from "react"; +import { useForgotPasswordAuthFormAction } from "@invertase/firebaseui-react"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + sendPasswordResetEmail: vi.fn(), + }; +}); + +vi.mock("@invertase/firebaseui-react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useForgotPasswordAuthFormAction: vi.fn(), + }; +}); + +vi.mock("./policies", () => ({ + Policies: () =>
Policies
, +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the form correctly", () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + expect(container.querySelector("input[name='email']")).toBeInTheDocument(); + expect(container.querySelector("button[type='submit']")).toBeInTheDocument(); + }); + + it("should render with back to sign in callback", () => { + const onBackToSignInClickMock = vi.fn(); + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + backToSignIn: "backToSignIn", + }, + }), + }); + + const { container } = render( + + + + ); + + const button = container.querySelector("button[type='button']"); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent("backToSignIn"); + + act(() => { + fireEvent.click(button!); + }); + + expect(onBackToSignInClickMock).toHaveBeenCalled(); + }); + + it("should call the onPasswordSent callback when the form is submitted successfully", async () => { + const mockAction = vi.fn().mockResolvedValue(undefined); + vi.mocked(useForgotPasswordAuthFormAction).mockReturnValue(mockAction); + const onPasswordSentMock = vi.fn(); + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + resetPassword: "Reset Password", + }, + errors: { + invalidEmail: "Invalid email", + }, + }), + }); + + const { container } = render( + + + + ); + + const form = container.querySelector("form"); + expect(form).toBeInTheDocument(); + + const emailInput = container.querySelector("input[name='email']"); + + act(() => { + fireEvent.change(emailInput!, { target: { value: "test@example.com" } }); + }); + + await act(async () => { + fireEvent.submit(form!); + }); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalled(); + }); + + expect(mockAction).toHaveBeenCalledWith({ email: "test@example.com" }); + expect(onPasswordSentMock).toHaveBeenCalled(); + }); + + it("should display error message when form submission fails", async () => { + const mockAction = vi.fn().mockRejectedValue(new Error("foo")); + + vi.mocked(useForgotPasswordAuthFormAction).mockReturnValue(mockAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + resetPassword: "Reset Password", + }, + }), + }); + + const { container } = render( + + + + ); + + const emailInput = container.querySelector("input[name='email']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(await screen.findByText("Error: foo")).toBeInTheDocument(); + }); + + it("should show success message after successful submission", async () => { + const mockAction = vi.fn().mockResolvedValue(undefined); + vi.mocked(useForgotPasswordAuthFormAction).mockReturnValue(mockAction); + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + resetPassword: "Reset Password", + }, + messages: { + checkEmailForReset: "Check your email for reset instructions", + }, + }), + }); + + const { container } = render( + + + + ); + + const form = container.querySelector("form"); + const emailInput = container.querySelector("input[name='email']"); + + act(() => { + fireEvent.change(emailInput!, { target: { value: "test@example.com" } }); + }); + + await act(async () => { + fireEvent.submit(form!); + }); + + await waitFor(() => { + expect(screen.getByText("Check your email for reset instructions")).toBeInTheDocument(); + }); + + // Form should no longer be visible + expect(container.querySelector("form")).not.toBeInTheDocument(); + }); + + it("should not show success message initially", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + messages: { + checkEmailForReset: "Check your email for reset instructions", + }, + }), + }); + + const { container } = render( + + + + ); + + expect(screen.queryByText("Check your email for reset instructions")).not.toBeInTheDocument(); + expect(container.querySelector("form")).toBeInTheDocument(); + }); +}); diff --git a/packages/shadcn/src/components/forgot-password-auth-form.tsx b/packages/shadcn/src/components/forgot-password-auth-form.tsx new file mode 100644 index 000000000..c7fad3fe8 --- /dev/null +++ b/packages/shadcn/src/components/forgot-password-auth-form.tsx @@ -0,0 +1,99 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import type { ForgotPasswordAuthFormSchema } from "@invertase/firebaseui-core"; +import { + useForgotPasswordAuthFormAction, + useForgotPasswordAuthFormSchema, + useUI, + type ForgotPasswordAuthFormProps, +} from "@invertase/firebaseui-react"; +import { useForm } from "react-hook-form"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import { FirebaseUIError, getTranslation } from "@invertase/firebaseui-core"; +import { useState } from "react"; + +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Policies } from "./policies"; + +export type { ForgotPasswordAuthFormProps }; + +export function ForgotPasswordAuthForm(props: ForgotPasswordAuthFormProps) { + const ui = useUI(); + const schema = useForgotPasswordAuthFormSchema(); + const action = useForgotPasswordAuthFormAction(); + const [emailSent, setEmailSent] = useState(false); + + const form = useForm({ + resolver: standardSchemaResolver(schema), + defaultValues: { + email: "", + }, + }); + + async function onSubmit(values: ForgotPasswordAuthFormSchema) { + try { + await action(values); + setEmailSent(true); + props.onPasswordSent?.(); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + } + + if (emailSent) { + return ( +
+
{getTranslation(ui, "messages", "checkEmailForReset")}
+
+ ); + } + + return ( +
+ + ( + + {getTranslation(ui, "labels", "emailAddress")} + + + + + + )} + /> + + + {form.formState.errors.root && {form.formState.errors.root.message}} + {props.onBackToSignInClick ? ( + + ) : null} + + + ); +} diff --git a/packages/shadcn/src/components/forgot-password-auth-screen.test.tsx b/packages/shadcn/src/components/forgot-password-auth-screen.test.tsx new file mode 100644 index 000000000..f35b9a18d --- /dev/null +++ b/packages/shadcn/src/components/forgot-password-auth-screen.test.tsx @@ -0,0 +1,90 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { ForgotPasswordAuthScreen } from "./forgot-password-auth-screen"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; + +vi.mock("./forgot-password-auth-form", () => ({ + ForgotPasswordAuthForm: ({ onPasswordSent, onBackToSignInClick }: any) => ( +
+
ForgotPasswordAuthForm
+ {onPasswordSent &&
onPasswordSent provided
} + {onBackToSignInClick &&
onBackToSignInClick provided
} +
+ ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the screen correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + resetPassword: "Reset Password", + }, + prompts: { + enterEmailToReset: "Enter your email to reset your password", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Reset Password")).toBeInTheDocument(); + expect(screen.getByText("Enter your email to reset your password")).toBeInTheDocument(); + expect(screen.getByTestId("forgot-password-auth-form")).toBeInTheDocument(); + }); + + it("should pass props to ForgotPasswordAuthForm", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + resetPassword: "Reset Password", + }, + prompts: { + enterEmailToReset: "Enter your email to reset your password", + }, + }), + }); + + const onPasswordSentMock = vi.fn(); + const onBackToSignInClickMock = vi.fn(); + + render( + + + + ); + + expect(screen.getByTestId("onPasswordSent-prop")).toBeInTheDocument(); + expect(screen.getByTestId("onBackToSignInClick-prop")).toBeInTheDocument(); + }); +}); diff --git a/packages/shadcn/src/components/forgot-password-auth-screen.tsx b/packages/shadcn/src/components/forgot-password-auth-screen.tsx new file mode 100644 index 000000000..6f92dd180 --- /dev/null +++ b/packages/shadcn/src/components/forgot-password-auth-screen.tsx @@ -0,0 +1,46 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type ForgotPasswordAuthScreenProps } from "@invertase/firebaseui-react"; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ForgotPasswordAuthForm } from "@/components/forgot-password-auth-form"; + +export type { ForgotPasswordAuthScreenProps }; + +export function ForgotPasswordAuthScreen(props: ForgotPasswordAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "resetPassword"); + const subtitleText = getTranslation(ui, "prompts", "enterEmailToReset"); + + return ( +
+ + + {titleText} + {subtitleText} + + + + + +
+ ); +} diff --git a/packages/shadcn/src/components/github-sign-in-button.test.tsx b/packages/shadcn/src/components/github-sign-in-button.test.tsx new file mode 100644 index 000000000..7a35558b1 --- /dev/null +++ b/packages/shadcn/src/components/github-sign-in-button.test.tsx @@ -0,0 +1,215 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { GitHubSignInButton } from "./github-sign-in-button"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { GithubAuthProvider } from "firebase/auth"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; + +vi.mock("./oauth-button", () => ({ + OAuthButton: ({ provider, children, themed, onSignIn }: any) => ( +
+
{provider.providerId}
+
{String(themed)}
+
{onSignIn ? "present" : "absent"}
+
{children}
+
+ ), +})); + +vi.mock("@invertase/firebaseui-react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + GitHubLogo: ({ className, ...props }: any) => ( + + GitHub Logo + + ), + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with default GitHub provider", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("github.com"); + expect(screen.getByTestId("github-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with GitHub")).toBeInTheDocument(); + }); + + it("renders with custom GitHub provider", () => { + const customProvider = new GithubAuthProvider(); + customProvider.addScope("user:email"); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("github.com"); + expect(screen.getByTestId("github-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with GitHub")).toBeInTheDocument(); + }); + + it("passes themed prop to OAuthButton", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).toHaveTextContent("true"); + }); + + it("renders GitHub logo with correct props", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + const githubLogo = screen.getByTestId("github-logo"); + expect(githubLogo).toBeInTheDocument(); + }); + + it("uses correct translation for button text", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Custom GitHub Sign In Text", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Custom GitHub Sign In Text")).toBeInTheDocument(); + }); + + it("renders children correctly", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + const childrenContainer = screen.getByTestId("children"); + expect(childrenContainer).toBeInTheDocument(); + + // Should contain both the GitHub logo and the text + expect(childrenContainer.querySelector('[data-testid="github-logo"]')).toBeInTheDocument(); + expect(childrenContainer).toHaveTextContent("Sign in with GitHub"); + }); + + it("handles missing themed prop", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).not.toHaveTextContent("true"); + }); + + it("passes onSignIn prop to OAuthButton", () => { + const onSignIn = vi.fn(); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("onSignIn")).toHaveTextContent("present"); + }); +}); diff --git a/packages/shadcn/src/components/github-sign-in-button.tsx b/packages/shadcn/src/components/github-sign-in-button.tsx new file mode 100644 index 000000000..094160352 --- /dev/null +++ b/packages/shadcn/src/components/github-sign-in-button.tsx @@ -0,0 +1,36 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { GithubAuthProvider } from "firebase/auth"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type GitHubSignInButtonProps, GitHubLogo } from "@invertase/firebaseui-react"; + +import { OAuthButton } from "@/components/oauth-button"; + +export type { GitHubSignInButtonProps }; + +export function GitHubSignInButton({ provider, ...props }: GitHubSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithGitHub")} + + ); +} diff --git a/packages/shadcn/src/components/google-sign-in-button.test.tsx b/packages/shadcn/src/components/google-sign-in-button.test.tsx new file mode 100644 index 000000000..d7765f1c8 --- /dev/null +++ b/packages/shadcn/src/components/google-sign-in-button.test.tsx @@ -0,0 +1,215 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { GoogleSignInButton } from "./google-sign-in-button"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { GoogleAuthProvider } from "firebase/auth"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; + +vi.mock("./oauth-button", () => ({ + OAuthButton: ({ provider, children, themed, onSignIn }: any) => ( +
+
{provider.providerId}
+
{themed}
+
{onSignIn ? "present" : "absent"}
+
{children}
+
+ ), +})); + +vi.mock("@invertase/firebaseui-react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + GoogleLogo: ({ className, ...props }: any) => ( + + Google Logo + + ), + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with default Google provider", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("google.com"); + expect(screen.getByTestId("google-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with Google")).toBeInTheDocument(); + }); + + it("renders with custom Google provider", () => { + const customProvider = new GoogleAuthProvider(); + customProvider.addScope("email"); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("google.com"); + expect(screen.getByTestId("google-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with Google")).toBeInTheDocument(); + }); + + it("passes themed prop to OAuthButton", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).toHaveTextContent("neutral"); + }); + + it("renders Google logo with correct props", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + const googleLogo = screen.getByTestId("google-logo"); + expect(googleLogo).toBeInTheDocument(); + }); + + it("uses correct translation for button text", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Custom Google Sign In Text", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Custom Google Sign In Text")).toBeInTheDocument(); + }); + + it("renders children correctly", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + const childrenContainer = screen.getByTestId("children"); + expect(childrenContainer).toBeInTheDocument(); + + // Should contain both the Google logo and the text + expect(childrenContainer.querySelector('[data-testid="google-logo"]')).toBeInTheDocument(); + expect(childrenContainer).toHaveTextContent("Sign in with Google"); + }); + + it("handles missing themed prop", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).toHaveTextContent(""); + }); + + it("passes onSignIn prop to OAuthButton", () => { + const onSignIn = vi.fn(); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("onSignIn")).toHaveTextContent("present"); + }); +}); diff --git a/packages/shadcn/src/components/google-sign-in-button.tsx b/packages/shadcn/src/components/google-sign-in-button.tsx new file mode 100644 index 000000000..3cb91d41f --- /dev/null +++ b/packages/shadcn/src/components/google-sign-in-button.tsx @@ -0,0 +1,36 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { GoogleAuthProvider } from "firebase/auth"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type GoogleSignInButtonProps, GoogleLogo } from "@invertase/firebaseui-react"; + +import { OAuthButton } from "@/components/oauth-button"; + +export type { GoogleSignInButtonProps }; + +export function GoogleSignInButton({ provider, ...props }: GoogleSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithGoogle")} + + ); +} diff --git a/packages/shadcn/src/components/microsoft-sign-in-button.test.tsx b/packages/shadcn/src/components/microsoft-sign-in-button.test.tsx new file mode 100644 index 000000000..e7b9667f8 --- /dev/null +++ b/packages/shadcn/src/components/microsoft-sign-in-button.test.tsx @@ -0,0 +1,215 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { MicrosoftSignInButton } from "./microsoft-sign-in-button"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { OAuthProvider } from "firebase/auth"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; + +vi.mock("./oauth-button", () => ({ + OAuthButton: ({ provider, children, themed, onSignIn }: any) => ( +
+
{provider.providerId}
+
{String(themed)}
+
{onSignIn ? "present" : "absent"}
+
{children}
+
+ ), +})); + +vi.mock("@invertase/firebaseui-react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + MicrosoftLogo: ({ className, ...props }: any) => ( + + Microsoft Logo + + ), + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with default Microsoft provider", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("microsoft.com"); + expect(screen.getByTestId("microsoft-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with Microsoft")).toBeInTheDocument(); + }); + + it("renders with custom Microsoft provider", () => { + const customProvider = new OAuthProvider("microsoft.com"); + customProvider.addScope("email"); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("microsoft.com"); + expect(screen.getByTestId("microsoft-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with Microsoft")).toBeInTheDocument(); + }); + + it("passes themed prop to OAuthButton", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).toHaveTextContent("true"); + }); + + it("renders Microsoft logo with correct props", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + const microsoftLogo = screen.getByTestId("microsoft-logo"); + expect(microsoftLogo).toBeInTheDocument(); + }); + + it("uses correct translation for button text", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Custom Microsoft Sign In Text", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Custom Microsoft Sign In Text")).toBeInTheDocument(); + }); + + it("renders children correctly", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + const childrenContainer = screen.getByTestId("children"); + expect(childrenContainer).toBeInTheDocument(); + + // Should contain both the Microsoft logo and the text + expect(childrenContainer.querySelector('[data-testid="microsoft-logo"]')).toBeInTheDocument(); + expect(childrenContainer).toHaveTextContent("Sign in with Microsoft"); + }); + + it("handles missing themed prop", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).not.toHaveTextContent("true"); + }); + + it("passes onSignIn prop to OAuthButton", () => { + const onSignIn = vi.fn(); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("onSignIn")).toHaveTextContent("present"); + }); +}); diff --git a/packages/shadcn/src/components/microsoft-sign-in-button.tsx b/packages/shadcn/src/components/microsoft-sign-in-button.tsx new file mode 100644 index 000000000..b2aa2a3c0 --- /dev/null +++ b/packages/shadcn/src/components/microsoft-sign-in-button.tsx @@ -0,0 +1,36 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { OAuthProvider } from "firebase/auth"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type MicrosoftSignInButtonProps, MicrosoftLogo } from "@invertase/firebaseui-react"; + +import { OAuthButton } from "@/components/oauth-button"; + +export type { MicrosoftSignInButtonProps }; + +export function MicrosoftSignInButton({ provider, ...props }: MicrosoftSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithMicrosoft")} + + ); +} diff --git a/packages/shadcn/src/components/multi-factor-auth-assertion-form.test.tsx b/packages/shadcn/src/components/multi-factor-auth-assertion-form.test.tsx new file mode 100644 index 000000000..e3995cfb6 --- /dev/null +++ b/packages/shadcn/src/components/multi-factor-auth-assertion-form.test.tsx @@ -0,0 +1,310 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { MultiFactorAuthAssertionForm } from "./multi-factor-auth-assertion-form"; +import { createFirebaseUIProvider, createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { MultiFactorResolver, PhoneMultiFactorGenerator, TotpMultiFactorGenerator } from "firebase/auth"; + +const mockUseMultiFactorAssertionCleanup = vi.fn(); +vi.mock("@invertase/firebaseui-react", async () => { + const actual = await vi.importActual("@invertase/firebaseui-react"); + return { + ...actual, + useMultiFactorAssertionCleanup: () => mockUseMultiFactorAssertionCleanup(), + }; +}); + +vi.mock("@/components/sms-multi-factor-assertion-form", () => ({ + SmsMultiFactorAssertionForm: ({ hint, onSuccess }: { hint: any; onSuccess?: (credential: any) => void }) => ( +
+
{hint?.factorId || "undefined"}
+ +
+ ), +})); + +vi.mock("@/components/totp-multi-factor-assertion-form", () => ({ + TotpMultiFactorAssertionForm: ({ hint, onSuccess }: { hint: any; onSuccess?: (credential: any) => void }) => ( +
+
{hint?.factorId || "undefined"}
+ +
+ ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseMultiFactorAssertionCleanup.mockClear(); + }); + + afterEach(() => { + cleanup(); + }); + + it("calls useMultiFactorAssertionCleanup when component renders", () => { + const mockResolver: MultiFactorResolver = { + hints: [ + { + uid: "test-uid", + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Test Phone", + }, + ], + } as MultiFactorResolver; + + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + createFirebaseUIProvider({ + children: , + ui: ui, + }) + ); + + expect(mockUseMultiFactorAssertionCleanup).toHaveBeenCalledTimes(1); + }); + + it("throws error when no multiFactorResolver is present", () => { + const ui = createMockUI(); + + expect(() => { + render( + createFirebaseUIProvider({ + children: , + ui: ui, + }) + ); + }).toThrow("MultiFactorAuthAssertionForm requires a multi-factor resolver"); + }); + + it("auto-selects single hint and renders corresponding form", () => { + const mockResolver: MultiFactorResolver = { + hints: [ + { + uid: "test-uid", + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Test Phone", + }, + ], + } as MultiFactorResolver; + + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + createFirebaseUIProvider({ + children: , + ui: ui, + }) + ); + + expect(screen.getByTestId("sms-assertion-form")).toBeInTheDocument(); + expect(screen.getByTestId("sms-hint-factor-id")).toHaveTextContent(PhoneMultiFactorGenerator.FACTOR_ID); + }); + + it("shows buttons for multiple hints and allows selection", () => { + const mockResolver: MultiFactorResolver = { + hints: [ + { + uid: "test-uid-1", + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Test Phone", + }, + { + uid: "test-uid-2", + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "Test TOTP", + }, + ], + } as MultiFactorResolver; + + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + createFirebaseUIProvider({ + children: , + ui: ui, + }) + ); + + expect(screen.getByRole("button", { name: "Set up SMS" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Set up TOTP" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Set up TOTP" })); + + expect(screen.getByTestId("totp-assertion-form")).toBeInTheDocument(); + expect(screen.getByTestId("totp-hint-factor-id")).toHaveTextContent(TotpMultiFactorGenerator.FACTOR_ID); + expect(screen.queryByTestId("sms-assertion-form")).not.toBeInTheDocument(); + }); + + it("renders SMS form when SMS hint is selected", () => { + const mockResolver: MultiFactorResolver = { + hints: [ + { + uid: "test-uid-1", + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Test Phone", + }, + { + uid: "test-uid-2", + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "Test TOTP", + }, + ], + } as MultiFactorResolver; + + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + createFirebaseUIProvider({ + children: , + ui: ui, + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "Set up SMS" })); + + expect(screen.getByTestId("sms-assertion-form")).toBeInTheDocument(); + expect(screen.getByTestId("sms-hint-factor-id")).toHaveTextContent(PhoneMultiFactorGenerator.FACTOR_ID); + expect(screen.queryByTestId("totp-assertion-form")).not.toBeInTheDocument(); + }); + + it("shows selection message when multiple hints are available", () => { + const mockResolver: MultiFactorResolver = { + hints: [ + { + uid: "test-uid-1", + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Test Phone", + }, + { + uid: "test-uid-2", + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "Test TOTP", + }, + ], + } as MultiFactorResolver; + + const ui = createMockUI({ + locale: registerLocale("test", { + prompts: { + mfaAssertionFactorPrompt: "Please choose a multi-factor authentication method", + }, + }), + }); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + createFirebaseUIProvider({ + children: , + ui: ui, + }) + ); + + expect(screen.getByText("Please choose a multi-factor authentication method")).toBeInTheDocument(); + }); + + it("calls onSuccess with credential when SMS form succeeds", () => { + const mockResolver: MultiFactorResolver = { + hints: [ + { + uid: "test-uid", + factorId: PhoneMultiFactorGenerator.FACTOR_ID, + displayName: "Test Phone", + }, + ], + } as MultiFactorResolver; + + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSuccess = vi.fn(); + + render( + createFirebaseUIProvider({ + children: , + ui: ui, + }) + ); + + expect(screen.getByTestId("sms-assertion-form")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("sms-on-success")); + + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "sms-mfa-user" }) }) + ); + }); + + it("calls onSuccess with credential when TOTP form succeeds", () => { + const mockResolver: MultiFactorResolver = { + hints: [ + { + uid: "test-uid", + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "Test TOTP", + }, + ], + } as MultiFactorResolver; + + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSuccess = vi.fn(); + + render( + createFirebaseUIProvider({ + children: , + ui: ui, + }) + ); + + expect(screen.getByTestId("totp-assertion-form")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("totp-on-success")); + + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "totp-mfa-user" }) }) + ); + }); +}); diff --git a/packages/shadcn/src/components/multi-factor-auth-assertion-form.tsx b/packages/shadcn/src/components/multi-factor-auth-assertion-form.tsx new file mode 100644 index 000000000..24930fb1d --- /dev/null +++ b/packages/shadcn/src/components/multi-factor-auth-assertion-form.tsx @@ -0,0 +1,92 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI } from "@invertase/firebaseui-react"; +import { + PhoneMultiFactorGenerator, + TotpMultiFactorGenerator, + type MultiFactorInfo, + type UserCredential, +} from "firebase/auth"; +import { useState, type ComponentProps } from "react"; +import { useMultiFactorAssertionCleanup } from "@invertase/firebaseui-react"; + +import { SmsMultiFactorAssertionForm } from "@/components/sms-multi-factor-assertion-form"; +import { TotpMultiFactorAssertionForm } from "@/components/totp-multi-factor-assertion-form"; +import { Button } from "@/components/ui/button"; + +export type MultiFactorAuthAssertionFormProps = { + onSuccess?: (credential: UserCredential) => void; +}; + +export function MultiFactorAuthAssertionForm({ onSuccess }: MultiFactorAuthAssertionFormProps) { + const ui = useUI(); + const resolver = ui.multiFactorResolver; + const mfaAssertionFactorPrompt = getTranslation(ui, "prompts", "mfaAssertionFactorPrompt"); + + useMultiFactorAssertionCleanup(); + + if (!resolver) { + throw new Error("MultiFactorAuthAssertionForm requires a multi-factor resolver"); + } + + // If only a single hint is provided, select it by default to improve UX. + const [hint, setHint] = useState( + resolver.hints.length === 1 ? resolver.hints[0] : undefined + ); + + if (hint) { + if (hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID) { + return ; + } + + if (hint.factorId === TotpMultiFactorGenerator.FACTOR_ID) { + return ; + } + } + + return ( +
+

{mfaAssertionFactorPrompt}

+ {resolver.hints.map((hint) => { + if (hint.factorId === TotpMultiFactorGenerator.FACTOR_ID) { + return setHint(hint)} />; + } + + if (hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID) { + return setHint(hint)} />; + } + + return null; + })} +
+ ); +} + +function TotpButton(props: ComponentProps) { + const ui = useUI(); + const labelText = getTranslation(ui, "labels", "mfaTotpVerification"); + return ; +} + +function SmsButton(props: ComponentProps) { + const ui = useUI(); + const labelText = getTranslation(ui, "labels", "mfaSmsVerification"); + return ; +} diff --git a/packages/shadcn/src/components/multi-factor-auth-assertion-screen.test.tsx b/packages/shadcn/src/components/multi-factor-auth-assertion-screen.test.tsx new file mode 100644 index 000000000..e0c12197c --- /dev/null +++ b/packages/shadcn/src/components/multi-factor-auth-assertion-screen.test.tsx @@ -0,0 +1,118 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createMockUI } from "../../tests/utils"; +import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; + +vi.mock("./multi-factor-auth-assertion-form", () => ({ + MultiFactorAuthAssertionForm: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+
{onSuccess &&
onSuccess
}
+
+ ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the screen correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + multiFactorAssertion: "Multi-Factor Authentication", + }, + prompts: { + mfaAssertionPrompt: "Please complete the multi-factor authentication process", + }, + }), + }); + + const { container } = render( + + + + ); + + expect(screen.getByText("Multi-Factor Authentication")).toBeInTheDocument(); + expect(screen.getByText("Please complete the multi-factor authentication process")).toBeInTheDocument(); + expect(screen.getByTestId("multi-factor-auth-assertion-form")).toBeInTheDocument(); + + const card = container.querySelector(".max-w-sm.mx-auto"); + expect(card).toBeInTheDocument(); + }); + + it("should pass props to the assertion form", () => { + const mockOnSuccess = vi.fn(); + const mockUI = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("on-success")).toBeInTheDocument(); + }); + + it("should use correct translation keys", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + multiFactorAssertion: "Complete MFA", + }, + prompts: { + mfaAssertionPrompt: "Verify your identity", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Complete MFA")).toBeInTheDocument(); + expect(screen.getByText("Verify your identity")).toBeInTheDocument(); + }); + + it("should render with correct CSS classes", () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + const mainContainer = container.querySelector(".max-w-sm.mx-auto"); + expect(mainContainer).toBeInTheDocument(); + + // Check for any card-like element instead of specific radix attribute + const card = container.querySelector(".max-w-sm.mx-auto > div"); + expect(card).toBeInTheDocument(); + }); +}); diff --git a/packages/shadcn/src/components/multi-factor-auth-assertion-screen.tsx b/packages/shadcn/src/components/multi-factor-auth-assertion-screen.tsx new file mode 100644 index 000000000..b841181c3 --- /dev/null +++ b/packages/shadcn/src/components/multi-factor-auth-assertion-screen.tsx @@ -0,0 +1,46 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type MultiFactorAuthAssertionScreenProps } from "@invertase/firebaseui-react"; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { MultiFactorAuthAssertionForm } from "@/components/multi-factor-auth-assertion-form"; + +export type MultiFactorAuthEnrollmentScreenProps = MultiFactorAuthAssertionScreenProps; + +export function MultiFactorAuthAssertionScreen(props: MultiFactorAuthEnrollmentScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "multiFactorAssertion"); + const subtitleText = getTranslation(ui, "prompts", "mfaAssertionPrompt"); + + return ( +
+ + + {titleText} + {subtitleText} + + + + + +
+ ); +} diff --git a/packages/shadcn/src/components/multi-factor-auth-enrollment-form.test.tsx b/packages/shadcn/src/components/multi-factor-auth-enrollment-form.test.tsx new file mode 100644 index 000000000..b2efec12e --- /dev/null +++ b/packages/shadcn/src/components/multi-factor-auth-enrollment-form.test.tsx @@ -0,0 +1,336 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { MultiFactorAuthEnrollmentForm } from "./multi-factor-auth-enrollment-form"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; +import { FactorId } from "firebase/auth"; + +vi.mock("./sms-multi-factor-enrollment-form", () => ({ + SmsMultiFactorEnrollmentForm: ({ onSuccess }: { onSuccess?: () => void }) => ( +
+
{onSuccess &&
onSuccess
}
+
+ ), +})); + +vi.mock("./totp-multi-factor-enrollment-form", () => ({ + TotpMultiFactorEnrollmentForm: ({ onSuccess }: { onSuccess?: () => void }) => ( +
+
{onSuccess &&
onSuccess
}
+
+ ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders with default hints (TOTP and PHONE) when no hints provided", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + render( + + + + ); + + // Should show both buttons since we have multiple hints (since no prop) + expect(screen.getByRole("button", { name: "Set up TOTP" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Set up SMS" })).toBeInTheDocument(); + }); + + it("renders with custom hints when provided", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument(); + }); + + it("auto-selects single hint and renders corresponding form", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument(); + }); + + it("auto-selects SMS hint and renders corresponding form", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("sms-multi-factor-enrollment-form")).toBeInTheDocument(); + }); + + it("shows buttons for multiple hints and allows selection", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "Set up TOTP" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Set up SMS" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Set up TOTP" })); + + expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument(); + }); + + it("shows buttons for multiple hints and allows SMS selection", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "Set up TOTP" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Set up SMS" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Set up SMS" })); + + expect(screen.getByTestId("sms-multi-factor-enrollment-form")).toBeInTheDocument(); + }); + + it("passes onEnrollment prop to TOTP form when auto-selected", () => { + const mockOnEnrollment = vi.fn(); + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("totp-on-success")).toBeInTheDocument(); + }); + + it("passes onEnrollment prop to SMS form when auto-selected", () => { + const mockOnEnrollment = vi.fn(); + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("sms-on-enrollment")).toBeInTheDocument(); + }); + + it("passes onEnrollment prop to TOTP form when selected via button", () => { + const mockOnEnrollment = vi.fn(); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Set up TOTP" })); + + expect(screen.getByTestId("totp-on-success")).toBeInTheDocument(); + }); + + it("passes onEnrollment prop to SMS form when selected via button", () => { + const mockOnEnrollment = vi.fn(); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Set up SMS" })); + + expect(screen.getByTestId("sms-on-enrollment")).toBeInTheDocument(); + }); + + it("throws error when hints array is empty", () => { + const ui = createMockUI(); + + expect(() => { + render( + + + + ); + }).toThrow("MultiFactorAuthEnrollmentForm must have at least one hint"); + }); + + it("throws error for unknown hint type", () => { + const ui = createMockUI(); + + const unknownHint = "unknown" as any; + + expect(() => { + render( + + + + ); + }).toThrow("Unknown multi-factor enrollment type: unknown"); + }); + + it("uses correct translation keys for buttons", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Configure TOTP Authentication", + mfaSmsVerification: "Configure SMS Authentication", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "Configure TOTP Authentication" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Configure SMS Authentication" })).toBeInTheDocument(); + }); + + it("renders with correct CSS classes", () => { + const ui = createMockUI(); + + const { container } = render( + + + + ); + + const contentDiv = container.querySelector(".flex.flex-col.gap-2"); + expect(contentDiv).toBeInTheDocument(); + }); + + it("handles mixed hint types correctly", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "Set up TOTP" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Set up SMS" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Set up TOTP" })); + + expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument(); + expect(screen.queryByTestId("sms-multi-factor-enrollment-form")).not.toBeInTheDocument(); + }); + + it("maintains state correctly when switching between hints", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + mfaTotpVerification: "Set up TOTP", + mfaSmsVerification: "Set up SMS", + }, + }), + }); + + const { rerender } = render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Set up TOTP" })); + expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument(); + + rerender( + + + + ); + + expect(screen.getByTestId("totp-multi-factor-enrollment-form")).toBeInTheDocument(); + }); +}); diff --git a/packages/shadcn/src/components/multi-factor-auth-enrollment-form.tsx b/packages/shadcn/src/components/multi-factor-auth-enrollment-form.tsx new file mode 100644 index 000000000..451757dfd --- /dev/null +++ b/packages/shadcn/src/components/multi-factor-auth-enrollment-form.tsx @@ -0,0 +1,94 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { type ComponentProps, useState } from "react"; +import { FactorId } from "firebase/auth"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI } from "@invertase/firebaseui-react"; + +import { SmsMultiFactorEnrollmentForm } from "@/components/sms-multi-factor-enrollment-form"; +import { TotpMultiFactorEnrollmentForm } from "@/components/totp-multi-factor-enrollment-form"; +import { Button } from "@/components/ui/button"; + +type Hint = (typeof FactorId)[keyof typeof FactorId]; + +export type MultiFactorAuthEnrollmentFormProps = { + onEnrollment?: () => void; + hints?: Hint[]; +}; + +const DEFAULT_HINTS = [FactorId.TOTP, FactorId.PHONE] as const; + +export function MultiFactorAuthEnrollmentForm(props: MultiFactorAuthEnrollmentFormProps) { + const hints = props.hints ?? DEFAULT_HINTS; + + if (hints.length === 0) { + throw new Error("MultiFactorAuthEnrollmentForm must have at least one hint"); + } + + // If only a single hint is provided, select it by default to improve UX. + const [hint, setHint] = useState(hints.length === 1 ? hints[0] : undefined); + + if (hint) { + if (hint === FactorId.TOTP) { + return ; + } + + if (hint === FactorId.PHONE) { + return ; + } + + throw new Error(`Unknown multi-factor enrollment type: ${hint}`); + } + + return ( +
+ {hints.map((hint) => { + if (hint === FactorId.TOTP) { + return setHint(hint)} />; + } + + if (hint === FactorId.PHONE) { + return setHint(hint)} />; + } + + return null; + })} +
+ ); +} + +function TotpButton(props: ComponentProps) { + const ui = useUI(); + const labelText = getTranslation(ui, "labels", "mfaTotpVerification"); + return ( + + ); +} + +function SmsButton(props: ComponentProps) { + const ui = useUI(); + const labelText = getTranslation(ui, "labels", "mfaSmsVerification"); + return ( + + ); +} diff --git a/packages/shadcn/src/components/multi-factor-auth-enrollment-screen.test.tsx b/packages/shadcn/src/components/multi-factor-auth-enrollment-screen.test.tsx new file mode 100644 index 000000000..553f4ed25 --- /dev/null +++ b/packages/shadcn/src/components/multi-factor-auth-enrollment-screen.test.tsx @@ -0,0 +1,118 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { MultiFactorAuthEnrollmentScreen } from "./multi-factor-auth-enrollment-screen"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; + +vi.mock("./multi-factor-auth-enrollment-form", () => ({ + MultiFactorAuthEnrollmentForm: ({ onEnrollment }: { onEnrollment?: () => void }) => ( +
+
{onEnrollment &&
onEnrollment
}
+
+ ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the screen correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + multiFactorEnrollment: "Multi-Factor Authentication Setup", + }, + prompts: { + mfaEnrollmentPrompt: "Set up an additional security method for your account", + }, + }), + }); + + const { container } = render( + + + + ); + + expect(screen.getByText("Multi-Factor Authentication Setup")).toBeInTheDocument(); + expect(screen.getByText("Set up an additional security method for your account")).toBeInTheDocument(); + expect(screen.getByTestId("multi-factor-auth-enrollment-form")).toBeInTheDocument(); + + const card = container.querySelector(".max-w-sm.mx-auto"); + expect(card).toBeInTheDocument(); + }); + + it("should pass props to the enrollment form", () => { + const mockOnEnrollment = vi.fn(); + const mockUI = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("on-enrollment")).toBeInTheDocument(); + }); + + it("should use correct translation keys", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + multiFactorEnrollment: "Configure MFA", + }, + prompts: { + mfaEnrollmentPrompt: "Add extra security", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Configure MFA")).toBeInTheDocument(); + expect(screen.getByText("Add extra security")).toBeInTheDocument(); + }); + + it("should render with correct CSS classes", () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + const mainContainer = container.querySelector(".max-w-sm.mx-auto"); + expect(mainContainer).toBeInTheDocument(); + + // Check for any card-like element instead of specific radix attribute + const card = container.querySelector(".max-w-sm.mx-auto > div"); + expect(card).toBeInTheDocument(); + }); +}); diff --git a/packages/shadcn/src/components/multi-factor-auth-enrollment-screen.tsx b/packages/shadcn/src/components/multi-factor-auth-enrollment-screen.tsx new file mode 100644 index 000000000..eba761160 --- /dev/null +++ b/packages/shadcn/src/components/multi-factor-auth-enrollment-screen.tsx @@ -0,0 +1,46 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type MultiFactorAuthEnrollmentFormProps } from "@invertase/firebaseui-react"; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { MultiFactorAuthEnrollmentForm } from "@/components/multi-factor-auth-enrollment-form"; + +export type MultiFactorAuthEnrollmentScreenProps = MultiFactorAuthEnrollmentFormProps; + +export function MultiFactorAuthEnrollmentScreen(props: MultiFactorAuthEnrollmentScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "multiFactorEnrollment"); + const subtitleText = getTranslation(ui, "prompts", "mfaEnrollmentPrompt"); + + return ( +
+ + + {titleText} + {subtitleText} + + + + + +
+ ); +} diff --git a/packages/shadcn/src/components/oauth-button.test.tsx b/packages/shadcn/src/components/oauth-button.test.tsx new file mode 100644 index 000000000..ccbddf858 --- /dev/null +++ b/packages/shadcn/src/components/oauth-button.test.tsx @@ -0,0 +1,309 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { OAuthButton } from "./oauth-button"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import type { AuthProvider, UserCredential } from "firebase/auth"; +import { ComponentProps } from "react"; + +import { signInWithProvider } from "@invertase/firebaseui-core"; +import { FirebaseError } from "firebase/app"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...(mod as object), + signInWithProvider: vi.fn(), + }; +}); + +vi.mock("@/components/ui/button", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + Button: (props: ComponentProps<"button">) => , + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + const mockGoogleProvider = { providerId: "google.com" } as AuthProvider; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders a button with the provided children", () => { + const ui = createMockUI(); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + expect(button).toBeDefined(); + expect(button.textContent).toBe("Sign in with Google"); + }); + + it("applies correct attributes", () => { + const ui = createMockUI(); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + expect(button.getAttribute("type")).toBe("button"); + expect(button.getAttribute("data-provider")).toBe("google.com"); + }); + + it("applies themed attribute when provided", () => { + const ui = createMockUI(); + + render( + + + Sign in with Google + + + ); + + const button = screen.getByTestId("oauth-button"); + expect(button.getAttribute("data-themed")).toBe("neutral"); + }); + + it("is disabled when UI state is not idle", () => { + const ui = createMockUI(); + ui.setKey("state", "pending"); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + expect(button).toHaveAttribute("disabled"); + }); + + it("is enabled when UI state is idle", () => { + const ui = createMockUI(); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + expect(button).not.toHaveAttribute("disabled"); + }); + + it("calls signInWithProvider when clicked", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + + const ui = createMockUI(); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + fireEvent.click(button); + + expect(mockSignInWithProvider).toHaveBeenCalledTimes(1); + expect(mockSignInWithProvider).toHaveBeenCalledWith(expect.anything(), mockGoogleProvider); + }); + + it("calls onSignIn callback when sign-in is successful", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const mockCredential = { user: { uid: "test-uid" } } as UserCredential; + const onSignIn = vi.fn(); + mockSignInWithProvider.mockResolvedValue(mockCredential); + + const ui = createMockUI(); + + render( + + + Sign in with Google + + + ); + + const button = screen.getByTestId("oauth-button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith(mockCredential); + }); + }); + + it("does not call onSignIn callback when sign-in fails", async () => { + const { FirebaseUIError } = await import("@invertase/firebaseui-core"); + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const onSignIn = vi.fn(); + const ui = createMockUI(); + const mockError = new FirebaseUIError( + ui.get(), + new FirebaseError("auth/user-not-found", "No account found with this email address") + ); + mockSignInWithProvider.mockRejectedValue(mockError); + + render( + + + Sign in with Google + + + ); + + const button = screen.getByTestId("oauth-button"); + fireEvent.click(button); + + await waitFor(() => { + expect(onSignIn).not.toHaveBeenCalled(); + }); + }); + + it("displays FirebaseUIError message when FirebaseUIError occurs", async () => { + const { FirebaseUIError } = await import("@invertase/firebaseui-core"); + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const ui = createMockUI(); + const mockError = new FirebaseUIError( + ui.get(), + new FirebaseError("auth/user-not-found", "No account found with this email address") + ); + mockSignInWithProvider.mockRejectedValue(mockError); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + fireEvent.click(button); + + await waitFor(() => { + const errorMessage = screen.getByText("No account found with this email address"); + expect(errorMessage).toBeDefined(); + + // Make sure we use the shadcn theme name, rather than a "text-red-500" + expect(errorMessage.className).toContain("text-destructive"); + }); + }); + + it("displays unknown error message when non-Firebase error occurs", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const regularError = new Error("Regular error"); + mockSignInWithProvider.mockRejectedValue(regularError); + + // Mock console.error to prevent test output noise + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const ui = createMockUI({ + locale: registerLocale("test", { + errors: { + unknownError: "unknownError", + }, + }), + }); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + fireEvent.click(button); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith(regularError); + + const errorMessage = screen.getByText("unknownError"); + expect(errorMessage).toBeDefined(); + + // Make sure we use the shadcn theme name, rather than a "text-red-500" + expect(errorMessage.className).toContain("text-destructive"); + }); + + // Restore console.error + consoleErrorSpy.mockRestore(); + }); + + it("clears error when button is clicked again", async () => { + const { FirebaseUIError } = await import("@invertase/firebaseui-core"); + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const ui = createMockUI(); + + // First call fails, second call succeeds + mockSignInWithProvider + .mockRejectedValueOnce( + new FirebaseUIError(ui.get(), new FirebaseError("auth/wrong-password", "Incorrect password")) + ) + .mockResolvedValueOnce({} as UserCredential); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + + // First click - should show error + fireEvent.click(button); + + await waitFor(() => { + const errorMessage = screen.getByText("Incorrect password"); + expect(errorMessage).toBeDefined(); + }); + + // Second click - should clear error + fireEvent.click(button); + await waitFor(() => { + expect(screen.queryByText("Incorrect password")).toBeNull(); + }); + }); + + it("does not display error message initially", () => { + const ui = createMockUI(); + + render( + + Sign in with Google + + ); + + expect(screen.queryByText("No account found with this email address")).toBeNull(); + }); +}); diff --git a/packages/shadcn/src/components/oauth-button.tsx b/packages/shadcn/src/components/oauth-button.tsx new file mode 100644 index 000000000..2d59b3553 --- /dev/null +++ b/packages/shadcn/src/components/oauth-button.tsx @@ -0,0 +1,45 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { useUI, type OAuthButtonProps, useSignInWithProvider } from "@invertase/firebaseui-react"; +import { Button } from "@/components/ui/button"; + +export type { OAuthButtonProps }; + +export function OAuthButton({ provider, children, themed, onSignIn }: OAuthButtonProps) { + const ui = useUI(); + + const { error, callback } = useSignInWithProvider(provider, onSignIn); + + return ( +
+ + {error &&
{error}
} +
+ ); +} diff --git a/packages/shadcn/src/components/oauth-screen.test.tsx b/packages/shadcn/src/components/oauth-screen.test.tsx new file mode 100644 index 000000000..bb49c5799 --- /dev/null +++ b/packages/shadcn/src/components/oauth-screen.test.tsx @@ -0,0 +1,334 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +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, type User } from "firebase/auth"; + +vi.mock("@/components/policies", () => ({ + Policies: () =>
Policies
, +})); + +vi.mock("@/components/redirect-error", () => ({ + RedirectError: () =>
Redirect Error
, +})); + +vi.mock("@/components/multi-factor-auth-assertion-screen", () => ({ + MultiFactorAuthAssertionScreen: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+ +
+ ), +})); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders with correct title and subtitle", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "signIn", + }, + prompts: { + signInToAccount: "signInToAccount", + }, + }), + }); + + render( + + OAuth Provider + + ); + + const title = screen.getByText("signIn"); + expect(title).toBeDefined(); + + const subtitle = screen.getByText("signInToAccount"); + expect(subtitle).toBeDefined(); + }); + + it("renders children", () => { + const ui = createMockUI(); + + render( + + OAuth Provider + + ); + + expect(screen.getByText("OAuth Provider")).toBeDefined(); + }); + + it("renders multiple children when provided", () => { + const ui = createMockUI(); + + render( + + +
Provider 1
+
Provider 2
+
+
+ ); + + expect(screen.getByText("Provider 1")).toBeDefined(); + expect(screen.getByText("Provider 2")).toBeDefined(); + }); + + it("includes the Policies component", () => { + const ui = createMockUI(); + + render( + + OAuth Provider + + ); + + expect(screen.getByTestId("policies")).toBeDefined(); + }); + + it("renders children before the Policies component", () => { + const ui = createMockUI(); + + render( + + +
OAuth Provider
+
+
+ ); + + const oauthProvider = screen.getByTestId("oauth-provider"); + const policies = screen.getByTestId("policies"); + + expect(oauthProvider).toBeDefined(); + expect(policies).toBeDefined(); + + const oauthContainer = oauthProvider.closest(".space-y-2"); + const policiesContainer = policies.closest(".mt-4.space-y-4"); + + expect(oauthContainer).toBeDefined(); + expect(policiesContainer).toBeDefined(); + + const cardContent = oauthContainer?.parentElement; + const children = Array.from(cardContent?.children || []); + const oauthContainerIndex = children.indexOf(oauthContainer as Element); + const policiesContainerIndex = children.indexOf(policiesContainer as Element); + + expect(oauthContainerIndex).toBeLessThan(policiesContainerIndex); + }); + + it("renders MultiFactorAuthAssertionScreen when multiFactorResolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + OAuth Provider + + ); + + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + expect(screen.queryByText("OAuth Provider")).toBeNull(); + expect(screen.queryByTestId("policies")).toBeNull(); + }); + + it("does not render children or Policies when MFA resolver exists", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + +
OAuth Provider
+
+
+ ); + + expect(screen.queryByTestId("oauth-provider")).toBeNull(); + expect(screen.queryByTestId("policies")).toBeNull(); + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + }); + + it("renders RedirectError component with children when no MFA resolver", () => { + const ui = createMockUI(); + + render( + + +
OAuth Provider
+
+
+ ); + + expect(screen.getByTestId("redirect-error")).toBeDefined(); + expect(screen.getByTestId("oauth-provider")).toBeDefined(); + expect(screen.getByTestId("policies")).toBeDefined(); + }); + + it("does not render RedirectError when MFA resolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + +
OAuth Provider
+
+
+ ); + + expect(screen.queryByTestId("redirect-error")).toBeNull(); + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + }); + + it("calls onSignIn with credential when MFA flow succeeds", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + 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(); + + render( + + OAuth Provider + + ); + + 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(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 new file mode 100644 index 000000000..97cd600ce --- /dev/null +++ b/packages/shadcn/src/components/oauth-screen.tsx @@ -0,0 +1,61 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { type User } from "firebase/auth"; +import { type PropsWithChildren } from "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?: (user: User) => void; +}>; + +export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signIn"); + const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; + } + + return ( +
+ + + {titleText} + {subtitleText} + + +
{children}
+
+ + +
+
+
+
+ ); +} diff --git a/packages/shadcn/src/components/phone-auth-form.test.tsx b/packages/shadcn/src/components/phone-auth-form.test.tsx new file mode 100644 index 000000000..d8c7e5c01 --- /dev/null +++ b/packages/shadcn/src/components/phone-auth-form.test.tsx @@ -0,0 +1,494 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { PhoneAuthForm } from "./phone-auth-form"; +import { act } from "react"; +import { usePhoneNumberFormAction, useVerifyPhoneNumberFormAction, useUI } from "@invertase/firebaseui-react"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; +import { User, UserCredential } from "firebase/auth"; +import { FirebaseUI, FirebaseUIError } from "@invertase/firebaseui-core"; +import { FirebaseError } from "firebase/app"; + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + verifyPhoneNumber: vi.fn(), + confirmPhoneNumber: vi.fn(), + formatPhoneNumber: vi.fn((phoneNumber, country) => { + // Mock formatPhoneNumber to return formatted phone number + return `${country.dialCode}${phoneNumber}`; + }), + getTranslation: vi.fn((_, category, key) => { + if (category === "labels" && key === "sendCode") return "Send Code"; + if (category === "labels" && key === "phoneNumber") return "Phone Number"; + if (category === "labels" && key === "verificationCode") return "Verification Code"; + if (category === "labels" && key === "verifyCode") return "Verify Code"; + if (category === "prompts" && key === "smsVerificationPrompt") + return "Enter the verification code sent to your phone number"; + if (category === "errors" && key === "invalidPhoneNumber") return "Error: Invalid phone number format"; + if (category === "errors" && key === "missingPhoneNumber") return "Phone number is required"; + return key; + }), + }; +}); + +vi.mock("@invertase/firebaseui-react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + usePhoneNumberFormAction: vi.fn().mockReturnValue(vi.fn().mockResolvedValue("verification-id-123")), + useVerifyPhoneNumberFormAction: vi.fn().mockReturnValue(vi.fn().mockResolvedValue({} as any)), + useUI: vi.fn().mockReturnValue({ + state: "idle", + auth: { + currentUser: null, + }, + locale: { + translations: { + labels: { + sendCode: "Send Code", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + phoneNumber: "Phone Number", + }, + }, + }, + }), + useRecaptchaVerifier: vi.fn().mockReturnValue({ + render: vi.fn(), + verify: vi.fn(), + reset: vi.fn(), + clear: vi.fn(), + }), + }; +}); + +vi.mock("./policies", () => ({ + Policies: () =>
Policies
, +})); + +vi.mock("./country-selector", () => ({ + CountrySelector: vi.fn().mockImplementation(({ ref }) => { + if (ref && typeof ref === "object" && "current" in ref) { + ref.current = { + getCountry: () => ({ + code: "US", + name: "United States", + dialCode: "+1", + emoji: "🇺🇸", + }), + }; + } + return null; + }), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + cleanup(); + vi.useRealTimers(); + }); + + it("should render the phone number form initially", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "Send Code", + phoneNumber: "Phone Number", + }, + }), + }); + + const { container } = render( + + + + ); + + expect(container.querySelector("input[name='phoneNumber']")).toBeInTheDocument(); + expect(container.querySelector("button[type='submit']")).toBeInTheDocument(); + expect(screen.getByTestId("policies")).toBeInTheDocument(); + }); + + it("should transition to verification form after phone number submission", async () => { + const mockVerificationId = "test-verification-id"; + const mockAction = vi.fn().mockResolvedValue(mockVerificationId); + vi.mocked(usePhoneNumberFormAction).mockReturnValue(mockAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "Send Code", + phoneNumber: "Phone Number", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + }), + }); + + const { container } = render( + + + + ); + + // Initially should show phone number form + expect(container.querySelector("input[name='phoneNumber']")).toBeInTheDocument(); + expect(container.querySelector("input[name='verificationCode']")).not.toBeInTheDocument(); + + const phoneInput = container.querySelector("input[name='phoneNumber']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + act(() => { + fireEvent.change(phoneInput, { target: { value: "1234567890" } }); + }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalled(); + }); + + expect(container.querySelector("input[name='verificationCode']")).toBeInTheDocument(); + expect(container.querySelector("input[name='phoneNumber']")).not.toBeInTheDocument(); + }); + + it("should render the verification code form with description after phone number submission", async () => { + const mockVerificationId = "test-verification-id"; + const mockAction = vi.fn().mockResolvedValue(mockVerificationId); + vi.mocked(usePhoneNumberFormAction).mockReturnValue(mockAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "Send Code", + phoneNumber: "Phone Number", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + prompts: { + smsVerificationPrompt: "Enter the verification code sent to your phone number", + }, + }), + }); + + const { container } = render( + + + + ); + + expect(container.querySelector("input[name='phoneNumber']")).toBeInTheDocument(); + + const phoneInput = container.querySelector("input[name='phoneNumber']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + act(() => { + fireEvent.change(phoneInput, { target: { value: "1234567890" } }); + }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(container.querySelector("input[name='verificationCode']")).toBeInTheDocument(); + }); + + const description = container.querySelector('[data-slot="form-description"]'); + expect(description).toBeInTheDocument(); + expect(description).toHaveTextContent("Enter the verification code sent to your phone number"); + }); + + it("should call onSignIn callback when verification is successful", async () => { + const mockVerificationId = "test-verification-id"; + const mockCredential = { credential: true } as unknown as UserCredential; + const mockPhoneAction = vi.fn().mockResolvedValue(mockVerificationId); + const mockVerifyAction = vi.fn().mockResolvedValue(mockCredential); + + vi.mocked(usePhoneNumberFormAction).mockReturnValue(mockPhoneAction); + vi.mocked(useVerifyPhoneNumberFormAction).mockReturnValue(mockVerifyAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "Send Code", + phoneNumber: "Phone Number", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + }), + }); + + const onSignInMock = vi.fn(); + + const { container } = render( + + + + ); + + // Submit phone number + const phoneInput = container.querySelector("input[name='phoneNumber']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + act(() => { + fireEvent.change(phoneInput, { target: { value: "1234567890" } }); + }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(mockPhoneAction).toHaveBeenCalled(); + }); + + // Submit verification code + const verificationInput = container.querySelector("input[name='verificationCode']")!; + const verifyButton = container.querySelector("button[type='submit']")!; + + act(() => { + fireEvent.change(verificationInput, { target: { value: "123456" } }); + }); + + await act(async () => { + fireEvent.click(verifyButton); + }); + + await waitFor(() => { + expect(mockVerifyAction).toHaveBeenCalled(); + }); + + expect(onSignInMock).toHaveBeenCalledWith(mockCredential); + }); + + it("should display error message when phone number submission fails", async () => { + const mockAction = vi.fn().mockRejectedValue(new Error("Phone verification failed")); + vi.mocked(usePhoneNumberFormAction).mockReturnValue(mockAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "Send Code", + phoneNumber: "Phone Number", + }, + }), + }); + + const { container } = render( + + + + ); + + const phoneInput = container.querySelector("input[name='phoneNumber']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + act(() => { + fireEvent.change(phoneInput, { target: { value: "1234567890" } }); + }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(await screen.findByText("Error: Phone verification failed")).toBeInTheDocument(); + }); + + it("should display error message when verification fails", async () => { + const mockVerificationId = "test-verification-id"; + const mockPhoneAction = vi.fn().mockResolvedValue(mockVerificationId); + const mockVerifyAction = vi.fn().mockRejectedValue(new Error("Invalid verification code")); + + vi.mocked(usePhoneNumberFormAction).mockReturnValue(mockPhoneAction); + vi.mocked(useVerifyPhoneNumberFormAction).mockReturnValue(mockVerifyAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "Send Code", + phoneNumber: "Phone Number", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + }), + }); + + const { container } = render( + + + + ); + + // Submit phone number first + const phoneInput = container.querySelector("input[name='phoneNumber']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + act(() => { + fireEvent.change(phoneInput, { target: { value: "1234567890" } }); + }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(mockPhoneAction).toHaveBeenCalled(); + }); + + // Now submit verification code + const verificationInput = container.querySelector("input[name='verificationCode']")!; + const verifyButton = container.querySelector("button[type='submit']")!; + + act(() => { + fireEvent.change(verificationInput, { target: { value: "123456" } }); + }); + + await act(async () => { + fireEvent.click(verifyButton); + }); + + expect(await screen.findByText("Error: Invalid verification code")).toBeInTheDocument(); + }); + + it.skip("should handle FirebaseUIError with proper error message", async () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "Send Code", + phoneNumber: "Phone Number", + }, + errors: { + invalidPhoneNumber: "Error: Invalid phone number format", + }, + }), + }); + + const firebaseError = new FirebaseUIError( + mockUI.get(), + new FirebaseError("auth/invalid-phone-number", "Invalid phone number format") + ); + const mockAction = vi.fn().mockRejectedValue(firebaseError); + vi.mocked(usePhoneNumberFormAction).mockReturnValue(mockAction); + + const { container } = render( + + + + ); + + const phoneInput = container.querySelector("input[name='phoneNumber']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + act(() => { + fireEvent.change(phoneInput, { target: { value: "1234567890" } }); + }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(await screen.findByText("Error: Invalid phone number format")).toBeInTheDocument(); + }); + + it("should disable submit button when UI state is not idle", () => { + // Mock useUI to return pending state + vi.mocked(useUI).mockReturnValue({ + state: "pending", + auth: { + currentUser: null, + }, + } as unknown as FirebaseUI); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "Send Code", + phoneNumber: "Phone Number", + }, + }), + }); + + const { container } = render( + + + + ); + + const submitButton = container.querySelector("button[type='submit']")!; + expect(submitButton).toBeDisabled(); + }); + + it.skip("should format phone number with country code before submission", async () => { + const mockVerificationId = "test-verification-id"; + const mockAction = vi.fn().mockResolvedValue(mockVerificationId); + vi.mocked(usePhoneNumberFormAction).mockReturnValue(mockAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "Send Code", + phoneNumber: "Phone Number", + }, + }), + }); + + const { container } = render( + + + + ); + + const phoneInput = container.querySelector("input[name='phoneNumber']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + act(() => { + fireEvent.change(phoneInput, { target: { value: "1234567890" } }); + }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalled(); + }); + + // Should be called with formatted phone number + expect(mockAction).toHaveBeenCalledWith({ + phoneNumber: "+11234567890", // formatted with country code + recaptchaVerifier: expect.any(Object), + }); + }); +}); diff --git a/packages/shadcn/src/components/phone-auth-form.tsx b/packages/shadcn/src/components/phone-auth-form.tsx new file mode 100644 index 000000000..adbe84f78 --- /dev/null +++ b/packages/shadcn/src/components/phone-auth-form.tsx @@ -0,0 +1,188 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { + type PhoneAuthFormProps, + usePhoneAuthNumberFormSchema, + usePhoneAuthVerifyFormSchema, + usePhoneNumberFormAction, + useRecaptchaVerifier, + useUI, + useVerifyPhoneNumberFormAction, +} from "@invertase/firebaseui-react"; +import { useState } from "react"; +import type { UserCredential } from "firebase/auth"; +import { useRef } from "react"; +import { useForm } from "react-hook-form"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import { + FirebaseUIError, + formatPhoneNumber, + getTranslation, + type PhoneAuthNumberFormSchema, + type PhoneAuthVerifyFormSchema, +} from "@invertase/firebaseui-core"; + +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Policies } from "@/components/policies"; +import { CountrySelector, type CountrySelectorRef } from "@/components/country-selector"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; + +type VerifyPhoneNumberFormProps = { + verificationId: string; + onSuccess: (credential: UserCredential) => void; +}; + +function VerifyPhoneNumberForm(props: VerifyPhoneNumberFormProps) { + const ui = useUI(); + const schema = usePhoneAuthVerifyFormSchema(); + const action = useVerifyPhoneNumberFormAction(); + + const form = useForm({ + resolver: standardSchemaResolver(schema), + defaultValues: { + verificationId: props.verificationId, + verificationCode: "", + }, + }); + + async function onSubmit(values: PhoneAuthVerifyFormSchema) { + try { + const credential = await action(values); + props.onSuccess(credential); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + } + + return ( +
+ + ( + + {getTranslation(ui, "labels", "verificationCode")} + {getTranslation(ui, "prompts", "smsVerificationPrompt")} + + + + + + + + + + + + + + + )} + /> + + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} + +type PhoneNumberFormProps = { + onSubmit: (verificationId: string) => void; +}; + +function PhoneNumberForm(props: PhoneNumberFormProps) { + const ui = useUI(); + const recaptchaContainerRef = useRef(null); + const recaptchaVerifier = useRecaptchaVerifier(recaptchaContainerRef); + const countrySelector = useRef(null); + const action = usePhoneNumberFormAction(); + const schema = usePhoneAuthNumberFormSchema(); + + const form = useForm({ + resolver: standardSchemaResolver(schema), + defaultValues: { + phoneNumber: "", + }, + }); + + async function onSubmit(values: PhoneAuthNumberFormSchema) { + try { + const formatted = formatPhoneNumber(values.phoneNumber, countrySelector.current!.getCountry()); + const verificationId = await action({ phoneNumber: formatted, recaptchaVerifier: recaptchaVerifier! }); + props.onSubmit(verificationId); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + } + + return ( +
+ + ( + + {getTranslation(ui, "labels", "phoneNumber")} + +
+ + +
+
+ +
+ )} + /> +
+ + + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} + +export type { PhoneAuthFormProps }; + +export function PhoneAuthForm(props: PhoneAuthFormProps) { + const [verificationId, setVerificationId] = useState(null); + + if (!verificationId) { + return ; + } + + return ( + { + props.onSignIn?.(credential); + }} + /> + ); +} diff --git a/packages/shadcn/src/components/phone-auth-screen.test.tsx b/packages/shadcn/src/components/phone-auth-screen.test.tsx new file mode 100644 index 000000000..d789517f3 --- /dev/null +++ b/packages/shadcn/src/components/phone-auth-screen.test.tsx @@ -0,0 +1,345 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +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, type User } from "firebase/auth"; + +vi.mock("@/components/phone-auth-form", () => ({ + PhoneAuthForm: ({ resendDelay }: { resendDelay?: number }) => ( +
+ Phone Auth Form +
+ ), +})); + +vi.mock("@/components/ui/separator", () => ({ + Separator: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+ ), +})); + +vi.mock("@/components/redirect-error", () => ({ + RedirectError: () =>
Redirect Error
, +})); + +vi.mock("@/components/multi-factor-auth-assertion-screen", () => ({ + MultiFactorAuthAssertionScreen: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+ +
+ ), +})); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders with correct title and subtitle", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "signIn", + }, + prompts: { + signInToAccount: "signInToAccount", + }, + }), + }); + + render( + + + + ); + + const title = screen.getByText("signIn"); + expect(title).toBeDefined(); + + const subtitle = screen.getByText("signInToAccount"); + expect(subtitle).toBeDefined(); + }); + + it("renders the component", () => { + const ui = createMockUI(); + + render( + + + + ); + + // Mocked so only has as test id + expect(screen.getByTestId("phone-auth-form")).toBeDefined(); + }); + + it("renders a separator with children when present", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
Test Child
+
+
+ ); + + expect(screen.getByTestId("separator")).toBeDefined(); + expect(screen.getByTestId("test-child")).toBeDefined(); + }); + + it("does not render separator and children when no children are provided", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.queryByTestId("separator")).toBeNull(); + }); + + it("renders multiple children when provided", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
Child 1
+
Child 2
+
+
+ ); + + expect(screen.getByTestId("separator")).toBeDefined(); + expect(screen.getByTestId("child-1")).toBeDefined(); + expect(screen.getByTestId("child-2")).toBeDefined(); + }); + + it("renders MultiFactorAuthAssertionScreen when multiFactorResolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + expect(screen.queryByTestId("phone-auth-form")).toBeNull(); + }); + + it("does not render PhoneAuthForm when MFA resolver exists", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.queryByTestId("phone-auth-form")).toBeNull(); + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + }); + + it("renders RedirectError component in children section when no MFA resolver", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
Test Child
+
+
+ ); + + expect(screen.getByTestId("redirect-error")).toBeDefined(); + expect(screen.getByTestId("test-child")).toBeDefined(); + }); + + it("does not render RedirectError when MFA resolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + +
Test Child
+
+
+ ); + + expect(screen.queryByTestId("redirect-error")).toBeNull(); + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + }); + + it("calls onSignIn with credential when MFA flow succeeds", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + 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(); + + render( + + + + ); + + 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(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 new file mode 100644 index 000000000..28d7e78e4 --- /dev/null +++ b/packages/shadcn/src/components/phone-auth-screen.tsx @@ -0,0 +1,67 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import type { PropsWithChildren } from "react"; +import { getTranslation } from "@invertase/firebaseui-core"; +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 } 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<{ + onSignIn?: (user: User) => void; +}>; + +export function PhoneAuthScreen({ children, onSignIn }: PhoneAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signIn"); + const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; + } + + return ( +
+ + + {titleText} + {subtitleText} + + + + {children ? ( + <> + +
+ {children} + +
+ + ) : null} +
+
+
+ ); +} diff --git a/packages/shadcn/src/components/policies.test.tsx b/packages/shadcn/src/components/policies.test.tsx new file mode 100644 index 000000000..85610b92e --- /dev/null +++ b/packages/shadcn/src/components/policies.test.tsx @@ -0,0 +1,134 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { Policies } from "./policies"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + signInWithEmailAndPassword: vi.fn(), + }; +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should return null when no policies are provided", () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + expect(container.firstChild).toBeNull(); + }); + + it("should render policies with navigation callback", () => { + const onNavigateMock = vi.fn(); + const mockUI = createMockUI({ + locale: registerLocale("test", { + messages: { + termsAndPrivacy: "{tos} and {privacy}", + }, + labels: { + termsOfService: "tos", + privacyPolicy: "pp", + }, + }), + }); + + const mockPolicies = { + termsOfServiceUrl: "https://example.com/terms", + privacyPolicyUrl: "https://example.com/privacy", + onNavigate: onNavigateMock, + }; + + const { container } = render( + + + + ); + + const buttons = container.querySelectorAll("button"); + expect(buttons).toHaveLength(2); + + const termsButton = screen.getByText("tos"); + const privacyButton = screen.getByText("pp"); + + expect(termsButton).toBeInTheDocument(); + expect(privacyButton).toBeInTheDocument(); + + fireEvent.click(termsButton); + expect(onNavigateMock).toHaveBeenCalledWith("https://example.com/terms"); + + fireEvent.click(privacyButton); + expect(onNavigateMock).toHaveBeenCalledWith("https://example.com/privacy"); + }); + + it("should render policies with external links when no navigation callback", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + messages: { + termsAndPrivacy: "{tos} and {privacy}", + }, + labels: { + termsOfService: "tos", + privacyPolicy: "pp", + }, + }), + }); + + const mockPolicies = { + termsOfServiceUrl: "https://example.com/terms", + privacyPolicyUrl: "https://example.com/privacy", + onNavigate: undefined, + }; + + const { container } = render( + + + + ); + + const links = container.querySelectorAll("a"); + expect(links).toHaveLength(2); + + const termsLink = screen.getByText("tos"); + const privacyLink = screen.getByText("pp"); + + expect(termsLink).toHaveAttribute("href", "https://example.com/terms"); + expect(termsLink).toHaveAttribute("target", "_blank"); + expect(termsLink).toHaveAttribute("rel", "noopener noreferrer"); + + expect(privacyLink).toHaveAttribute("href", "https://example.com/privacy"); + expect(privacyLink).toHaveAttribute("target", "_blank"); + expect(privacyLink).toHaveAttribute("rel", "noopener noreferrer"); + }); +}); diff --git a/packages/shadcn/src/components/policies.tsx b/packages/shadcn/src/components/policies.tsx new file mode 100644 index 000000000..f15645afb --- /dev/null +++ b/packages/shadcn/src/components/policies.tsx @@ -0,0 +1,66 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cn } from "@/lib/utils"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, PolicyContext } from "@invertase/firebaseui-react"; +import { cloneElement, useContext } from "react"; + +export function Policies() { + const ui = useUI(); + const policies = useContext(PolicyContext); + + if (!policies) { + return null; + } + + const { termsOfServiceUrl, privacyPolicyUrl, onNavigate } = policies; + const termsAndPrivacyText = getTranslation(ui, "messages", "termsAndPrivacy"); + const parts = termsAndPrivacyText.split(/(\{tos\}|\{privacy\})/); + + const className = cn("hover:underline font-semibold"); + const Handler = onNavigate ? ( +
+ ) : null} + + + + + + + )} + /> + + + {form.formState.errors.root && {form.formState.errors.root.message}} + {props.onSignUpClick ? ( + <> + + + ) : null} + + + ); +} diff --git a/packages/shadcn/src/components/sign-in-auth-screen.test.tsx b/packages/shadcn/src/components/sign-in-auth-screen.test.tsx new file mode 100644 index 000000000..dca816c20 --- /dev/null +++ b/packages/shadcn/src/components/sign-in-auth-screen.test.tsx @@ -0,0 +1,354 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +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, type User } from "firebase/auth"; + +vi.mock("./sign-in-auth-form", () => ({ + SignInAuthForm: ({ onSignIn, onForgotPasswordClick, onRegisterClick }: any) => ( +
+
SignInAuthForm
+ {onSignIn &&
onSignIn provided
} + {onForgotPasswordClick &&
onForgotPasswordClick provided
} + {onRegisterClick &&
onRegisterClick provided
} +
+ ), +})); + +vi.mock("@/components/ui/card", () => ({ + Card: ({ children }: { children: React.ReactNode }) =>
{children}
, + CardHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + CardTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, + CardDescription: ({ children }: { children: React.ReactNode }) =>

{children}

, + CardContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/ui/separator", () => ({ + Separator: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("./multi-factor-auth-assertion-screen", () => ({ + MultiFactorAuthAssertionScreen: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+ +
+ ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the screen with title and subtitle correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "Sign In", + }, + prompts: { + signInToAccount: "Sign in to your account", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("card")).toBeInTheDocument(); + expect(screen.getByTestId("card-header")).toBeInTheDocument(); + expect(screen.getByTestId("card-content")).toBeInTheDocument(); + + expect(screen.getByTestId("card-title")).toHaveTextContent("Sign In"); + expect(screen.getByTestId("card-description")).toHaveTextContent("Sign in to your account"); + }); + + it("should render the SignInAuthForm within the card content", () => { + const mockUI = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("sign-in-auth-form")).toBeInTheDocument(); + expect(screen.getByTestId("card-content")).toContainElement(screen.getByTestId("sign-in-auth-form")); + }); + + it("should not render separator and children section when no children provided", () => { + const mockUI = createMockUI(); + + render( + + + + ); + + expect(screen.queryByTestId("separator")).not.toBeInTheDocument(); + expect(screen.queryByText("dividerOr")).not.toBeInTheDocument(); + }); + + it("should render children with separator when children are provided", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "or", + }, + }), + }); + + const TestChild = () =>
Test Child Component
; + + render( + + + + + + ); + + expect(screen.getByTestId("separator")).toBeInTheDocument(); + + expect(screen.getByTestId("test-child")).toBeInTheDocument(); + expect(screen.getByTestId("test-child")).toHaveTextContent("Test Child Component"); + }); + + it("should forward props to SignInAuthForm", () => { + const mockUI = createMockUI(); + const onForgotPasswordClickMock = vi.fn(); + const onSignUpClickMock = vi.fn(); + + render( + + + + ); + + const form = screen.getByTestId("sign-in-auth-form"); + expect(form).toBeInTheDocument(); + }); + + it("should render multiple children correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "or", + }, + }), + }); + + const TestChild1 = () =>
Child 1
; + const TestChild2 = () =>
Child 2
; + + render( + + + + + + + ); + + expect(screen.getByTestId("separator")).toBeInTheDocument(); + + expect(screen.getByTestId("test-child-1")).toBeInTheDocument(); + expect(screen.getByTestId("test-child-2")).toBeInTheDocument(); + expect(screen.getByTestId("test-child-1")).toHaveTextContent("Child 1"); + expect(screen.getByTestId("test-child-2")).toHaveTextContent("Child 2"); + }); + + it("should handle empty children array", () => { + const mockUI = createMockUI(); + + render( + + {[]} + + ); + + expect(screen.getByTestId("separator")).toBeInTheDocument(); + }); + + it("should not render separator when children is null", () => { + const mockUI = createMockUI(); + + render( + + + + ); + + expect(screen.queryByTestId("separator")).not.toBeInTheDocument(); + }); + + it("should use default translations when custom locale is not provided", () => { + const mockUI = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("card-title")).toBeInTheDocument(); + expect(screen.getByTestId("card-description")).toBeInTheDocument(); + expect(screen.getByTestId("sign-in-auth-form")).toBeInTheDocument(); + }); + + it("renders MultiFactorAuthAssertionScreen when multiFactorResolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeDefined(); + expect(screen.queryByTestId("sign-in-auth-form")).toBeNull(); + }); + + it("calls onSignIn with credential when MFA flow succeeds", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + 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(); + + render( + + + + ); + + 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(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 new file mode 100644 index 000000000..ba2f8812b --- /dev/null +++ b/packages/shadcn/src/components/sign-in-auth-screen.tsx @@ -0,0 +1,60 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +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"; +import { SignInAuthForm } from "@/components/sign-in-auth-form"; +import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen"; + +export type { SignInAuthScreenProps }; + +export function SignInAuthScreen({ children, onSignIn, ...props }: SignInAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signIn"); + const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + + useOnUserAuthenticated(onSignIn); + + if (ui.multiFactorResolver) { + return ; + } + + return ( +
+ + + {titleText} + {subtitleText} + + + + {children ? ( + <> + +
{children}
+ + ) : null} +
+
+
+ ); +} diff --git a/packages/shadcn/src/components/sign-up-auth-form.test.tsx b/packages/shadcn/src/components/sign-up-auth-form.test.tsx new file mode 100644 index 000000000..951ad5771 --- /dev/null +++ b/packages/shadcn/src/components/sign-up-auth-form.test.tsx @@ -0,0 +1,298 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { SignUpAuthForm } from "./sign-up-auth-form"; +import { act } from "react"; +import { useSignUpAuthFormAction, useRequireDisplayName } from "@invertase/firebaseui-react"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; +import { UserCredential } from "firebase/auth"; + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + createUserWithEmailAndPassword: vi.fn(), + }; +}); + +vi.mock("@invertase/firebaseui-react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useSignUpAuthFormAction: vi.fn(), + useRequireDisplayName: vi.fn(), + }; +}); + +vi.mock("./policies", () => ({ + Policies: () =>
Policies
, +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the form correctly", () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + expect(container.querySelector("input[name='email']")).toBeInTheDocument(); + expect(container.querySelector("input[name='password']")).toBeInTheDocument(); + expect(container.querySelector("button[type='submit']")).toBeInTheDocument(); + }); + + it("should render with back to sign in callback", () => { + const onSignInClickMock = vi.fn(); + const mockUI = createMockUI({ + locale: registerLocale("test", { + prompts: { + haveAccount: "haveAccount", + }, + labels: { + signIn: "signIn", + }, + }), + }); + + const { container } = render( + + + + ); + + const button = container.querySelector("button[type='button']"); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent("haveAccount signIn"); + + act(() => { + fireEvent.click(button!); + }); + + expect(onSignInClickMock).toHaveBeenCalled(); + }); + + it("should call the onSignUp callback when the form is submitted", async () => { + const mockAction = vi.fn().mockResolvedValue({} as unknown as UserCredential); + vi.mocked(useSignUpAuthFormAction).mockReturnValue(mockAction); + const onSignUpMock = vi.fn(); + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + password: "Password", + createAccount: "Create Account", + }, + errors: { + invalidEmail: "Invalid email", + weakPassword: "Password too weak", + }, + }), + }); + + const { container } = render( + + + + ); + + const form = container.querySelector("form"); + expect(form).toBeInTheDocument(); + + const emailInput = container.querySelector("input[name='email']"); + const passwordInput = container.querySelector("input[name='password']"); + + act(() => { + fireEvent.change(emailInput!, { target: { value: "test@example.com" } }); + fireEvent.change(passwordInput!, { target: { value: "password123" } }); + }); + + await act(async () => { + fireEvent.submit(form!); + }); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalled(); + }); + + expect(mockAction).toHaveBeenCalledWith({ + email: "test@example.com", + password: "password123", + displayName: undefined, + }); + expect(onSignUpMock).toHaveBeenCalled(); + }); + + it("should display error message when form submission fails", async () => { + const mockAction = vi.fn().mockRejectedValue(new Error("foo")); + + vi.mocked(useSignUpAuthFormAction).mockReturnValue(mockAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + password: "Password", + createAccount: "Create Account", + }, + }), + }); + + const { container } = render( + + + + ); + + const emailInput = container.querySelector("input[name='email']")!; + const passwordInput = container.querySelector("input[name='password']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + fireEvent.change(passwordInput, { target: { value: "somepassword" } }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(await screen.findByText("Error: foo")).toBeInTheDocument(); + }); + + it("should render displayName field when requireDisplayName is true", () => { + vi.mocked(useRequireDisplayName).mockReturnValue(true); + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + password: "Password", + displayName: "Display Name", + createAccount: "Create Account", + }, + }), + behaviors: [ + { + requireDisplayName: { type: "callable" as const, handler: vi.fn() }, + }, + ], + }); + + const { container } = render( + + + + ); + + expect(container.querySelector("input[name='email']")).toBeInTheDocument(); + expect(container.querySelector("input[name='password']")).toBeInTheDocument(); + expect(container.querySelector("input[name='displayName']")).toBeInTheDocument(); + expect(container.querySelector("button[type='submit']")).toBeInTheDocument(); + }); + + it("should not render displayName field when requireDisplayName is false", () => { + vi.mocked(useRequireDisplayName).mockReturnValue(false); + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + password: "Password", + displayName: "Display Name", + createAccount: "Create Account", + }, + }), + behaviors: [], // Explicitly set empty behaviors array + }); + + const { container } = render( + + + + ); + + expect(container.querySelector("input[name='email']")).toBeInTheDocument(); + expect(container.querySelector("input[name='password']")).toBeInTheDocument(); + expect(container.querySelector("input[name='displayName']")).not.toBeInTheDocument(); + expect(container.querySelector("button[type='submit']")).toBeInTheDocument(); + }); + + it("should call the onSignUp callback with displayName when requireDisplayName is true", async () => { + vi.mocked(useRequireDisplayName).mockReturnValue(true); + const mockAction = vi.fn().mockResolvedValue({} as unknown as UserCredential); + vi.mocked(useSignUpAuthFormAction).mockReturnValue(mockAction); + const onSignUpMock = vi.fn(); + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + password: "Password", + displayName: "Display Name", + createAccount: "Create Account", + }, + }), + behaviors: [ + { + requireDisplayName: { type: "callable" as const, handler: vi.fn() }, + }, + ], + }); + + const { container } = render( + + + + ); + + const form = container.querySelector("form"); + expect(form).toBeInTheDocument(); + + const emailInput = container.querySelector("input[name='email']"); + const passwordInput = container.querySelector("input[name='password']"); + const displayNameInput = container.querySelector("input[name='displayName']"); + + act(() => { + fireEvent.change(emailInput!, { target: { value: "test@example.com" } }); + fireEvent.change(passwordInput!, { target: { value: "password123" } }); + fireEvent.change(displayNameInput!, { target: { value: "John Doe" } }); + }); + + await act(async () => { + fireEvent.submit(form!); + }); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalled(); + }); + + expect(mockAction).toHaveBeenCalledWith({ + email: "test@example.com", + password: "password123", + displayName: "John Doe", + }); + expect(onSignUpMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/shadcn/src/components/sign-up-auth-form.tsx b/packages/shadcn/src/components/sign-up-auth-form.tsx new file mode 100644 index 000000000..fca4bb3ab --- /dev/null +++ b/packages/shadcn/src/components/sign-up-auth-form.tsx @@ -0,0 +1,122 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import type { SignUpAuthFormSchema } from "@invertase/firebaseui-core"; +import { + useSignUpAuthFormAction, + useSignUpAuthFormSchema, + useUI, + type SignUpAuthFormProps, + useRequireDisplayName, +} from "@invertase/firebaseui-react"; +import { useForm } from "react-hook-form"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import { FirebaseUIError, getTranslation } from "@invertase/firebaseui-core"; + +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Policies } from "./policies"; + +export type { SignUpAuthFormProps }; + +export function SignUpAuthForm(props: SignUpAuthFormProps) { + const ui = useUI(); + const schema = useSignUpAuthFormSchema(); + const action = useSignUpAuthFormAction(); + const requireDisplayName = useRequireDisplayName(); + + const form = useForm({ + resolver: standardSchemaResolver(schema), + defaultValues: { + email: "", + password: "", + displayName: requireDisplayName ? "" : undefined, + }, + }); + + async function onSubmit(values: SignUpAuthFormSchema) { + try { + const credential = await action(values); + props.onSignUp?.(credential); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + } + + return ( +
+ + {requireDisplayName ? ( + ( + + {getTranslation(ui, "labels", "displayName")} + + + + + + )} + /> + ) : null} + ( + + {getTranslation(ui, "labels", "emailAddress")} + + + + + + )} + /> + ( + + {getTranslation(ui, "labels", "password")} + + + + + + )} + /> + + + {form.formState.errors.root && {form.formState.errors.root.message}} + {props.onSignInClick ? ( + + ) : null} + + + ); +} diff --git a/packages/shadcn/src/components/sign-up-auth-screen.test.tsx b/packages/shadcn/src/components/sign-up-auth-screen.test.tsx new file mode 100644 index 000000000..afbaf6567 --- /dev/null +++ b/packages/shadcn/src/components/sign-up-auth-screen.test.tsx @@ -0,0 +1,350 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +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, type User } from "firebase/auth"; + +vi.mock("./sign-up-auth-form", () => ({ + SignUpAuthForm: ({ onSignUp, onSignInClick }: any) => ( +
+
SignUpAuthForm
+ {onSignUp &&
onSignUp provided
} + {onSignInClick &&
onSignInClick provided
} +
+ ), +})); + +vi.mock("@/components/multi-factor-auth-assertion-screen", () => ({ + MultiFactorAuthAssertionScreen: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => ( +
+ +
+ ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the screen correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signUp: "Register", + }, + prompts: { + enterDetailsToCreate: "Enter your details to create an account", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Register")).toBeInTheDocument(); + expect(screen.getByText("Enter your details to create an account")).toBeInTheDocument(); + expect(screen.getByTestId("sign-up-auth-form")).toBeInTheDocument(); + }); + + it("should render with children", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signUp: "Register", + }, + prompts: { + enterDetailsToCreate: "Enter your details to create an account", + }, + messages: { + dividerOr: "or", + }, + }), + }); + + render( + + +
Child Component
+
+
+ ); + + expect(screen.getByText("Register")).toBeInTheDocument(); + expect(screen.getByText("Enter your details to create an account")).toBeInTheDocument(); + expect(screen.getByTestId("sign-up-auth-form")).toBeInTheDocument(); + expect(screen.getByTestId("child-component")).toBeInTheDocument(); + }); + + it("should pass props to SignUpAuthForm", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signUp: "Register", + }, + prompts: { + enterDetailsToCreate: "Enter your details to create an account", + }, + }), + }); + + const onSignInClickMock = vi.fn(); + + render( + + + + ); + + expect(screen.getByTestId("onSignInClick-prop")).toBeInTheDocument(); + }); + + it("should not render separator when no children", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signUp: "Register", + }, + prompts: { + enterDetailsToCreate: "Enter your details to create an account", + }, + messages: { + dividerOr: "or", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Register")).toBeInTheDocument(); + expect(screen.getByText("Enter your details to create an account")).toBeInTheDocument(); + expect(screen.getByTestId("sign-up-auth-form")).toBeInTheDocument(); + expect(screen.queryByText("or")).not.toBeInTheDocument(); + }); + + it("should render MultiFactorAuthAssertionScreen when multiFactorResolver is present", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signUp: "Register", + }, + prompts: { + enterDetailsToCreate: "Enter your details to create an account", + }, + }), + }); + mockUI.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + + + ); + + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("sign-up-auth-form")).not.toBeInTheDocument(); + }); + + it("should not render SignUpAuthForm when MFA resolver exists", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signUp: "Register", + }, + prompts: { + enterDetailsToCreate: "Enter your details to create an account", + }, + messages: { + dividerOr: "or", + }, + }), + }); + mockUI.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + render( + + +
Child Component
+
+
+ ); + + expect(screen.queryByTestId("sign-up-auth-form")).not.toBeInTheDocument(); + expect(screen.getByTestId("multi-factor-auth-assertion-screen")).toBeInTheDocument(); + expect(screen.queryByText("or")).not.toBeInTheDocument(); + expect(screen.queryByTestId("child-component")).not.toBeInTheDocument(); + }); + + it("should render SignUpAuthForm when MFA resolver is not present", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signUp: "Register", + }, + prompts: { + enterDetailsToCreate: "Enter your details to create an account", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("sign-up-auth-form")).toBeInTheDocument(); + expect(screen.queryByTestId("multi-factor-auth-assertion-screen")).not.toBeInTheDocument(); + }); + + it("calls onSignUp with credential when MFA flow succeeds", () => { + const mockResolver = { + auth: {} as any, + session: null, + hints: [], + }; + 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(); + + render( + + + + ); + + 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 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 new file mode 100644 index 000000000..9b0ca46ff --- /dev/null +++ b/packages/shadcn/src/components/sign-up-auth-screen.tsx @@ -0,0 +1,60 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +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"; +import { SignUpAuthForm } from "@/components/sign-up-auth-form"; +import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen"; + +export type { SignUpAuthScreenProps }; + +export function SignUpAuthScreen({ children, onSignUp, ...props }: SignUpAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signUp"); + const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate"); + + useOnUserAuthenticated(onSignUp); + + if (ui.multiFactorResolver) { + return ; + } + + return ( +
+ + + {titleText} + {subtitleText} + + + + {children ? ( + <> + +
{children}
+ + ) : null} +
+
+
+ ); +} diff --git a/packages/shadcn/src/components/sms-multi-factor-assertion-form.test.tsx b/packages/shadcn/src/components/sms-multi-factor-assertion-form.test.tsx new file mode 100644 index 000000000..9d7e5aac7 --- /dev/null +++ b/packages/shadcn/src/components/sms-multi-factor-assertion-form.test.tsx @@ -0,0 +1,341 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { SmsMultiFactorAssertionForm } from "./sms-multi-factor-assertion-form"; +import { createFirebaseUIProvider, createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { + useSmsMultiFactorAssertionPhoneFormAction, + useSmsMultiFactorAssertionVerifyFormAction, +} from "@invertase/firebaseui-react"; +import React from "react"; + +// Mock input-otp components to prevent window access issues +vi.mock("@/components/ui/input-otp", () => ({ + InputOTP: ({ children, ...props }: any) => + React.createElement("div", { "data-testid": "input-otp", ...props }, children), + InputOTPGroup: ({ children, ...props }: any) => + React.createElement("div", { "data-testid": "input-otp-group", ...props }, children), + InputOTPSlot: ({ index, ...props }: any) => + React.createElement("input", { + "data-testid": `input-otp-slot-${index}`, + "aria-label": "Verification Code", + ...props, + }), +})); + +vi.mock("@/components/ui/form", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + FormItem: ({ children, ...props }: any) => React.createElement("div", { ...props }, children), + FormLabel: ({ children, ...props }: any) => React.createElement("label", { ...props }, children), + FormDescription: ({ children, ...props }: any) => React.createElement("p", { ...props }, children), + }; +}); + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + verifyPhoneNumber: vi.fn(), + signInWithMultiFactorAssertion: vi.fn(), + }; +}); + +vi.mock("@invertase/firebaseui-react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useRecaptchaVerifier: () => ({ + render: vi.fn(), + verify: vi.fn(), + }), + useSmsMultiFactorAssertionPhoneFormAction: vi.fn(), + useSmsMultiFactorAssertionVerifyFormAction: vi.fn(), + }; +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the phone number form initially", () => { + const mockHint = { + uid: "test-uid", + factorId: "phone" as const, + displayName: "Test Phone", + phoneNumber: "+1234567890", + enrollmentTime: "2023-01-01T00:00:00.000Z", + }; + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + }, + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + expect(screen.getByText("Phone Number")).toBeInTheDocument(); + expect( + screen.getByText("A verification code will be sent to +1234567890 to complete the authentication process.") + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send Code" })).toBeInTheDocument(); + }); + + it("should transition to verification form on successful phone number submission", async () => { + const mockHint = { + uid: "test-uid", + factorId: "phone" as const, + displayName: "Test Phone", + phoneNumber: "+1234567890", + enrollmentTime: "2023-01-01T00:00:00.000Z", + }; + + const mockPhoneAction = vi.fn().mockResolvedValue("verification-id-123"); + vi.mocked(useSmsMultiFactorAssertionPhoneFormAction).mockReturnValue(mockPhoneAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "Send Code" })); + + await waitFor(() => { + expect(screen.getByTestId("input-otp-slot-0")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); + }); + }); + + it("should call onSuccess when verification is successful", async () => { + const mockHint = { + uid: "test-uid", + factorId: "phone" as const, + displayName: "Test Phone", + phoneNumber: "+1234567890", + enrollmentTime: "2023-01-01T00:00:00.000Z", + }; + + const mockPhoneAction = vi.fn().mockResolvedValue("verification-id-123"); + vi.mocked(useSmsMultiFactorAssertionPhoneFormAction).mockReturnValue(mockPhoneAction); + + const mockVerifyAction = vi.fn().mockResolvedValue({ user: { uid: "sms-mfa-user" } }); + vi.mocked(useSmsMultiFactorAssertionVerifyFormAction).mockReturnValue(mockVerifyAction); + + const mockOnSuccess = vi.fn(); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "Send Code" })); + + await waitFor(() => { + expect(screen.getByTestId("input-otp-slot-0")).toBeInTheDocument(); + }); + + // Simulate entering verification code + const verificationInput = screen.getByTestId("input-otp-slot-0"); + fireEvent.change(verificationInput, { target: { value: "123456" } }); + fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + + await waitFor(() => { + expect(mockOnSuccess).toHaveBeenCalledWith({ user: { uid: "sms-mfa-user" } }); + }); + }); + + it("should handle phone number form submission error", async () => { + const mockHint = { + uid: "test-uid", + factorId: "phone" as const, + displayName: "Test Phone", + phoneNumber: "+1234567890", + enrollmentTime: "2023-01-01T00:00:00.000Z", + }; + + const mockPhoneAction = vi.fn().mockRejectedValue(new Error("Phone verification failed")); + vi.mocked(useSmsMultiFactorAssertionPhoneFormAction).mockReturnValue(mockPhoneAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + }, + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "Send Code" })); + + await waitFor(() => { + expect(screen.getByText("Error: Phone verification failed")).toBeInTheDocument(); + }); + }); + + it("should handle verification form submission error", async () => { + const mockHint = { + uid: "test-uid", + factorId: "phone" as const, + displayName: "Test Phone", + phoneNumber: "+1234567890", + enrollmentTime: "2023-01-01T00:00:00.000Z", + }; + + const mockPhoneAction = vi.fn().mockResolvedValue("verification-id-123"); + vi.mocked(useSmsMultiFactorAssertionPhoneFormAction).mockReturnValue(mockPhoneAction); + + const mockVerifyAction = vi.fn().mockRejectedValue(new Error("Verification failed")); + vi.mocked(useSmsMultiFactorAssertionVerifyFormAction).mockReturnValue(mockVerifyAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "Send Code" })); + + await waitFor(() => { + expect(screen.getByTestId("input-otp-slot-0")).toBeInTheDocument(); + }); + + // Simulate entering verification code + const verificationInput = screen.getByTestId("input-otp-slot-0"); + fireEvent.change(verificationInput, { target: { value: "123456" } }); + fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + + await waitFor(() => { + expect(screen.getByText("Error: Verification failed")).toBeInTheDocument(); + }); + }); + + it("should handle missing phone number in hint", () => { + const mockHint = { + uid: "test-uid", + factorId: "phone" as const, + displayName: "Test Phone", + enrollmentTime: "2023-01-01T00:00:00.000Z", + }; + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + }, + messages: { + mfaSmsAssertionPrompt: + "A verification code will be sent to {phoneNumber} to complete the authentication process.", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + // When phone number is missing, the placeholder remains because empty string is falsy in the replacement logic + expect( + screen.getByText("A verification code will be sent to {phoneNumber} to complete the authentication process.") + ).toBeInTheDocument(); + }); +}); diff --git a/packages/shadcn/src/components/sms-multi-factor-assertion-form.tsx b/packages/shadcn/src/components/sms-multi-factor-assertion-form.tsx new file mode 100644 index 000000000..0be341003 --- /dev/null +++ b/packages/shadcn/src/components/sms-multi-factor-assertion-form.tsx @@ -0,0 +1,176 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { useRef, useState } from "react"; +import { type UserCredential, type MultiFactorInfo } from "firebase/auth"; + +import { FirebaseUIError, getTranslation } from "@invertase/firebaseui-core"; +import { + useMultiFactorPhoneAuthVerifyFormSchema, + useRecaptchaVerifier, + useUI, + useSmsMultiFactorAssertionPhoneFormAction, + useSmsMultiFactorAssertionVerifyFormAction, +} from "@invertase/firebaseui-react"; +import { useForm } from "react-hook-form"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; + +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Button } from "@/components/ui/button"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; + +type PhoneMultiFactorInfo = MultiFactorInfo & { + phoneNumber?: string; +}; + +type SmsMultiFactorAssertionPhoneFormProps = { + hint: MultiFactorInfo; + onSubmit: (verificationId: string) => void; +}; + +function SmsMultiFactorAssertionPhoneForm(props: SmsMultiFactorAssertionPhoneFormProps) { + const ui = useUI(); + const recaptchaContainerRef = useRef(null); + const recaptchaVerifier = useRecaptchaVerifier(recaptchaContainerRef); + const action = useSmsMultiFactorAssertionPhoneFormAction(); + const [error, setError] = useState(null); + + const onSubmit = async () => { + try { + setError(null); + const verificationId = await action({ hint: props.hint, recaptchaVerifier: recaptchaVerifier! }); + props.onSubmit(verificationId); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + setError(message); + } + }; + + return ( +
+ + {getTranslation(ui, "labels", "phoneNumber")} + + {getTranslation(ui, "messages", "mfaSmsAssertionPrompt", { + phoneNumber: (props.hint as PhoneMultiFactorInfo).phoneNumber || "", + })} + + +
+ + {error &&
{error}
} +
+ ); +} + +type SmsMultiFactorAssertionVerifyFormProps = { + verificationId: string; + onSuccess: (credential: UserCredential) => void; +}; + +function SmsMultiFactorAssertionVerifyForm(props: SmsMultiFactorAssertionVerifyFormProps) { + const ui = useUI(); + const schema = useMultiFactorPhoneAuthVerifyFormSchema(); + const action = useSmsMultiFactorAssertionVerifyFormAction(); + + const form = useForm<{ verificationId: string; verificationCode: string }>({ + resolver: standardSchemaResolver(schema), + defaultValues: { + verificationId: props.verificationId, + verificationCode: "", + }, + }); + + const onSubmit = async (values: { verificationId: string; verificationCode: string }) => { + try { + const credential = await action({ + verificationId: values.verificationId, + verificationCode: values.verificationCode, + }); + props.onSuccess(credential); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + }; + + return ( +
+ + ( + + {getTranslation(ui, "labels", "verificationCode")} + {getTranslation(ui, "prompts", "smsVerificationPrompt")} + + + + + + + + + + + + + + + )} + /> + + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} + +export type SmsMultiFactorAssertionFormProps = { + hint: MultiFactorInfo; + onSuccess?: (credential: UserCredential) => void; +}; + +export function SmsMultiFactorAssertionForm(props: SmsMultiFactorAssertionFormProps) { + const [verification, setVerification] = useState<{ + verificationId: string; + } | null>(null); + + if (!verification) { + return ( + setVerification({ verificationId })} + /> + ); + } + + return ( + { + props.onSuccess?.(credential); + }} + /> + ); +} diff --git a/packages/shadcn/src/components/sms-multi-factor-enrollment-form.test.tsx b/packages/shadcn/src/components/sms-multi-factor-enrollment-form.test.tsx new file mode 100644 index 000000000..7d92ced2a --- /dev/null +++ b/packages/shadcn/src/components/sms-multi-factor-enrollment-form.test.tsx @@ -0,0 +1,291 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { SmsMultiFactorEnrollmentForm } from "./sms-multi-factor-enrollment-form"; +import { createFirebaseUIProvider, createMockUIWithUser } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { verifyPhoneNumber, enrollWithMultiFactorAssertion } from "@invertase/firebaseui-core"; +import React from "react"; + +// Mock input-otp components to prevent window access issues +vi.mock("@/components/ui/input-otp", () => ({ + InputOTP: ({ children, ...props }: any) => + React.createElement("div", { "data-testid": "input-otp", ...props }, children), + InputOTPGroup: ({ children, ...props }: any) => + React.createElement("div", { "data-testid": "input-otp-group", ...props }, children), + InputOTPSlot: ({ index, ...props }: any) => + React.createElement("input", { "data-testid": `input-otp-slot-${index}`, ...props }), +})); + +// Mock the schema hooks +vi.mock("@invertase/firebaseui-react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useRecaptchaVerifier: vi.fn().mockReturnValue({}), + }; +}); + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + verifyPhoneNumber: vi.fn(), + enrollWithMultiFactorAssertion: vi.fn(), + }; +}); + +// Mock Firebase Auth multiFactor function +vi.mock("firebase/auth", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + multiFactor: vi.fn().mockReturnValue({ + enrolledFactors: [], + enroll: vi.fn(), + unenroll: vi.fn(), + getSession: vi.fn(), + }), + PhoneAuthProvider: { + credential: vi.fn().mockReturnValue({}), + }, + PhoneMultiFactorGenerator: { + assertion: vi.fn().mockReturnValue({}), + }, + }; +}); + +// Mock CountrySelector +vi.mock("./country-selector", () => ({ + CountrySelector: vi.fn().mockImplementation(({ ref }) => { + if (ref && typeof ref === "object" && "current" in ref) { + ref.current = { + getCountry: () => ({ + code: "US", + name: "United States", + dialCode: "+1", + emoji: "🇺🇸", + }), + }; + } + return null; + }), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the phone number form initially", () => { + const mockUI = createMockUIWithUser({ + locale: registerLocale("test", { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + }, + }), + }); + + const { container } = render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + expect(container.querySelector("input[name='phoneNumber']")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send Code" })).toBeInTheDocument(); + }); + + it("should transition to verification form on successful phone number submission", async () => { + vi.mocked(verifyPhoneNumber).mockResolvedValue("verification-id-123"); + + const mockUI = createMockUIWithUser({ + locale: registerLocale("test", { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + prompts: { + smsVerificationPrompt: "smsVerificationPrompt", + }, + }), + }); + + const { container } = render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + // Fill in display name first + const displayNameInput = container.querySelector("input[name='displayName']")!; + fireEvent.change(displayNameInput, { target: { value: "Test User" } }); + + const phoneInput = container.querySelector("input[name='phoneNumber']")!; + fireEvent.change(phoneInput, { target: { value: "1234567890" } }); + fireEvent.click(screen.getByRole("button", { name: "Send Code" })); + + await waitFor(() => { + expect(screen.getByTestId("input-otp")).toBeInTheDocument(); + }); + + const description = container.querySelector('[data-slot="form-description"]'); + expect(description).toBeInTheDocument(); + expect(description).toHaveTextContent("smsVerificationPrompt"); + + expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); + }); + + it("should handle phone number form submission error", async () => { + vi.mocked(verifyPhoneNumber).mockRejectedValue(new Error("Phone verification failed")); + + const mockUI = createMockUIWithUser({ + locale: registerLocale("test", { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + }, + }), + }); + + const { container } = render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + // Fill in display name first + const displayNameInput = container.querySelector("input[name='displayName']")!; + fireEvent.change(displayNameInput, { target: { value: "Test User" } }); + + const phoneInput = container.querySelector("input[name='phoneNumber']")!; + fireEvent.change(phoneInput, { target: { value: "1234567890" } }); + fireEvent.click(screen.getByRole("button", { name: "Send Code" })); + + await waitFor(() => { + expect(screen.getByText("Error: Phone verification failed")).toBeInTheDocument(); + }); + }); + + it("should handle verification form submission error", async () => { + vi.mocked(verifyPhoneNumber).mockResolvedValue("verification-id-123"); + vi.mocked(enrollWithMultiFactorAssertion).mockRejectedValue(new Error("Verification failed")); + + const mockUI = createMockUIWithUser({ + locale: registerLocale("test", { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + prompts: { + smsVerificationPrompt: "smsVerificationPrompt", + }, + }), + }); + + const { container } = render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const displayNameInput = container.querySelector("input[name='displayName']")!; + fireEvent.change(displayNameInput, { target: { value: "Test User" } }); + + const phoneInput = container.querySelector("input[name='phoneNumber']")!; + fireEvent.change(phoneInput, { target: { value: "1234567890" } }); + fireEvent.click(screen.getByRole("button", { name: "Send Code" })); + + await waitFor(() => { + expect(screen.getByTestId("input-otp")).toBeInTheDocument(); + }); + + const description = container.querySelector('[data-slot="form-description"]'); + expect(description).toBeInTheDocument(); + expect(description).toHaveTextContent("smsVerificationPrompt"); + + const verificationInput = screen.getByTestId("input-otp-slot-0"); + fireEvent.change(verificationInput, { target: { value: "123456" } }); + fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + + await waitFor(() => { + expect(screen.getByText("Error: Verification failed")).toBeInTheDocument(); + }); + }); + + it("should complete enrollment successfully", async () => { + vi.mocked(verifyPhoneNumber).mockResolvedValue("verification-id-123"); + vi.mocked(enrollWithMultiFactorAssertion).mockResolvedValue({} as any); + + const mockUI = createMockUIWithUser({ + locale: registerLocale("test", { + labels: { + phoneNumber: "Phone Number", + sendCode: "Send Code", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + prompts: { + smsVerificationPrompt: "smsVerificationPrompt", + }, + }), + }); + + const { container } = render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const displayNameInput = container.querySelector("input[name='displayName']")!; + fireEvent.change(displayNameInput, { target: { value: "Test User" } }); + + const phoneInput = container.querySelector("input[name='phoneNumber']")!; + fireEvent.change(phoneInput, { target: { value: "1234567890" } }); + fireEvent.click(screen.getByRole("button", { name: "Send Code" })); + + await waitFor(() => { + expect(screen.getByTestId("input-otp")).toBeInTheDocument(); + }); + + const description = container.querySelector('[data-slot="form-description"]'); + expect(description).toBeInTheDocument(); + expect(description).toHaveTextContent("smsVerificationPrompt"); + + const verificationInput = screen.getByTestId("input-otp-slot-0"); + fireEvent.change(verificationInput, { target: { value: "123456" } }); + fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + + await waitFor(() => { + expect(enrollWithMultiFactorAssertion).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/shadcn/src/components/sms-multi-factor-enrollment-form.tsx b/packages/shadcn/src/components/sms-multi-factor-enrollment-form.tsx new file mode 100644 index 000000000..cff880858 --- /dev/null +++ b/packages/shadcn/src/components/sms-multi-factor-enrollment-form.tsx @@ -0,0 +1,213 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { useRef, useState } from "react"; +import { multiFactor, PhoneAuthProvider, PhoneMultiFactorGenerator } from "firebase/auth"; +import { + enrollWithMultiFactorAssertion, + FirebaseUIError, + formatPhoneNumber, + getTranslation, + verifyPhoneNumber, +} from "@invertase/firebaseui-core"; +import { CountrySelector, type CountrySelectorRef } from "@/components/country-selector"; +import { + useMultiFactorPhoneAuthNumberFormSchema, + useMultiFactorPhoneAuthVerifyFormSchema, + useRecaptchaVerifier, + useUI, +} from "@invertase/firebaseui-react"; +import { useForm } from "react-hook-form"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; + +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; + +type MultiFactorEnrollmentPhoneNumberFormProps = { + onSubmit: (verificationId: string, displayName?: string) => void; +}; + +function MultiFactorEnrollmentPhoneNumberForm(props: MultiFactorEnrollmentPhoneNumberFormProps) { + const ui = useUI(); + const recaptchaContainerRef = useRef(null); + const recaptchaVerifier = useRecaptchaVerifier(recaptchaContainerRef); + const countrySelector = useRef(null); + const schema = useMultiFactorPhoneAuthNumberFormSchema(); + + const form = useForm<{ displayName: string; phoneNumber: string }>({ + resolver: standardSchemaResolver(schema), + defaultValues: { + displayName: "", + phoneNumber: "", + }, + }); + + const onSubmit = async (values: { displayName: string; phoneNumber: string }) => { + try { + const formatted = formatPhoneNumber(values.phoneNumber, countrySelector.current!.getCountry()); + const mfaUser = multiFactor(ui.auth.currentUser!); + const confirmationResult = await verifyPhoneNumber(ui, formatted, recaptchaVerifier!, mfaUser); + props.onSubmit(confirmationResult, values.displayName); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + }; + + return ( +
+ + ( + + {getTranslation(ui, "labels", "displayName")} + + + + + + )} + /> + ( + + {getTranslation(ui, "labels", "phoneNumber")} + +
+ + +
+
+ +
+ )} + /> +
+ + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} + +type MultiFactorEnrollmentVerifyPhoneNumberFormProps = { + verificationId: string; + displayName?: string; + onSuccess: () => void; +}; + +export function MultiFactorEnrollmentVerifyPhoneNumberForm(props: MultiFactorEnrollmentVerifyPhoneNumberFormProps) { + const ui = useUI(); + const schema = useMultiFactorPhoneAuthVerifyFormSchema(); + + const form = useForm<{ verificationId: string; verificationCode: string }>({ + resolver: standardSchemaResolver(schema), + defaultValues: { + verificationId: props.verificationId, + verificationCode: "", + }, + }); + + const onSubmit = async (values: { verificationId: string; verificationCode: string }) => { + try { + const credential = PhoneAuthProvider.credential(values.verificationId, values.verificationCode); + const assertion = PhoneMultiFactorGenerator.assertion(credential); + await enrollWithMultiFactorAssertion(ui, assertion, props.displayName); + props.onSuccess(); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + }; + + return ( +
+ + ( + + {getTranslation(ui, "labels", "verificationCode")} + {getTranslation(ui, "prompts", "smsVerificationPrompt")} + + + + + + + + + + + + + + + )} + /> + + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} + +export type SmsMultiFactorEnrollmentFormProps = { + onSuccess?: () => void; +}; + +export function SmsMultiFactorEnrollmentForm(props: SmsMultiFactorEnrollmentFormProps) { + const ui = useUI(); + + const [verification, setVerification] = useState<{ + verificationId: string; + displayName?: string; + } | null>(null); + + if (!ui.auth.currentUser) { + throw new Error("User must be authenticated to enroll with multi-factor authentication"); + } + + if (!verification) { + return ( + setVerification({ verificationId, displayName })} + /> + ); + } + + return ( + { + props.onSuccess?.(); + }} + /> + ); +} diff --git a/packages/shadcn/src/components/totp-multi-factor-assertion-form.test.tsx b/packages/shadcn/src/components/totp-multi-factor-assertion-form.test.tsx new file mode 100644 index 000000000..6bcdcae72 --- /dev/null +++ b/packages/shadcn/src/components/totp-multi-factor-assertion-form.test.tsx @@ -0,0 +1,237 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { TotpMultiFactorAssertionForm } from "./totp-multi-factor-assertion-form"; +import { createFirebaseUIProvider, createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { TotpMultiFactorGenerator } from "firebase/auth"; +import { useTotpMultiFactorAssertionFormAction } from "@invertase/firebaseui-react"; +import React from "react"; + +// Mock input-otp components to prevent window access issues +vi.mock("@/components/ui/input-otp", () => ({ + InputOTP: ({ children, ...props }: any) => + React.createElement("div", { "data-testid": "input-otp", ...props }, children), + InputOTPGroup: ({ children, ...props }: any) => + React.createElement("div", { "data-testid": "input-otp-group", ...props }, children), + InputOTPSlot: ({ index, ...props }: any) => + React.createElement("input", { + "data-testid": `input-otp-slot-${index}`, + "aria-label": "Verification Code", + ...props, + }), +})); + +vi.mock("@invertase/firebaseui-react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useTotpMultiFactorAssertionFormAction: vi.fn(), + }; +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the verification form", () => { + const mockHint = { + uid: "test-uid", + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "Test TOTP", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + expect(screen.getByTestId("input-otp-slot-0")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); + }); + + it("should call onSuccess when verification is successful", async () => { + const mockHint = { + uid: "test-uid", + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "Test TOTP", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + const mockAction = vi.fn().mockResolvedValue({ user: { uid: "totp-mfa-user" } }); + vi.mocked(useTotpMultiFactorAssertionFormAction).mockReturnValue(mockAction); + + const mockOnSuccess = vi.fn(); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + // Simulate entering verification code + const verificationInput = screen.getByTestId("input-otp-slot-0"); + fireEvent.change(verificationInput, { target: { value: "123456" } }); + fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + + await waitFor(() => { + expect(mockOnSuccess).toHaveBeenCalledWith({ user: { uid: "totp-mfa-user" } }); + }); + }); + + it("should handle verification form submission error", async () => { + const mockHint = { + uid: "test-uid", + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "Test TOTP", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + const mockAction = vi.fn().mockRejectedValue(new Error("TOTP verification failed")); + vi.mocked(useTotpMultiFactorAssertionFormAction).mockReturnValue(mockAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + // Simulate entering verification code + const verificationInput = screen.getByTestId("input-otp-slot-0"); + fireEvent.change(verificationInput, { target: { value: "123456" } }); + fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + + await waitFor(() => { + expect(screen.getByText("Error: TOTP verification failed")).toBeInTheDocument(); + }); + }); + + it("should not call onSuccess when verification fails", async () => { + const mockHint = { + uid: "test-uid", + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "Test TOTP", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + const mockAction = vi.fn().mockRejectedValue(new Error("Invalid code")); + vi.mocked(useTotpMultiFactorAssertionFormAction).mockReturnValue(mockAction); + + const mockOnSuccess = vi.fn(); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + // Simulate entering verification code + const verificationInput = screen.getByTestId("input-otp-slot-0"); + fireEvent.change(verificationInput, { target: { value: "123456" } }); + fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + + await waitFor(() => { + expect(screen.getByText("Error: Invalid code")).toBeInTheDocument(); + }); + + expect(mockOnSuccess).not.toHaveBeenCalled(); + }); + + it("should work without onSuccess callback", async () => { + const mockHint = { + uid: "test-uid", + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "Test TOTP", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + const mockAction = vi.fn().mockResolvedValue({ user: { uid: "totp-mfa-user" } }); + vi.mocked(useTotpMultiFactorAssertionFormAction).mockReturnValue(mockAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + const verificationInput = screen.getByTestId("input-otp-slot-0"); + fireEvent.change(verificationInput, { target: { value: "123456" } }); + fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/shadcn/src/components/totp-multi-factor-assertion-form.tsx b/packages/shadcn/src/components/totp-multi-factor-assertion-form.tsx new file mode 100644 index 000000000..a960d12f1 --- /dev/null +++ b/packages/shadcn/src/components/totp-multi-factor-assertion-form.tsx @@ -0,0 +1,92 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { type UserCredential, type MultiFactorInfo } from "firebase/auth"; +import { FirebaseUIError, getTranslation } from "@invertase/firebaseui-core"; +import { + useMultiFactorTotpAuthVerifyFormSchema, + useUI, + useTotpMultiFactorAssertionFormAction, +} from "@invertase/firebaseui-react"; +import { useForm } from "react-hook-form"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; + +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Button } from "@/components/ui/button"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; + +type TotpMultiFactorAssertionFormProps = { + hint: MultiFactorInfo; + onSuccess?: (credential: UserCredential) => void; +}; + +export function TotpMultiFactorAssertionForm(props: TotpMultiFactorAssertionFormProps) { + const ui = useUI(); + const schema = useMultiFactorTotpAuthVerifyFormSchema(); + const action = useTotpMultiFactorAssertionFormAction(); + + const form = useForm<{ verificationCode: string }>({ + resolver: standardSchemaResolver(schema), + defaultValues: { + verificationCode: "", + }, + }); + + const onSubmit = async (values: { verificationCode: string }) => { + try { + const credential = await action({ verificationCode: values.verificationCode, hint: props.hint }); + props.onSuccess?.(credential); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + }; + + return ( +
+ + ( + + {getTranslation(ui, "labels", "verificationCode")} + + + + + + + + + + + + + + + )} + /> + + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} diff --git a/packages/shadcn/src/components/totp-multi-factor-enrollment-form.test.tsx b/packages/shadcn/src/components/totp-multi-factor-enrollment-form.test.tsx new file mode 100644 index 000000000..8b3f100f9 --- /dev/null +++ b/packages/shadcn/src/components/totp-multi-factor-enrollment-form.test.tsx @@ -0,0 +1,181 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { TotpMultiFactorEnrollmentForm } from "./totp-multi-factor-enrollment-form"; +import { createFirebaseUIProvider, createMockUIWithUser } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { generateTotpSecret, generateTotpQrCode, enrollWithMultiFactorAssertion } from "@invertase/firebaseui-core"; +import React from "react"; + +// Mock input-otp components to prevent window access issues +vi.mock("@/components/ui/input-otp", () => ({ + InputOTP: ({ children, ...props }: any) => + React.createElement("div", { "data-testid": "input-otp", ...props }, children), + InputOTPGroup: ({ children, ...props }: any) => + React.createElement("div", { "data-testid": "input-otp-group", ...props }, children), + InputOTPSlot: ({ index, ...props }: any) => + React.createElement("input", { + "data-testid": `input-otp-slot-${index}`, + "aria-label": "Verification Code", + ...props, + }), +})); + +vi.mock("@invertase/firebaseui-core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + generateTotpSecret: vi.fn(), + generateTotpQrCode: vi.fn(), + enrollWithMultiFactorAssertion: vi.fn(), + }; +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the secret generation form initially", () => { + const mockUI = createMockUIWithUser({ + locale: registerLocale("test", { + labels: { + displayName: "Display Name", + generateQrCode: "Generate Secret", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + expect(screen.getByLabelText("Display Name")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Generate Secret" })).toBeInTheDocument(); + }); + + it("should transition to verification form after secret generation", async () => { + const mockSecret = { secretKey: "test-secret" } as any; + vi.mocked(generateTotpSecret).mockResolvedValue(mockSecret); + + const mockUI = createMockUIWithUser({ + locale: registerLocale("test", { + labels: { + displayName: "Display Name", + generateQrCode: "Generate Secret", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + prompts: { + mfaTotpQrCodePrompt: "Scan this QR code with your authenticator app", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "Test TOTP" } }); + fireEvent.click(screen.getByRole("button", { name: "Generate Secret" })); + + await waitFor(() => { + expect(screen.getByTestId("input-otp-slot-0")).toBeInTheDocument(); + }); + + expect(screen.getByRole("img", { name: "TOTP QR Code" })).toBeInTheDocument(); + expect(screen.getByText("test-secret")).toBeInTheDocument(); + expect(screen.getByText("Scan this QR code with your authenticator app")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); + }); + + it("should handle secret generation error", async () => { + vi.mocked(generateTotpSecret).mockRejectedValue(new Error("Secret generation failed")); + + const mockUI = createMockUIWithUser({ + locale: registerLocale("test", { + labels: { + displayName: "Display Name", + generateQrCode: "Generate Secret", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "Test TOTP" } }); + fireEvent.click(screen.getByRole("button", { name: "Generate Secret" })); + + await waitFor(() => { + expect(screen.getByText("Error: Secret generation failed")).toBeInTheDocument(); + }); + }); + + it("should handle verification error", async () => { + const mockSecret = { secretKey: "test-secret" } as any; + vi.mocked(generateTotpSecret).mockResolvedValue(mockSecret); + vi.mocked(enrollWithMultiFactorAssertion).mockRejectedValue(new Error("Verification failed")); + + const mockUI = createMockUIWithUser({ + locale: registerLocale("test", { + labels: { + displayName: "Display Name", + generateQrCode: "Generate Secret", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + }), + }); + + render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "Test TOTP" } }); + fireEvent.click(screen.getByRole("button", { name: "Generate Secret" })); + + await waitFor(() => { + expect(screen.getByTestId("input-otp-slot-0")).toBeInTheDocument(); + }); + + const verificationInput = screen.getByTestId("input-otp-slot-0"); + fireEvent.change(verificationInput, { target: { value: "123456" } }); + fireEvent.click(screen.getByRole("button", { name: "Verify Code" })); + + await waitFor(() => { + expect(screen.getByText("Error: Verification failed")).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/shadcn/src/components/totp-multi-factor-enrollment-form.tsx b/packages/shadcn/src/components/totp-multi-factor-enrollment-form.tsx new file mode 100644 index 000000000..734ebef61 --- /dev/null +++ b/packages/shadcn/src/components/totp-multi-factor-enrollment-form.tsx @@ -0,0 +1,194 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { useState } from "react"; +import { TotpMultiFactorGenerator, type TotpSecret } from "firebase/auth"; +import { + enrollWithMultiFactorAssertion, + FirebaseUIError, + generateTotpQrCode, + generateTotpSecret, + getTranslation, +} from "@invertase/firebaseui-core"; +import { + useMultiFactorTotpAuthNumberFormSchema, + useMultiFactorTotpAuthVerifyFormSchema, + useUI, +} from "@invertase/firebaseui-react"; +import { useForm } from "react-hook-form"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; + +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; + +type TotpMultiFactorSecretGenerationFormProps = { + onSubmit: (secret: TotpSecret, displayName: string) => void; +}; + +function TotpMultiFactorSecretGenerationForm(props: TotpMultiFactorSecretGenerationFormProps) { + const ui = useUI(); + const schema = useMultiFactorTotpAuthNumberFormSchema(); + + const form = useForm<{ displayName: string }>({ + resolver: standardSchemaResolver(schema), + defaultValues: { + displayName: "", + }, + }); + + const onSubmit = async (values: { displayName: string }) => { + try { + const secret = await generateTotpSecret(ui); + props.onSubmit(secret, values.displayName); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + }; + + return ( +
+ + ( + + {getTranslation(ui, "labels", "displayName")} + + + + + + )} + /> + + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} + +type MultiFactorEnrollmentVerifyTotpFormProps = { + secret: TotpSecret; + displayName: string; + onSuccess: () => void; +}; + +export function MultiFactorEnrollmentVerifyTotpForm(props: MultiFactorEnrollmentVerifyTotpFormProps) { + const ui = useUI(); + const schema = useMultiFactorTotpAuthVerifyFormSchema(); + + const form = useForm<{ verificationCode: string }>({ + resolver: standardSchemaResolver(schema), + defaultValues: { + verificationCode: "", + }, + }); + + const onSubmit = async (values: { verificationCode: string }) => { + try { + const assertion = TotpMultiFactorGenerator.assertionForEnrollment(props.secret, values.verificationCode); + await enrollWithMultiFactorAssertion(ui, assertion, values.verificationCode); + props.onSuccess(); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + }; + + const qrCodeDataUrl = generateTotpQrCode(ui, props.secret, props.displayName); + + return ( +
+
+ TOTP QR Code + {props.secret.secretKey.toString()} +

+ {getTranslation(ui, "prompts", "mfaTotpQrCodePrompt")} +

+
+
+ + ( + + {getTranslation(ui, "labels", "verificationCode")} + + + + + + + + + + + + + + + )} + /> + + {form.formState.errors.root && {form.formState.errors.root.message}} + + +
+ ); +} + +export type TotpMultiFactorEnrollmentFormProps = { + onSuccess?: () => void; +}; + +export function TotpMultiFactorEnrollmentForm(props: TotpMultiFactorEnrollmentFormProps) { + const ui = useUI(); + + const [enrollment, setEnrollment] = useState<{ + secret: TotpSecret; + displayName: string; + } | null>(null); + + if (!ui.auth.currentUser) { + throw new Error("User must be authenticated to enroll with multi-factor authentication"); + } + + if (!enrollment) { + return ( + setEnrollment({ secret, displayName })} /> + ); + } + + return ( + { + props.onSuccess?.(); + }} + /> + ); +} diff --git a/packages/shadcn/src/components/twitter-sign-in-button.test.tsx b/packages/shadcn/src/components/twitter-sign-in-button.test.tsx new file mode 100644 index 000000000..053580c4e --- /dev/null +++ b/packages/shadcn/src/components/twitter-sign-in-button.test.tsx @@ -0,0 +1,215 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { TwitterSignInButton } from "./twitter-sign-in-button"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { TwitterAuthProvider } from "firebase/auth"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; + +vi.mock("./oauth-button", () => ({ + OAuthButton: ({ provider, children, themed, onSignIn }: any) => ( +
+
{provider.providerId}
+
{String(themed)}
+
{onSignIn ? "present" : "absent"}
+
{children}
+
+ ), +})); + +vi.mock("@invertase/firebaseui-react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + TwitterLogo: ({ className, ...props }: any) => ( + + Twitter Logo + + ), + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with default Twitter provider", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("twitter.com"); + expect(screen.getByTestId("twitter-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with Twitter")).toBeInTheDocument(); + }); + + it("renders with custom Twitter provider", () => { + const customProvider = new TwitterAuthProvider(); + customProvider.addScope("email"); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("twitter.com"); + expect(screen.getByTestId("twitter-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with Twitter")).toBeInTheDocument(); + }); + + it("passes themed prop to OAuthButton", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).toHaveTextContent("true"); + }); + + it("renders Twitter logo with correct props", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + const twitterLogo = screen.getByTestId("twitter-logo"); + expect(twitterLogo).toBeInTheDocument(); + }); + + it("uses correct translation for button text", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Custom Twitter Sign In Text", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Custom Twitter Sign In Text")).toBeInTheDocument(); + }); + + it("renders children correctly", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + const childrenContainer = screen.getByTestId("children"); + expect(childrenContainer).toBeInTheDocument(); + + // Should contain both the Twitter logo and the text + expect(childrenContainer.querySelector('[data-testid="twitter-logo"]')).toBeInTheDocument(); + expect(childrenContainer).toHaveTextContent("Sign in with Twitter"); + }); + + it("handles missing themed prop", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).not.toHaveTextContent("true"); + }); + + it("passes onSignIn prop to OAuthButton", () => { + const onSignIn = vi.fn(); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("onSignIn")).toHaveTextContent("present"); + }); +}); diff --git a/packages/shadcn/src/components/twitter-sign-in-button.tsx b/packages/shadcn/src/components/twitter-sign-in-button.tsx new file mode 100644 index 000000000..de379fc0b --- /dev/null +++ b/packages/shadcn/src/components/twitter-sign-in-button.tsx @@ -0,0 +1,36 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use client"; + +import { TwitterAuthProvider } from "firebase/auth"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type TwitterSignInButtonProps, TwitterLogo } from "@invertase/firebaseui-react"; + +import { OAuthButton } from "@/components/oauth-button"; + +export type { TwitterSignInButtonProps }; + +export function TwitterSignInButton({ provider, ...props }: TwitterSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithTwitter")} + + ); +} diff --git a/packages/shadcn/src/components/ui/alert.tsx b/packages/shadcn/src/components/ui/alert.tsx new file mode 100644 index 000000000..c6f7846fd --- /dev/null +++ b/packages/shadcn/src/components/ui/alert.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps) { + return
; +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription }; diff --git a/packages/shadcn/src/components/ui/button.tsx b/packages/shadcn/src/components/ui/button.tsx new file mode 100644 index 000000000..1ee147901 --- /dev/null +++ b/packages/shadcn/src/components/ui/button.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : "button"; + + return ; +} + +export { Button, buttonVariants }; diff --git a/packages/shadcn/src/components/ui/card.tsx b/packages/shadcn/src/components/ui/card.tsx new file mode 100644 index 000000000..9939da87c --- /dev/null +++ b/packages/shadcn/src/components/ui/card.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; diff --git a/packages/shadcn/src/components/ui/field.tsx b/packages/shadcn/src/components/ui/field.tsx new file mode 100644 index 000000000..4dcc23b35 --- /dev/null +++ b/packages/shadcn/src/components/ui/field.tsx @@ -0,0 +1,222 @@ +import { useMemo } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ); +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-slot=field-group]]:gap-4", + className + )} + {...props} + /> + ); +} + +const fieldVariants = cva("group/field flex w-full gap-3 data-[invalid=true]:text-destructive", { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + responsive: [ + "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, +}); + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function FieldLabel({ className, ...props }: React.ComponentProps) { + return ( +