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..b27da9b21 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,49 @@ +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: 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/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/README.md b/README.md index 33afe5bed..edcea6ed7 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,10 @@ Packages have been created for both `React` and `Angular`. For now, they're only ```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" + "@invertase/firebaseui-react": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-react-0.0.1.tgz", + "@invertase/firebaseui-core": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-core-0.0.1.tgz", + "@invertase/firebaseui-styles": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-styles-0.0.1.tgz", + "@invertase/firebaseui-translations": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-translations-0.0.1.tgz" } } ``` @@ -41,10 +41,10 @@ FirebaseUI for Angular depends on the [AngularFire](https://github.com/angular/a { "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" + "@invertase/firebaseui-angular": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-angular-0.0.1.tgz", + "@invertase/firebaseui-core": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-core-0.0.1.tgz", + "@invertase/firebaseui-styles": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-styles-0.0.1.tgz", + "@invertase/firebaseui-translations": "https://github.com/firebase/firebaseui-web/raw/refs/heads/v7-alpha/releases/firebase-ui-translations-0.0.1.tgz" } } ``` @@ -63,10 +63,10 @@ import { initializeApp } from 'firebase/app'; const app = initializeApp({ ... }); ``` -Next, setup and configure FirebaseUI, import the `initializeUI` function from `@firebase-ui/core`: +Next, setup and configure FirebaseUI, import the `initializeUI` function from `@invertase/firebaseui-core`: ```ts -import { initializeUI } from "@firebase-ui/core"; +import { initializeUI } from "@invertase/firebaseui-core"; const ui = initializeUI(); ``` @@ -82,8 +82,8 @@ FirebaseUI for React requires that your application be wrapped in the `ConfigPro ```tsx import { initializeApp } from 'firebase/app'; -import { initializeUI } from "@firebase-ui/core"; -import { ConfigProvider } from '@firebase-ui/react'; +import { initializeUI } from "@invertase/firebaseui-core"; +import { ConfigProvider } from '@invertase/firebaseui-react'; const app = initializeApp({ .. }); const ui = initializeUI({ app }); @@ -107,8 +107,8 @@ FirebaseUI depends on [AngularFire](https://github.com/angular/angularfire) bein ```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'; +import { provideFirebaseUI } from '@invertase/firebaseui-angular'; +import { initializeUI } from '@invertase/firebaseui-core'; export const appConfig: ApplicationConfig = { providers: [ @@ -127,17 +127,17 @@ export const appConfig: ApplicationConfig = { Next, import the CSS styles for the FirebaseUI project. -If you are using [TailwindCSS](https://tailwindcss.com/), import the base CSS from the `@firebase-ui/styles` package after your Tailwind import: +If you are using [TailwindCSS](https://tailwindcss.com/), import the base CSS from the `@invertase/firebaseui-styles` package after your Tailwind import: ```css @import "tailwindcss"; -@import "@firebase-ui/styles/src/base.css"; +@import "@invertase/firebaseui-styles/tailwind"; ``` If you are not using Tailwind, import the distributable CSS in your project: ```css -@import "@firebase-ui/styles/dist.css"; +@import "@invertase/firebaseui-styles"; ``` To learn more about theming, view the [theming](#theming) section. @@ -154,7 +154,7 @@ Allows users to sign in with an email and password: React ```tsx -import { SignInAuthScreen } from "@firebase-ui/react"; +import { SignInAuthScreen } from "@invertase/firebaseui-react"; function App() { return ; @@ -166,7 +166,7 @@ Props: `onForgotPasswordClick` / `onRegisterClick` Additionally, allow the user to sign in with an OAuth provider by providing children: ```tsx -import { SignInAuthScreen, GoogleSignInButton } from "@firebase-ui/react"; +import { SignInAuthScreen, GoogleSignInButton } from "@invertase/firebaseui-react"; function App() { return ( @@ -183,7 +183,7 @@ function App() { Angular ```tsx -import { SignUpAuthScreenComponent } from "@firebase-ui/angular"; +import { SignUpAuthScreenComponent } from "@invertase/firebaseui-angular"; @Component({ selector: "app-root", @@ -202,12 +202,11 @@ The initializeUI function accepts an options object that allows you to customize ### Type Definition ```js -type FirebaseUIConfigurationOptions = { +type FirebaseUIOptions = { app: FirebaseApp; - locale?: Locale | undefined; - translations?: RegisteredTranslations[] | undefined; - behaviors?: Partial>[] | undefined; - recaptchaMode?: 'normal' | 'invisible' | undefined; + auth?: Auth; + locale?: Locale; + behaviors?: Behavior[]; }; ``` @@ -258,7 +257,7 @@ The default values are based on the [TailwindCSS](https://tailwindcss.com/docs/t ## FirebaseUI Core Integration -`@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. +`@invertase/firebaseui-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. ### What FirebaseUI Core Provides @@ -279,7 +278,7 @@ The default values are based on the [TailwindCSS](https://tailwindcss.com/docs/t Call initializeUI() with your Firebase app and configuration options: ```js -import { initializeUI } from '@firebase-ui/core'; +import { initializeUI } from '@invertase/firebaseui-core'; const ui = initializeUI({ app: firebaseApp, @@ -290,7 +289,7 @@ const ui = initializeUI({ Configuration Type: ```js -type FirebaseUIConfigurationOptions = { +type FirebaseUIOptions = { app: FirebaseApp; locale?: Locale | undefined; translations?: RegisteredTranslations[] | undefined; @@ -303,61 +302,61 @@ type FirebaseUIConfigurationOptions = { **signInWithEmailAndPassword**: Signs in the user based on an email/password credential. -- _ui_: FirebaseUIConfiguration +- _ui_: FirebaseUI - _email_: string - _password_: string **createUserWithEmailAndPassword**: Creates a user account based on an email/password credential. -- _ui_: FirebaseUIConfiguration +- _ui_: FirebaseUI - _email_: string - _password_: string **signInWithPhoneNumber**: Signs in the user based on a provided phone number, using ReCaptcha to verify the sign-in. -- _ui_: FirebaseUIConfiguration +- _ui_: FirebaseUI - _phoneNumber_: string - _recaptchaVerifier_: string **confirmPhoneNumber**: Verifies the phonenumber credential and signs in the user. -- _ui_: FirebaseUIConfiguration +- _ui_: FirebaseUI - _confirmationResult_: [ConfirmationResult](https://firebase.google.com/docs/reference/node/firebase.auth.ConfirmationResult) - _verificationCode_: string **sendPasswordResetEmail**: Sends password reset instructions to an email account. -- _ui_: FirebaseUIConfiguration +- _ui_: FirebaseUI - _email_: string **sendSignInLinkToEmail**: Send an sign-in links to an email account. -- _ui_: FirebaseUIConfiguration +- _ui_: FirebaseUI - _email_: string **signInWithEmailLink**: Signs in with the user with the email link. If `autoUpgradeAnonymousCredential` then a pending credential will be handled. -- _ui_: FirebaseUIConfiguration +- _ui_: FirebaseUI - _email_: string - _link_: string **signInAnonymously**: Signs in as an anonymous user. -- _ui_: FirebaseUIConfiguration +- _ui_: FirebaseUI **signInWithOAuth**: Signs in with a provider such as Google via a redirect link. If `autoUpgradeAnonymousCredential` then the account will upgraded. -- _ui_: FirebaseUIConfiguration +- _ui_: FirebaseUI - _provider_: [AuthProvider](https://firebase.google.com/docs/reference/node/firebase.auth.AuthProvider) **completeEmailLinkSignIn**: Completes the signing process based on a user signing in with an email link. -- _ui_: FirebaseUIConfiguration +- _ui_: FirebaseUI - _currentUrl_: string #### Provide a Store via Context -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: +Using the returned `FirebaseUI`, it is reccomended to use local context/providers/dependency-injection to expose the FirebaseUI to the application. Here is an example context wrapper which accepts the configuration as a `ui` parameter: ```js /** Creates a framework-agnostic context for Firebase UI configuration **/ @@ -391,7 +390,7 @@ export function createFirebaseUIContext(initialConfig) { FirebaseUI Configuration Type: ```js -export type FirebaseUIConfiguration = { +export type FirebaseUI = { app: FirebaseApp, getAuth: () => Auth, setLocale: (locale: Locale) => void, @@ -460,7 +459,7 @@ uiStore.subscribe((ui) => { You can pass one or more translations to support localized strings. ```js -import { english } from "@firebase-ui/translations"; +import { english } from "@invertase/firebaseui-translations"; initializeUI({ app, @@ -485,7 +484,7 @@ const customFr = { To use a string at runtime (e.g., in an error message): ```js -import { getTranslation } from "@firebase-ui/core"; +import { getTranslation } from "@invertase/firebaseui-core"; const message = getTranslation(config, "errors", "unknownError"); ``` @@ -508,7 +507,7 @@ Validates a sign-in or sign-up form using email and password. - _password_: Must be at least 8 characters. ```js -import { createEmailFormSchema } from "@firebase-ui/core"; +import { createEmailFormSchema } from "@invertase/firebaseui-core"; const schema = createEmailFormSchema(translations); ``` @@ -556,7 +555,7 @@ The core library provides a function for handling errors. ```js export function handleFirebaseError( - ui: FirebaseUIConfiguration, + ui: FirebaseUI, error: any, opts?: { enableHandleExistingCredential?: boolean; diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 000000000..cdbc5aa62 --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,91 @@ +/* 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/firebase.json b/examples/angular/firebase.json new file mode 100644 index 000000000..69d36f31e --- /dev/null +++ b/examples/angular/firebase.json @@ -0,0 +1,7 @@ +{ + "hosting": { + "site": "fir-ui-rework-angular", + "source": ".", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"] + } +} 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/index.ts b/examples/angular/src/app/auth/password-reset/index.ts deleted file mode 100644 index f969fada9..000000000 --- a/examples/angular/src/app/auth/password-reset/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 './password-reset.component'; 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/email-link-auth-screen-w-oauth.component.ts b/examples/angular/src/app/screens/email-link-auth-screen-w-oauth/email-link-auth-screen-w-oauth.component.ts new file mode 100644 index 000000000..331551c38 --- /dev/null +++ b/examples/angular/src/app/screens/email-link-auth-screen-w-oauth/email-link-auth-screen-w-oauth.component.ts @@ -0,0 +1,45 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { EmailLinkAuthScreenComponent, GoogleSignInButtonComponent } from "@invertase/firebaseui-angular"; +import type { UserCredential } from "firebase/auth"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-email-link-auth-screen-w-oauth", + standalone: true, + imports: [CommonModule, EmailLinkAuthScreenComponent, GoogleSignInButtonComponent], + template: ` + + + + `, + styles: [], +}) +export class EmailLinkAuthScreenWithOAuthComponent { + private router = inject(Router); + + onEmailSent() { + alert("email sent - please check your email"); + } + + onSignIn(credential: UserCredential) { + console.log("sign in", credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/email-link-auth-screen-w-oauth/index.ts b/examples/angular/src/app/screens/email-link-auth-screen-w-oauth/index.ts new file mode 100644 index 000000000..13f2e186d --- /dev/null +++ b/examples/angular/src/app/screens/email-link-auth-screen-w-oauth/index.ts @@ -0,0 +1 @@ +export * from "./email-link-auth-screen-w-oauth.component"; diff --git a/examples/angular/src/app/screens/email-link-auth-screen/email-link-auth-screen.component.ts b/examples/angular/src/app/screens/email-link-auth-screen/email-link-auth-screen.component.ts new file mode 100644 index 000000000..55dcefb66 --- /dev/null +++ b/examples/angular/src/app/screens/email-link-auth-screen/email-link-auth-screen.component.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 { EmailLinkAuthScreenComponent } from "@invertase/firebaseui-angular"; +import type { UserCredential } from "firebase/auth"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-email-link-auth-screen", + standalone: true, + imports: [CommonModule, EmailLinkAuthScreenComponent], + template: ` `, + styles: [], +}) +export class EmailLinkAuthScreenWrapperComponent { + private router = inject(Router); + + onEmailSent() { + alert("email sent - please check your email"); + } + + onSignIn(credential: UserCredential) { + console.log("sign in", credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/email-link-auth-screen/index.ts b/examples/angular/src/app/screens/email-link-auth-screen/index.ts new file mode 100644 index 000000000..3d995dddc --- /dev/null +++ b/examples/angular/src/app/screens/email-link-auth-screen/index.ts @@ -0,0 +1 @@ +export * from "./email-link-auth-screen.component"; diff --git a/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/forgot-password-auth-screen-w-handlers.component.ts b/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/forgot-password-auth-screen-w-handlers.component.ts new file mode 100644 index 000000000..e8c09a3ca --- /dev/null +++ b/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/forgot-password-auth-screen-w-handlers.component.ts @@ -0,0 +1,49 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router } from "@angular/router"; +import { ForgotPasswordAuthScreenComponent } from "@invertase/firebaseui-angular"; + +@Component({ + selector: "app-forgot-password-auth-screen-w-handlers", + standalone: true, + imports: [CommonModule, ForgotPasswordAuthScreenComponent], + template: ` + + `, + styles: [], +}) +export class ForgotPasswordAuthScreenWithHandlersComponent { + private router = inject(Router); + + goToSignIn() { + this.router.navigate(["/screens/sign-in-auth-screen"]); + } + + goToForgotPassword() { + this.router.navigate(["/screens/forgot-password-auth-screen"]); + } + + goToSignUp() { + this.router.navigate(["/screens/sign-up-auth-screen"]); + } +} diff --git a/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/index.ts b/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/index.ts new file mode 100644 index 000000000..227203450 --- /dev/null +++ b/examples/angular/src/app/screens/forgot-password-auth-screen-w-handlers/index.ts @@ -0,0 +1 @@ +export * from "./forgot-password-auth-screen-w-handlers.component"; diff --git a/examples/angular/src/app/screens/forgot-password-auth-screen/forgot-password-auth-screen.component.ts b/examples/angular/src/app/screens/forgot-password-auth-screen/forgot-password-auth-screen.component.ts new file mode 100644 index 000000000..ceb20bc35 --- /dev/null +++ b/examples/angular/src/app/screens/forgot-password-auth-screen/forgot-password-auth-screen.component.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/forgot-password-auth-screen/index.ts b/examples/angular/src/app/screens/forgot-password-auth-screen/index.ts new file mode 100644 index 000000000..6cc32654d --- /dev/null +++ b/examples/angular/src/app/screens/forgot-password-auth-screen/index.ts @@ -0,0 +1 @@ +export * from "./forgot-password-auth-screen.component"; diff --git a/examples/angular/src/app/screens/mfa-enrollment-screen/index.ts b/examples/angular/src/app/screens/mfa-enrollment-screen/index.ts new file mode 100644 index 000000000..1402c2ecd --- /dev/null +++ b/examples/angular/src/app/screens/mfa-enrollment-screen/index.ts @@ -0,0 +1 @@ +export * from "./mfa-enrollment-screen.component"; diff --git a/examples/angular/src/app/screens/mfa-enrollment-screen/mfa-enrollment-screen.component.ts b/examples/angular/src/app/screens/mfa-enrollment-screen/mfa-enrollment-screen.component.ts new file mode 100644 index 000000000..4e7c7fd8e --- /dev/null +++ b/examples/angular/src/app/screens/mfa-enrollment-screen/mfa-enrollment-screen.component.ts @@ -0,0 +1,42 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router } from "@angular/router"; +import { MultiFactorAuthEnrollmentScreenComponent } from "@invertase/firebaseui-angular"; +import { FactorId } from "firebase/auth"; + +@Component({ + selector: "app-mfa-enrollment-screen", + standalone: true, + imports: [CommonModule, MultiFactorAuthEnrollmentScreenComponent], + template: ` + + `, + styles: [], +}) +export class MfaEnrollmentScreenComponent { + FactorId = FactorId; + private router = inject(Router); + + onEnrollment() { + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/oauth-screen/index.ts b/examples/angular/src/app/screens/oauth-screen/index.ts new file mode 100644 index 000000000..6fed7e762 --- /dev/null +++ b/examples/angular/src/app/screens/oauth-screen/index.ts @@ -0,0 +1 @@ +export * from "./oauth-screen.component"; diff --git a/examples/angular/src/app/screens/oauth-screen/oauth-screen.component.ts b/examples/angular/src/app/screens/oauth-screen/oauth-screen.component.ts new file mode 100644 index 000000000..80690c2dd --- /dev/null +++ b/examples/angular/src/app/screens/oauth-screen/oauth-screen.component.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, inject, signal } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + OAuthScreenComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, +} from "@invertase/firebaseui-angular"; +import type { UserCredential } from "firebase/auth"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-oauth-screen", + standalone: true, + imports: [ + CommonModule, + OAuthScreenComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, + ], + template: ` + + + + + + + + +
+ +
+ `, + styles: [], +}) +export class OAuthScreenWrapperComponent { + themed = signal(false); + private router = inject(Router); + + onSignIn(credential: UserCredential) { + console.log("sign in", credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/phone-auth-screen-w-oauth/index.ts b/examples/angular/src/app/screens/phone-auth-screen-w-oauth/index.ts new file mode 100644 index 000000000..3d0a30e0d --- /dev/null +++ b/examples/angular/src/app/screens/phone-auth-screen-w-oauth/index.ts @@ -0,0 +1 @@ +export * from "./phone-auth-screen-w-oauth.component"; diff --git a/examples/angular/src/app/screens/phone-auth-screen-w-oauth/phone-auth-screen-w-oauth.component.ts b/examples/angular/src/app/screens/phone-auth-screen-w-oauth/phone-auth-screen-w-oauth.component.ts new file mode 100644 index 000000000..9fac00203 --- /dev/null +++ b/examples/angular/src/app/screens/phone-auth-screen-w-oauth/phone-auth-screen-w-oauth.component.ts @@ -0,0 +1,43 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { PhoneAuthScreenComponent, GoogleSignInButtonComponent, ContentComponent } from "@invertase/firebaseui-angular"; +import type { UserCredential } from "firebase/auth"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-phone-auth-screen-w-oauth", + standalone: true, + imports: [CommonModule, PhoneAuthScreenComponent, GoogleSignInButtonComponent, ContentComponent], + template: ` + + + + + + `, + styles: [], +}) +export class PhoneAuthScreenWithOAuthComponent { + private router = inject(Router); + + onSignIn(credential: UserCredential) { + console.log("sign in", credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/phone-auth-screen/index.ts b/examples/angular/src/app/screens/phone-auth-screen/index.ts new file mode 100644 index 000000000..ae65a8ce7 --- /dev/null +++ b/examples/angular/src/app/screens/phone-auth-screen/index.ts @@ -0,0 +1 @@ +export * from "./phone-auth-screen.component"; diff --git a/examples/angular/src/app/screens/phone-auth-screen/phone-auth-screen.component.ts b/examples/angular/src/app/screens/phone-auth-screen/phone-auth-screen.component.ts new file mode 100644 index 000000000..add17ca3f --- /dev/null +++ b/examples/angular/src/app/screens/phone-auth-screen/phone-auth-screen.component.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 { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { PhoneAuthScreenComponent } from "@invertase/firebaseui-angular"; +import type { UserCredential } from "firebase/auth"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-phone-auth-screen", + standalone: true, + imports: [CommonModule, PhoneAuthScreenComponent], + template: ` `, + styles: [], +}) +export class PhoneAuthScreenWrapperComponent { + private router = inject(Router); + + onSignIn(credential: UserCredential) { + console.log("sign in", credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/index.ts b/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/index.ts new file mode 100644 index 000000000..d498e8f0b --- /dev/null +++ b/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/index.ts @@ -0,0 +1 @@ +export * from "./sign-in-auth-screen-w-handlers.component"; diff --git a/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/sign-in-auth-screen-w-handlers.component.ts b/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/sign-in-auth-screen-w-handlers.component.ts new file mode 100644 index 000000000..ce7a7d296 --- /dev/null +++ b/examples/angular/src/app/screens/sign-in-auth-screen-w-handlers/sign-in-auth-screen-w-handlers.component.ts @@ -0,0 +1,50 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router } from "@angular/router"; +import { SignInAuthScreenComponent } from "@invertase/firebaseui-angular"; + +@Component({ + selector: "app-sign-in-auth-screen-w-handlers", + standalone: true, + imports: [CommonModule, SignInAuthScreenComponent], + template: ` + + `, + styles: [], +}) +export class SignInAuthScreenWithHandlersComponent { + private router = inject(Router); + + goToForgotPassword() { + this.router.navigate(["/screens/forgot-password-auth-screen"]); + } + + goToSignUp() { + this.router.navigate(["/screens/sign-up-auth-screen"]); + } + + onSignIn(credential: unknown) { + console.log(credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/index.ts b/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/index.ts new file mode 100644 index 000000000..2697e1510 --- /dev/null +++ b/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/index.ts @@ -0,0 +1 @@ +export * from "./sign-in-auth-screen-w-oauth.component"; diff --git a/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/sign-in-auth-screen-w-oauth.component.ts b/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/sign-in-auth-screen-w-oauth.component.ts new file mode 100644 index 000000000..dd0048828 --- /dev/null +++ b/examples/angular/src/app/screens/sign-in-auth-screen-w-oauth/sign-in-auth-screen-w-oauth.component.ts @@ -0,0 +1,67 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + SignInAuthScreenComponent, + ContentComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, +} from "@invertase/firebaseui-angular"; +import type { UserCredential } from "firebase/auth"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-sign-in-auth-screen-w-oauth", + standalone: true, + imports: [ + CommonModule, + SignInAuthScreenComponent, + ContentComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, + ], + template: ` + + + + + + + + + + + `, + styles: [], +}) +export class SignInAuthScreenWithOAuthComponent { + private router = inject(Router); + + onSignIn(credential: UserCredential) { + console.log("sign in", credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/sign-in-auth-screen/index.ts b/examples/angular/src/app/screens/sign-in-auth-screen/index.ts new file mode 100644 index 000000000..744e4f844 --- /dev/null +++ b/examples/angular/src/app/screens/sign-in-auth-screen/index.ts @@ -0,0 +1 @@ +export * from "./sign-in-auth-screen.component"; diff --git a/examples/angular/src/app/screens/sign-in-auth-screen/sign-in-auth-screen.component.ts b/examples/angular/src/app/screens/sign-in-auth-screen/sign-in-auth-screen.component.ts new file mode 100644 index 000000000..2475549dd --- /dev/null +++ b/examples/angular/src/app/screens/sign-in-auth-screen/sign-in-auth-screen.component.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 { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { SignInAuthScreenComponent } from "@invertase/firebaseui-angular"; +import type { UserCredential } from "firebase/auth"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-sign-in-auth-screen", + standalone: true, + imports: [CommonModule, SignInAuthScreenComponent], + template: ` `, + styles: [], +}) +export class SignInAuthScreenWrapperComponent { + private router = inject(Router); + + onSignIn(credential: UserCredential) { + console.log("sign in", credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/index.ts b/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/index.ts new file mode 100644 index 000000000..64db728c0 --- /dev/null +++ b/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/index.ts @@ -0,0 +1 @@ +export * from "./sign-up-auth-screen-w-handlers.component"; diff --git a/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/sign-up-auth-screen-w-handlers.component.ts b/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/sign-up-auth-screen-w-handlers.component.ts new file mode 100644 index 000000000..8aa94af46 --- /dev/null +++ b/examples/angular/src/app/screens/sign-up-auth-screen-w-handlers/sign-up-auth-screen-w-handlers.component.ts @@ -0,0 +1,40 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router } from "@angular/router"; +import { SignUpAuthScreenComponent } from "@invertase/firebaseui-angular"; + +@Component({ + selector: "app-sign-up-auth-screen-w-handlers", + standalone: true, + imports: [CommonModule, SignUpAuthScreenComponent], + template: ` `, + styles: [], +}) +export class SignUpAuthScreenWithHandlersComponent { + private router = inject(Router); + + goToSignIn() { + this.router.navigate(["/screens/sign-in-auth-screen"]); + } + + onSignUp(credential: unknown) { + console.log(credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/index.ts b/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/index.ts new file mode 100644 index 000000000..cc1567d01 --- /dev/null +++ b/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/index.ts @@ -0,0 +1 @@ +export * from "./sign-up-auth-screen-w-oauth.component"; diff --git a/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/sign-up-auth-screen-w-oauth.component.ts b/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/sign-up-auth-screen-w-oauth.component.ts new file mode 100644 index 000000000..c172ead67 --- /dev/null +++ b/examples/angular/src/app/screens/sign-up-auth-screen-w-oauth/sign-up-auth-screen-w-oauth.component.ts @@ -0,0 +1,67 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + SignUpAuthScreenComponent, + ContentComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, +} from "@invertase/firebaseui-angular"; +import type { UserCredential } from "firebase/auth"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-sign-up-auth-screen-w-oauth", + standalone: true, + imports: [ + CommonModule, + SignUpAuthScreenComponent, + ContentComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, + ], + template: ` + + + + + + + + + + + `, + styles: [], +}) +export class SignUpAuthScreenWithOAuthComponent { + private router = inject(Router); + + onSignUp(credential: UserCredential) { + console.log("sign up", credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/screens/sign-up-auth-screen/index.ts b/examples/angular/src/app/screens/sign-up-auth-screen/index.ts new file mode 100644 index 000000000..41aa28348 --- /dev/null +++ b/examples/angular/src/app/screens/sign-up-auth-screen/index.ts @@ -0,0 +1 @@ +export * from "./sign-up-auth-screen.component"; diff --git a/examples/angular/src/app/screens/sign-up-auth-screen/sign-up-auth-screen.component.ts b/examples/angular/src/app/screens/sign-up-auth-screen/sign-up-auth-screen.component.ts new file mode 100644 index 000000000..6671ecba7 --- /dev/null +++ b/examples/angular/src/app/screens/sign-up-auth-screen/sign-up-auth-screen.component.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 { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { SignUpAuthScreenComponent } from "@invertase/firebaseui-angular"; +import { Router } from "@angular/router"; +import type { UserCredential } from "firebase/auth"; + +@Component({ + selector: "app-sign-up-auth-screen", + standalone: true, + imports: [CommonModule, SignUpAuthScreenComponent], + template: ` `, + styles: [], +}) +export class SignUpAuthScreenWrapperComponent { + private router = inject(Router); + + onSignUp(credential: UserCredential) { + console.log("sign up", credential); + this.router.navigate(["/"]); + } +} diff --git a/examples/angular/src/app/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/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..7e62d3ba8 --- /dev/null +++ b/examples/nextjs-ssr/app/globals.css @@ -0,0 +1,21 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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"; +@import "@invertase/firebaseui-styles/tailwind"; + +/* @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..bea3a80e7 --- /dev/null +++ b/examples/nextjs-ssr/app/layout.tsx @@ -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 { getCurrentUser } from "@/lib/firebase/serverApp"; +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; + +import { Header } from "@/lib/components/header"; +import { FirebaseUIProviderHoc } from "@/lib/firebase/ui"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +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; +}>) { + const { currentUser } = await getCurrentUser(); + + return ( + + +
+ {children} + + + ); +} diff --git a/examples/nextjs-ssr/app/page.tsx b/examples/nextjs-ssr/app/page.tsx new file mode 100644 index 000000000..85aefa3f9 --- /dev/null +++ b/examples/nextjs-ssr/app/page.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. + */ + +import { getCurrentUser } from "@/lib/firebase/serverApp"; +import Link from "next/link"; + +export default async function Home() { + const { currentUser } = await getCurrentUser(); + + return ( +
+

Firebase UI Demo (SSR)

+
{currentUser &&
Welcome: {currentUser.email || currentUser.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 + +
  • +
+
+
+ ); +} diff --git a/examples/nextjs-ssr/app/register/page.tsx b/examples/nextjs-ssr/app/register/page.tsx new file mode 100644 index 000000000..608d50d7a --- /dev/null +++ b/examples/nextjs-ssr/app/register/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 RegisterScreen from "./screen"; + +export default async function RegisterPage() { + const { currentUser } = await getCurrentUser(); + + if (currentUser) { + redirect("/"); + } + + return ; +} diff --git a/examples/nextjs-ssr/app/register/screen.tsx b/examples/nextjs-ssr/app/register/screen.tsx new file mode 100644 index 000000000..d53d41292 --- /dev/null +++ b/examples/nextjs-ssr/app/register/screen.tsx @@ -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. + */ + +"use client"; + +import { useUser } from "@/lib/firebase/hooks"; +import { GoogleSignInButton, SignUpAuthScreen } from "@invertase/firebaseui-react"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function Screen() { + const router = useRouter(); + const user = useUser(); + + // If the user signs in, redirect to the home page from the client. + useEffect(() => { + if (user) { + router.push("/"); + } + }, [user, router]); + + return ( + router.push("/sign-in")}> + + + ); +} diff --git a/examples/nextjs-ssr/app/screens/email-link-auth-screen-w-oauth/page.tsx b/examples/nextjs-ssr/app/screens/email-link-auth-screen-w-oauth/page.tsx new file mode 100644 index 000000000..2886c4113 --- /dev/null +++ b/examples/nextjs-ssr/app/screens/email-link-auth-screen-w-oauth/page.tsx @@ -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. + */ + +"use client"; + +import { EmailLinkAuthScreen, GoogleSignInButton } from "@invertase/firebaseui-react"; + +export default function EmailLinkAuthScreenWithOAuthPage() { + return ( + + + + ); +} diff --git a/packages/firebaseui-react/tests/setup-test.ts b/examples/nextjs-ssr/app/screens/email-link-auth-screen/page.tsx similarity index 77% rename from packages/firebaseui-react/tests/setup-test.ts rename to examples/nextjs-ssr/app/screens/email-link-auth-screen/page.tsx index f302fe174..532261734 100644 --- a/packages/firebaseui-react/tests/setup-test.ts +++ b/examples/nextjs-ssr/app/screens/email-link-auth-screen/page.tsx @@ -14,8 +14,10 @@ * limitations under the License. */ -import { expect } from "vitest"; -import * as matchers from "@testing-library/jest-dom/matchers"; +"use client"; -// Extend Vitest's expect with jest-dom matchers -expect.extend(matchers); +import { EmailLinkAuthScreen } from "@invertase/firebaseui-react"; + +export default function EmailLinkAuthScreenPage() { + return ; +} diff --git a/examples/nextjs-ssr/app/screens/forgot-password-auth-screen/page.tsx b/examples/nextjs-ssr/app/screens/forgot-password-auth-screen/page.tsx new file mode 100644 index 000000000..d0cd87312 --- /dev/null +++ b/examples/nextjs-ssr/app/screens/forgot-password-auth-screen/page.tsx @@ -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. + */ + +"use client"; + +import { ForgotPasswordAuthScreen } from "@invertase/firebaseui-react"; + +export default function ForgotPasswordAuthScreenPage() { + return {}} />; +} diff --git a/examples/nextjs/lib/examples/4/page.tsx b/examples/nextjs-ssr/app/screens/oauth-screen/page.tsx similarity index 76% rename from examples/nextjs/lib/examples/4/page.tsx rename to examples/nextjs-ssr/app/screens/oauth-screen/page.tsx index f8ba31146..46872be1e 100644 --- a/examples/nextjs/lib/examples/4/page.tsx +++ b/examples/nextjs-ssr/app/screens/oauth-screen/page.tsx @@ -16,10 +16,12 @@ "use client"; -import { SignInAuthScreen } from "@firebase-ui/react"; +import { GoogleSignInButton, OAuthScreen } from "@invertase/firebaseui-react"; -export default function Example4() { +export default function OAuthScreenPage() { return ( - {}} onRegisterClick={() => {}} /> + + + ); } diff --git a/examples/react/src/screens/password-reset-screen.tsx b/examples/nextjs-ssr/app/screens/password-reset-screen/page.tsx similarity index 82% rename from examples/react/src/screens/password-reset-screen.tsx rename to examples/nextjs-ssr/app/screens/password-reset-screen/page.tsx index 0bea4b34e..d400ee5f8 100644 --- a/examples/react/src/screens/password-reset-screen.tsx +++ b/examples/nextjs-ssr/app/screens/password-reset-screen/page.tsx @@ -16,12 +16,8 @@ "use client"; -import { PasswordResetScreen } from "@firebase-ui/react"; +import { ForgotPasswordAuthScreen } from "@invertase/firebaseui-react"; export default function PasswordResetScreenPage() { - return ( - {}} - /> - ); + return {}} />; } diff --git a/examples/nextjs-ssr/app/screens/phone-auth-screen-w-oauth/page.tsx b/examples/nextjs-ssr/app/screens/phone-auth-screen-w-oauth/page.tsx new file mode 100644 index 000000000..abb815b59 --- /dev/null +++ b/examples/nextjs-ssr/app/screens/phone-auth-screen-w-oauth/page.tsx @@ -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. + */ + +"use client"; + +import { GoogleSignInButton, PhoneAuthScreen } from "@invertase/firebaseui-react"; + +export default function PhoneAuthScreenWithOAuthPage() { + return ( + + + + ); +} diff --git a/packages/firebaseui-core/src/types.ts b/examples/nextjs-ssr/app/screens/phone-auth-screen/page.tsx similarity index 79% rename from packages/firebaseui-core/src/types.ts rename to examples/nextjs-ssr/app/screens/phone-auth-screen/page.tsx index 4e1b0b484..980cb9362 100644 --- a/packages/firebaseui-core/src/types.ts +++ b/examples/nextjs-ssr/app/screens/phone-auth-screen/page.tsx @@ -14,9 +14,10 @@ * limitations under the License. */ -export interface CountryData { - name: string; - dialCode: string; - code: string; - emoji: string; +"use client"; + +import { PhoneAuthScreen } from "@invertase/firebaseui-react"; + +export default function PhoneAuthScreenPage() { + return ; } diff --git a/examples/nextjs-ssr/app/screens/sign-in-auth-screen-w-handlers/page.tsx b/examples/nextjs-ssr/app/screens/sign-in-auth-screen-w-handlers/page.tsx new file mode 100644 index 000000000..d457b7ad8 --- /dev/null +++ b/examples/nextjs-ssr/app/screens/sign-in-auth-screen-w-handlers/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. + */ + +"use client"; + +import { SignInAuthScreen } from "@invertase/firebaseui-react"; + +export default function SignInAuthScreenWithHandlersPage() { + return ( + { + console.log(credential); + }} + /> + ); +} diff --git a/examples/nextjs-ssr/app/screens/sign-in-auth-screen-w-oauth/page.tsx b/examples/nextjs-ssr/app/screens/sign-in-auth-screen-w-oauth/page.tsx new file mode 100644 index 000000000..4789f1f98 --- /dev/null +++ b/examples/nextjs-ssr/app/screens/sign-in-auth-screen-w-oauth/page.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 { GoogleSignInButton, SignInAuthScreen } from "@invertase/firebaseui-react"; +import { useRouter } from "next/navigation"; + +export default function SignInAuthScreenWithOAuthPage() { + const router = useRouter(); + + return ( + router.push("/password-reset-screen")} + onSignIn={() => { + router.push("/"); + }} + > + + + ); +} diff --git a/examples/angular/src/app/auth/phone-screen/index.ts b/examples/nextjs-ssr/app/screens/sign-in-auth-screen/page.tsx similarity index 78% rename from examples/angular/src/app/auth/phone-screen/index.ts rename to examples/nextjs-ssr/app/screens/sign-in-auth-screen/page.tsx index 8a203ed34..9952a1f3a 100644 --- a/examples/angular/src/app/auth/phone-screen/index.ts +++ b/examples/nextjs-ssr/app/screens/sign-in-auth-screen/page.tsx @@ -14,4 +14,10 @@ * limitations under the License. */ -export * from './phone-screen.component'; +"use client"; + +import { SignInAuthScreen } from "@invertase/firebaseui-react"; + +export default function SignInAuthScreenPage() { + return ; +} diff --git a/examples/nextjs-ssr/app/screens/sign-up-auth-screen-w-oauth/page.tsx b/examples/nextjs-ssr/app/screens/sign-up-auth-screen-w-oauth/page.tsx new file mode 100644 index 000000000..4c649f2e9 --- /dev/null +++ b/examples/nextjs-ssr/app/screens/sign-up-auth-screen-w-oauth/page.tsx @@ -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. + */ + +"use client"; + +import { GoogleSignInButton, SignUpAuthScreen } from "@invertase/firebaseui-react"; + +export default function SignUpAuthScreenWithOAuthPage() { + return ( + + + + ); +} diff --git a/examples/angular/src/app/auth/email-link-screen/index.ts b/examples/nextjs-ssr/app/screens/sign-up-auth-screen/page.tsx similarity index 78% rename from examples/angular/src/app/auth/email-link-screen/index.ts rename to examples/nextjs-ssr/app/screens/sign-up-auth-screen/page.tsx index 28546d59c..04b840189 100644 --- a/examples/angular/src/app/auth/email-link-screen/index.ts +++ b/examples/nextjs-ssr/app/screens/sign-up-auth-screen/page.tsx @@ -14,4 +14,10 @@ * limitations under the License. */ -export * from './email-link-screen.component'; +"use client"; + +import { SignUpAuthScreen } from "@invertase/firebaseui-react"; + +export default function SignUpAuthScreenPage() { + return ; +} diff --git a/examples/nextjs-ssr/app/sign-in/email/page.tsx b/examples/nextjs-ssr/app/sign-in/email/page.tsx new file mode 100644 index 000000000..4d5d64fdb --- /dev/null +++ b/examples/nextjs-ssr/app/sign-in/email/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 EmailLinkAuthScreen from "./screen"; + +export default async function SignInWithEmailLinkPage() { + const { currentUser } = await getCurrentUser(); + + if (currentUser) { + redirect("/"); + } + + return ; +} diff --git a/examples/nextjs-ssr/app/sign-in/email/screen.tsx b/examples/nextjs-ssr/app/sign-in/email/screen.tsx new file mode 100644 index 000000000..3251fe345 --- /dev/null +++ b/examples/nextjs-ssr/app/sign-in/email/screen.tsx @@ -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. + */ + +"use client"; + +import { useUser } from "@/lib/firebase/hooks"; +import { EmailLinkAuthScreen } from "@invertase/firebaseui-react"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function Screen() { + const router = useRouter(); + const user = useUser(); + + // If the user signs in, redirect to the home page from the client. + useEffect(() => { + if (user) { + router.push("/"); + } + }, [user, router]); + + return ; +} diff --git a/examples/nextjs-ssr/app/sign-in/page.tsx b/examples/nextjs-ssr/app/sign-in/page.tsx new file mode 100644 index 000000000..dd8ac829f --- /dev/null +++ b/examples/nextjs-ssr/app/sign-in/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 SignInScreen from "./screen"; + +export default async function SignInPage() { + const { currentUser } = await getCurrentUser(); + + if (currentUser) { + redirect("/"); + } + + return ; +} diff --git a/examples/nextjs-ssr/app/sign-in/phone/page.tsx b/examples/nextjs-ssr/app/sign-in/phone/page.tsx new file mode 100644 index 000000000..142be9778 --- /dev/null +++ b/examples/nextjs-ssr/app/sign-in/phone/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 SignInWithPhoneNumberScreen from "./screen"; + +export default async function SignInWithPhoneNumberPage() { + const { currentUser } = await getCurrentUser(); + + if (currentUser) { + redirect("/"); + } + + return ; +} diff --git a/examples/nextjs-ssr/app/sign-in/phone/screen.tsx b/examples/nextjs-ssr/app/sign-in/phone/screen.tsx new file mode 100644 index 000000000..6e7cfd2bf --- /dev/null +++ b/examples/nextjs-ssr/app/sign-in/phone/screen.tsx @@ -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. + */ + +"use client"; + +import { useUser } from "@/lib/firebase/hooks"; +import { PhoneAuthScreen } from "@invertase/firebaseui-react"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function Screen() { + const router = useRouter(); + const user = useUser(); + + // If the user signs in, redirect to the home page from the client. + useEffect(() => { + if (user) { + router.push("/"); + } + }, [user, router]); + + return ; +} diff --git a/examples/nextjs-ssr/app/sign-in/screen.tsx b/examples/nextjs-ssr/app/sign-in/screen.tsx new file mode 100644 index 000000000..3d304b91c --- /dev/null +++ b/examples/nextjs-ssr/app/sign-in/screen.tsx @@ -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. + */ + +"use client"; + +import { useUser } from "@/lib/firebase/hooks"; +import { GoogleSignInButton, SignInAuthScreen } from "@invertase/firebaseui-react"; +import Link from "next/link"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function Screen() { + const router = useRouter(); + const user = useUser(); + + // If the user signs in, redirect to the home page from the client. + useEffect(() => { + if (user) { + router.push("/"); + } + }, [user, router]); + + return ( + router.push("/forgot-password")} + onSignIn={() => router.push("/register")} + > + +
+ Sign in with phone number +
+
+ Sign in with email link +
+
+ ); +} diff --git a/examples/nextjs-ssr/firebase.json b/examples/nextjs-ssr/firebase.json new file mode 100644 index 000000000..8a4b9898c --- /dev/null +++ b/examples/nextjs-ssr/firebase.json @@ -0,0 +1,7 @@ +{ + "hosting": { + "site": "fir-ui-rework-nextjs-ssr", + "source": ".", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"] + } +} diff --git a/examples/nextjs-ssr/lib/components/header.tsx b/examples/nextjs-ssr/lib/components/header.tsx new file mode 100644 index 000000000..87b44c796 --- /dev/null +++ b/examples/nextjs-ssr/lib/components/header.tsx @@ -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. + */ + +"use client"; + +import { signOut, type User } from "firebase/auth"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { auth } from "../firebase/clientApp"; +import { useUser } from "../firebase/hooks"; + +export function Header(props: { currentUser?: User | null }) { + const router = useRouter(); + const user = useUser(props.currentUser || null); + + async function onSignOut() { + await signOut(auth); + router.push("/sign-in"); + } + + return ( +
+
+
+ FirebaseUI +
+
+
    + {user ? ( +
  • + +
  • + ) : ( +
  • + Sign In +
  • + )} +
+
+
+
+ ); +} diff --git a/examples/react/lib/firebase/clientApp.ts b/examples/nextjs-ssr/lib/firebase/clientApp.ts similarity index 62% rename from examples/react/lib/firebase/clientApp.ts rename to examples/nextjs-ssr/lib/firebase/clientApp.ts index cc06a0c9c..447415d82 100644 --- a/examples/react/lib/firebase/clientApp.ts +++ b/examples/nextjs-ssr/lib/firebase/clientApp.ts @@ -19,32 +19,17 @@ import { initializeApp, getApps } from "firebase/app"; import { firebaseConfig } from "./config"; import { connectAuthEmulator, getAuth } from "firebase/auth"; -import { autoAnonymousLogin, initializeUI } from "@firebase-ui/core"; -import { customLanguage, english } from "@firebase-ui/translations"; +import { autoAnonymousLogin, initializeUI } from "@invertase/firebaseui-core"; -export const firebaseApp = - getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0]; +export const firebaseApp = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0]; export const auth = getAuth(firebaseApp); export const ui = initializeUI({ app: firebaseApp, behaviors: [autoAnonymousLogin()], - translations: [ - customLanguage(english.locale, { - labels: { - signIn: "Sign In", - }, - prompts: { - signInToAccount: "Sign in to your account", - }, - errors: { - invalidEmail: "Please enter a valid email address", - }, - }), - ], }); -if (import.meta.env.MODE === "development") { +if (process.env.NODE_ENV === "development") { connectAuthEmulator(auth, "http://localhost:9099"); } diff --git a/examples/nextjs-ssr/lib/firebase/config.ts b/examples/nextjs-ssr/lib/firebase/config.ts new file mode 100644 index 000000000..90abb8628 --- /dev/null +++ b/examples/nextjs-ssr/lib/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/lib/firebase/hooks.ts b/examples/nextjs-ssr/lib/firebase/hooks.ts similarity index 94% rename from examples/react/lib/firebase/hooks.ts rename to examples/nextjs-ssr/lib/firebase/hooks.ts index 5cfaa6637..295cdf036 100644 --- a/examples/react/lib/firebase/hooks.ts +++ b/examples/nextjs-ssr/lib/firebase/hooks.ts @@ -14,10 +14,11 @@ * limitations under the License. */ +"use client"; import { useState } from "react"; import { onAuthStateChanged } from "firebase/auth"; -import { User } from "firebase/auth"; +import { type User } from "firebase/auth"; import { useEffect } from "react"; import { auth } from "./clientApp"; @@ -30,4 +31,4 @@ export function useUser(initalUser?: User | null) { }, []); return user; -} \ No newline at end of file +} diff --git a/examples/nextjs-ssr/lib/firebase/serverApp.ts b/examples/nextjs-ssr/lib/firebase/serverApp.ts new file mode 100644 index 000000000..791f59f20 --- /dev/null +++ b/examples/nextjs-ssr/lib/firebase/serverApp.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. + */ + +// enforces that this code can only be called on the server +// https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns#keeping-server-only-code-out-of-the-client-environment +import "server-only"; + +import { headers } from "next/headers"; +import { initializeServerApp } from "firebase/app"; + +import { firebaseConfig } from "./config"; +import { getAuth } from "firebase/auth"; + +// TODO: This won't work until we support Cookie auth (or service worker auth) +export async function getCurrentUser() { + const idToken = (await headers()).get("Authorization")?.split("Bearer ")[1]; + + const firebaseServerApp = initializeServerApp( + firebaseConfig, + idToken + ? { + authIdToken: idToken, + } + : {} + ); + + const auth = getAuth(firebaseServerApp); + await auth.authStateReady(); + + return { currentUser: auth.currentUser }; +} diff --git a/examples/react/lib/firebase/ui.tsx b/examples/nextjs-ssr/lib/firebase/ui.tsx similarity index 76% rename from examples/react/lib/firebase/ui.tsx rename to examples/nextjs-ssr/lib/firebase/ui.tsx index 5620f04d2..93b5b1aef 100644 --- a/examples/react/lib/firebase/ui.tsx +++ b/examples/nextjs-ssr/lib/firebase/ui.tsx @@ -16,16 +16,12 @@ "use client"; -import { ui } from "./clientApp"; -import { ConfigProvider } from "@firebase-ui/react"; +import { ui } from "@/lib/firebase/clientApp"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; -export function FirebaseUIProvider({ - children, -}: { - children: React.ReactNode; -}) { +export function FirebaseUIProviderHoc({ children }: { children: React.ReactNode }) { return ( - {children} - + ); } diff --git a/examples/nextjs-ssr/next.config.ts b/examples/nextjs-ssr/next.config.ts new file mode 100644 index 000000000..382d27f0b --- /dev/null +++ b/examples/nextjs-ssr/next.config.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 type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + // Removed output: "export" to enable SSR + trailingSlash: true, + images: { + unoptimized: true, + }, +}; + +export default nextConfig; diff --git a/examples/nextjs-ssr/package-lock.json b/examples/nextjs-ssr/package-lock.json new file mode 100644 index 000000000..ba54bbb78 --- /dev/null +++ b/examples/nextjs-ssr/package-lock.json @@ -0,0 +1,3341 @@ +{ + "name": "nextjs", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nextjs", + "version": "0.1.0", + "dependencies": { + "@invertase/firebaseui-core": "^0.0.1", + "@invertase/firebaseui-react": "^0.0.1", + "@invertase/firebaseui-styles": "^0.0.1", + "@invertase/firebaseui-translations": "^0.0.1", + "firebase": "^11.3.1", + "next": "15.1.7", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "server-only": "^0.0.1" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.0.6", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "postcss": "^8.5.2", + "postcss-load-config": "^6.0.1", + "tailwindcss": "^4.0.6", + "typescript": "^5" + } + }, + "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==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz", + "integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@firebase/ai": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-1.4.1.tgz", + "integrity": "sha512-bcusQfA/tHjUjBTnMx6jdoPMpDl3r8K15Z+snHz9wq0Foox0F/V+kNLXucEOHoTL2hTc9l+onZCyBJs2QoIC3g==", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "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.17", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.17.tgz", + "integrity": "sha512-n5vfBbvzduMou/2cqsnKrIes4auaBjdhg8QNA2ZQZ59QgtO2QiwBaXQZQE4O4sgB0Ds1tvLgUUkY+pwzu6/xEg==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.23", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.23.tgz", + "integrity": "sha512-3AdO10RN18G5AzREPoFgYhW6vWXr3u+OYQv6pl3CX6Fky8QRk0AHurZlY3Q1xkXO0TDxIsdhO3y65HF7PBOJDw==", + "dependencies": { + "@firebase/analytics": "0.10.17", + "@firebase/analytics-types": "0.8.3", + "@firebase/component": "0.6.18", + "@firebase/util": "1.12.1", + "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.2", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.13.2.tgz", + "integrity": "sha512-jwtMmJa1BXXDCiDx1vC6SFN/+HfYG53UkfJa6qeN5ogvOunzbFDO3wISZy5n9xgYFUrEP6M7e8EG++riHNTv9w==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.10.1.tgz", + "integrity": "sha512-MgNdlms9Qb0oSny87pwpjKush9qUwCJhfmTJHDfrcKo4neLGiSeVE4qJkzP7EQTIUFKp84pbTxobSAXkiuQVYQ==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.3.26", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.3.26.tgz", + "integrity": "sha512-PkX+XJMLDea6nmnopzFKlr+s2LMQGqdyT2DHdbx1v1dPSqOol2YzgpgymmhC67vitXVpNvS3m/AiWQWWhhRRPQ==", + "dependencies": { + "@firebase/app-check": "0.10.1", + "@firebase/app-check-types": "0.5.3", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "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.2", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.4.2.tgz", + "integrity": "sha512-LssbyKHlwLeiV8GBATyOyjmHcMpX/tFjzRUCS1jnwGAew1VsBB4fJowyS5Ud5LdFbYpJeS+IQoC+RQxpK7eH3Q==", + "dependencies": { + "@firebase/app": "0.13.2", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "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.8", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.10.8.tgz", + "integrity": "sha512-GpuTz5ap8zumr/ocnPY57ZanX02COsXloY6Y/2LYPAuXYiaJRf6BAGDEdRq1BMjP93kqQnKNuKZUTMZbQ8MNYA==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "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.28", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.5.28.tgz", + "integrity": "sha512-HpMSo/cc6Y8IX7bkRIaPPqT//Jt83iWy5rmDWeThXQCAImstkdNo3giFLORJwrZw2ptiGkOij64EH1ztNJzc7Q==", + "dependencies": { + "@firebase/auth": "1.10.8", + "@firebase/auth-types": "0.13.0", + "@firebase/component": "0.6.18", + "@firebase/util": "1.12.1", + "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.18", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.18.tgz", + "integrity": "sha512-n28kPCkE2dL2U28fSxZJjzPPVpKsQminJ6NrzcKXAI0E/lYC8YhfwpyllScqVEvAI3J2QgJZWYgrX+1qGI+SQQ==", + "dependencies": { + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.3.10.tgz", + "integrity": "sha512-VMVk7zxIkgwlVQIWHOKFahmleIjiVFwFOjmakXPd/LDgaB/5vzwsB5DWIYo+3KhGxWpidQlR8geCIn39YflJIQ==", + "dependencies": { + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.20.tgz", + "integrity": "sha512-H9Rpj1pQ1yc9+4HQOotFGLxqAXwOzCHsRSRjcQFNOr8lhUt6LeYjf0NSRL04sc4X0dWe8DsCvYKxMYvFG/iOJw==", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.0.11.tgz", + "integrity": "sha512-itEsHARSsYS95+udF/TtIzNeQ0Uhx4uIna0sk4E0wQJBUnLc/G1X6D7oRljoOuwwCezRLGvWBRyNrugv/esOEw==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/database": "1.0.20", + "@firebase/database-types": "1.0.15", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.15.tgz", + "integrity": "sha512-XWHJ0VUJ0k2E9HDMlKxlgy/ZuTa9EvHCGLjaKSUvrQnwhgZuRU5N3yX6SZ+ftf2hTzZmfRkv+b3QRvGg40bKNw==", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.12.1" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.8.0.tgz", + "integrity": "sha512-QSRk+Q1/CaabKyqn3C32KSFiOdZpSqI9rpLK5BHPcooElumOBooPFa6YkDdiT+/KhJtel36LdAacha9BptMj2A==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "@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.53", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.3.53.tgz", + "integrity": "sha512-qI3yZL8ljwAYWrTousWYbemay2YZa+udLWugjdjju2KODWtLG94DfO4NALJgPLv8CVGcDHNFXoyQexdRA0Cz8Q==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/firestore": "4.8.0", + "@firebase/firestore-types": "3.0.3", + "@firebase/util": "1.12.1", + "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.9", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.12.9.tgz", + "integrity": "sha512-FG95w6vjbUXN84Ehezc2SDjGmGq225UYbHrb/ptkRT7OTuCiQRErOQuyt1jI1tvcDekdNog+anIObihNFz79Lg==", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.18", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.3.26", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.3.26.tgz", + "integrity": "sha512-A798/6ff5LcG2LTWqaGazbFYnjBW8zc65YfID/en83ALmkhu2b0G8ykvQnLtakbV9ajrMYPn7Yc/XcYsZIUsjA==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/functions": "0.12.9", + "@firebase/functions-types": "0.6.3", + "@firebase/util": "1.12.1", + "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.18", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.18.tgz", + "integrity": "sha512-NQ86uGAcvO8nBRwVltRL9QQ4Reidc/3whdAasgeWCPIcrhOKDuNpAALa6eCVryLnK14ua2DqekCOX5uC9XbU/A==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/util": "1.12.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.18.tgz", + "integrity": "sha512-aLFohRpJO5kKBL/XYL4tN+GdwEB/Q6Vo9eZOM/6Kic7asSUgmSfGPpGUZO1OAaSRGwF4Lqnvi1f/f9VZnKzChw==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/installations-types": "0.5.3", + "@firebase/util": "1.12.1", + "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.22", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.22.tgz", + "integrity": "sha512-GJcrPLc+Hu7nk+XQ70Okt3M1u1eRr2ZvpMbzbc54oTPJZySHcX9ccZGVFcsZbSZ6o1uqumm8Oc7OFkD3Rn1/og==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.12.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.22.tgz", + "integrity": "sha512-5ZHtRnj6YO6f/QPa/KU6gryjmX4Kg33Kn4gRpNU6M1K47Gm8kcQwPkX7erRUYEH1mIWptfvjvXMHWoZaWjkU7A==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/messaging": "0.12.22", + "@firebase/util": "1.12.1", + "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.7", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.7.tgz", + "integrity": "sha512-JTlTQNZKAd4+Q5sodpw6CN+6NmwbY72av3Lb6wUKTsL7rb3cuBIhQSrslWbVz0SwK3x0ZNcqX24qtRbwKiv+6w==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.20", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.20.tgz", + "integrity": "sha512-XkFK5NmOKCBuqOKWeRgBUFZZGz9SzdTZp4OqeUg+5nyjapTiZ4XoiiUL8z7mB2q+63rPmBl7msv682J3rcDXIQ==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/performance": "0.7.7", + "@firebase/performance-types": "0.2.3", + "@firebase/util": "1.12.1", + "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.5", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.6.5.tgz", + "integrity": "sha512-fU0c8HY0vrVHwC+zQ/fpXSqHyDMuuuglV94VF6Yonhz8Fg2J+KOowPGANM0SZkLvVOYpTeWp3ZmM+F6NjwWLnw==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.18.tgz", + "integrity": "sha512-YiETpldhDy7zUrnS8e+3l7cNs0sL7+tVAxvVYU0lu7O+qLHbmdtAxmgY+wJqWdW2c9nDvBFec7QiF58pEUu0qQ==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/remote-config": "0.6.5", + "@firebase/remote-config-types": "0.4.0", + "@firebase/util": "1.12.1", + "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.14", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.13.14.tgz", + "integrity": "sha512-xTq5ixxORzx+bfqCpsh+o3fxOsGoDjC1nO0Mq2+KsOcny3l7beyBhP/y1u5T6mgsFQwI1j6oAkbT5cWdDBx87g==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.3.24.tgz", + "integrity": "sha512-XHn2tLniiP7BFKJaPZ0P8YQXKiVJX+bMyE2j2YWjYfaddqiJnROJYqSomwW6L3Y+gZAga35ONXUJQju6MB6SOQ==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/storage": "0.13.14", + "@firebase/storage-types": "0.8.3", + "@firebase/util": "1.12.1", + "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.1", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.12.1.tgz", + "integrity": "sha512-zGlBn/9Dnya5ta9bX/fgEoNC3Cp8s6h+uYPYaDieZsFOAdHP/ExzQ/eaDgxD3GOROdPkLKpvKY0iIzr9adle0w==", + "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/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@invertase/firebaseui-core": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@invertase/firebaseui-core/-/firebaseui-core-0.0.1.tgz", + "integrity": "sha512-ZoXsh0uEo13dc+GL21+jL4rBu/euZheXcGbPFLXdCgPHKX9vKhf6GkGEKghpuCV9SmEs4q66ZECS81DYW3aLxw==", + "dependencies": { + "@invertase/firebaseui-translations": "0.0.1", + "nanostores": "^0.11.3", + "zod": "^3.24.1" + }, + "peerDependencies": { + "firebase": "^11" + } + }, + "node_modules/@invertase/firebaseui-react": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@invertase/firebaseui-react/-/firebaseui-react-0.0.1.tgz", + "integrity": "sha512-ioFiM2cxZxXDIl5VLIYkray1qajTKEHEN9A+od20s3Qwkzn4LbpLHdOeKm58yct2BJOmIkMxD/Kem2eOpsruag==", + "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" + }, + "peerDependencies": { + "@invertase/firebaseui-core": "0.0.1", + "@invertase/firebaseui-styles": "0.0.1" + } + }, + "node_modules/@invertase/firebaseui-styles": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@invertase/firebaseui-styles/-/firebaseui-styles-0.0.1.tgz", + "integrity": "sha512-fgG4W1iN7VdhepxywWDMPqxZLIcD5P9xu2f24bcS2VUawjzg76aQekBWOVw5WsfsYKGiXW0NUyIXm+14WSZNgg==" + }, + "node_modules/@invertase/firebaseui-translations": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@invertase/firebaseui-translations/-/firebaseui-translations-0.0.1.tgz", + "integrity": "sha512-g6N0Ik8l5ydaMZ4vpW3Oa9/rUm6AdbfwG8oqMt985wulRUUfm2n8IQE1wcLNCQjXqySgZwtTMSvQ9aa1uOup1g==" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "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==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nanostores/react": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/@nanostores/react/-/react-0.8.4.tgz", + "integrity": "sha512-EciHSzDXg7GmGODjegGG1VldPEinbAK+12/Uz5+MAdHmxf082Rl6eXqKFxAAu4pZAcr5dNTpv6wMfEe7XacjkQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "nanostores": "^0.9.0 || ^0.10.0 || ^0.11.0", + "react": ">=18.0.0" + } + }, + "node_modules/@next/env": { + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.7.tgz", + "integrity": "sha512-d9jnRrkuOH7Mhi+LHav2XW91HOgTAWHxjMPkXMGBc9B2b7614P7kjt8tAplRvJpbSt4nbO1lugcT/kAaWzjlLQ==" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.7.tgz", + "integrity": "sha512-hPFwzPJDpA8FGj7IKV3Yf1web3oz2YsR8du4amKw8d+jAOHfYHYFpMkoF6vgSY4W6vB29RtZEklK9ayinGiCmQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.7.tgz", + "integrity": "sha512-2qoas+fO3OQKkU0PBUfwTiw/EYpN+kdAx62cePRyY1LqKtP09Vp5UcUntfZYajop5fDFTjSxCHfZVRxzi+9FYQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.7.tgz", + "integrity": "sha512-sKLLwDX709mPdzxMnRIXLIT9zaX2w0GUlkLYQnKGoXeWUhcvpCrK+yevcwCJPdTdxZEUA0mOXGLdPsGkudGdnA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.7.tgz", + "integrity": "sha512-zblK1OQbQWdC8fxdX4fpsHDw+VSpBPGEUX4PhSE9hkaWPrWoeIJn+baX53vbsbDRaDKd7bBNcXRovY1hEhFd7w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.7.tgz", + "integrity": "sha512-GOzXutxuLvLHFDAPsMP2zDBMl1vfUHHpdNpFGhxu90jEzH6nNIgmtw/s1MDwpTOiM+MT5V8+I1hmVFeAUhkbgQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.7.tgz", + "integrity": "sha512-WrZ7jBhR7ATW1z5iEQ0ZJfE2twCNSXbpCSaAunF3BKcVeHFADSI/AW1y5Xt3DzTqPF1FzQlwQTewqetAABhZRQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.7.tgz", + "integrity": "sha512-LDnj1f3OVbou1BqvvXVqouJZKcwq++mV2F+oFHptToZtScIEnhNRJAhJzqAtTE2dB31qDYL45xJwrc+bLeKM2Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.7.tgz", + "integrity": "sha512-dC01f1quuf97viOfW05/K8XYv2iuBgAxJZl7mbCKEjMgdQl5JjAKJ0D2qMKZCgPWDeFbFT0Q0nYWwytEW0DWTQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "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/@remix-run/node": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/@remix-run/node/-/node-2.17.1.tgz", + "integrity": "sha512-pHmHTuLE1Lwazulx3gjrHobgBCsa+Xiq8WUO0ruLeDfEw2DU0c0SNSiyNkugu3rIZautroBwRaOoy7CWJL9xhQ==", + "dependencies": { + "@remix-run/server-runtime": "2.17.1", + "@remix-run/web-fetch": "^4.4.2", + "@web3-storage/multipart-parser": "^1.0.0", + "cookie-signature": "^1.1.0", + "source-map-support": "^0.5.21", + "stream-slice": "^0.1.2", + "undici": "^6.21.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "typescript": "^5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@remix-run/server-runtime": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-2.17.1.tgz", + "integrity": "sha512-d1Vp9FxX4KafB111vP2E5C1fmWzPI+gHZ674L1drq+N8Bp9U6FBspi7GAZSU5K5Kxa4T6UF+aE1gK6pVi9R8sw==", + "dependencies": { + "@remix-run/router": "1.23.0", + "@types/cookie": "^0.6.0", + "@web3-storage/multipart-parser": "^1.0.0", + "cookie": "^0.7.2", + "set-cookie-parser": "^2.4.8", + "source-map": "^0.7.3", + "turbo-stream": "2.4.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "typescript": "^5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@remix-run/web-blob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@remix-run/web-blob/-/web-blob-3.1.0.tgz", + "integrity": "sha512-owGzFLbqPH9PlKb8KvpNJ0NO74HWE2euAn61eEiyCXX/oteoVzTVSN8mpLgDjaxBf2btj5/nUllSUgpyd6IH6g==", + "dependencies": { + "@remix-run/web-stream": "^1.1.0", + "web-encoding": "1.1.5" + } + }, + "node_modules/@remix-run/web-fetch": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@remix-run/web-fetch/-/web-fetch-4.4.2.tgz", + "integrity": "sha512-jgKfzA713/4kAW/oZ4bC3MoLWyjModOVDjFPNseVqcJKSafgIscrYL9G50SurEYLswPuoU3HzSbO0jQCMYWHhA==", + "dependencies": { + "@remix-run/web-blob": "^3.1.0", + "@remix-run/web-file": "^3.1.0", + "@remix-run/web-form-data": "^3.1.0", + "@remix-run/web-stream": "^1.1.0", + "@web3-storage/multipart-parser": "^1.0.0", + "abort-controller": "^3.0.0", + "data-uri-to-buffer": "^3.0.1", + "mrmime": "^1.0.0" + }, + "engines": { + "node": "^10.17 || >=12.3" + } + }, + "node_modules/@remix-run/web-file": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@remix-run/web-file/-/web-file-3.1.0.tgz", + "integrity": "sha512-dW2MNGwoiEYhlspOAXFBasmLeYshyAyhIdrlXBi06Duex5tDr3ut2LFKVj7tyHLmn8nnNwFf1BjNbkQpygC2aQ==", + "dependencies": { + "@remix-run/web-blob": "^3.1.0" + } + }, + "node_modules/@remix-run/web-form-data": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@remix-run/web-form-data/-/web-form-data-3.1.0.tgz", + "integrity": "sha512-NdeohLMdrb+pHxMQ/Geuzdp0eqPbea+Ieo8M8Jx2lGC6TBHsgHzYcBvr0LyPdPVycNRDEpWpiDdCOdCryo3f9A==", + "dependencies": { + "web-encoding": "1.1.5" + } + }, + "node_modules/@remix-run/web-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@remix-run/web-stream/-/web-stream-1.1.0.tgz", + "integrity": "sha512-KRJtwrjRV5Bb+pM7zxcTJkhIqWWSy+MYsIxHK+0m5atcznsf15YwUBWHWulZerV2+vvHH1Lp1DD7pw6qKW8SgA==", + "dependencies": { + "web-streams-polyfill": "^3.1.1" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz", + "integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.19", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.16" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz", + "integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==", + "dev": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.16", + "@tailwindcss/oxide-darwin-arm64": "4.1.16", + "@tailwindcss/oxide-darwin-x64": "4.1.16", + "@tailwindcss/oxide-freebsd-x64": "4.1.16", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.16", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.16", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.16", + "@tailwindcss/oxide-linux-x64-musl": "4.1.16", + "@tailwindcss/oxide-wasm32-wasi": "4.1.16", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.16", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.16" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz", + "integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz", + "integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz", + "integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz", + "integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz", + "integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz", + "integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz", + "integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz", + "integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz", + "integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz", + "integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz", + "integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz", + "integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.16.tgz", + "integrity": "sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.16", + "@tailwindcss/oxide": "4.1.16", + "postcss": "^8.4.41", + "tailwindcss": "4.1.16" + } + }, + "node_modules/@tanstack/form-core": { + "version": "0.41.4", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-0.41.4.tgz", + "integrity": "sha512-XZJtN7mWJmi3apsc2J+GpWbcsXbv0pWBkZKP47ZW1QD/2Tj1UWsM6JjcaAkzIlrBdaoEFYmrHToLKr/Ddk8BVg==", + "dependencies": { + "@tanstack/store": "^0.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-form": { + "version": "0.41.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-0.41.4.tgz", + "integrity": "sha512-uIfIDZJNqR1dLW03TNByK/woyKd2jfXIrEBq6DPJbqupqyfYXTDo5TMd/7koTYLO4dgTM5wd+2v3uBX3M2bRaA==", + "dependencies": { + "@remix-run/node": "^2.15.0", + "@tanstack/form-core": "0.41.4", + "@tanstack/react-store": "^0.7.0", + "decode-formdata": "^0.8.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/start": "^1.43.13", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/start": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz", + "integrity": "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==", + "dependencies": { + "@tanstack/store": "0.7.7", + "use-sync-external-store": "^1.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/store": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.7.tgz", + "integrity": "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" + }, + "node_modules/@types/node": { + "version": "20.19.23", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz", + "integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "dev": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", + "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@web3-storage/multipart-parser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@web3-storage/multipart-parser/-/multipart-parser-1.0.0.tgz", + "integrity": "sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==" + }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "optional": true + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "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/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/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/caniuse-lite": { + "version": "1.0.30001751", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", + "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", + "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/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" + }, + "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/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.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/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true + }, + "node_modules/data-uri-to-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz", + "integrity": "sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/decode-formdata": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/decode-formdata/-/decode-formdata-0.8.0.tgz", + "integrity": "sha512-iUzDgnWsw5ToSkFY7VPFA5Gfph6ROoOxOB7Ybna4miUSzLZ4KaSJk6IAB2AdW6+C9vCVWhjjNA4gjT6wF3eZHQ==" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "engines": { + "node": ">=8" + } + }, + "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/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/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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-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/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/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "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/firebase": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-11.10.0.tgz", + "integrity": "sha512-nKBXoDzF0DrXTBQJlZa+sbC5By99ysYU1D6PkMRYknm0nCW7rJly47q492Ht7Ndz5MeYSBuboKuhS1e6mFC03w==", + "dependencies": { + "@firebase/ai": "1.4.1", + "@firebase/analytics": "0.10.17", + "@firebase/analytics-compat": "0.2.23", + "@firebase/app": "0.13.2", + "@firebase/app-check": "0.10.1", + "@firebase/app-check-compat": "0.3.26", + "@firebase/app-compat": "0.4.2", + "@firebase/app-types": "0.9.3", + "@firebase/auth": "1.10.8", + "@firebase/auth-compat": "0.5.28", + "@firebase/data-connect": "0.3.10", + "@firebase/database": "1.0.20", + "@firebase/database-compat": "2.0.11", + "@firebase/firestore": "4.8.0", + "@firebase/firestore-compat": "0.3.53", + "@firebase/functions": "0.12.9", + "@firebase/functions-compat": "0.3.26", + "@firebase/installations": "0.6.18", + "@firebase/installations-compat": "0.2.18", + "@firebase/messaging": "0.12.22", + "@firebase/messaging-compat": "0.2.22", + "@firebase/performance": "0.7.7", + "@firebase/performance-compat": "0.2.20", + "@firebase/remote-config": "0.6.5", + "@firebase/remote-config-compat": "0.2.18", + "@firebase/storage": "0.13.14", + "@firebase/storage-compat": "0.3.24", + "@firebase/util": "1.12.1" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "engines": { + "node": ">= 0.4" + } + }, + "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-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/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==", + "dev": true + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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/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/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" + }, + "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/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "optional": true + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "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.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "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.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "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.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "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.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "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.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "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/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/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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/mrmime": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", + "integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==", + "engines": { + "node": ">=10" + } + }, + "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/next": { + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/next/-/next-15.1.7.tgz", + "integrity": "sha512-GNeINPGS9c6OZKCvKypbL8GTsT5GhWPp4DM0fzkXJuXMilOO2EeFxuAY6JZbtk6XIl6Ws10ag3xRINDjSO5+wg==", + "dependencies": { + "@next/env": "15.1.7", + "@swc/counter": "0.1.3", + "@swc/helpers": "0.5.15", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.1.7", + "@next/swc-darwin-x64": "15.1.7", + "@next/swc-linux-arm64-gnu": "15.1.7", + "@next/swc-linux-arm64-musl": "15.1.7", + "@next/swc-linux-x64-gnu": "15.1.7", + "@next/swc-linux-x64-musl": "15.1.7", + "@next/swc-win32-arm64-msvc": "15.1.7", + "@next/swc-win32-x64-msvc": "15.1.7", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "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.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "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/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "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/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==", + "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/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "engines": { + "node": ">= 12" + } + }, + "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-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==", + "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==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stream-slice": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/stream-slice/-/stream-slice-0.1.2.tgz", + "integrity": "sha512-QzQxpoacatkreL6jsxnVb7X5R/pGw9OUv2qWTYWnmLpg4NdN31snPy/f3TdQE1ZUXaThRvj1Zw4/OGg0ZkaLMA==" + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "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/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/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tailwind-merge": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", + "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", + "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==", + "dev": true + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "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/turbo-stream": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.1.tgz", + "integrity": "sha512-v8kOJXpG3WoTN/+at8vK7erSzo6nW6CIaeOvNOkHQVDajfz1ZVeSxCbc6tOH4hrGZW7VUCV0TOXd8CPzYnYkrw==" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz", + "integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/web-encoding": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/web-encoding/-/web-encoding-1.1.5.tgz", + "integrity": "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==", + "dependencies": { + "util": "^0.12.3" + }, + "optionalDependencies": { + "@zxing/text-encoding": "0.9.0" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "engines": { + "node": ">= 8" + } + }, + "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/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-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/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/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/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/examples/nextjs-ssr/package.json b/examples/nextjs-ssr/package.json new file mode 100644 index 000000000..b0f43d4a2 --- /dev/null +++ b/examples/nextjs-ssr/package.json @@ -0,0 +1,36 @@ +{ + "name": "nextjs-ssr", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "next lint", + "lint:fix": "next lint --fix", + "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"", + "format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,css,md}\"", + "deploy": "pnpm run build && firebase deploy --only hosting:fir-ui-rework-nextjs-ssr" + }, + "dependencies": { + "@invertase/firebaseui-react": "workspace:*", + "@invertase/firebaseui-core": "workspace:*", + "@invertase/firebaseui-styles": "workspace:*", + "@invertase/firebaseui-translations": "workspace:*", + "firebase": "^11.10.0", + "next": "15.1.7", + "react": "19.1.1", + "react-dom": "19.1.1", + "server-only": "^0.0.1" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.0.6", + "@types/node": "^20", + "@types/react": "19.1.16", + "@types/react-dom": "19.1.9", + "postcss": "^8.5.2", + "postcss-load-config": "^6.0.1", + "tailwindcss": "^4.0.6", + "typescript": "^5.9.2" + } +} diff --git a/examples/react/lib/firebase/config.ts b/examples/nextjs-ssr/postcss.config.mjs similarity index 80% rename from examples/react/lib/firebase/config.ts rename to examples/nextjs-ssr/postcss.config.mjs index 2d95ca076..f36de9c84 100644 --- a/examples/react/lib/firebase/config.ts +++ b/examples/nextjs-ssr/postcss.config.mjs @@ -14,6 +14,11 @@ * limitations under the License. */ -export const firebaseConfig = { - // your Firebase config here +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, }; + +export default config; diff --git a/examples/nextjs-ssr/public/file.svg b/examples/nextjs-ssr/public/file.svg new file mode 100644 index 000000000..004145cdd --- /dev/null +++ b/examples/nextjs-ssr/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/nextjs-ssr/public/globe.svg b/examples/nextjs-ssr/public/globe.svg new file mode 100644 index 000000000..567f17b0d --- /dev/null +++ b/examples/nextjs-ssr/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/nextjs-ssr/public/next.svg b/examples/nextjs-ssr/public/next.svg new file mode 100644 index 000000000..5174b28c5 --- /dev/null +++ b/examples/nextjs-ssr/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/nextjs-ssr/public/vercel.svg b/examples/nextjs-ssr/public/vercel.svg new file mode 100644 index 000000000..770539603 --- /dev/null +++ b/examples/nextjs-ssr/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/nextjs-ssr/public/window.svg b/examples/nextjs-ssr/public/window.svg new file mode 100644 index 000000000..b2b2a44f6 --- /dev/null +++ b/examples/nextjs-ssr/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/nextjs-ssr/tsconfig.json b/examples/nextjs-ssr/tsconfig.json new file mode 100644 index 000000000..d8b93235f --- /dev/null +++ b/examples/nextjs-ssr/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/examples/nextjs/.eslintrc.json b/examples/nextjs/.eslintrc.json new file mode 100644 index 000000000..c9515dbe1 --- /dev/null +++ b/examples/nextjs/.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/.firebaserc b/examples/nextjs/.firebaserc new file mode 100644 index 000000000..043e32416 --- /dev/null +++ b/examples/nextjs/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "fir-ui-rework" + } +} diff --git a/examples/nextjs/.gitignore b/examples/nextjs/.gitignore index 5ef6a5207..577f1099d 100644 --- a/examples/nextjs/.gitignore +++ b/examples/nextjs/.gitignore @@ -36,6 +36,9 @@ yarn-error.log* # vercel .vercel +# firebase +.firebase + # typescript *.tsbuildinfo next-env.d.ts diff --git a/examples/nextjs/.prettierrc b/examples/nextjs/.prettierrc new file mode 100644 index 000000000..37702140f --- /dev/null +++ b/examples/nextjs/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "endOfLine": "auto" +} diff --git a/examples/nextjs/app/forgot-password/page.tsx b/examples/nextjs/app/forgot-password/page.tsx index a4b9f5f9b..54c354b83 100644 --- a/examples/nextjs/app/forgot-password/page.tsx +++ b/examples/nextjs/app/forgot-password/page.tsx @@ -14,16 +14,10 @@ * 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(); +"use client"; - if (currentUser) { - return redirect("/"); - } +import ForgotPasswordScreen from "./screen"; +export default function ForgotPasswordPage() { return ; } diff --git a/examples/nextjs/app/forgot-password/screen.tsx b/examples/nextjs/app/forgot-password/screen.tsx index 2cc978882..acda21245 100644 --- a/examples/nextjs/app/forgot-password/screen.tsx +++ b/examples/nextjs/app/forgot-password/screen.tsx @@ -16,13 +16,11 @@ "use client"; -import { PasswordResetScreen } from "@firebase-ui/react"; +import { ForgotPasswordAuthScreen } from "@invertase/firebaseui-react"; import { useRouter } from "next/navigation"; export default function Screen() { const router = useRouter(); - return ( - router.push("/sign-in")} /> - ); + return router.push("/sign-in")} />; } diff --git a/examples/nextjs/app/globals.css b/examples/nextjs/app/globals.css index fd915417c..7e62d3ba8 100644 --- a/examples/nextjs/app/globals.css +++ b/examples/nextjs/app/globals.css @@ -15,7 +15,7 @@ */ @import "tailwindcss"; -@import "@firebase-ui/styles/src/base.css"; +@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 +/* @import "@invertase/firebaseui-styles/themes/dark.css"; */ +/* @import "@invertase/firebaseui-styles/themes/brutalist.css"; */ diff --git a/examples/nextjs/app/layout.tsx b/examples/nextjs/app/layout.tsx index 8c282b998..9c7acf604 100644 --- a/examples/nextjs/app/layout.tsx +++ b/examples/nextjs/app/layout.tsx @@ -14,13 +14,14 @@ * limitations under the License. */ -import { getCurrentUser } from "@/lib/firebase/serverApp"; -import { FirebaseUIProvider } from "@/lib/firebase/ui"; +// import { getCurrentUser } from "@/lib/firebase/serverApp"; import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { FirebaseUIProviderHoc } from "../lib/firebase/ui"; +// import { Header } from "@/lib/components/header"; -import { Header } from "@/lib/components/header"; import "./globals.css"; +// import { useUser } from "@/lib/firebase/hooks"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -42,15 +43,13 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const { currentUser } = await getCurrentUser(); + // const user = await useUser(); return ( - -
- {children} + + {/*
*/} + {children} ); diff --git a/examples/nextjs/app/page.tsx b/examples/nextjs/app/page.tsx index 84f162123..3e60dfe04 100644 --- a/examples/nextjs/app/page.tsx +++ b/examples/nextjs/app/page.tsx @@ -25,97 +25,62 @@ export default function Home() { return (

Firebase UI Demo

-
- {user &&
Welcome: {user.email || user.phoneNumber}
} -
+
{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
  • diff --git a/examples/nextjs/app/register/page.tsx b/examples/nextjs/app/register/page.tsx index 16425dacf..75a35a6c3 100644 --- a/examples/nextjs/app/register/page.tsx +++ b/examples/nextjs/app/register/page.tsx @@ -14,16 +14,10 @@ * limitations under the License. */ -import { getCurrentUser } from "@/lib/firebase/serverApp"; -import { redirect } from "next/navigation"; -import RegisterScreen from "./screen"; - -export default async function RegisterPage() { - const { currentUser } = await getCurrentUser(); +"use client"; - if (currentUser) { - return redirect("/"); - } +import RegisterScreen from "./screen"; +export default function RegisterPage() { return ; } diff --git a/examples/nextjs/app/register/screen.tsx b/examples/nextjs/app/register/screen.tsx index 97abc25de..d53d41292 100644 --- a/examples/nextjs/app/register/screen.tsx +++ b/examples/nextjs/app/register/screen.tsx @@ -17,7 +17,7 @@ "use client"; import { useUser } from "@/lib/firebase/hooks"; -import { GoogleSignInButton, SignUpAuthScreen } from "@firebase-ui/react"; +import { GoogleSignInButton, SignUpAuthScreen } from "@invertase/firebaseui-react"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; @@ -33,7 +33,7 @@ export default function Screen() { }, [user, router]); return ( - router.push("/sign-in")}> + router.push("/sign-in")}> ); diff --git a/examples/nextjs/app/screens/email-link-auth-screen-w-oauth/page.tsx b/examples/nextjs/app/screens/email-link-auth-screen-w-oauth/page.tsx index b1e15278a..2886c4113 100644 --- a/examples/nextjs/app/screens/email-link-auth-screen-w-oauth/page.tsx +++ b/examples/nextjs/app/screens/email-link-auth-screen-w-oauth/page.tsx @@ -16,7 +16,7 @@ "use client"; -import { EmailLinkAuthScreen, GoogleSignInButton } from "@firebase-ui/react"; +import { EmailLinkAuthScreen, GoogleSignInButton } from "@invertase/firebaseui-react"; export default function EmailLinkAuthScreenWithOAuthPage() { return ( diff --git a/examples/nextjs/app/screens/email-link-auth-screen/page.tsx b/examples/nextjs/app/screens/email-link-auth-screen/page.tsx index 6f5f03912..532261734 100644 --- a/examples/nextjs/app/screens/email-link-auth-screen/page.tsx +++ b/examples/nextjs/app/screens/email-link-auth-screen/page.tsx @@ -16,7 +16,7 @@ "use client"; -import { EmailLinkAuthScreen } from "@firebase-ui/react"; +import { EmailLinkAuthScreen } from "@invertase/firebaseui-react"; export default function EmailLinkAuthScreenPage() { return ; diff --git a/examples/nextjs/app/screens/forgot-password-auth-screen/page.tsx b/examples/nextjs/app/screens/forgot-password-auth-screen/page.tsx new file mode 100644 index 000000000..d0cd87312 --- /dev/null +++ b/examples/nextjs/app/screens/forgot-password-auth-screen/page.tsx @@ -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. + */ + +"use client"; + +import { ForgotPasswordAuthScreen } from "@invertase/firebaseui-react"; + +export default function ForgotPasswordAuthScreenPage() { + return {}} />; +} diff --git a/examples/nextjs/app/screens/oauth-screen/page.tsx b/examples/nextjs/app/screens/oauth-screen/page.tsx index 662ccecd6..46872be1e 100644 --- a/examples/nextjs/app/screens/oauth-screen/page.tsx +++ b/examples/nextjs/app/screens/oauth-screen/page.tsx @@ -16,7 +16,7 @@ "use client"; -import { GoogleSignInButton, OAuthScreen } from "@firebase-ui/react"; +import { GoogleSignInButton, OAuthScreen } from "@invertase/firebaseui-react"; export default function OAuthScreenPage() { return ( diff --git a/examples/nextjs/app/screens/password-reset-screen/page.tsx b/examples/nextjs/app/screens/password-reset-screen/page.tsx index 0bea4b34e..d400ee5f8 100644 --- a/examples/nextjs/app/screens/password-reset-screen/page.tsx +++ b/examples/nextjs/app/screens/password-reset-screen/page.tsx @@ -16,12 +16,8 @@ "use client"; -import { PasswordResetScreen } from "@firebase-ui/react"; +import { ForgotPasswordAuthScreen } from "@invertase/firebaseui-react"; export default function PasswordResetScreenPage() { - return ( - {}} - /> - ); + return {}} />; } diff --git a/examples/nextjs/app/screens/phone-auth-screen-w-oauth/page.tsx b/examples/nextjs/app/screens/phone-auth-screen-w-oauth/page.tsx index 45637cc5c..abb815b59 100644 --- a/examples/nextjs/app/screens/phone-auth-screen-w-oauth/page.tsx +++ b/examples/nextjs/app/screens/phone-auth-screen-w-oauth/page.tsx @@ -16,7 +16,7 @@ "use client"; -import { GoogleSignInButton, PhoneAuthScreen } from "@firebase-ui/react"; +import { GoogleSignInButton, PhoneAuthScreen } from "@invertase/firebaseui-react"; export default function PhoneAuthScreenWithOAuthPage() { return ( diff --git a/examples/nextjs/app/screens/phone-auth-screen/page.tsx b/examples/nextjs/app/screens/phone-auth-screen/page.tsx index 032a66dff..980cb9362 100644 --- a/examples/nextjs/app/screens/phone-auth-screen/page.tsx +++ b/examples/nextjs/app/screens/phone-auth-screen/page.tsx @@ -16,8 +16,8 @@ "use client"; -import { PhoneAuthScreen } from "@firebase-ui/react"; +import { PhoneAuthScreen } from "@invertase/firebaseui-react"; export default function PhoneAuthScreenPage() { - return ; + return ; } diff --git a/examples/nextjs/app/screens/sign-in-auth-screen-w-handlers/page.tsx b/examples/nextjs/app/screens/sign-in-auth-screen-w-handlers/page.tsx index a968d8e74..d457b7ad8 100644 --- a/examples/nextjs/app/screens/sign-in-auth-screen-w-handlers/page.tsx +++ b/examples/nextjs/app/screens/sign-in-auth-screen-w-handlers/page.tsx @@ -16,13 +16,14 @@ "use client"; -import { SignInAuthScreen } from "@firebase-ui/react"; +import { SignInAuthScreen } from "@invertase/firebaseui-react"; export default function SignInAuthScreenWithHandlersPage() { return ( {}} - onRegisterClick={() => {}} + onSignIn={(credential) => { + console.log(credential); + }} /> ); } diff --git a/examples/nextjs/app/screens/sign-in-auth-screen-w-oauth/page.tsx b/examples/nextjs/app/screens/sign-in-auth-screen-w-oauth/page.tsx index 887545e40..4789f1f98 100644 --- a/examples/nextjs/app/screens/sign-in-auth-screen-w-oauth/page.tsx +++ b/examples/nextjs/app/screens/sign-in-auth-screen-w-oauth/page.tsx @@ -16,7 +16,7 @@ "use client"; -import { GoogleSignInButton, SignInAuthScreen } from "@firebase-ui/react"; +import { GoogleSignInButton, SignInAuthScreen } from "@invertase/firebaseui-react"; import { useRouter } from "next/navigation"; export default function SignInAuthScreenWithOAuthPage() { @@ -25,7 +25,9 @@ export default function SignInAuthScreenWithOAuthPage() { return ( router.push("/password-reset-screen")} - onRegisterClick={() => router.push("/sign-up-auth-screen")} + onSignIn={() => { + router.push("/"); + }} > diff --git a/examples/nextjs/app/screens/sign-in-auth-screen/page.tsx b/examples/nextjs/app/screens/sign-in-auth-screen/page.tsx index 01bac68ef..9952a1f3a 100644 --- a/examples/nextjs/app/screens/sign-in-auth-screen/page.tsx +++ b/examples/nextjs/app/screens/sign-in-auth-screen/page.tsx @@ -16,7 +16,7 @@ "use client"; -import { SignInAuthScreen } from "@firebase-ui/react"; +import { SignInAuthScreen } from "@invertase/firebaseui-react"; export default function SignInAuthScreenPage() { return ; diff --git a/examples/nextjs/app/screens/sign-up-auth-screen-w-oauth/page.tsx b/examples/nextjs/app/screens/sign-up-auth-screen-w-oauth/page.tsx index 98a803bdf..4c649f2e9 100644 --- a/examples/nextjs/app/screens/sign-up-auth-screen-w-oauth/page.tsx +++ b/examples/nextjs/app/screens/sign-up-auth-screen-w-oauth/page.tsx @@ -16,7 +16,7 @@ "use client"; -import { GoogleSignInButton, SignUpAuthScreen } from "@firebase-ui/react"; +import { GoogleSignInButton, SignUpAuthScreen } from "@invertase/firebaseui-react"; export default function SignUpAuthScreenWithOAuthPage() { return ( diff --git a/examples/nextjs/app/screens/sign-up-auth-screen/page.tsx b/examples/nextjs/app/screens/sign-up-auth-screen/page.tsx index 0af1806ae..04b840189 100644 --- a/examples/nextjs/app/screens/sign-up-auth-screen/page.tsx +++ b/examples/nextjs/app/screens/sign-up-auth-screen/page.tsx @@ -16,7 +16,7 @@ "use client"; -import { SignUpAuthScreen } from "@firebase-ui/react"; +import { SignUpAuthScreen } from "@invertase/firebaseui-react"; export default function SignUpAuthScreenPage() { return ; diff --git a/examples/nextjs/app/sign-in/email/page.tsx b/examples/nextjs/app/sign-in/email/page.tsx index 125ab00f6..3be91d794 100644 --- a/examples/nextjs/app/sign-in/email/page.tsx +++ b/examples/nextjs/app/sign-in/email/page.tsx @@ -14,16 +14,10 @@ * limitations under the License. */ -import { getCurrentUser } from "@/lib/firebase/serverApp"; -import { redirect } from "next/navigation"; -import EmailLinkAuthScreen from "./screen"; - -export default async function SignInWithEmailLinkPage() { - const { currentUser } = await getCurrentUser(); +"use client"; - if (currentUser) { - return redirect("/"); - } +import EmailLinkAuthScreen from "./screen"; +export default function SignInWithEmailLinkPage() { return ; } diff --git a/examples/nextjs/app/sign-in/email/screen.tsx b/examples/nextjs/app/sign-in/email/screen.tsx index 37112e710..3251fe345 100644 --- a/examples/nextjs/app/sign-in/email/screen.tsx +++ b/examples/nextjs/app/sign-in/email/screen.tsx @@ -17,7 +17,7 @@ "use client"; import { useUser } from "@/lib/firebase/hooks"; -import { EmailLinkAuthScreen } from "@firebase-ui/react"; +import { EmailLinkAuthScreen } from "@invertase/firebaseui-react"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; diff --git a/examples/nextjs/app/sign-in/page.tsx b/examples/nextjs/app/sign-in/page.tsx index 57f6c62b0..8e13460fe 100644 --- a/examples/nextjs/app/sign-in/page.tsx +++ b/examples/nextjs/app/sign-in/page.tsx @@ -14,16 +14,10 @@ * limitations under the License. */ -import { getCurrentUser } from "@/lib/firebase/serverApp"; -import { redirect } from "next/navigation"; -import SignInScreen from "./screen"; - -export default async function SignInPage() { - const { currentUser } = await getCurrentUser(); +"use client"; - if (currentUser) { - return redirect("/"); - } +import SignInScreen from "./screen"; +export default function SignInPage() { return ; } diff --git a/examples/nextjs/app/sign-in/phone/page.tsx b/examples/nextjs/app/sign-in/phone/page.tsx index e33bdeeab..4b6a28c66 100644 --- a/examples/nextjs/app/sign-in/phone/page.tsx +++ b/examples/nextjs/app/sign-in/phone/page.tsx @@ -14,16 +14,10 @@ * limitations under the License. */ -import { getCurrentUser } from "@/lib/firebase/serverApp"; -import { redirect } from "next/navigation"; -import SignInWithPhoneNumberScreen from "./screen"; - -export default async function SignInWithPhoneNumberPage() { - const { currentUser } = await getCurrentUser(); +"use client"; - if (currentUser) { - return redirect("/"); - } +import SignInWithPhoneNumberScreen from "./screen"; +export default function SignInWithPhoneNumberPage() { return ; } diff --git a/examples/nextjs/app/sign-in/phone/screen.tsx b/examples/nextjs/app/sign-in/phone/screen.tsx index 839e60433..6e7cfd2bf 100644 --- a/examples/nextjs/app/sign-in/phone/screen.tsx +++ b/examples/nextjs/app/sign-in/phone/screen.tsx @@ -17,7 +17,7 @@ "use client"; import { useUser } from "@/lib/firebase/hooks"; -import { PhoneAuthScreen } from "@firebase-ui/react"; +import { PhoneAuthScreen } from "@invertase/firebaseui-react"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; diff --git a/examples/nextjs/app/sign-in/screen.tsx b/examples/nextjs/app/sign-in/screen.tsx index 7d89bd46d..3d304b91c 100644 --- a/examples/nextjs/app/sign-in/screen.tsx +++ b/examples/nextjs/app/sign-in/screen.tsx @@ -17,7 +17,7 @@ "use client"; import { useUser } from "@/lib/firebase/hooks"; -import { GoogleSignInButton, SignInAuthScreen } from "@firebase-ui/react"; +import { GoogleSignInButton, SignInAuthScreen } from "@invertase/firebaseui-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -37,7 +37,7 @@ export default function Screen() { return ( router.push("/forgot-password")} - onRegisterClick={() => router.push("/register")} + onSignIn={() => router.push("/register")} >
    diff --git a/examples/nextjs/firebase.json b/examples/nextjs/firebase.json new file mode 100644 index 000000000..6a6962a67 --- /dev/null +++ b/examples/nextjs/firebase.json @@ -0,0 +1,7 @@ +{ + "hosting": { + "site": "fir-ui-rework-nextjs-ssg", + "public": "out", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"] + } +} diff --git a/examples/nextjs/lib/components/header.tsx b/examples/nextjs/lib/components/header.tsx index 4e43e3311..87b44c796 100644 --- a/examples/nextjs/lib/components/header.tsx +++ b/examples/nextjs/lib/components/header.tsx @@ -14,17 +14,17 @@ * limitations under the License. */ -'use client'; +"use client"; -import Link from "next/link"; -import { useUser } from "../firebase/hooks"; import { signOut, type User } from "firebase/auth"; -import { auth } from "../firebase/clientApp"; +import Link from "next/link"; import { useRouter } from "next/navigation"; +import { auth } from "../firebase/clientApp"; +import { useUser } from "../firebase/hooks"; -export function Header(props: { currentUser: User | null }) { +export function Header(props: { currentUser?: User | null }) { const router = useRouter(); - const user = useUser(props.currentUser); + const user = useUser(props.currentUser || null); async function onSignOut() { await signOut(auth); @@ -39,10 +39,18 @@ export function Header(props: { currentUser: User | null }) {
      - {user ?
    • :
    • Sign In
    • } + {user ? ( +
    • + +
    • + ) : ( +
    • + Sign In +
    • + )}
); -} \ No newline at end of file +} diff --git a/examples/nextjs/lib/examples/1/page.tsx b/examples/nextjs/lib/examples/1/page.tsx deleted file mode 100644 index 2ab4ec191..000000000 --- a/examples/nextjs/lib/examples/1/page.tsx +++ /dev/null @@ -1,317 +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 { EmailPasswordForm, RegisterForm } from "@firebase-ui/react"; -import { useState } from "react"; - -export default function Example1() { - const [showRegister, setShowRegister] = useState(false); - - return ( -
-
- -
- -
-
-
-
- - {showRegister ? ( -
- -
-
-

Create Account

-

- Join thousands of users worldwide -

-
- -
- By signing up, you agree to our Terms of Service -
-
-
- ) : ( -
-
-
-

Welcome Back

-

- Sign in to your account to continue -

-
- {}} /> -
- -
- -
-
-

New Here?

-

- Create an account and get access to all features -

-
- -
-
-
- - - -
-
-

Free Forever

-

- Get started with our free plan and upgrade anytime -

-
-
-
-
- - - -
-
-

Premium Support

-

- 24/7 support for all your questions -

-
-
-
-
- - - -
-
-

Regular Updates

-

- New features and improvements every month -

-
-
-
- - -
-
- )} -
-
- - -
- ); -} diff --git a/examples/nextjs/lib/examples/2/page.tsx b/examples/nextjs/lib/examples/2/page.tsx deleted file mode 100644 index b66edb00e..000000000 --- a/examples/nextjs/lib/examples/2/page.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. - */ - -"use client"; - -import { useState } from "react"; -import { - EmailPasswordForm, - RegisterForm, - Card, - CardHeader, - CardTitle, - CardSubtitle, -} from "@firebase-ui/react"; - -export default function Example2() { - const [showRegister, setShowRegister] = useState(false); - - return ( -
-
-
-
-
- DemoStyle -
- -
- -
-
-
-
- -
- - - - {showRegister ? "Join DemoStyle" : "Welcome Back"} - - - {showRegister - ? "Experience this demo styling" - : "Sign in to continue your journey"} - - - {showRegister ? ( - <> - -

- By signing up, you agree to our Terms of Service -

- - ) : ( - <> - {}} /> -
-
- - Example Style Demo - -
-
- - )} -
-
-
-
- - -
-
- ); -} diff --git a/examples/nextjs/lib/examples/3/page.tsx b/examples/nextjs/lib/examples/3/page.tsx deleted file mode 100644 index 397f9072f..000000000 --- a/examples/nextjs/lib/examples/3/page.tsx +++ /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. - */ - -"use client"; - -import { useState } from "react"; -import { EmailPasswordForm, RegisterForm } from "@firebase-ui/react"; - -export default function Example3() { - const [showModal, setShowModal] = useState(false); - const [isRegister, setIsRegister] = useState(false); - - return ( -
-
-
-
-
Modal Example
- -
-
- - -
-
-
- -
-
-

Modal Example

-

- Click the Sign In or Get Started button in the header to see the - modal. -

-
-
- - {showModal && ( - <> -
setShowModal(false)} - /> -
-
e.stopPropagation()} - > -
- - - {isRegister ? ( -
-
-

Create Account

-

- Join us to get started with your journey -

-
- setIsRegister(false)} - /> -
- ) : ( -
-
-

Welcome Back

-

- Sign in to your account to continue -

-
- {}} - onRegisterClick={() => setIsRegister(true)} - /> -
- )} -
-
-
- - )} -
- ); -} diff --git a/examples/nextjs/lib/examples/TODO b/examples/nextjs/lib/examples/TODO deleted file mode 100644 index 6bc740744..000000000 --- a/examples/nextjs/lib/examples/TODO +++ /dev/null @@ -1 +0,0 @@ -Move back to app/ once we figure out what to do with these examples \ No newline at end of file diff --git a/examples/nextjs/lib/firebase/clientApp.ts b/examples/nextjs/lib/firebase/clientApp.ts index 5a840aba1..447415d82 100644 --- a/examples/nextjs/lib/firebase/clientApp.ts +++ b/examples/nextjs/lib/firebase/clientApp.ts @@ -19,30 +19,17 @@ import { initializeApp, getApps } from "firebase/app"; import { firebaseConfig } from "./config"; import { connectAuthEmulator, getAuth } from "firebase/auth"; -import { autoAnonymousLogin, initializeUI } from "@firebase-ui/core"; -import { customLanguage, english } from "@firebase-ui/translations"; +import { autoAnonymousLogin, initializeUI } from "@invertase/firebaseui-core"; -export const firebaseApp = - getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0]; +export const firebaseApp = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0]; export const auth = getAuth(firebaseApp); export const ui = initializeUI({ app: firebaseApp, behaviors: [autoAnonymousLogin()], - translations: [ - customLanguage(english.locale, { - labels: { - signIn: "Sign In", - }, - prompts: { - signInToAccount: "Sign in to your account", - }, - errors: { - invalidEmail: "Please enter a valid email address", - }, - }), - ], }); -connectAuthEmulator(auth, "http://localhost:9099"); +if (process.env.NODE_ENV === "development") { + connectAuthEmulator(auth, "http://localhost:9099"); +} diff --git a/examples/nextjs/lib/firebase/config.ts b/examples/nextjs/lib/firebase/config.ts index 2d95ca076..90abb8628 100644 --- a/examples/nextjs/lib/firebase/config.ts +++ b/examples/nextjs/lib/firebase/config.ts @@ -15,5 +15,10 @@ */ export 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", }; diff --git a/examples/nextjs/lib/firebase/hooks.ts b/examples/nextjs/lib/firebase/hooks.ts index 5cfaa6637..295cdf036 100644 --- a/examples/nextjs/lib/firebase/hooks.ts +++ b/examples/nextjs/lib/firebase/hooks.ts @@ -14,10 +14,11 @@ * limitations under the License. */ +"use client"; import { useState } from "react"; import { onAuthStateChanged } from "firebase/auth"; -import { User } from "firebase/auth"; +import { type User } from "firebase/auth"; import { useEffect } from "react"; import { auth } from "./clientApp"; @@ -30,4 +31,4 @@ export function useUser(initalUser?: User | null) { }, []); return user; -} \ No newline at end of file +} diff --git a/examples/nextjs/lib/firebase/serverApp.ts b/examples/nextjs/lib/firebase/serverApp.ts index ebe2f41a9..791f59f20 100644 --- a/examples/nextjs/lib/firebase/serverApp.ts +++ b/examples/nextjs/lib/firebase/serverApp.ts @@ -41,4 +41,4 @@ export async function getCurrentUser() { await auth.authStateReady(); return { currentUser: auth.currentUser }; -} \ No newline at end of file +} diff --git a/examples/nextjs/lib/firebase/ui.tsx b/examples/nextjs/lib/firebase/ui.tsx index 1f2f3e48a..93b5b1aef 100644 --- a/examples/nextjs/lib/firebase/ui.tsx +++ b/examples/nextjs/lib/firebase/ui.tsx @@ -17,15 +17,11 @@ "use client"; import { ui } from "@/lib/firebase/clientApp"; -import { ConfigProvider } from "@firebase-ui/react"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; -export function FirebaseUIProvider({ - children, -}: { - children: React.ReactNode; -}) { +export function FirebaseUIProviderHoc({ children }: { children: React.ReactNode }) { return ( - {children} - + ); } diff --git a/examples/nextjs/next.config.ts b/examples/nextjs/next.config.ts index 4796dd1ab..8dddeb6b3 100644 --- a/examples/nextjs/next.config.ts +++ b/examples/nextjs/next.config.ts @@ -17,7 +17,11 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "export", + trailingSlash: true, + images: { + unoptimized: true, + }, }; export default nextConfig; diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index 27528778a..d1a1ec007 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -6,27 +6,31 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "lint:fix": "next lint --fix", + "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"", + "format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,css,md}\"", + "deploy": "pnpm run build && firebase deploy --only hosting:fir-ui-rework-nextjs-ssg" }, "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", - "firebase": "^11.3.1", + "@invertase/firebaseui-react": "workspace:*", + "@invertase/firebaseui-core": "workspace:*", + "@invertase/firebaseui-styles": "workspace:*", + "@invertase/firebaseui-translations": "workspace:*", + "firebase": "catalog:", "next": "15.1.7", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "catalog:", + "react-dom": "catalog:", "server-only": "^0.0.1" }, "devDependencies": { "@tailwindcss/postcss": "^4.0.6", "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", "postcss": "^8.5.2", "postcss-load-config": "^6.0.1", "tailwindcss": "^4.0.6", - "typescript": "^5" + "typescript": "catalog:" } } diff --git a/examples/react/.firebaserc b/examples/react/.firebaserc new file mode 100644 index 000000000..855f82834 --- /dev/null +++ b/examples/react/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "fir-ui-2025" + } +} diff --git a/examples/react/.gitignore b/examples/react/.gitignore index a547bf36d..1bbf49f71 100644 --- a/examples/react/.gitignore +++ b/examples/react/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# firebase +.firebase diff --git a/examples/react/.prettierrc b/examples/react/.prettierrc new file mode 100644 index 000000000..37702140f --- /dev/null +++ b/examples/react/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "endOfLine": "auto" +} diff --git a/examples/react/eslint.config.js b/examples/react/eslint.config.js deleted file mode 100644 index edd1be373..000000000 --- a/examples/react/eslint.config.js +++ /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 js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' - -export default [ - { ignores: ['dist'] }, - { - files: ['**/*.{js,jsx}'], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - parserOptions: { - ecmaVersion: 'latest', - ecmaFeatures: { jsx: true }, - sourceType: 'module', - }, - }, - plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, - }, - rules: { - ...js.configs.recommended.rules, - ...reactHooks.configs.recommended.rules, - 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - }, - }, -] diff --git a/examples/react/firebase.json b/examples/react/firebase.json new file mode 100644 index 000000000..8bc7667dd --- /dev/null +++ b/examples/react/firebase.json @@ -0,0 +1,13 @@ +{ + "hosting": { + "site": "fir-ui-2025-react", + "public": "dist", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ] + } +} diff --git a/examples/react/index.html b/examples/react/index.html index d65098d37..d863af427 100644 --- a/examples/react/index.html +++ b/examples/react/index.html @@ -22,9 +22,12 @@ Vite + React - + +
- + diff --git a/examples/react/package.json b/examples/react/package.json index 906986805..2c90a08a3 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-2025-react" }, "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..90af4a284 --- /dev/null +++ b/examples/react/src/routes.ts @@ -0,0 +1,103 @@ +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: "Forgot Password Screen (with handlers)", + description: "A screen allowing a user to reset their password, with forgot password and register handlers.", + path: "/screens/forgot-password-auth-screen-w-handlers", + component: ForgotPasswordAuthScreenPage, + }, + { + name: "OAuth Screen", + description: "A screen which allows a user to sign in with OAuth only.", + 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..5725e66ae 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,36 @@ "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={(credential) => { + console.log(credential); + 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..b803ea13e 100644 --- a/examples/react/src/screens/email-link-auth-screen.tsx +++ b/examples/react/src/screens/email-link-auth-screen.tsx @@ -16,8 +16,21 @@ "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={(credential) => { + console.log(credential); + navigate("/"); + }} + /> + ); } diff --git a/examples/react/src/screens/forgot-password-auth-screen-w-handlers.tsx b/examples/react/src/screens/forgot-password-auth-screen-w-handlers.tsx new file mode 100644 index 000000000..e69de29bb 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/examples/react/src/screens/mfa-enrollment-screen.tsx b/examples/react/src/screens/mfa-enrollment-screen.tsx new file mode 100644 index 000000000..2eec5af26 --- /dev/null +++ b/examples/react/src/screens/mfa-enrollment-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 { MultiFactorAuthEnrollmentScreen } from "@invertase/firebaseui-react"; +import { FactorId } from "firebase/auth"; +import { useNavigate } from "react-router"; + +export default function MultiFactorAuthEnrollmentScreenPage() { + const navigate = useNavigate(); + + return ( + { + navigate("/"); + }} + /> + ); +} diff --git a/examples/react/src/screens/oauth-screen.tsx b/examples/react/src/screens/oauth-screen.tsx index 662ccecd6..6fb8521fc 100644 --- a/examples/react/src/screens/oauth-screen.tsx +++ b/examples/react/src/screens/oauth-screen.tsx @@ -14,14 +14,57 @@ * 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"; export default function OAuthScreenPage() { + const [themed, setThemed] = useState(false); + + return ( + <> + + + + + + + + + +
+ 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..3d765569e 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,33 @@ "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 ( - + { + console.log(credential); + navigate("/"); + }} + > + + + + + ); } diff --git a/examples/react/src/screens/phone-auth-screen.tsx b/examples/react/src/screens/phone-auth-screen.tsx index 032a66dff..4bd364a66 100644 --- a/examples/react/src/screens/phone-auth-screen.tsx +++ b/examples/react/src/screens/phone-auth-screen.tsx @@ -16,8 +16,18 @@ "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 ( + { + console.log(credential); + 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..7999a07d2 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,19 @@ "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"); + }} /> ); } 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..4909fcaf3 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,35 @@ * 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={(credential) => { + console.log(credential); + 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..af5df82b5 100644 --- a/examples/react/src/screens/sign-in-auth-screen.tsx +++ b/examples/react/src/screens/sign-in-auth-screen.tsx @@ -14,10 +14,18 @@ * 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 ( + { + console.log(credential); + 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..fbe456b66 --- /dev/null +++ b/examples/react/src/screens/sign-up-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 { 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={(credential) => { + console.log(credential); + 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..3762afd90 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,33 @@ "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 ( - + { + console.log(credential); + 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..f284aebc5 100644 --- a/examples/react/src/screens/sign-up-auth-screen.tsx +++ b/examples/react/src/screens/sign-up-auth-screen.tsx @@ -16,8 +16,18 @@ "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 ( + { + console.log(credential); + 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..855f82834 --- /dev/null +++ b/examples/shadcn/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "fir-ui-2025" + } +} 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..e207682d5 --- /dev/null +++ b/examples/shadcn/package.json @@ -0,0 +1,81 @@ +{ + "name": "shadcn", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview", + "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..88828712c --- /dev/null +++ b/examples/shadcn/src/components/email-link-auth-screen.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type EmailLinkAuthScreenProps } 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, ...props }: EmailLinkAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signIn"); + const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + const mfaResolver = ui.multiFactorResolver; + + if (mfaResolver) { + 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..1281d74e1 --- /dev/null +++ b/examples/shadcn/src/components/oauth-screen.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { type UserCredential } from "firebase/auth"; +import { type PropsWithChildren } from "react"; +import { useUI } from "@invertase/firebaseui-react"; +import { Card, CardContent, CardHeader, CardDescription, CardTitle } from "@/components/ui/card"; +import { Policies } from "@/components/policies"; +import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen"; +import { RedirectError } from "@/components/redirect-error"; + +export type OAuthScreenProps = PropsWithChildren<{ + onSignIn?: (credential: UserCredential) => void; +}>; + +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; + + if (mfaResolver) { + 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..ad31fae73 --- /dev/null +++ b/examples/shadcn/src/components/phone-auth-screen.tsx @@ -0,0 +1,47 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI } from "@invertase/firebaseui-react"; +import { Card, CardContent, CardHeader, CardDescription, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { PhoneAuthForm, type PhoneAuthFormProps } from "@/components/phone-auth-form"; +import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen"; +import { RedirectError } from "@/components/redirect-error"; + +export type PhoneAuthScreenProps = PropsWithChildren; + +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; + + if (mfaResolver) { + 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..322c34848 --- /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 } 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, ...props }: SignInAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signIn"); + const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + + const mfaResolver = ui.multiFactorResolver; + + if (mfaResolver) { + 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..23838f3f7 --- /dev/null +++ b/examples/shadcn/src/components/sign-up-auth-screen.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type SignUpAuthScreenProps } 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, ...props }: SignUpAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signUp"); + const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate"); + const mfaResolver = ui.multiFactorResolver; + + if (mfaResolver) { + 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..cdd7d07ac --- /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..f6435c3c2 --- /dev/null +++ b/examples/shadcn/src/screens/email-link-auth-screen-w-oauth.tsx @@ -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. + */ + +"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"; + +export default function EmailLinkAuthScreenWithOAuthPage() { + return ( + + + + + + + + + ); +} 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..fd1bf5837 --- /dev/null +++ b/examples/shadcn/src/screens/email-link-auth-screen.tsx @@ -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. + */ + +"use client"; + +import { EmailLinkAuthScreen } from "@/components/email-link-auth-screen"; + +export default function EmailLinkAuthScreenPage() { + return ; +} 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/shadcn/src/screens/forgot-password-auth-screen.tsx b/examples/shadcn/src/screens/forgot-password-auth-screen.tsx new file mode 100644 index 000000000..24647d5c9 --- /dev/null +++ b/examples/shadcn/src/screens/forgot-password-auth-screen.tsx @@ -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. + */ + +"use client"; + +import { ForgotPasswordAuthScreen } from "@/components/forgot-password-auth-screen"; + +export default function ForgotPasswordAuthScreenPage() { + return ; +} diff --git a/examples/shadcn/src/screens/mfa-enrollment-screen.tsx b/examples/shadcn/src/screens/mfa-enrollment-screen.tsx new file mode 100644 index 000000000..b03a7a36b --- /dev/null +++ b/examples/shadcn/src/screens/mfa-enrollment-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 { MultiFactorAuthEnrollmentScreen } from "@/components/multi-factor-auth-enrollment-screen"; +import { useNavigate } from "react-router"; + +export default function MultiFactorAuthEnrollmentScreenPage() { + const navigate = useNavigate(); + + 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..4258fc9be --- /dev/null +++ b/examples/shadcn/src/screens/oauth-screen.tsx @@ -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. + */ + +"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"; + +export default function OAuthScreenPage() { + const [themed, setThemed] = useState(false); + + return ( + <> + + + + + + + + + +
+ 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..75fef1faf --- /dev/null +++ b/examples/shadcn/src/screens/phone-auth-screen-w-oauth.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 { 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 ( + { + console.log(credential); + 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..d85868e42 --- /dev/null +++ b/examples/shadcn/src/screens/phone-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 { PhoneAuthScreen } from "@/components/phone-auth-screen"; +import { useNavigate } from "react-router"; + +export default function PhoneAuthScreenPage() { + const navigate = useNavigate(); + + return ( + { + console.log(credential); + 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..2b53baf1c --- /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..22a179269 --- /dev/null +++ b/examples/shadcn/src/screens/sign-up-auth-screen-w-oauth.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 { 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 ( + { + console.log(credential); + 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..8cdb93436 100644 --- a/firebase.json +++ b/firebase.json @@ -7,5 +7,22 @@ "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" + } + ] + } + ] } diff --git a/package.json b/package.json index 61ad05637..fd7dbc166 100644 --- a/package.json +++ b/package.json @@ -1,33 +1,75 @@ { "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", + "emulators": "firebase emulators:start --only auth --project demo-test", + "build": "pnpm run build:translations && pnpm run build:styles && pnpm run build:core && pnpm run build:react", + "build:core": "pnpm --filter=@invertase/firebaseui-core run build", + "build:translations": "pnpm --filter=@invertase/firebaseui-translations run build", + "build:styles": "pnpm --filter=@invertase/firebaseui-styles run build", + "build:react": "pnpm --filter=@invertase/firebaseui-react run build", + "build:angular": "pnpm --filter=@invertase/firebaseui-angular run build", + "build:shadcn": "pnpm --filter=@invertase/firebaseui-shadcn run build", + "build:examples": "pnpm run build && pnpm --filter react run build && pnpm --filter nextjs run build && pnpm --filter angular-example run build", + "build:all": "pnpm run build && pnpm run build:angular && pnpm run build:examples", + "build:packages": "pnpm run build && pnpm run build:angular", + "deploy": "pnpm run build && pnpm --filter react run deploy && pnpm --filter nextjs run deploy && pnpm --filter angular-example run deploy", + "deploy:react": "pnpm --filter react run deploy", + "deploy:nextjs": "pnpm --filter nextjs run deploy", + "deploy:angular": "pnpm --filter angular-example run deploy", + "deploy:shadcn": "pnpm run build:shadcn && firebase deploy --only hosting:fir-ui-shadcn", + "deploy:all": "pnpm run build && pnpm run build:shadcn && pnpm --filter react run deploy && pnpm --filter nextjs 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 run test:core && pnpm run test:translations && pnpm run test:styles && pnpm run test:react && pnpm run test:shadcn && pnpm run test:angular", + "test:core": "pnpm --filter=@invertase/firebaseui-core run test", + "test:react": "pnpm --filter=@invertase/firebaseui-react run test", + "test:angular": "pnpm --filter=@invertase/firebaseui-angular run test", + "test:translations": "pnpm --filter=@invertase/firebaseui-translations run test", + "test:styles": "pnpm --filter=@invertase/firebaseui-styles run test", + "test:shadcn": "pnpm --filter=@invertase/firebaseui-shadcn run test", + "test:watch": "pnpm run test:core:watch & pnpm run test:react:watch & pnpm run test:angular:watch", + "test:core:watch": "pnpm --filter=@invertase/firebaseui-core run test:unit:watch", + "test:react:watch": "pnpm --filter=@invertase/firebaseui-react run test:unit:watch", + "test:angular:watch": "pnpm --filter=@invertase/firebaseui-angular run test:watch", + "version:bump:core": "pnpm --filter=@invertase/firebaseui-core run version:bump", + "version:bump:translations": "pnpm --filter=@invertase/firebaseui-translations run version:bump", + "version:bump:react": "pnpm --filter=@invertase/firebaseui-react run version:bump", + "version:bump:angular": "pnpm --filter=@invertase/firebaseui-angular run version:bump", + "version:bump:styles": "pnpm --filter=@invertase/firebaseui-styles run version:bump", + "version:bump:all": "pnpm run version:bump:core && pnpm run version:bump:translations && pnpm run version:bump:react && pnpm run version:bump:styles && pnpm run version:bump:angular", + "publish:tags:core": "pnpm --filter=@invertase/firebaseui-core run publish:tags", + "publish:tags:translations": "pnpm --filter=@invertase/firebaseui-translations run publish:tags", + "publish:tags:react": "pnpm --filter=@invertase/firebaseui-react run publish:tags", + "publish:tags:angular": "pnpm --filter=@invertase/firebaseui-angular run publish:tags", + "publish:tags:styles": "pnpm --filter=@invertase/firebaseui-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", + "publish:npm:core": "pnpm --filter=@invertase/firebaseui-core publish --access public", + "publish:npm:translations": "pnpm --filter=@invertase/firebaseui-translations publish --access public", + "publish:npm:react": "pnpm --filter=@invertase/firebaseui-react publish --access public", + "publish:npm:angular": "pnpm --filter=@invertase/firebaseui-angular publish --access public", + "publish:npm:styles": "pnpm --filter=@invertase/firebaseui-styles publish --access public", + "publish:npm:all": "pnpm install && 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", + "release:core": "pnpm --filter=@invertase/firebaseui-core run release", + "release:translations": "pnpm --filter=@invertase/firebaseui-translations run release", + "release:react": "pnpm --filter=@invertase/firebaseui-react run release", + "release:angular": "pnpm --filter=@invertase/firebaseui-angular run release", + "release:styles": "pnpm --filter=@invertase/firebaseui-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" }, "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..5b1d8abf3 --- /dev/null +++ b/packages/angular/jest.config.ts @@ -0,0 +1,21 @@ +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..1f451c29e --- /dev/null +++ b/packages/angular/package.json @@ -0,0 +1,67 @@ +{ + "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\"'", + "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..8be54e5c9 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/email-link-auth-form.ts @@ -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 { 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() }} + + +
+ + } + `, +}) +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"); + + @Output() emailSent = new EventEmitter(); + @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..fa72689eb --- /dev/null +++ b/packages/angular/src/lib/auth/forms/forgot-password-auth-form.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 { 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) { + + } + + } + `, +}) +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"); + + backToSignIn = input>(); + + @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..353b816c7 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-assertion-form.ts @@ -0,0 +1,250 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You 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() }} + + +
+
+ `, +}) +export class SmsMultiFactorAssertionPhoneFormComponent { + private ui = injectUI(); + + hint = input.required(); + @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() }} + + +
+
+ `, +}) +export class SmsMultiFactorAssertionVerifyFormComponent { + private ui = injectUI(); + private formSchema = injectMultiFactorPhoneAuthVerifyFormSchema(); + + verificationId = input.required(); + @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 { + + } +
+ `, +}) +export class SmsMultiFactorAssertionFormComponent { + hint = input.required(); + @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..7f65985ce --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/sms-multi-factor-enrollment-form.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 { 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() }} + + +
+
+ } +
+ `, +}) +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"); + + @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..4471e66da --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-assertion-form.ts @@ -0,0 +1,105 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You 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() }} + + +
+
+ `, +}) +export class TotpMultiFactorAssertionFormComponent { + private ui = injectUI(); + private formSchema = injectMultiFactorTotpAuthVerifyFormSchema(); + + hint = input.required(); + @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..e96732791 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/mfa/totp-multi-factor-enrollment-form.ts @@ -0,0 +1,242 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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() }} + + +
+
+ `, +}) +export class TotpMultiFactorSecretGenerationFormComponent { + private ui = injectUI(); + private formSchema = injectMultiFactorTotpAuthNumberFormSchema(); + + @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() }} + + +
+
+ `, +}) +export class TotpMultiFactorVerificationFormComponent { + private ui = injectUI(); + private formSchema = injectMultiFactorTotpAuthVerifyFormSchema(); + + secret = input.required(); + displayName = input.required(); + @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 { + + } +
+ `, +}) +export class TotpMultiFactorEnrollmentFormComponent { + private ui = injectUI(); + + enrollment = signal<{ secret: TotpSecret; displayName: string } | null>(null); + @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..55fcc1b33 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-assertion-form.ts @@ -0,0 +1,98 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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) { + + } + } + } +
+ `, +}) +export class MultiFactorAuthAssertionFormComponent { + private ui = injectUI(); + + constructor() { + effect((onCleanup) => { + // Cleanup the multi-factor resolver when the component unmounts. + onCleanup(() => { + this.ui().setMultiFactorResolver(); + }); + }); + } + + @Output() onSuccess = new EventEmitter(); + + resolver = computed(() => { + const resolver = this.ui().multiFactorResolver; + 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..30ee40402 --- /dev/null +++ b/packages/angular/src/lib/auth/forms/multi-factor-auth-enrollment-form.ts @@ -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 { 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) { + + } + } + } +
+ `, +}) +export class MultiFactorAuthEnrollmentFormComponent implements OnInit { + hints = input([FactorId.TOTP, FactorId.PHONE]); + @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..acd988c2c --- /dev/null +++ b/packages/angular/src/lib/auth/forms/phone-auth-form.ts @@ -0,0 +1,269 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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() }} + + +
+ + `, +}) +export class PhoneNumberFormComponent { + private ui = injectUI(); + private formSchema = injectPhoneAuthFormSchema(); + + @Output() onSubmit = new EventEmitter<{ verificationId: string; phoneNumber: string }>(); + 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() }} + + +
+ + `, +}) +export class VerificationFormComponent { + private ui = injectUI(); + private formSchema = injectPhoneAuthVerifyFormSchema(); + + verificationId = input.required(); + @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 { + + } +
+ `, +}) +export class PhoneAuthFormComponent { + verificationId = signal(null); + @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..94ba33aaa --- /dev/null +++ b/packages/angular/src/lib/auth/forms/sign-in-auth-form.ts @@ -0,0 +1,145 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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) { + + } + + `, +}) +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"); + + forgotPassword = input>(); + signUp = input>(); + + @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..5c3a9d35a --- /dev/null +++ b/packages/angular/src/lib/auth/forms/sign-up-auth-form.ts @@ -0,0 +1,145 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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) { + + } + + `, +}) +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"); + + signIn = input>(); + + @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..acdb77964 --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.spec.ts @@ -0,0 +1,96 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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"); + }); +}); 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..8681b5765 --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/apple-sign-in-button.ts @@ -0,0 +1,50 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { OAuthButtonComponent } from "./oauth-button"; +import { injectTranslation, injectUI } from "../../provider"; +import { OAuthProvider } 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() }} + + `, +}) +export class AppleSignInButtonComponent { + ui = injectUI(); + signInWithAppleLabel = injectTranslation("labels", "signInWithApple"); + themed = input(false); + + private defaultProvider = new OAuthProvider("apple.com"); + + 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..24a732cf3 --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.spec.ts @@ -0,0 +1,96 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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"); + }); +}); 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..06443abe2 --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/facebook-sign-in-button.ts @@ -0,0 +1,50 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { FacebookAuthProvider } 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() }} + + `, +}) +export class FacebookSignInButtonComponent { + ui = injectUI(); + signInWithFacebookLabel = injectTranslation("labels", "signInWithFacebook"); + themed = input(false); + + private defaultProvider = new FacebookAuthProvider(); + + 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..061e15e9a --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/github-sign-in-button.spec.ts @@ -0,0 +1,96 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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"); + }); +}); 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..6e253e175 --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/github-sign-in-button.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, input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { OAuthButtonComponent } from "./oauth-button"; +import { injectTranslation } from "../../provider"; +import { GithubAuthProvider } 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() }} + + `, +}) +export class GitHubSignInButtonComponent { + signInWithGitHubLabel = injectTranslation("labels", "signInWithGitHub"); + themed = input(false); + + private defaultProvider = new GithubAuthProvider(); + + 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..89267c092 --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/google-sign-in-button.spec.ts @@ -0,0 +1,96 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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"); + }); +}); 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..a8201b0a6 --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/google-sign-in-button.ts @@ -0,0 +1,50 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { GoogleAuthProvider } 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() }} + + `, +}) +export class GoogleSignInButtonComponent { + ui = injectUI(); + signInWithGoogleLabel = injectTranslation("labels", "signInWithGoogle"); + themed = input(false); + + private defaultProvider = new GoogleAuthProvider(); + + 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..512901edd --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.spec.ts @@ -0,0 +1,96 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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"); + }); +}); 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..641aeeb98 --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/microsoft-sign-in-button.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, input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { OAuthButtonComponent } from "./oauth-button"; +import { injectTranslation } from "../../provider"; +import { OAuthProvider } 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() }} + + `, +}) +export class MicrosoftSignInButtonComponent { + signInWithMicrosoftLabel = injectTranslation("labels", "signInWithMicrosoft"); + themed = input(false); + + private defaultProvider = new OAuthProvider("microsoft.com"); + + 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..32856f17e --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/oauth-button.spec.ts @@ -0,0 +1,183 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 } 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; +} + +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(); + }); + }); +}); 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..727c2c5f0 --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/oauth-button.ts @@ -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 { Component, input, signal, computed } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { ButtonComponent } from "../../components/button"; +import { injectUI } from "../../provider"; +import { AuthProvider } 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() }}
+ } +
+ `, +}) +export class OAuthButtonComponent { + ui = injectUI(); + provider = input.required(); + themed = input(); + error = signal(null); + + buttonVariant = computed(() => { + return this.themed() ? "primary" : "secondary"; + }); + + async handleOAuthSignIn() { + this.error.set(null); + try { + await signInWithProvider(this.ui(), this.provider()); + } 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..404ecd8ca --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.spec.ts @@ -0,0 +1,96 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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"); + }); +}); 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..db486aef9 --- /dev/null +++ b/packages/angular/src/lib/auth/oauth/twitter-sign-in-button.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, input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { OAuthButtonComponent } from "./oauth-button"; +import { injectTranslation } from "../../provider"; +import { TwitterAuthProvider } 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() }} + + `, +}) +export class TwitterSignInButtonComponent { + signInWithTwitterLabel = injectTranslation("labels", "signInWithTwitter"); + themed = input(false); + + private defaultProvider = new TwitterAuthProvider(); + + 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..ef9e77d37 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/email-link-auth-screen.spec.ts @@ -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. + */ + +import { render, screen, fireEvent } from "@testing-library/angular"; +import { Component, EventEmitter } from "@angular/core"; + +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("", () => { + beforeEach(() => { + const { injectTranslation, injectUI } = require("../../../provider"); + 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(), + })); + }); + + 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("calls signIn output when MFA flow succeeds", async () => { + const { injectUI } = require("../../../provider"); + injectUI.mockImplementation(() => () => ({ + multiFactorResolver: { auth: {}, session: null, hints: [] }, + setMultiFactorResolver: jest.fn(), + })); + + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + EmailLinkAuthScreenComponent, + MockEmailLinkAuthFormComponent, + 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 MFA success by directly calling the onSuccess handler + const mfaComponent = fixture.debugElement.query( + (el) => el.name === "fui-multi-factor-auth-assertion-screen" + ).componentInstance; + mfaComponent.onSuccess.emit({ user: { uid: "mfa-user" } }); + + expect(signInSpy).toHaveBeenCalledTimes(1); + expect(signInSpy).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) }) + ); + }); +}); diff --git a/packages/angular/src/lib/auth/screens/email-link-auth-screen.ts b/packages/angular/src/lib/auth/screens/email-link-auth-screen.ts new file mode 100644 index 000000000..343ebad09 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/email-link-auth-screen.ts @@ -0,0 +1,79 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 } from "../../provider"; +import { EmailLinkAuthFormComponent } from "../forms/email-link-auth-form"; +import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; +import { RedirectErrorComponent } from "../../components/redirect-error"; +import { UserCredential } from "@angular/fire/auth"; + +@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() }} + + + + + + + +
+ } + `, +}) +export class EmailLinkAuthScreenComponent { + private ui = injectUI(); + + mfaResolver = computed(() => this.ui().multiFactorResolver); + + titleText = injectTranslation("labels", "signIn"); + subtitleText = injectTranslation("prompts", "signInToAccount"); + + @Output() emailSent = new EventEmitter(); + @Output() signIn = new EventEmitter(); +} diff --git a/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.spec.ts 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..93248d871 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/forgot-password-auth-screen.ts @@ -0,0 +1,64 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, Output, EventEmitter } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + 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() }} + + + + + +
+ `, +}) +export class ForgotPasswordAuthScreenComponent { + titleText = injectTranslation("labels", "resetPassword"); + subtitleText = injectTranslation("prompts", "enterEmailToReset"); + + @Output() passwordSent = new EventEmitter(); + @Output() backToSignIn = new EventEmitter(); +} diff --git a/packages/angular/src/lib/auth/screens/multi-factor-auth-assertion-screen.spec.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-assertion-screen.spec.ts new file mode 100644 index 000000000..2efac64f7 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-assertion-screen.spec.ts @@ -0,0 +1,171 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from "@testing-library/angular"; +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { MultiFactorAuthAssertionScreenComponent } from "./multi-factor-auth-assertion-screen"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; + +@Component({ + template: ``, + standalone: true, + imports: [MultiFactorAuthAssertionScreenComponent], +}) +class TestHostWithoutContentComponent {} + +describe("", () => { + beforeEach(() => { + const { injectTranslation, injectUI } = require("../../../provider"); + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + multiFactorAssertion: "Multi-Factor Assertion", + }, + prompts: { + mfaAssertionPrompt: "Verify your multi-factor authentication", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + injectUI.mockImplementation(() => () => ({ + multiFactorResolver: { + auth: {}, + session: null, + hints: [], + }, + setMultiFactorResolver: jest.fn(), + })); + }); + + it("renders with correct title and subtitle", async () => { + await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(screen.getByRole("heading", { name: "Multi-Factor Assertion" })).toBeInTheDocument(); + expect(screen.getByText("Verify your multi-factor authentication")).toBeInTheDocument(); + }); + + it("includes the MultiFactorAuthAssertionForm component", async () => { + TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + set: { + template: '
MFA Assertion Form
', + }, + }); + + await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const form = screen.getByTestId("mfa-assertion-form"); + expect(form).toBeInTheDocument(); + expect(form).toHaveTextContent("MFA Assertion Form"); + }); + + it("has correct CSS classes", async () => { + const { container } = await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(container.querySelector(".fui-screen")).toBeInTheDocument(); + expect(container.querySelector(".fui-card")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__header")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__title")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__subtitle")).toBeInTheDocument(); + expect(container.querySelector(".fui-card__content")).toBeInTheDocument(); + }); + + it("calls injectTranslation with correct parameters", async () => { + const { injectTranslation } = require("../../../provider"); + await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(injectTranslation).toHaveBeenCalledWith("labels", "multiFactorAssertion"); + expect(injectTranslation).toHaveBeenCalledWith("prompts", "mfaAssertionPrompt"); + }); + + it("emits onSuccess event when form emits onSuccess", async () => { + TestBed.overrideComponent(MultiFactorAuthAssertionFormComponent, { + set: { + template: '
MFA Assertion Form
', + }, + }); + + const { fixture } = await render(TestHostWithoutContentComponent, { + imports: [ + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + const component = fixture.debugElement.query( + (el) => el.name === "fui-multi-factor-auth-assertion-screen" + ).componentInstance; + const onSuccessSpy = jest.spyOn(component.onSuccess, "emit"); + + const formComponent = fixture.debugElement.query( + (el) => el.name === "fui-multi-factor-auth-assertion-form" + ).componentInstance; + formComponent.onSuccess.emit({ user: { uid: "mfa-user" } }); + + expect(onSuccessSpy).toHaveBeenCalledTimes(1); + expect(onSuccessSpy).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) }) + ); + }); +}); diff --git a/packages/angular/src/lib/auth/screens/multi-factor-auth-assertion-screen.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-assertion-screen.ts new file mode 100644 index 000000000..038244804 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-assertion-screen.ts @@ -0,0 +1,64 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, Output, EventEmitter } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { UserCredential } from "@angular/fire/auth"; +import { injectTranslation } from "../../provider"; +import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, +} from "../../components/card"; + +@Component({ + selector: "fui-multi-factor-auth-assertion-screen", + standalone: true, + host: { + style: "display: block;", + }, + imports: [ + CommonModule, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + MultiFactorAuthAssertionFormComponent, + ], + template: ` +
+ + + {{ titleText() }} + {{ subtitleText() }} + + + + + +
+ `, +}) +export class MultiFactorAuthAssertionScreenComponent { + @Output() onSuccess = new EventEmitter(); + + titleText = injectTranslation("labels", "multiFactorAssertion"); + subtitleText = injectTranslation("prompts", "mfaAssertionPrompt"); +} diff --git a/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.spec.ts b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.spec.ts 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..69faaac2a --- /dev/null +++ b/packages/angular/src/lib/auth/screens/multi-factor-auth-enrollment-screen.ts @@ -0,0 +1,67 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, 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() }} + + + + + +
+ `, +}) +export class MultiFactorAuthEnrollmentScreenComponent { + hints = input([FactorId.TOTP, FactorId.PHONE]); + @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..1b87973fa --- /dev/null +++ b/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts @@ -0,0 +1,384 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { 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(), +})); + +@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("", () => { + beforeEach(() => { + const { injectTranslation, injectPolicies, injectRedirectError, injectUI } = require("../../../provider"); + 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, + }); + }); + }); + + 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 with credential when MFA flow succeeds", 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"); + + const mfaScreenComponent = fixture.debugElement.query( + (el) => el.name === "fui-multi-factor-auth-assertion-screen" + ).componentInstance; + mfaScreenComponent.onSuccess.emit({ user: { uid: "angular-oauth-mfa-user" } }); + + expect(onSignInSpy).toHaveBeenCalledTimes(1); + expect(onSignInSpy).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "angular-oauth-mfa-user" }) }) + ); + }); +}); diff --git a/packages/angular/src/lib/auth/screens/oauth-screen.ts b/packages/angular/src/lib/auth/screens/oauth-screen.ts new file mode 100644 index 000000000..cb30c6c9d --- /dev/null +++ b/packages/angular/src/lib/auth/screens/oauth-screen.ts @@ -0,0 +1,80 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 } from "../../provider"; +import { PoliciesComponent } from "../../components/policies"; +import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; +import { RedirectErrorComponent } from "../../components/redirect-error"; +import { type UserCredential } from "firebase/auth"; + +@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() }} + + +
+ + + +
+
+
+
+ } + `, +}) +export class OAuthScreenComponent { + private ui = injectUI(); + + mfaResolver = computed(() => this.ui().multiFactorResolver); + + titleText = injectTranslation("labels", "signIn"); + subtitleText = injectTranslation("prompts", "signInToAccount"); + + @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..138ea477d --- /dev/null +++ b/packages/angular/src/lib/auth/screens/phone-auth-screen.spec.ts @@ -0,0 +1,318 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { 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("", () => { + beforeEach(() => { + const { injectTranslation, injectUI } = require("../../../provider"); + 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, + }); + }); + }); + + 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 with credential when MFA flow succeeds", 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"); + + const mfaScreenComponent = fixture.debugElement.query( + (el) => el.name === "fui-multi-factor-auth-assertion-screen" + ).componentInstance; + mfaScreenComponent.onSuccess.emit({ user: { uid: "angular-phone-mfa-user" } }); + + expect(signInSpy).toHaveBeenCalledTimes(1); + expect(signInSpy).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "angular-phone-mfa-user" }) }) + ); + }); +}); diff --git a/packages/angular/src/lib/auth/screens/phone-auth-screen.ts b/packages/angular/src/lib/auth/screens/phone-auth-screen.ts new file mode 100644 index 000000000..64ed89364 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/phone-auth-screen.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 { 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 } from "../../provider"; +import { PhoneAuthFormComponent } from "../forms/phone-auth-form"; +import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; +import { RedirectErrorComponent } from "../../components/redirect-error"; +import { UserCredential } from "@angular/fire/auth"; + +@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() }} + + + + + + + +
+ } + `, +}) +export class PhoneAuthScreenComponent { + private ui = injectUI(); + + mfaResolver = computed(() => this.ui().multiFactorResolver); + + titleText = injectTranslation("labels", "signIn"); + subtitleText = injectTranslation("prompts", "signInToAccount"); + + @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..77e1c0d28 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts @@ -0,0 +1,318 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { 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("", () => { + beforeEach(() => { + const { injectTranslation, injectUI } = require("../../../provider"); + 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, + }); + }); + }); + + 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 with credential when MFA flow succeeds", 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"); + + const mfaScreenComponent = fixture.debugElement.query( + (el) => el.name === "fui-multi-factor-auth-assertion-screen" + ).componentInstance; + mfaScreenComponent.onSuccess.emit({ user: { uid: "angular-mfa-user" } }); + + expect(signInSpy).toHaveBeenCalledTimes(1); + expect(signInSpy).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "angular-mfa-user" }) }) + ); + }); +}); diff --git a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts new file mode 100644 index 000000000..02efd57b2 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts @@ -0,0 +1,80 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { injectTranslation, injectUI } 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 { 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() }} + + + + + + + +
+ } + `, +}) +export class SignInAuthScreenComponent { + private ui = injectUI(); + + mfaResolver = computed(() => this.ui().multiFactorResolver); + + titleText = injectTranslation("labels", "signIn"); + subtitleText = injectTranslation("prompts", "signInToAccount"); + + @Output() forgotPassword = new EventEmitter(); + @Output() signUp = new EventEmitter(); + @Output() signIn = new EventEmitter(); +} diff --git a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts new file mode 100644 index 000000000..02c113fc9 --- /dev/null +++ b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.spec.ts @@ -0,0 +1,317 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { 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("", () => { + beforeEach(() => { + const { injectTranslation, injectUI } = require("../../../provider"); + 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, + }); + }); + }); + + 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 with credential when MFA flow succeeds", 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"); + + const mfaScreenComponent = fixture.debugElement.query( + (el) => el.name === "fui-multi-factor-auth-assertion-screen" + ).componentInstance; + mfaScreenComponent.onSuccess.emit({ user: { uid: "angular-signup-mfa-user" } }); + + expect(signUpSpy).toHaveBeenCalledTimes(1); + expect(signUpSpy).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "angular-signup-mfa-user" }) }) + ); + }); +}); diff --git a/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts new file mode 100644 index 000000000..48e16a14e --- /dev/null +++ b/packages/angular/src/lib/auth/screens/sign-up-auth-screen.ts @@ -0,0 +1,80 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { UserCredential } from "@angular/fire/auth"; + +import { injectTranslation, injectUI } 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() }} + + + + + + + +
+ } + `, +}) +export class SignUpAuthScreenComponent { + private ui = injectUI(); + + mfaResolver = computed(() => this.ui().multiFactorResolver); + + titleText = injectTranslation("labels", "signUp"); + subtitleText = injectTranslation("prompts", "enterDetailsToCreate"); + + @Output() signUp = new EventEmitter(); + @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/angular/src/lib/components/button.ts b/packages/angular/src/lib/components/button.ts new file mode 100644 index 000000000..dd73008c7 --- /dev/null +++ b/packages/angular/src/lib/components/button.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, HostBinding, input } from "@angular/core"; +import { buttonVariant, type ButtonVariant } from "@invertase/firebaseui-styles"; + +@Component({ + selector: "button[fui-button]", + template: ``, + standalone: true, +}) +export class ButtonComponent { + 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..48fe36cac --- /dev/null +++ b/packages/angular/src/lib/components/card.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 } from "@angular/core"; +import { CommonModule } from "@angular/common"; + +@Component({ + selector: "fui-card", + standalone: true, + imports: [], + host: { + class: "fui-card", + style: "display: block;", + }, + template: ` + + + `, +}) +export class CardComponent {} + +@Component({ + selector: "fui-card-header", + standalone: true, + imports: [CommonModule], + host: { + class: "fui-card__header", + style: "display: block;", + }, + template: ` + + + `, +}) +export class CardHeaderComponent {} + +@Component({ + selector: "fui-card-title", + standalone: true, + imports: [CommonModule], + host: { + class: "fui-card__title", + style: "display: block;", + }, + template: ` +

+ +

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

+ +

+ `, +}) +export class CardSubtitleComponent {} + +@Component({ + selector: "fui-card-content", + standalone: true, + imports: [CommonModule], + host: { + class: "fui-card__content", + style: "display: block;", + }, + template: ` `, +}) +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/firebaseui-angular/src/lib/components/button/button.component.ts b/packages/angular/src/lib/components/content.ts similarity index 60% rename from packages/firebaseui-angular/src/lib/components/button/button.component.ts rename to packages/angular/src/lib/components/content.ts index 9e23bd2b3..ce3ddc4b9 100644 --- a/packages/firebaseui-angular/src/lib/components/button/button.component.ts +++ b/packages/angular/src/lib/components/content.ts @@ -14,23 +14,24 @@ * limitations under the License. */ -import { Component, Directive, ElementRef, Input } from '@angular/core'; +import { Component } from "@angular/core"; +import { DividerComponent } from "./divider"; +import { injectTranslation } from "../provider"; @Component({ - selector: 'fui-button', + selector: "fui-content", + standalone: true, + imports: [DividerComponent], + host: { + style: "display: block;", + }, template: ` - +
`, - standalone: true, }) -export class ButtonComponent { - @Input() type: 'button' | 'submit' | 'reset' = 'button'; - @Input() disabled: boolean = false; - @Input() variant: 'primary' | 'secondary' = 'primary'; -} \ No newline at end of file +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..4ac72704c --- /dev/null +++ b/packages/angular/src/lib/components/country-selector.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, 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 }} + +
+
+
+ `, +}) +export class CountrySelectorComponent { + countries = injectCountries(); + defaultCountry = injectDefaultCountry(); + 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..40b3e43db --- /dev/null +++ b/packages/angular/src/lib/components/divider.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, 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() }}
+
+ } +
+ `, +}) +export class DividerComponent { + 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..034cb860c --- /dev/null +++ b/packages/angular/src/lib/components/form.ts @@ -0,0 +1,129 @@ +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) { +
+ +
+ } + `, +}) +export class FormMetadataComponent { + 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: ` + + `, +}) +export class FormInputComponent { + field = injectField(); + label = input.required(); + type = input("text"); + description = input(); +} + +@Component({ + selector: "button[fui-form-action]", + standalone: true, + host: { + class: "fui-form__action", + type: "button", + }, + template: ` `, +}) +export class FormActionComponent {} + +@Component({ + selector: "fui-form-submit", + standalone: true, + imports: [ButtonComponent], + host: { + type: "submit", + style: "display: block;", + }, + template: ` + + `, +}) +export class FormSubmitComponent { + class = input(); + state = input.required(); + + isSubmitting = computed(() => this.state().isSubmitting); +} + +@Component({ + selector: "fui-form-error-message", + standalone: true, + host: { + style: "display: block;", + }, + template: ` + @if (errorMessage()) { +
+ {{ errorMessage() }} +
+ } + `, +}) +export class FormErrorMessageComponent { + 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..7e9727b1d --- /dev/null +++ b/packages/angular/src/lib/components/logos/apple.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. + */ + +// GENERATED BY generate-logos.ts + +import { Component, input } from "@angular/core"; + +@Component({ + selector: "fui-apple-logo", + standalone: true, + template: ` + + + + `, +}) +export class AppleLogoComponent { + width = input("1em"); + height = input("1em"); + 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..7812040d3 --- /dev/null +++ b/packages/angular/src/lib/components/logos/facebook.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. + */ + +// GENERATED BY generate-logos.ts + +import { Component, input } from "@angular/core"; + +@Component({ + selector: "fui-facebook-logo", + standalone: true, + template: ` + + + + `, +}) +export class FacebookLogoComponent { + width = input("1em"); + height = input("1em"); + 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..fce4b33c6 --- /dev/null +++ b/packages/angular/src/lib/components/logos/github.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. + */ + +// GENERATED BY generate-logos.ts + +import { Component, input } from "@angular/core"; + +@Component({ + selector: "fui-github-logo", + standalone: true, + template: ` + + + + `, +}) +export class GithubLogoComponent { + width = input("1em"); + height = input("1em"); + 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..8acab00de --- /dev/null +++ b/packages/angular/src/lib/components/logos/google.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. + */ + +// GENERATED BY generate-logos.ts + +import { Component, input } from "@angular/core"; + +@Component({ + selector: "fui-google-logo", + standalone: true, + template: ` + + + + + + + `, +}) +export class GoogleLogoComponent { + width = input("1em"); + height = input("1em"); + className = input(""); +} diff --git a/packages/angular/src/lib/components/logos/index.ts b/packages/angular/src/lib/components/logos/index.ts new file mode 100644 index 000000000..677873a2b --- /dev/null +++ b/packages/angular/src/lib/components/logos/index.ts @@ -0,0 +1,8 @@ +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..d3b98fd3f --- /dev/null +++ b/packages/angular/src/lib/components/logos/line.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. + */ + +// GENERATED BY generate-logos.ts + +import { Component, input } from "@angular/core"; + +@Component({ + selector: "fui-line-logo", + standalone: true, + template: ` + + + + + `, +}) +export class LineLogoComponent { + width = input("1em"); + height = input("1em"); + 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..88006fafe --- /dev/null +++ b/packages/angular/src/lib/components/logos/microsoft.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. + */ + +// GENERATED BY generate-logos.ts + +import { Component, input } from "@angular/core"; + +@Component({ + selector: "fui-microsoft-logo", + standalone: true, + template: ` + + + + + + + `, +}) +export class MicrosoftLogoComponent { + width = input("1em"); + height = input("1em"); + 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..c11ec3c00 --- /dev/null +++ b/packages/angular/src/lib/components/logos/snapchat.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. + */ + +// GENERATED BY generate-logos.ts + +import { Component, input } from "@angular/core"; + +@Component({ + selector: "fui-snapchat-logo", + standalone: true, + template: ` + + + + `, +}) +export class SnapchatLogoComponent { + width = input("1em"); + height = input("1em"); + 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..3acbbb71d --- /dev/null +++ b/packages/angular/src/lib/components/logos/twitter.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. + */ + +// GENERATED BY generate-logos.ts + +import { Component, input } from "@angular/core"; + +@Component({ + selector: "fui-twitter-logo", + standalone: true, + template: ` + + + + `, +}) +export class TwitterLogoComponent { + width = input("1em"); + height = input("1em"); + 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..b8675b7e1 --- /dev/null +++ b/packages/angular/src/lib/components/policies.ts @@ -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. + */ + +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 }} + } + } + } + `, +}) +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..224e5be99 --- /dev/null +++ b/packages/angular/src/lib/components/redirect-error.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 } 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() }}
+ } + `, +}) +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..815dc6c3e --- /dev/null +++ b/packages/angular/src/lib/provider.ts @@ -0,0 +1,216 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 } 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"); + +type PolicyConfig = { + termsOfServiceUrl: string; + privacyPolicyUrl: string; +}; + +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); +} + +export function provideFirebaseUIPolicies(factory: () => PolicyConfig) { + const providers: Provider[] = [{ provide: FIREBASE_UI_POLICIES, useFactory: factory }]; + + return makeEnvironmentProviders(providers); +} + +export function injectUI() { + const store = inject(FIREBASE_UI_STORE); + const ui = signal(store.get()); + + effect(() => { + return store.subscribe(ui.set); + }); + + return ui.asReadonly(); +} + +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; +} + +export function injectTranslation(category: string, key: string) { + const ui = injectUI(); + return computed(() => getTranslation(ui(), category as any, key as any)); +} + +export function injectSignInAuthFormSchema(): Signal> { + const ui = injectUI(); + return computed(() => createSignInAuthFormSchema(ui())); +} + +export function injectSignUpAuthFormSchema(): Signal> { + const ui = injectUI(); + return computed(() => createSignUpAuthFormSchema(ui())); +} + +export function injectForgotPasswordAuthFormSchema(): Signal> { + const ui = injectUI(); + return computed(() => createForgotPasswordAuthFormSchema(ui())); +} + +export function injectEmailLinkAuthFormSchema(): Signal> { + const ui = injectUI(); + return computed(() => createEmailLinkAuthFormSchema(ui())); +} + +export function injectPhoneAuthFormSchema(): Signal> { + const ui = injectUI(); + return computed(() => createPhoneAuthNumberFormSchema(ui())); +} + +export function injectPhoneAuthVerifyFormSchema(): Signal> { + const ui = injectUI(); + return computed(() => createPhoneAuthVerifyFormSchema(ui())); +} + +export function injectMultiFactorPhoneAuthNumberFormSchema(): Signal< + ReturnType +> { + const ui = injectUI(); + return computed(() => createMultiFactorPhoneAuthNumberFormSchema(ui())); +} + +export function injectMultiFactorPhoneAuthAssertionFormSchema(): Signal< + ReturnType +> { + const ui = injectUI(); + return computed(() => createMultiFactorPhoneAuthAssertionFormSchema(ui())); +} + +export function injectMultiFactorPhoneAuthVerifyFormSchema(): Signal< + ReturnType +> { + const ui = injectUI(); + return computed(() => createMultiFactorPhoneAuthVerifyFormSchema(ui())); +} + +export function injectMultiFactorTotpAuthNumberFormSchema(): Signal< + ReturnType +> { + const ui = injectUI(); + return computed(() => createMultiFactorTotpAuthNumberFormSchema(ui())); +} + +export function injectMultiFactorTotpAuthVerifyFormSchema(): Signal< + ReturnType +> { + const ui = injectUI(); + return computed(() => createMultiFactorTotpAuthVerifyFormSchema(ui())); +} + +export function injectPolicies(): PolicyConfig | null { + return inject(FIREBASE_UI_POLICIES, { optional: true }); +} + +export function injectCountries(): Signal { + const ui = injectUI(); + return computed(() => getBehavior(ui(), "countryCodes")().allowedCountries); +} + +export function injectDefaultCountry(): Signal { + const ui = injectUI(); + return computed(() => getBehavior(ui(), "countryCodes")().defaultCountry); +} + +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..4d6a977c0 --- /dev/null +++ b/packages/angular/src/lib/tests/test-helpers.ts @@ -0,0 +1,325 @@ +// 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 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 52% rename from packages/firebaseui-core/package.json rename to packages/core/package.json index 7f0a2fde2..a30cf3f3f 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,25 +11,30 @@ "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\"'", "release": "pnpm run build && pnpm pack --pack-destination --pack-destination ../../releases/" }, @@ -42,22 +47,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..07908689a --- /dev/null +++ b/packages/core/src/auth.test.ts @@ -0,0 +1,1456 @@ +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..c8ab743cf --- /dev/null +++ b/packages/core/src/auth.ts @@ -0,0 +1,379 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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"); +} + +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"); + } +} + +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"); + } +} + +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"); + } +} + +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"); + } +} + +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"); + } +} + +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"); + } +} + +export async function signInWithEmailLink(ui: FirebaseUI, email: string, link: string): Promise { + const credential = EmailAuthProvider.credentialWithLink(email, link); + return signInWithCredential(ui, 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"); + } +} + +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"); + } +} + +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"); + } +} + +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"); + } +} + +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"); + } +} + +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(); +} + +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"); + } +} + +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"); + } +} + +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..a8e6fdaee --- /dev/null +++ b/packages/core/src/behaviors/anonymous-upgrade.test.ts @@ -0,0 +1,263 @@ +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..ce534de17 --- /dev/null +++ b/packages/core/src/behaviors/anonymous-upgrade.ts @@ -0,0 +1,76 @@ +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..9dd033a96 --- /dev/null +++ b/packages/core/src/behaviors/auto-anonymous-login.test.ts @@ -0,0 +1,32 @@ +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..8d625f383 --- /dev/null +++ b/packages/core/src/behaviors/auto-anonymous-login.ts @@ -0,0 +1,10 @@ +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..b348b0556 --- /dev/null +++ b/packages/core/src/behaviors/country-codes.test.ts @@ -0,0 +1,222 @@ +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..4c3e503aa --- /dev/null +++ b/packages/core/src/behaviors/country-codes.ts @@ -0,0 +1,41 @@ +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..d043547c7 --- /dev/null +++ b/packages/core/src/behaviors/index.test.ts @@ -0,0 +1,260 @@ +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(), +})); + +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..69bc10453 --- /dev/null +++ b/packages/core/src/behaviors/index.ts @@ -0,0 +1,131 @@ +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; +}; + +export type Behavior = Pick; +export type Behaviors = Partial; + +export function autoAnonymousLogin(): Behavior<"autoAnonymousLogin"> { + return { + autoAnonymousLogin: initBehavior(autoAnonymousLoginHandlers.autoAnonymousLoginHandler), + }; +} + +export type AutoUpgradeAnonymousUsersOptions = { + onUpgrade?: anonymousUpgradeHandlers.OnUpgradeCallback; +}; + +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) + ), + }; +} + +export type RecaptchaVerificationOptions = recaptchaHandlers.RecaptchaVerificationOptions; + +export function recaptchaVerification(options?: RecaptchaVerificationOptions): Behavior<"recaptchaVerification"> { + return { + recaptchaVerification: callableBehavior((ui, element) => + recaptchaHandlers.recaptchaVerificationHandler(ui, element, options) + ), + }; +} + +export function providerRedirectStrategy(): Behavior<"providerSignInStrategy" | "providerLinkStrategy"> { + return { + providerSignInStrategy: callableBehavior(providerStrategyHandlers.signInWithRediectHandler), + providerLinkStrategy: callableBehavior(providerStrategyHandlers.linkWithRedirectHandler), + }; +} + +export function providerPopupStrategy(): Behavior<"providerSignInStrategy" | "providerLinkStrategy"> { + return { + providerSignInStrategy: callableBehavior(providerStrategyHandlers.signInWithPopupHandler), + providerLinkStrategy: callableBehavior(providerStrategyHandlers.linkWithPopupHandler), + }; +} + +export type OneTapSignInOptions = oneTapSignInHandlers.OneTapSignInOptions; + +export function oneTapSignIn(options: OneTapSignInOptions): Behavior<"oneTapSignIn"> { + return { + oneTapSignIn: initBehavior((ui) => oneTapSignInHandlers.oneTapSignInHandler(ui, options)), + }; +} + +export function requireDisplayName(): Behavior<"requireDisplayName"> { + return { + requireDisplayName: callableBehavior(requireDisplayNameHandlers.requireDisplayNameHandler), + }; +} + +export function countryCodes(options?: countryCodesHandlers.CountryCodesOptions): Behavior<"countryCodes"> { + return { + countryCodes: callableBehavior(() => countryCodesHandlers.countryCodesHandler(options)), + }; +} + +export function hasBehavior(ui: FirebaseUI, key: T): boolean { + return !!ui.behaviors[key]; +} + +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; +} + +export const defaultBehaviors: Behavior<"recaptchaVerification"> = { + ...recaptchaVerification(), + ...providerRedirectStrategy(), + ...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..43a6c7de6 --- /dev/null +++ b/packages/core/src/behaviors/one-tap.test.ts @@ -0,0 +1,324 @@ +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..5154a4f79 --- /dev/null +++ b/packages/core/src/behaviors/one-tap.ts @@ -0,0 +1,50 @@ +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..9eda32cb1 --- /dev/null +++ b/packages/core/src/behaviors/provider-strategy.test.ts @@ -0,0 +1,114 @@ +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..32c395ac6 --- /dev/null +++ b/packages/core/src/behaviors/provider-strategy.ts @@ -0,0 +1,33 @@ +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..08fce46f2 --- /dev/null +++ b/packages/core/src/behaviors/recaptcha.test.ts @@ -0,0 +1,198 @@ +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..707070ee2 --- /dev/null +++ b/packages/core/src/behaviors/recaptcha.ts @@ -0,0 +1,20 @@ +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..e9134de0f --- /dev/null +++ b/packages/core/src/behaviors/require-display-name.test.ts @@ -0,0 +1,39 @@ +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..1402d1a48 --- /dev/null +++ b/packages/core/src/behaviors/require-display-name.ts @@ -0,0 +1,6 @@ +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..abfc34c0a --- /dev/null +++ b/packages/core/src/behaviors/utils.test.ts @@ -0,0 +1,174 @@ +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..7d332e7a7 --- /dev/null +++ b/packages/core/src/behaviors/utils.ts @@ -0,0 +1,34 @@ +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..1312c8ca9 --- /dev/null +++ b/packages/core/src/config.test.ts @@ -0,0 +1,495 @@ +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..43655865c --- /dev/null +++ b/packages/core/src/config.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. + */ + +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"; + +export type FirebaseUIOptions = { + app: FirebaseApp; + auth?: Auth; + locale?: RegisteredLocale; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + behaviors?: Behavior[]; +}; + +export type FirebaseUI = { + app: FirebaseApp; + auth: Auth; + setLocale: (locale: RegisteredLocale) => void; + state: FirebaseUIState; + setState: (state: FirebaseUIState) => void; + locale: RegisteredLocale; + behaviors: Behaviors; + multiFactorResolver?: MultiFactorResolver; + setMultiFactorResolver: (multiFactorResolver?: MultiFactorResolver) => void; + redirectError?: Error; + setRedirectError: (error?: Error) => void; +}; + +export const $config = map>>({}); + +export type FirebaseUIStore = DeepMapStore; + +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..d13bc7c8f --- /dev/null +++ b/packages/core/src/country-data.test.ts @@ -0,0 +1,179 @@ +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..20a563473 --- /dev/null +++ b/packages/core/src/country-data.ts @@ -0,0 +1,303 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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"; + +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[]; + +export type CountryData = { + name: string; + dialCode: string; + code: CountryCode; + emoji: string; +}; + +export type { CountryCode }; + +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..a4226ea2b --- /dev/null +++ b/packages/core/src/errors.test.ts @@ -0,0 +1,290 @@ +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..782f2191d --- /dev/null +++ b/packages/core/src/errors.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 { 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"; +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); + } +} + +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..1bb61b362 --- /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..30b0f16c8 --- /dev/null +++ b/packages/core/src/register-framework.test.ts @@ -0,0 +1,62 @@ +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..3817ad7c0 --- /dev/null +++ b/packages/core/src/register-framework.ts @@ -0,0 +1,11 @@ +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..13edc30e3 --- /dev/null +++ b/packages/core/src/schemas.test.ts @@ -0,0 +1,382 @@ +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..509193d99 --- /dev/null +++ b/packages/core/src/schemas.ts @@ -0,0 +1,115 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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"; + +export const LoginTypes = ["email", "phone", "anonymous", "emailLink", "google"] as const; +export type LoginType = (typeof LoginTypes)[number]; +export type AuthMode = "signIn" | "signUp"; + +export function createSignInAuthFormSchema(ui: FirebaseUI) { + return z.object({ + email: z.email(getTranslation(ui, "errors", "invalidEmail")), + password: z.string().min(6, getTranslation(ui, "errors", "weakPassword")), + }); +} + +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(), + }); +} + +export function createForgotPasswordAuthFormSchema(ui: FirebaseUI) { + return z.object({ + email: z.email(getTranslation(ui, "errors", "invalidEmail")), + }); +} + +export function createEmailLinkAuthFormSchema(ui: FirebaseUI) { + return z.object({ + email: z.email(getTranslation(ui, "errors", "invalidEmail")), + }); +} + +export function createPhoneAuthNumberFormSchema(ui: FirebaseUI) { + return z.object({ + phoneNumber: z + .string() + .min(1, getTranslation(ui, "errors", "missingPhoneNumber")) + .max(10, getTranslation(ui, "errors", "invalidPhoneNumber")), + }); +} + +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"), + }), + }); +} + +export function createMultiFactorPhoneAuthNumberFormSchema(ui: FirebaseUI) { + const base = createPhoneAuthNumberFormSchema(ui); + return base.extend({ + displayName: z.string().min(1, getTranslation(ui, "errors", "displayNameRequired")), + }); +} + +export function createMultiFactorPhoneAuthAssertionFormSchema(ui: FirebaseUI) { + return createPhoneAuthNumberFormSchema(ui); +} + +export function createMultiFactorPhoneAuthVerifyFormSchema(ui: FirebaseUI) { + return createPhoneAuthVerifyFormSchema(ui); +} + +export function createMultiFactorTotpAuthNumberFormSchema(ui: FirebaseUI) { + return z.object({ + displayName: z.string().min(1, getTranslation(ui, "errors", "displayNameRequired")), + }); +} + +export function createMultiFactorTotpAuthVerifyFormSchema(ui: FirebaseUI) { + return z.object({ + verificationCode: z.string().refine((val) => val.length === 6, { + error: getTranslation(ui, "errors", "invalidVerificationCode"), + }), + }); +} + +export type SignInAuthFormSchema = z.infer>; +export type SignUpAuthFormSchema = z.infer>; +export type ForgotPasswordAuthFormSchema = z.infer>; +export type EmailLinkAuthFormSchema = z.infer>; +export type PhoneAuthNumberFormSchema = z.infer>; +export type PhoneAuthVerifyFormSchema = z.infer>; +export type MultiFactorPhoneAuthNumberFormSchema = z.infer< + ReturnType +>; +export type MultiFactorTotpAuthNumberFormSchema = z.infer>; +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..3118eb25a --- /dev/null +++ b/packages/core/src/translations.test.ts @@ -0,0 +1,49 @@ +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/firebaseui-core/src/translations.ts b/packages/core/src/translations.ts similarity index 66% rename from packages/firebaseui-core/src/translations.ts rename to packages/core/src/translations.ts index afccff2ee..e2139699e 100644 --- a/packages/firebaseui-core/src/translations.ts +++ b/packages/core/src/translations.ts @@ -14,13 +14,18 @@ * limitations under the License. */ -import { getTranslation as _getTranslation, TranslationCategory, TranslationKey } from '@firebase-ui/translations'; -import { FirebaseUIConfiguration } from './config'; +import { + getTranslation as _getTranslation, + type TranslationCategory, + type TranslationKey, +} from "@invertase/firebaseui-translations"; +import { type FirebaseUI } from "./config"; export function getTranslation( - ui: FirebaseUIConfiguration, + ui: FirebaseUI, category: T, - key: TranslationKey + key: TranslationKey, + replacements?: Record ) { - return _getTranslation(category, key, ui.translations, ui.locale); + 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..948540cac --- /dev/null +++ b/packages/core/tests/utils.ts @@ -0,0 +1,23 @@ +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..8220a98e6 --- /dev/null +++ b/packages/core/vite.config.ts @@ -0,0 +1,18 @@ +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/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/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/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..0bf435a23 --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,73 @@ +{ + "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\"'", + "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..f6b1d03ef --- /dev/null +++ b/packages/react/src/auth/forms/email-link-auth-form.tsx @@ -0,0 +1,132 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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"; + +export type EmailLinkAuthFormProps = { + onEmailSent?: () => void; + onSignIn?: (credential: UserCredential) => void; +}; + +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] + ); +} + +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); + } + }, + }, + }); +} + +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]); +} + +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..cd0946110 --- /dev/null +++ b/packages/react/src/auth/forms/forgot-password-auth-form.tsx @@ -0,0 +1,110 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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"; + +export type ForgotPasswordAuthFormProps = { + onPasswordSent?: () => void; + onBackToSignInClick?: () => void; +}; + +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] + ); +} + +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); + } + }, + }, + }); +} + +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..28a784339 --- /dev/null +++ b/packages/react/src/auth/forms/mfa/sms-multi-factor-assertion-form.tsx @@ -0,0 +1,220 @@ +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; +}; + +export function useSmsMultiFactorAssertionPhoneFormAction() { + const ui = useUI(); + + return useCallback( + async ({ hint, recaptchaVerifier }: { hint: MultiFactorInfo; recaptchaVerifier: RecaptchaVerifier }) => { + return await verifyPhoneNumber(ui, "", recaptchaVerifier, undefined, hint); + }, + [ui] + ); +} + +type UseSmsMultiFactorAssertionPhoneForm = { + hint: MultiFactorInfo; + recaptchaVerifier: RecaptchaVerifier; + onSuccess: (verificationId: string) => void; +}; + +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")} + +
+
+
+ ); +} + +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] + ); +} + +type UseSmsMultiFactorAssertionVerifyForm = { + verificationId: string; + onSuccess: (credential: UserCredential) => void; +}; + +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")} + +
+
+
+ ); +} + +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/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..7a521d0e3 --- /dev/null +++ b/packages/react/src/auth/forms/mfa/sms-multi-factor-enrollment-form.tsx @@ -0,0 +1,246 @@ +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"; + +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] + ); +} + +export type UseSmsMultiFactorEnrollmentPhoneNumberForm = { + recaptchaVerifier: RecaptchaVerifier; + onSuccess: (verificationId: string, displayName?: string) => void; + formatPhoneNumber?: (phoneNumber: string) => string; +}; + +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")} + +
+
+
+ ); +} + +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] + ); +} + +type UseMultiFactorEnrollmentVerifyPhoneNumberForm = { + verificationId: string; + displayName?: string; + onSuccess: () => void; +}; + +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); + } + }, + }, + }); +} + +type MultiFactorEnrollmentVerifyPhoneNumberFormProps = { + verificationId: string; + displayName?: string; + onSuccess: () => void; +}; + +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")} + +
+
+
+ ); +} + +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/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..a3eb97af6 --- /dev/null +++ b/packages/react/src/auth/forms/mfa/totp-multi-factor-assertion-form.test.tsx @@ -0,0 +1,256 @@ +/** + * @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..0a69fb230 --- /dev/null +++ b/packages/react/src/auth/forms/mfa/totp-multi-factor-assertion-form.tsx @@ -0,0 +1,90 @@ +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"; + +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] + ); +} + +export type UseTotpMultiFactorAssertionForm = { + hint: MultiFactorInfo; + onSuccess: (credential: UserCredential) => void; +}; + +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); + } + }, + }, + }); +} + +export type TotpMultiFactorAssertionFormProps = { + hint: MultiFactorInfo; + onSuccess?: (credential: UserCredential) => void; +}; + +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..ef36fe05e --- /dev/null +++ b/packages/react/src/auth/forms/mfa/totp-multi-factor-enrollment-form.tsx @@ -0,0 +1,212 @@ +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"; + +export function useTotpMultiFactorSecretGenerationFormAction() { + const ui = useUI(); + + return useCallback(async () => { + return await generateTotpSecret(ui); + }, [ui]); +} + +export type UseTotpMultiFactorEnrollmentForm = { + onSuccess: (secret: TotpSecret, displayName: string) => void; +}; + +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")} + +
+
+
+ ); +} + +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] + ); +} + +type UseMultiFactorEnrollmentVerifyTotpForm = { + secret: TotpSecret; + displayName: string; + onSuccess: () => void; +}; + +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); + } + }, + }, + }); +} + +type MultiFactorEnrollmentVerifyTotpFormProps = { + secret: TotpSecret; + displayName: string; + onSuccess: () => void; +}; + +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")} + +
+
+
+ ); +} + +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/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..a06eb79ed --- /dev/null +++ b/packages/react/src/auth/forms/multi-factor-auth-assertion-form.tsx @@ -0,0 +1,97 @@ +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"; + +export type MultiFactorAuthAssertionFormProps = { + onSuccess?: (credential: UserCredential) => void; +}; + +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? + }, []); +} + +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..a3a41ad34 --- /dev/null +++ b/packages/react/src/auth/forms/multi-factor-auth-enrollment-form.tsx @@ -0,0 +1,76 @@ +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]; + +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/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..cbaa7032e --- /dev/null +++ b/packages/react/src/auth/forms/phone-auth-form.tsx @@ -0,0 +1,219 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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"; + +export function usePhoneNumberFormAction() { + const ui = useUI(); + + return useCallback( + async ({ phoneNumber, recaptchaVerifier }: { phoneNumber: string; recaptchaVerifier: RecaptchaVerifier }) => { + return await verifyPhoneNumber(ui, phoneNumber, recaptchaVerifier); + }, + [ui] + ); +} + +type UsePhoneNumberForm = { + recaptchaVerifier: RecaptchaVerifier; + onSuccess: (verificationId: string) => void; + formatPhoneNumber?: (phoneNumber: string) => string; +}; + +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); + } + }, + }, + }); +} + +type PhoneNumberFormProps = { + onSubmit: (verificationId: string) => void; +}; + +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")} + +
+
+
+ ); +} + +export function useVerifyPhoneNumberFormAction() { + const ui = useUI(); + + return useCallback( + async ({ verificationId, verificationCode }: { verificationId: string; verificationCode: string }) => { + return await confirmPhoneNumber(ui, verificationId, verificationCode); + }, + [ui] + ); +} + +type UseVerifyPhoneNumberForm = { + verificationId: string; + onSuccess: (credential: UserCredential) => void; +}; + +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")} + +
+
+
+ ); +} + +export type PhoneAuthFormProps = { + onSignIn?: (credential: UserCredential) => void; +}; + +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..c5a25ce7c --- /dev/null +++ b/packages/react/src/auth/forms/sign-in-auth-form.tsx @@ -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. + */ + +"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"; + +export type SignInAuthFormProps = { + onSignIn?: (credential: UserCredential) => void; + onForgotPasswordClick?: () => void; + onSignUpClick?: () => void; +}; + +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] + ); +} + +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); + } + }, + }, + }); +} + +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..876f19ee2 --- /dev/null +++ b/packages/react/src/auth/forms/sign-up-auth-form.tsx @@ -0,0 +1,132 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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"; + +export function useRequireDisplayName() { + const ui = useUI(); + return hasBehavior(ui, "requireDisplayName"); +} + +export type SignUpAuthFormProps = { + onSignUp?: (credential: UserCredential) => void; + onSignInClick?: () => void; +}; + +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] + ); +} + +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); + } + }, + }, + }); +} + +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..34685c3c3 --- /dev/null +++ b/packages/react/src/auth/oauth/apple-sign-in-button.test.tsx @@ -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, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup } 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"; + +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; + }, + }; +}); + +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"); + }); +}); + +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..4df4731e0 --- /dev/null +++ b/packages/react/src/auth/oauth/apple-sign-in-button.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 { getTranslation } from "@invertase/firebaseui-core"; +import { OAuthProvider } from "firebase/auth"; +import { useUI } from "~/hooks"; +import { OAuthButton } from "./oauth-button"; +import AppleSvgLogo from "~/components/logos/apple/Logo"; +import { cn } from "~/utils/cn"; + +export type AppleSignInButtonProps = { + provider?: OAuthProvider; + themed?: boolean; +}; + +export function AppleSignInButton({ provider, themed }: AppleSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithApple")} + + ); +} + +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..3bf7e25dc --- /dev/null +++ b/packages/react/src/auth/oauth/facebook-sign-in-button.test.tsx @@ -0,0 +1,191 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { FacebookLogo, FacebookSignInButton } from "./facebook-sign-in-button"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; + +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + FacebookAuthProvider: class FacebookAuthProvider { + constructor() { + this.providerId = "facebook.com"; + } + providerId: string; + }, + }; +}); + +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"); + }); +}); + +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..456647fb9 --- /dev/null +++ b/packages/react/src/auth/oauth/facebook-sign-in-button.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 { getTranslation } from "@invertase/firebaseui-core"; +import { FacebookAuthProvider } from "firebase/auth"; +import { useUI } from "~/hooks"; +import { OAuthButton } from "./oauth-button"; +import FacebookSvgLogo from "~/components/logos/facebook/Logo"; +import { cn } from "~/utils/cn"; + +export type FacebookSignInButtonProps = { + provider?: FacebookAuthProvider; + themed?: boolean; +}; + +export function FacebookSignInButton({ provider, themed }: FacebookSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithFacebook")} + + ); +} + +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..851100d6d --- /dev/null +++ b/packages/react/src/auth/oauth/github-sign-in-button.test.tsx @@ -0,0 +1,191 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { GitHubLogo, GitHubSignInButton } from "./github-sign-in-button"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; + +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + GithubAuthProvider: class GithubAuthProvider { + constructor() { + this.providerId = "github.com"; + } + providerId: string; + }, + }; +}); + +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"); + }); +}); + +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..7fbf31984 --- /dev/null +++ b/packages/react/src/auth/oauth/github-sign-in-button.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 { getTranslation } from "@invertase/firebaseui-core"; +import { GithubAuthProvider } from "firebase/auth"; +import { useUI } from "~/hooks"; +import { OAuthButton } from "./oauth-button"; +import GitHubSvgLogo from "~/components/logos/github/Logo"; +import { cn } from "~/utils/cn"; + +export type GitHubSignInButtonProps = { + provider?: GithubAuthProvider; + themed?: boolean; +}; + +export function GitHubSignInButton({ provider, themed }: GitHubSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithGitHub")} + + ); +} + +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..41170a713 --- /dev/null +++ b/packages/react/src/auth/oauth/google-sign-in-button.test.tsx @@ -0,0 +1,198 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { GoogleLogo, GoogleSignInButton } from "./google-sign-in-button"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; + +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + GoogleAuthProvider: class GoogleAuthProvider { + constructor() { + this.providerId = "google.com"; + } + providerId: string; + }, + }; +}); + +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"); + }); +}); + +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..cbaca2bc7 --- /dev/null +++ b/packages/react/src/auth/oauth/google-sign-in-button.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 { getTranslation } from "@invertase/firebaseui-core"; +import { GoogleAuthProvider } from "firebase/auth"; +import { useUI } from "~/hooks"; +import { OAuthButton } from "./oauth-button"; +import GoogleSvgLogo from "~/components/logos/google/Logo"; +import { cn } from "~/utils/cn"; + +export type GoogleSignInButtonProps = { + provider?: GoogleAuthProvider; + themed?: boolean | "neutral"; +}; + +export function GoogleSignInButton({ provider, themed }: GoogleSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithGoogle")} + + ); +} + +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..03bd497ef --- /dev/null +++ b/packages/react/src/auth/oauth/microsoft-sign-in-button.test.tsx @@ -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, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup } 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"; + +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; + }, + }; +}); + +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"); + }); +}); + +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..f706d4a25 --- /dev/null +++ b/packages/react/src/auth/oauth/microsoft-sign-in-button.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 { getTranslation } from "@invertase/firebaseui-core"; +import { OAuthProvider } from "firebase/auth"; +import { useUI } from "~/hooks"; +import { OAuthButton } from "./oauth-button"; +import MicrosoftSvgLogo from "~/components/logos/microsoft/Logo"; +import { cn } from "~/utils/cn"; + +export type MicrosoftSignInButtonProps = { + provider?: OAuthProvider; + themed?: boolean; +}; + +export function MicrosoftSignInButton({ provider, themed }: MicrosoftSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithMicrosoft")} + + ); +} + +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..e08ad2a39 --- /dev/null +++ b/packages/react/src/auth/oauth/oauth-button.test.tsx @@ -0,0 +1,381 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 } 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("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); + + // Next tick - wait for the mock to resolve + await new Promise((resolve) => setTimeout(resolve, 0)); + + 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); + + // Wait for error to appear + await new Promise((resolve) => setTimeout(resolve, 0)); + + 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); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const expectedError = enUs.translations.errors!.wrongPassword!; + + // 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 new Promise((resolve) => setTimeout(resolve, 0)); + + 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("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/firebaseui-react/src/auth/oauth/oauth-button.tsx b/packages/react/src/auth/oauth/oauth-button.tsx similarity index 64% rename from packages/firebaseui-react/src/auth/oauth/oauth-button.tsx rename to packages/react/src/auth/oauth/oauth-button.tsx index cf3ef5ce2..b188ca894 100644 --- a/packages/firebaseui-react/src/auth/oauth/oauth-button.tsx +++ b/packages/react/src/auth/oauth/oauth-button.tsx @@ -16,30 +16,26 @@ "use client"; -import { - FirebaseUIError, - getTranslation, - signInWithOAuth, -} from "@firebase-ui/core"; +import { FirebaseUIError, getTranslation, signInWithProvider } from "@invertase/firebaseui-core"; import type { AuthProvider } from "firebase/auth"; import type { PropsWithChildren } from "react"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { Button } from "~/components/button"; import { useUI } from "~/hooks"; export type OAuthButtonProps = PropsWithChildren<{ provider: AuthProvider; + themed?: boolean | string; }>; -export function OAuthButton({ provider, children }: OAuthButtonProps) { +export function useSignInWithProvider(provider: AuthProvider) { const ui = useUI(); - const [error, setError] = useState(null); - const handleOAuthSignIn = async () => { + const callback = useCallback(async () => { setError(null); try { - await signInWithOAuth(ui, provider); + await signInWithProvider(ui, provider); } catch (error) { if (error instanceof FirebaseUIError) { setError(error.message); @@ -48,19 +44,30 @@ export function OAuthButton({ provider, children }: OAuthButtonProps) { console.error(error); setError(getTranslation(ui, "errors", "unknownError")); } - }; + }, [ui, provider, setError]); + + return { error, callback }; +} + +export function OAuthButton({ provider, children, themed }: OAuthButtonProps) { + const ui = useUI(); + + const { error, callback } = useSignInWithProvider(provider); return (
- {error &&
{error}
} + {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..5a014cde4 --- /dev/null +++ b/packages/react/src/auth/oauth/twitter-sign-in-button.test.tsx @@ -0,0 +1,191 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { TwitterLogo, TwitterSignInButton } from "./twitter-sign-in-button"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; + +vi.mock("firebase/auth", async () => { + const actual = await vi.importActual("firebase/auth"); + return { + ...actual, + TwitterAuthProvider: class TwitterAuthProvider { + constructor() { + this.providerId = "twitter.com"; + } + providerId: string; + }, + }; +}); + +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"); + }); +}); + +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..39627e3dd --- /dev/null +++ b/packages/react/src/auth/oauth/twitter-sign-in-button.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 { getTranslation } from "@invertase/firebaseui-core"; +import { TwitterAuthProvider } from "firebase/auth"; +import { useUI } from "~/hooks"; +import { OAuthButton } from "./oauth-button"; +import TwitterSvgLogo from "~/components/logos/twitter/Logo"; +import { cn } from "~/utils/cn"; + +export type TwitterSignInButtonProps = { + provider?: TwitterAuthProvider; + themed?: boolean; +}; + +export function TwitterSignInButton({ provider, themed }: TwitterSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithTwitter")} + + ); +} + +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..b20df6d42 --- /dev/null +++ b/packages/react/src/auth/screens/email-link-auth-screen.test.tsx @@ -0,0 +1,222 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 } from "@testing-library/react"; +import { EmailLinkAuthScreen } from "~/auth/screens/email-link-auth-screen"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import type { MultiFactorResolver } from "firebase/auth"; + +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: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSignIn = vi.fn(); + + render( + + + + ); + + fireEvent.click(screen.getByTestId("mfa-on-success")); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) }) + ); + }); +}); 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..103e1e85e --- /dev/null +++ b/packages/react/src/auth/screens/email-link-auth-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. + */ + +import type { PropsWithChildren } from "react"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { Divider } from "~/components/divider"; +import { useUI } from "~/hooks"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card"; +import { EmailLinkAuthForm, type EmailLinkAuthFormProps } from "../forms/email-link-auth-form"; +import { RedirectError } from "~/components/redirect-error"; +import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; + +export type EmailLinkAuthScreenProps = PropsWithChildren; + +export 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; + + 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 68% 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..6dbbe79ba 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,14 @@ * 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; -}; +export type ForgotPasswordAuthScreenProps = ForgotPasswordAuthFormProps; -export function PasswordResetScreen({ - onBackToSignInClick, -}: PasswordResetScreenProps) { +export function ForgotPasswordAuthScreen(props: ForgotPasswordAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "resetPassword"); @@ -43,7 +34,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..1d6ff4b97 --- /dev/null +++ b/packages/react/src/auth/screens/multi-factor-auth-assertion-screen.tsx @@ -0,0 +1,30 @@ +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"; + +export type MultiFactorAuthAssertionScreenProps = MultiFactorAuthAssertionFormProps; + +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..7c4275925 --- /dev/null +++ b/packages/react/src/auth/screens/multi-factor-auth-enrollment-screen.tsx @@ -0,0 +1,30 @@ +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"; + +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/react/src/auth/screens/oauth-screen.test.tsx b/packages/react/src/auth/screens/oauth-screen.test.tsx new file mode 100644 index 000000000..3d9e3aee3 --- /dev/null +++ b/packages/react/src/auth/screens/oauth-screen.test.tsx @@ -0,0 +1,251 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 } from "@testing-library/react"; +import { OAuthScreen } from "~/auth/screens/oauth-screen"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { MultiFactorResolver } from "firebase/auth"; + +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: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSignIn = vi.fn(); + + render( + + OAuth Provider + + ); + + fireEvent.click(screen.getByTestId("mfa-on-success")); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "oauth-mfa-user" }) }) + ); + }); +}); diff --git a/packages/firebaseui-react/src/auth/screens/oauth-screen.tsx b/packages/react/src/auth/screens/oauth-screen.tsx similarity index 55% rename from packages/firebaseui-react/src/auth/screens/oauth-screen.tsx rename to packages/react/src/auth/screens/oauth-screen.tsx index c21063521..4fb263d5e 100644 --- a/packages/firebaseui-react/src/auth/screens/oauth-screen.tsx +++ b/packages/react/src/auth/screens/oauth-screen.tsx @@ -14,25 +14,29 @@ * limitations under the License. */ -import { getTranslation } from "@firebase-ui/core"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { type UserCredential } from "firebase/auth"; +import { type PropsWithChildren } from "react"; import { useUI } from "~/hooks"; -import { - Card, - CardHeader, - CardSubtitle, - CardTitle, -} from "../../components/card"; -import { PropsWithChildren } from "react"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card"; import { Policies } from "~/components/policies"; +import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; +import { RedirectError } from "~/components/redirect-error"; -export type OAuthScreenProps = PropsWithChildren; +export type OAuthScreenProps = PropsWithChildren<{ + onSignIn?: (credential: UserCredential) => void; +}>; -export function OAuthScreen({ children }: OAuthScreenProps) { +export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { const ui = useUI(); - // TODO: Translations for oauth providers const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + const mfaResolver = ui.multiFactorResolver; + + if (mfaResolver) { + return ; + } return (
@@ -41,8 +45,11 @@ export function OAuthScreen({ children }: OAuthScreenProps) { {titleText} {subtitleText} - {children} - + + {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..07ecb4686 --- /dev/null +++ b/packages/react/src/auth/screens/phone-auth-screen.test.tsx @@ -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, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { PhoneAuthScreen } from "~/auth/screens/phone-auth-screen"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { MultiFactorResolver } from "firebase/auth"; + +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("passes resendDelay prop to PhoneAuthForm", () => { + // const ui = createMockUI(); + + // render( + // + // + // + // ); + + // const phoneForm = screen.getByTestId("phone-auth-form"); + // expect(phoneForm).toBeDefined(); + // expect(phoneForm.getAttribute("data-resend-delay")).toBe("60"); + // }); + + it("renders a divider with children when present", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + 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: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSignIn = vi.fn(); + + render( + + + + ); + + // Simulate nested MFA form success + const trigger = screen.getByTestId("mfa-on-success"); + trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "phone-mfa-user" }) }) + ); + }); +}); diff --git a/packages/firebaseui-react/src/auth/screens/phone-auth-screen.tsx b/packages/react/src/auth/screens/phone-auth-screen.tsx similarity index 51% rename from packages/firebaseui-react/src/auth/screens/phone-auth-screen.tsx rename to packages/react/src/auth/screens/phone-auth-screen.tsx index 460691c40..8e234d0be 100644 --- a/packages/firebaseui-react/src/auth/screens/phone-auth-screen.tsx +++ b/packages/react/src/auth/screens/phone-auth-screen.tsx @@ -15,29 +15,26 @@ */ import type { PropsWithChildren } from "react"; -import { getTranslation } from "@firebase-ui/core"; +import { getTranslation } from "@invertase/firebaseui-core"; import { Divider } from "~/components/divider"; import { useUI } from "~/hooks"; -import { - Card, - CardHeader, - CardSubtitle, - CardTitle, -} from "../../components/card"; -import { PhoneForm } from "../forms/phone-form"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/components/card"; +import { PhoneAuthForm, type PhoneAuthFormProps } from "../forms/phone-auth-form"; +import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; +import { RedirectError } from "~/components/redirect-error"; -export type PhoneAuthScreenProps = PropsWithChildren<{ - resendDelay?: number; -}>; +export type PhoneAuthScreenProps = PropsWithChildren; -export function PhoneAuthScreen({ - children, - resendDelay, -}: PhoneAuthScreenProps) { +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; + + if (mfaResolver) { + return ; + } return (
@@ -46,13 +43,18 @@ export function PhoneAuthScreen({ {titleText} {subtitleText} - - {children ? ( - <> - {getTranslation(ui, "messages", "dividerOr")} -
{children}
- - ) : null} + + + {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..2e7154f19 --- /dev/null +++ b/packages/react/src/auth/screens/sign-in-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, afterEach, beforeEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { SignInAuthScreen } from "~/auth/screens/sign-in-auth-screen"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { MultiFactorResolver } from "firebase/auth"; + +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: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSignIn = vi.fn(); + + render( + + + + ); + + // Simulate the MFA child reporting success with a credential + fireEvent.click(screen.getByTestId("mfa-on-success")); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) }) + ); + }); +}); diff --git a/packages/firebaseui-react/src/auth/screens/sign-in-auth-screen.tsx b/packages/react/src/auth/screens/sign-in-auth-screen.tsx similarity index 52% rename from packages/firebaseui-react/src/auth/screens/sign-in-auth-screen.tsx rename to packages/react/src/auth/screens/sign-in-auth-screen.tsx index ea32aff8f..857bf4738 100644 --- a/packages/firebaseui-react/src/auth/screens/sign-in-auth-screen.tsx +++ b/packages/react/src/auth/screens/sign-in-auth-screen.tsx @@ -15,32 +15,28 @@ */ import type { PropsWithChildren } from "react"; -import { getTranslation } from "@firebase-ui/core"; +import { getTranslation } from "@invertase/firebaseui-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"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; +import { SignInAuthForm, type SignInAuthFormProps } from "../forms/sign-in-auth-form"; +import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; +import { RedirectError } from "~/components/redirect-error"; -export type SignInAuthScreenProps = PropsWithChildren<{ - onForgotPasswordClick?: () => void; - onRegisterClick?: () => void; -}>; +export type SignInAuthScreenProps = PropsWithChildren; -export function SignInAuthScreen({ - onForgotPasswordClick, - onRegisterClick, - children, -}: SignInAuthScreenProps) { +export function SignInAuthScreen({ children, ...props }: SignInAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + const mfaResolver = ui.multiFactorResolver; + + if (mfaResolver) { + return ; + } + return (
@@ -48,16 +44,18 @@ export function SignInAuthScreen({ {titleText} {subtitleText} - - {children ? ( - <> - {getTranslation(ui, "messages", "dividerOr")} -
{children}
- - ) : null} + + + {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..c77148c60 --- /dev/null +++ b/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx @@ -0,0 +1,282 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 } from "@testing-library/react"; +import { SignUpAuthScreen } from "~/auth/screens/sign-up-auth-screen"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { MultiFactorResolver } from "firebase/auth"; + +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: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSignUp = vi.fn(); + + render( + + + + ); + + // Simulate nested MFA form success + const trigger = screen.getByTestId("mfa-on-success"); + trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onSignUp).toHaveBeenCalledTimes(1); + expect(onSignUp).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "signup-mfa-user" }) }) + ); + }); +}); 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..1a9de7329 --- /dev/null +++ b/packages/react/src/auth/screens/sign-up-auth-screen.tsx @@ -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 { type PropsWithChildren } from "react"; +import { Divider } from "~/components/divider"; +import { useUI } from "~/hooks"; +import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../components/card"; +import { SignUpAuthForm, type SignUpAuthFormProps } from "../forms/sign-up-auth-form"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { RedirectError } from "~/components/redirect-error"; +import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; + +export type SignUpAuthScreenProps = PropsWithChildren; + +export function SignUpAuthScreen({ children, ...props }: SignUpAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signUp"); + const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate"); + + const mfaResolver = ui.multiFactorResolver; + + if (mfaResolver) { + 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/firebaseui-react/src/components/button.tsx b/packages/react/src/components/button.tsx similarity index 54% rename from packages/firebaseui-react/src/components/button.tsx rename to packages/react/src/components/button.tsx index f1ff56970..b7445c57e 100644 --- a/packages/firebaseui-react/src/components/button.tsx +++ b/packages/react/src/components/button.tsx @@ -14,33 +14,17 @@ * limitations under the License. */ -import { ButtonHTMLAttributes } from "react"; +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"; -const buttonVariants = { - primary: "fui-button", - secondary: "fui-button fui-button--secondary", -} as const; - -type ButtonVariant = keyof typeof buttonVariants; - -interface ButtonProps extends ButtonHTMLAttributes { +export type ButtonProps = ComponentProps<"button"> & { variant?: ButtonVariant; -} + asChild?: boolean; +}; -export function Button({ - children, - className, - variant = "primary", - ...props -}: ButtonProps) { - return ( - - ); +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 70% rename from packages/firebaseui-react/src/components/card.tsx rename to packages/react/src/components/card.tsx index fd8d3954a..c382af907 100644 --- a/packages/firebaseui-react/src/components/card.tsx +++ b/packages/react/src/components/card.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import type { HTMLAttributes, PropsWithChildren } from "react"; +import type { ComponentProps, PropsWithChildren } from "react"; import { cn } from "~/utils/cn"; -type CardProps = PropsWithChildren>; +export type CardProps = PropsWithChildren>; export function Card({ children, className, ...props }: CardProps) { return ( @@ -35,11 +35,7 @@ export function CardHeader({ children, className, ...props }: CardProps) { ); } -export function CardTitle({ - children, - className, - ...props -}: HTMLAttributes) { +export function CardTitle({ children, className, ...props }: ComponentProps<"h2">) { return (

{children} @@ -47,14 +43,18 @@ export function CardTitle({ ); } -export function CardSubtitle({ - children, - className, - ...props -}: HTMLAttributes) { +export function CardSubtitle({ children, className, ...props }: ComponentProps<"p">) { return (

{children}

); } + +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..c6c8a85cb --- /dev/null +++ b/packages/react/src/components/country-selector.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 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"; + +export interface CountrySelectorRef { + getCountry: () => CountryData; + setCountry: (code: CountryCode) => void; +} + +export type CountrySelectorProps = ComponentProps<"div">; + +export function useCountries() { + const ui = useUI(); + return getBehavior(ui, "countryCodes")().allowedCountries; +} + +export function useDefaultCountry() { + const ui = useUI(); + return getBehavior(ui, "countryCodes")().defaultCountry; +} + +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 88% rename from packages/firebaseui-react/src/components/divider.tsx rename to packages/react/src/components/divider.tsx index 171031f8c..f7258eccc 100644 --- a/packages/firebaseui-react/src/components/divider.tsx +++ b/packages/react/src/components/divider.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import { HTMLAttributes } from "react"; +import { type ComponentProps, type PropsWithChildren } from "react"; import { cn } from "~/utils/cn"; -type DividerProps = HTMLAttributes; +export type DividerProps = PropsWithChildren>; export function Divider({ className, children, ...props }: DividerProps) { if (!children) { diff --git a/packages/react/src/components/form.test.tsx b/packages/react/src/components/form.test.tsx new file mode 100644 index 000000000..db1557dff --- /dev/null +++ b/packages/react/src/components/form.test.tsx @@ -0,0 +1,306 @@ +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..a27e9501f --- /dev/null +++ b/packages/react/src/components/form.tsx @@ -0,0 +1,105 @@ +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..371c48407 --- /dev/null +++ b/packages/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/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..eea16df10 --- /dev/null +++ b/packages/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/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..29b98b0c0 --- /dev/null +++ b/packages/shadcn/src/components/email-link-auth-screen.test.tsx @@ -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 in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 } from "@testing-library/react"; +import { EmailLinkAuthScreen } from "./email-link-auth-screen"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; +import { MultiFactorResolver } from "firebase/auth"; + +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(); + const onSignInMock = vi.fn(); + + render( + + + + ); + + expect(screen.getByTestId("onEmailSent-prop")).toBeInTheDocument(); + expect(screen.getByTestId("onSignIn-prop")).toBeInTheDocument(); + }); + + it("should not render separator when no children", () => { + 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: [], + }; + const mockUI = createMockUI(); + mockUI.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSignIn = vi.fn(); + + render( + + + + ); + + fireEvent.click(screen.getByTestId("mfa-on-success")); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "email-link-mfa-user" }) }) + ); + }); +}); 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..88828712c --- /dev/null +++ b/packages/shadcn/src/components/email-link-auth-screen.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type EmailLinkAuthScreenProps } 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, ...props }: EmailLinkAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signIn"); + const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + const mfaResolver = ui.multiFactorResolver; + + if (mfaResolver) { + 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..1e9f911ed --- /dev/null +++ b/packages/shadcn/src/components/facebook-sign-in-button.test.tsx @@ -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, 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 }: any) => ( +
+
{provider.providerId}
+
{String(themed)}
+
{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"); + }); +}); 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..3bd0bc52c --- /dev/null +++ b/packages/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/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..28c721b68 --- /dev/null +++ b/packages/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/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..98991d51d --- /dev/null +++ b/packages/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/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..2a4701bdb --- /dev/null +++ b/packages/shadcn/src/components/github-sign-in-button.test.tsx @@ -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, 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 }: any) => ( +
+
{provider.providerId}
+
{String(themed)}
+
{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"); + }); +}); 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..a2b92a65b --- /dev/null +++ b/packages/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/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..8e9837e30 --- /dev/null +++ b/packages/shadcn/src/components/google-sign-in-button.test.tsx @@ -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, 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 }: any) => ( +
+
{provider.providerId}
+
{themed}
+
{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(""); + }); +}); 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..4d0796c70 --- /dev/null +++ b/packages/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/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..22cfa0080 --- /dev/null +++ b/packages/shadcn/src/components/microsoft-sign-in-button.test.tsx @@ -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, 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 }: any) => ( +
+
{provider.providerId}
+
{String(themed)}
+
{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"); + }); +}); 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..f5288b6a1 --- /dev/null +++ b/packages/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/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..86a1fda2c --- /dev/null +++ b/packages/shadcn/src/components/multi-factor-auth-assertion-form.test.tsx @@ -0,0 +1,294 @@ +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..4ad2eeeb8 --- /dev/null +++ b/packages/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/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..17a1dda7d --- /dev/null +++ b/packages/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/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..5935c159e --- /dev/null +++ b/packages/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/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..c226b87ea --- /dev/null +++ b/packages/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/packages/shadcn/src/components/oauth-button.test.tsx b/packages/shadcn/src/components/oauth-button.test.tsx new file mode 100644 index 000000000..41731f813 --- /dev/null +++ b/packages/shadcn/src/components/oauth-button.test.tsx @@ -0,0 +1,258 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 } 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("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); + + // Next tick - wait for the mock to resolve + await new Promise((resolve) => setTimeout(resolve, 0)); + + 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); + + // Wait for error to appear + await new Promise((resolve) => setTimeout(resolve, 0)); + + 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 new Promise((resolve) => setTimeout(resolve, 0)); + + const errorMessage = screen.getByText("Incorrect password"); + expect(errorMessage).toBeDefined(); + + // Second click - should clear error + fireEvent.click(button); + await new Promise((resolve) => setTimeout(resolve, 0)); + + 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..3707c2012 --- /dev/null +++ b/packages/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/packages/shadcn/src/components/oauth-screen.test.tsx b/packages/shadcn/src/components/oauth-screen.test.tsx new file mode 100644 index 000000000..855ad6280 --- /dev/null +++ b/packages/shadcn/src/components/oauth-screen.test.tsx @@ -0,0 +1,249 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 } from "@testing-library/react"; +import { OAuthScreen } from "@/components/oauth-screen"; +import { CreateFirebaseUIProvider, createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { MultiFactorResolver } from "firebase/auth"; + +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: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSignIn = vi.fn(); + + render( + + OAuth Provider + + ); + + fireEvent.click(screen.getByTestId("mfa-on-success")); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "oauth-mfa-user" }) }) + ); + }); +}); diff --git a/packages/shadcn/src/components/oauth-screen.tsx b/packages/shadcn/src/components/oauth-screen.tsx new file mode 100644 index 000000000..1281d74e1 --- /dev/null +++ b/packages/shadcn/src/components/oauth-screen.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { type UserCredential } from "firebase/auth"; +import { type PropsWithChildren } from "react"; +import { useUI } from "@invertase/firebaseui-react"; +import { Card, CardContent, CardHeader, CardDescription, CardTitle } from "@/components/ui/card"; +import { Policies } from "@/components/policies"; +import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen"; +import { RedirectError } from "@/components/redirect-error"; + +export type OAuthScreenProps = PropsWithChildren<{ + onSignIn?: (credential: UserCredential) => void; +}>; + +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; + + if (mfaResolver) { + 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..c1a4dd1d1 --- /dev/null +++ b/packages/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/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..edac411f8 --- /dev/null +++ b/packages/shadcn/src/components/phone-auth-screen.test.tsx @@ -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 { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { PhoneAuthScreen } from "@/components/phone-auth-screen"; +import { CreateFirebaseUIProvider, createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { MultiFactorResolver } from "firebase/auth"; + +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: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSignIn = vi.fn(); + + render( + + + + ); + + fireEvent.click(screen.getByTestId("mfa-on-success")); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "phone-mfa-user" }) }) + ); + }); +}); 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..ad31fae73 --- /dev/null +++ b/packages/shadcn/src/components/phone-auth-screen.tsx @@ -0,0 +1,47 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI } from "@invertase/firebaseui-react"; +import { Card, CardContent, CardHeader, CardDescription, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { PhoneAuthForm, type PhoneAuthFormProps } from "@/components/phone-auth-form"; +import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen"; +import { RedirectError } from "@/components/redirect-error"; + +export type PhoneAuthScreenProps = PropsWithChildren; + +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; + + if (mfaResolver) { + 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..b0cfef637 --- /dev/null +++ b/packages/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/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..cf5abfdd7 --- /dev/null +++ b/packages/shadcn/src/components/sign-in-auth-screen.test.tsx @@ -0,0 +1,269 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 } from "@testing-library/react"; +import { SignInAuthScreen } from "./sign-in-auth-screen"; +import { createMockUI } from "../../tests/utils"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { MultiFactorResolver } from "firebase/auth"; + +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 onRegisterClickMock = 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: [], + }; + const ui = createMockUI(); + ui.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSignIn = vi.fn(); + + render( + + + + ); + + fireEvent.click(screen.getByTestId("mfa-on-success")); + + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "mfa-user" }) }) + ); + }); +}); 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..322c34848 --- /dev/null +++ b/packages/shadcn/src/components/sign-in-auth-screen.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type SignInAuthScreenProps } 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, ...props }: SignInAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signIn"); + const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + + const mfaResolver = ui.multiFactorResolver; + + if (mfaResolver) { + 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..565ca3430 --- /dev/null +++ b/packages/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/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..5e7437845 --- /dev/null +++ b/packages/shadcn/src/components/sign-up-auth-screen.test.tsx @@ -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 in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 } from "@testing-library/react"; +import { SignUpAuthScreen } from "./sign-up-auth-screen"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@invertase/firebaseui-translations"; +import { FirebaseUIProvider } from "@invertase/firebaseui-react"; +import { MultiFactorResolver } from "firebase/auth"; + +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 onSignUpMock = vi.fn(); + const onSignInClickMock = vi.fn(); + + render( + + + + ); + + expect(screen.getByTestId("onSignUp-prop")).toBeInTheDocument(); + 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: [], + }; + const mockUI = createMockUI(); + mockUI.get().setMultiFactorResolver(mockResolver as unknown as MultiFactorResolver); + + const onSignUp = vi.fn(); + + render( + + + + ); + + fireEvent.click(screen.getByTestId("mfa-on-success")); + + expect(onSignUp).toHaveBeenCalledTimes(1); + expect(onSignUp).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ uid: "signup-mfa-user" }) }) + ); + }); +}); 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..23838f3f7 --- /dev/null +++ b/packages/shadcn/src/components/sign-up-auth-screen.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { getTranslation } from "@invertase/firebaseui-core"; +import { useUI, type SignUpAuthScreenProps } 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, ...props }: SignUpAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signUp"); + const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate"); + const mfaResolver = ui.multiFactorResolver; + + if (mfaResolver) { + 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..6e2f4b9f3 --- /dev/null +++ b/packages/shadcn/src/components/sms-multi-factor-assertion-form.test.tsx @@ -0,0 +1,327 @@ +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 { PhoneMultiFactorGenerator } from "firebase/auth"; +import { verifyPhoneNumber, signInWithMultiFactorAssertion } from "@invertase/firebaseui-core"; +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..7f6c424b0 --- /dev/null +++ b/packages/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/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..db6d3ddc7 --- /dev/null +++ b/packages/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/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..f18b269d9 --- /dev/null +++ b/packages/shadcn/src/components/totp-multi-factor-assertion-form.test.tsx @@ -0,0 +1,221 @@ +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..1c0324c52 --- /dev/null +++ b/packages/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/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..bd9aa2f84 --- /dev/null +++ b/packages/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/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..0f7493347 --- /dev/null +++ b/packages/shadcn/src/components/twitter-sign-in-button.test.tsx @@ -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, 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 }: any) => ( +
+
{provider.providerId}
+
{String(themed)}
+
{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"); + }); +}); 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..7d4cc39ad --- /dev/null +++ b/packages/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/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 ( +