From 7d23a076dec6fe52710ac7f1bd94376117273c2f Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Fri, 15 Aug 2025 13:17:38 -0400 Subject: [PATCH 01/30] [PM-24785] Skip/Exit early from "Autofill BIT checks" workflow if there is no associated pull request or branch (#16034) * check autofill bit checks workflow job requirements at the start of the job * add source branch name to executed workflow title --- .github/workflows/test-browser-interactions.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-browser-interactions.yml b/.github/workflows/test-browser-interactions.yml index 872b4c35264..3960bd04ef9 100644 --- a/.github/workflows/test-browser-interactions.yml +++ b/.github/workflows/test-browser-interactions.yml @@ -1,4 +1,5 @@ name: Autofill BIT checks +run-name: Autofill BIT checks on ${{ github.event.workflow_run.head_branch }} build on: workflow_run: @@ -11,10 +12,16 @@ jobs: name: Check files runs-on: ubuntu-22.04 permissions: - actions: read + actions: write contents: read id-token: write steps: + - name: Check for job requirements + if: ${{ !(github.event.workflow_run.pull_requests && github.event.workflow_run.pull_requests.length > 0 && github.event.workflow_run.head_branch) }} + run: | + gh run cancel ${{ github.run_id }} + gh run watch ${{ github.run_id }} + - name: Log in to Azure uses: bitwarden/gh-actions/azure-login@main with: From 931f8650cfa2d562b42f5898cda983062b46ed67 Mon Sep 17 00:00:00 2001 From: Vladimir Pakhomchik Date: Fri, 15 Aug 2025 19:26:42 +0200 Subject: [PATCH 02/30] Enhance trimAndRemoveNonPrintableText to support extended characters (#15919) Co-authored-by: Jonathan Prusik --- .../collect-autofill-content.service.spec.ts | 48 +++++++++++++++++++ .../collect-autofill-content.service.ts | 2 +- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts index 5f3bc4dfff9..f0aa9c1c440 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts @@ -1757,6 +1757,54 @@ describe("CollectAutofillContentService", () => { expect(parsedText).toEqual("Hello! This is a test string."); }); + + it("preserves extended Latin letters like Š and ć", () => { + const text = "Šifra ćevapčići korisnika"; + const result = collectAutofillContentService["trimAndRemoveNonPrintableText"](text); + expect(result).toEqual("Šifra ćevapčići korisnika"); + }); + + it("removes zero-width and control characters", () => { + const text = "Hello\u200B\u200C\u200D\u2060World\x00\x1F!"; + const result = collectAutofillContentService["trimAndRemoveNonPrintableText"](text); + expect(result).toEqual("Hello World !"); + }); + + it("removes leading and trailing whitespace", () => { + const text = " padded text with spaces "; + const result = collectAutofillContentService["trimAndRemoveNonPrintableText"](text); + expect(result).toEqual("padded text with spaces"); + }); + + it("replaces multiple whitespaces (tabs, newlines, spaces) with one space", () => { + const text = "one\t\ntwo \n three\t\tfour"; + const result = collectAutofillContentService["trimAndRemoveNonPrintableText"](text); + expect(result).toEqual("one two three four"); + }); + + it("preserves emoji and symbols", () => { + const text = "Text with emoji 🐍🚀 and ©®✓ symbols"; + const result = collectAutofillContentService["trimAndRemoveNonPrintableText"](text); + expect(result).toEqual("Text with emoji 🐍🚀 and ©®✓ symbols"); + }); + + it("handles RTL and LTR marks", () => { + const text = "abc\u200F\u202Edеf"; + const result = collectAutofillContentService["trimAndRemoveNonPrintableText"](text); + expect(result).toEqual("abc dеf"); + }); + + it("handles mathematical unicode letters", () => { + const text = "Unicode math: 𝒜𝒷𝒸𝒹"; + const result = collectAutofillContentService["trimAndRemoveNonPrintableText"](text); + expect(result).toEqual("Unicode math: 𝒜𝒷𝒸𝒹"); + }); + + it("removes only invisible non-printables, keeps Japanese", () => { + const text = "これは\u200Bテストです"; + const result = collectAutofillContentService["trimAndRemoveNonPrintableText"](text); + expect(result).toEqual("これは テストです"); + }); }); describe("recursivelyGetTextFromPreviousSiblings", () => { diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index 0f9c8993014..c6af9739773 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -713,7 +713,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ */ private trimAndRemoveNonPrintableText(textContent: string): string { return (textContent || "") - .replace(/[^\x20-\x7E]+|\s+/g, " ") // Strip out non-primitive characters and replace multiple spaces with a single space + .replace(/\p{C}+|\s+/gu, " ") // Strip out non-printable characters and replace multiple spaces with a single space .trim(); // Trim leading and trailing whitespace } From 351a945f02264b465a8ac506c9799218b57544cf Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Fri, 15 Aug 2025 13:52:28 -0400 Subject: [PATCH 03/30] fix missing token error (#16036) --- .github/workflows/test-browser-interactions.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test-browser-interactions.yml b/.github/workflows/test-browser-interactions.yml index 3960bd04ef9..776a53b4200 100644 --- a/.github/workflows/test-browser-interactions.yml +++ b/.github/workflows/test-browser-interactions.yml @@ -18,6 +18,8 @@ jobs: steps: - name: Check for job requirements if: ${{ !(github.event.workflow_run.pull_requests && github.event.workflow_run.pull_requests.length > 0 && github.event.workflow_run.head_branch) }} + env: + GH_TOKEN: ${{ github.token }} run: | gh run cancel ${{ github.run_id }} gh run watch ${{ github.run_id }} From 6a65a6ff48bc39a4eb3b115f57d174a9c6d17773 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Fri, 15 Aug 2025 14:35:41 -0400 Subject: [PATCH 04/30] Fix job step order (#16038) --- .github/workflows/test-browser-interactions.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-browser-interactions.yml b/.github/workflows/test-browser-interactions.yml index 776a53b4200..5f2c78fe5a2 100644 --- a/.github/workflows/test-browser-interactions.yml +++ b/.github/workflows/test-browser-interactions.yml @@ -16,6 +16,11 @@ jobs: contents: read id-token: write steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + - name: Check for job requirements if: ${{ !(github.event.workflow_run.pull_requests && github.event.workflow_run.pull_requests.length > 0 && github.event.workflow_run.head_branch) }} env: @@ -51,11 +56,6 @@ jobs: repositories: browser-interactions-testing permission-actions: write - - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 - - name: Get changed files id: changed-files uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 From d6a48c4f3f57ed7b87647e9806447e073eb0bf55 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Fri, 15 Aug 2025 12:30:17 -0700 Subject: [PATCH 05/30] reset password input back to password (#16010) --- .../login-credentials-view.component.html | 1 + .../login-credentials/login-credentials-view.component.ts | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html index 256aec34b50..76a2d466369 100644 --- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html +++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html @@ -36,6 +36,7 @@ [ngClass]="{ 'tw-hidden': passwordRevealed }" readonly bitInput + #passwordInput type="password" [value]="cipher.login.password" aria-readonly="true" diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts index 5eeb3f9e8f3..9a40caf031b 100644 --- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts +++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts @@ -3,12 +3,14 @@ import { CommonModule, DatePipe } from "@angular/common"; import { Component, + ElementRef, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges, + ViewChild, } from "@angular/core"; import { Observable, switchMap } from "rxjs"; @@ -61,6 +63,8 @@ export class LoginCredentialsViewComponent implements OnChanges { @Input() activeUserId: UserId; @Input() hadPendingChangePasswordTask: boolean; @Output() handleChangePassword = new EventEmitter(); + @ViewChild("passwordInput") + private passwordInput!: ElementRef; isPremium$: Observable = this.accountService.activeAccount$.pipe( switchMap((account) => @@ -92,6 +96,10 @@ export class LoginCredentialsViewComponent implements OnChanges { ngOnChanges(changes: SimpleChanges): void { if (changes["cipher"]) { + if (this.passwordInput?.nativeElement) { + // Reset password input type in case it's been toggled + this.passwordInput.nativeElement.type = "password"; + } this.passwordRevealed = false; this.showPasswordCount = false; } From d4bb34184788c89f35fa42b8fd15c5f0dae1ae4d Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Fri, 15 Aug 2025 16:36:18 -0400 Subject: [PATCH 06/30] simplify step conditional (#16040) --- .github/workflows/test-browser-interactions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-browser-interactions.yml b/.github/workflows/test-browser-interactions.yml index 5f2c78fe5a2..c6427b2e0d8 100644 --- a/.github/workflows/test-browser-interactions.yml +++ b/.github/workflows/test-browser-interactions.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Check for job requirements - if: ${{ !(github.event.workflow_run.pull_requests && github.event.workflow_run.pull_requests.length > 0 && github.event.workflow_run.head_branch) }} + if: ${{ !github.event.workflow_run.pull_requests || !github.event.workflow_run.head_branch }} env: GH_TOKEN: ${{ github.token }} run: | From a60b7fed9a2a5a516b1ffa8dd8d8332addbb59fa Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Fri, 15 Aug 2025 23:49:49 +0200 Subject: [PATCH 07/30] [PM-17667] Move key-generation service to KM ownership (#16015) * Move key-generation service * Update comment * Add deprecation comments * Fix firefox build * Update comment * Update DI import * Update module imports --- .../browser/src/background/main.background.ts | 10 +- .../src/popup/services/services.module.ts | 2 +- .../service-container/service-container.ts | 10 +- .../src/app/services/services.module.ts | 4 +- .../src/services/jslib-services.module.ts | 24 ++--- .../common/src/key-management/crypto/index.ts | 2 + .../default-key-generation.service.ts | 94 +++++++++++++++++++ .../key-generation.service.spec.ts | 16 ++-- .../key-generation/key-generation.service.ts | 90 ++++++++++++++++++ .../device-trust.service.implementation.ts | 2 +- .../services/device-trust.service.spec.ts | 2 +- .../services/key-connector.service.spec.ts | 2 +- .../services/key-connector.service.ts | 2 +- .../services/master-password.service.spec.ts | 2 +- .../services/master-password.service.ts | 2 +- .../pin/pin.service.implementation.ts | 2 +- .../key-management/pin/pin.service.spec.ts | 2 +- .../abstractions/key-generation.service.ts | 68 +------------- .../services/key-generation.service.ts | 94 +------------------ libs/key-management/src/key.service.spec.ts | 2 +- libs/key-management/src/key.service.ts | 2 +- 21 files changed, 236 insertions(+), 198 deletions(-) create mode 100644 libs/common/src/key-management/crypto/index.ts create mode 100644 libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts rename libs/common/src/{platform/services => key-management/crypto/key-generation}/key-generation.service.spec.ts (88%) create mode 100644 libs/common/src/key-management/crypto/key-generation/key-generation.service.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 18399faa726..0b55ebb7357 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -69,6 +69,10 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; +import { + DefaultKeyGenerationService, + KeyGenerationService, +} from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation"; @@ -97,7 +101,6 @@ import { Fido2ClientService as Fido2ClientServiceAbstraction } from "@bitwarden/ import { Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-user-interface.service.abstraction"; import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; @@ -135,7 +138,6 @@ import { Fido2ActiveRequestManager } from "@bitwarden/common/platform/services/f import { Fido2AuthenticatorService } from "@bitwarden/common/platform/services/fido2/fido2-authenticator.service"; import { Fido2ClientService } from "@bitwarden/common/platform/services/fido2/fido2-client.service"; import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service"; -import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { DefaultSdkClientFactory } from "@bitwarden/common/platform/services/sdk/default-sdk-client-factory"; @@ -311,7 +313,7 @@ export default class MainBackground { i18nService: I18nServiceAbstraction; platformUtilsService: PlatformUtilsServiceAbstraction; logService: LogServiceAbstraction; - keyGenerationService: KeyGenerationServiceAbstraction; + keyGenerationService: KeyGenerationService; keyService: KeyServiceAbstraction; cryptoFunctionService: CryptoFunctionServiceAbstraction; masterPasswordService: InternalMasterPasswordServiceAbstraction; @@ -472,7 +474,7 @@ export default class MainBackground { const isDev = process.env.ENV === "development"; this.logService = new ConsoleLogService(isDev); this.cryptoFunctionService = new WebCryptoFunctionService(self); - this.keyGenerationService = new KeyGenerationService(this.cryptoFunctionService); + this.keyGenerationService = new DefaultKeyGenerationService(this.cryptoFunctionService); this.storageService = new BrowserLocalStorageService(this.logService); this.intraprocessMessagingSubject = new Subject>>(); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 7c67e672889..5950bdc214c 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -62,6 +62,7 @@ import { } from "@bitwarden/common/autofill/services/user-notification-settings.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service"; @@ -79,7 +80,6 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 0ec24768b79..e98481ee0fe 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -60,6 +60,10 @@ import { import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; +import { + DefaultKeyGenerationService, + KeyGenerationService, +} from "@bitwarden/common/key-management/crypto"; import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation"; @@ -81,7 +85,6 @@ import { EnvironmentService, RegionConfig, } from "@bitwarden/common/platform/abstractions/environment.service"; -import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { KeySuffixOptions, LogLevelType } from "@bitwarden/common/platform/enums"; @@ -99,7 +102,6 @@ import { DefaultConfigService } from "@bitwarden/common/platform/services/config import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { DefaultEnvironmentService } from "@bitwarden/common/platform/services/default-environment.service"; import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service"; -import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; @@ -239,7 +241,7 @@ export class ServiceContainer { individualExportService: IndividualVaultExportServiceAbstraction; organizationExportService: OrganizationVaultExportServiceAbstraction; searchService: SearchService; - keyGenerationService: KeyGenerationServiceAbstraction; + keyGenerationService: KeyGenerationService; cryptoFunctionService: NodeCryptoFunctionService; encryptService: EncryptServiceImplementation; authService: AuthService; @@ -397,7 +399,7 @@ export class ServiceContainer { process.env.ADDITIONAL_REGIONS as unknown as RegionConfig[], ); - this.keyGenerationService = new KeyGenerationService(this.cryptoFunctionService); + this.keyGenerationService = new DefaultKeyGenerationService(this.cryptoFunctionService); this.tokenService = new TokenService( this.singleUserStateProvider, diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 95d1f4643fa..3d65094ba60 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -51,6 +51,7 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { ClientType } from "@bitwarden/common/enums"; import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service"; @@ -67,7 +68,6 @@ import { Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction } fro import { Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-user-interface.service.abstraction"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { LogService, LogService as LogServiceAbstraction, @@ -304,7 +304,7 @@ const safeProviders: SafeProvider[] = [ deps: [ PinServiceAbstraction, InternalMasterPasswordServiceAbstraction, - KeyGenerationServiceAbstraction, + KeyGenerationService, CryptoFunctionServiceAbstraction, EncryptService, PlatformUtilsServiceAbstraction, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 6a145fb3210..11948a04047 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -149,6 +149,10 @@ import { OrganizationBillingApiService } from "@bitwarden/common/billing/service import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; import { TaxService } from "@bitwarden/common/billing/services/tax.service"; +import { + DefaultKeyGenerationService, + KeyGenerationService, +} from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation"; @@ -184,7 +188,6 @@ import { } from "@bitwarden/common/platform/abstractions/environment.service"; import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -222,7 +225,6 @@ import { DefaultBroadcasterService } from "@bitwarden/common/platform/services/d import { DefaultEnvironmentService } from "@bitwarden/common/platform/services/default-environment.service"; import { DefaultServerSettingsService } from "@bitwarden/common/platform/services/default-server-settings.service"; import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service"; -import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service"; @@ -660,15 +662,15 @@ const safeProviders: SafeProvider[] = [ GlobalStateProvider, SUPPORTS_SECURE_STORAGE, SECURE_STORAGE, - KeyGenerationServiceAbstraction, + KeyGenerationService, EncryptService, LogService, LOGOUT_CALLBACK, ], }), safeProvider({ - provide: KeyGenerationServiceAbstraction, - useClass: KeyGenerationService, + provide: KeyGenerationService, + useClass: DefaultKeyGenerationService, deps: [CryptoFunctionServiceAbstraction], }), safeProvider({ @@ -677,7 +679,7 @@ const safeProviders: SafeProvider[] = [ deps: [ PinServiceAbstraction, InternalMasterPasswordServiceAbstraction, - KeyGenerationServiceAbstraction, + KeyGenerationService, CryptoFunctionServiceAbstraction, EncryptService, PlatformUtilsServiceAbstraction, @@ -767,7 +769,7 @@ const safeProviders: SafeProvider[] = [ deps: [ KeyService, I18nServiceAbstraction, - KeyGenerationServiceAbstraction, + KeyGenerationService, SendStateProviderAbstraction, EncryptService, ], @@ -1013,7 +1015,7 @@ const safeProviders: SafeProvider[] = [ deps: [ StateProvider, StateServiceAbstraction, - KeyGenerationServiceAbstraction, + KeyGenerationService, EncryptService, LogService, CryptoFunctionServiceAbstraction, @@ -1035,7 +1037,7 @@ const safeProviders: SafeProvider[] = [ TokenServiceAbstraction, LogService, OrganizationServiceAbstraction, - KeyGenerationServiceAbstraction, + KeyGenerationService, LOGOUT_CALLBACK, StateProvider, ], @@ -1194,7 +1196,7 @@ const safeProviders: SafeProvider[] = [ provide: DeviceTrustServiceAbstraction, useClass: DeviceTrustService, deps: [ - KeyGenerationServiceAbstraction, + KeyGenerationService, CryptoFunctionServiceAbstraction, KeyService, EncryptService, @@ -1230,7 +1232,7 @@ const safeProviders: SafeProvider[] = [ CryptoFunctionServiceAbstraction, EncryptService, KdfConfigService, - KeyGenerationServiceAbstraction, + KeyGenerationService, LogService, StateProvider, ], diff --git a/libs/common/src/key-management/crypto/index.ts b/libs/common/src/key-management/crypto/index.ts new file mode 100644 index 00000000000..8c0dc5a0732 --- /dev/null +++ b/libs/common/src/key-management/crypto/index.ts @@ -0,0 +1,2 @@ +export { KeyGenerationService } from "./key-generation/key-generation.service"; +export { DefaultKeyGenerationService } from "./key-generation/default-key-generation.service"; diff --git a/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts b/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts new file mode 100644 index 00000000000..8e8d2de1ce4 --- /dev/null +++ b/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts @@ -0,0 +1,94 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore + +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { KdfConfig } from "@bitwarden/key-management"; +import { PureCrypto } from "@bitwarden/sdk-internal"; + +import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service"; +import { EncryptionType } from "../../../platform/enums"; +import { Utils } from "../../../platform/misc/utils"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "../../../types/csprng"; +import { CryptoFunctionService } from "../abstractions/crypto-function.service"; + +import { KeyGenerationService } from "./key-generation.service"; + +export class DefaultKeyGenerationService implements KeyGenerationService { + constructor(private cryptoFunctionService: CryptoFunctionService) {} + + async createKey(bitLength: 256 | 512): Promise { + const key = await this.cryptoFunctionService.aesGenerateKey(bitLength); + return new SymmetricCryptoKey(key); + } + + async createKeyWithPurpose( + bitLength: 128 | 192 | 256 | 512, + purpose: string, + salt?: string, + ): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }> { + if (salt == null) { + const bytes = await this.cryptoFunctionService.randomBytes(32); + salt = Utils.fromBufferToUtf8(bytes); + } + const material = await this.cryptoFunctionService.aesGenerateKey(bitLength); + const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256"); + return { salt, material, derivedKey: new SymmetricCryptoKey(key) }; + } + + async deriveKeyFromMaterial( + material: CsprngArray, + salt: string, + purpose: string, + ): Promise { + const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256"); + return new SymmetricCryptoKey(key); + } + + async deriveKeyFromPassword( + password: string | Uint8Array, + salt: string | Uint8Array, + kdfConfig: KdfConfig, + ): Promise { + if (typeof password === "string") { + password = new TextEncoder().encode(password); + } + if (typeof salt === "string") { + salt = new TextEncoder().encode(salt); + } + + await SdkLoadService.Ready; + return new SymmetricCryptoKey( + PureCrypto.derive_kdf_material(password, salt, kdfConfig.toSdkConfig()), + ); + } + + async stretchKey(key: SymmetricCryptoKey): Promise { + // The key to be stretched is actually usually the output of a KDF, and not actually meant for AesCbc256_B64 encryption, + // but has the same key length. Only 256-bit key materials should be stretched. + if (key.inner().type != EncryptionType.AesCbc256_B64) { + throw new Error("Key passed into stretchKey is not a 256-bit key."); + } + + const newKey = new Uint8Array(64); + // Master key and pin key are always 32 bytes + const encKey = await this.cryptoFunctionService.hkdfExpand( + key.inner().encryptionKey, + "enc", + 32, + "sha256", + ); + const macKey = await this.cryptoFunctionService.hkdfExpand( + key.inner().encryptionKey, + "mac", + 32, + "sha256", + ); + + newKey.set(new Uint8Array(encKey)); + newKey.set(new Uint8Array(macKey), 32); + + return new SymmetricCryptoKey(newKey); + } +} diff --git a/libs/common/src/platform/services/key-generation.service.spec.ts b/libs/common/src/key-management/crypto/key-generation/key-generation.service.spec.ts similarity index 88% rename from libs/common/src/platform/services/key-generation.service.spec.ts rename to libs/common/src/key-management/crypto/key-generation/key-generation.service.spec.ts index fb6c0a459b3..b8408cbb4cf 100644 --- a/libs/common/src/platform/services/key-generation.service.spec.ts +++ b/libs/common/src/key-management/crypto/key-generation/key-generation.service.spec.ts @@ -4,21 +4,21 @@ import { mock } from "jest-mock-extended"; // eslint-disable-next-line no-restricted-imports import { PBKDF2KdfConfig, Argon2KdfConfig } from "@bitwarden/key-management"; -import { CryptoFunctionService } from "../../key-management/crypto/abstractions/crypto-function.service"; -import { CsprngArray } from "../../types/csprng"; -import { SdkLoadService } from "../abstractions/sdk/sdk-load.service"; -import { EncryptionType } from "../enums"; -import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; +import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service"; +import { EncryptionType } from "../../../platform/enums"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "../../../types/csprng"; +import { CryptoFunctionService } from "../abstractions/crypto-function.service"; -import { KeyGenerationService } from "./key-generation.service"; +import { DefaultKeyGenerationService } from "./default-key-generation.service"; describe("KeyGenerationService", () => { - let sut: KeyGenerationService; + let sut: DefaultKeyGenerationService; const cryptoFunctionService = mock(); beforeEach(() => { - sut = new KeyGenerationService(cryptoFunctionService); + sut = new DefaultKeyGenerationService(cryptoFunctionService); }); describe("createKey", () => { diff --git a/libs/common/src/key-management/crypto/key-generation/key-generation.service.ts b/libs/common/src/key-management/crypto/key-generation/key-generation.service.ts new file mode 100644 index 00000000000..d6be436384e --- /dev/null +++ b/libs/common/src/key-management/crypto/key-generation/key-generation.service.ts @@ -0,0 +1,90 @@ +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { KdfConfig } from "@bitwarden/key-management"; + +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "../../../types/csprng"; + +/** + * @deprecated This is a low-level cryptographic service. New functionality should not be built + * on top of it, and instead should be built in the sdk. + */ +export abstract class KeyGenerationService { + /** + * Generates a key of the given length suitable for use in AES encryption + * + * @deprecated WARNING: DO NOT USE THIS FOR NEW CODE. Direct generation and handling of keys should only be done in the SDK, + * as memory safety cannot be ensured in a JS context. + * + * @param bitLength Length of key. + * 256 bits = 32 bytes + * 512 bits = 64 bytes + * @returns Generated key. + */ + abstract createKey(bitLength: 256 | 512): Promise; + /** + * Generates key material from CSPRNG and derives a 64 byte key from it. + * Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869} + * for details. + * + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function. + * New functionality should not be built on top of it, and instead should be built in the sdk. + * + * @param bitLength Length of key material. + * @param purpose Purpose for the key derivation function. + * Different purposes results in different keys, even with the same material. + * @param salt Optional. If not provided will be generated from CSPRNG. + * @returns An object containing the salt, key material, and derived key. + */ + abstract createKeyWithPurpose( + bitLength: 128 | 192 | 256 | 512, + purpose: string, + salt?: string, + ): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }>; + /** + * Derives a 64 byte key from key material. + * + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function. + * New functionality should not be built on top of it, and instead should be built in the sdk. + * + * @remark The key material should be generated from {@link createKey}, or {@link createKeyWithPurpose}. + * Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869} for details. + * @param material key material. + * @param salt Salt for the key derivation function. + * @param purpose Purpose for the key derivation function. + * Different purposes results in different keys, even with the same material. + * @returns 64 byte derived key. + */ + abstract deriveKeyFromMaterial( + material: CsprngArray, + salt: string, + purpose: string, + ): Promise; + /** + * Derives a 32 byte key from a password using a key derivation function. + * + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function. + * New functionality should not be built on top of it, and instead should be built in the sdk. + * + * @param password Password to derive the key from. + * @param salt Salt for the key derivation function. + * @param kdfConfig Configuration for the key derivation function. + * @returns 32 byte derived key. + */ + abstract deriveKeyFromPassword( + password: string | Uint8Array, + salt: string | Uint8Array, + kdfConfig: KdfConfig, + ): Promise; + + /** + * Derives a 64 byte key from a 32 byte key using a key derivation function. + * + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function. + * New functionality should not be built on top of it, and instead should be built in the sdk. + * + * @param key 32 byte key. + * @returns 64 byte derived key. + */ + abstract stretchKey(key: SymmetricCryptoKey): Promise; +} diff --git a/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts b/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts index 372b3282a72..58a2c680afa 100644 --- a/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts +++ b/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts @@ -20,7 +20,6 @@ import { import { AppIdService } from "../../../platform/abstractions/app-id.service"; import { ConfigService } from "../../../platform/abstractions/config/config.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; -import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service"; import { LogService } from "../../../platform/abstractions/log.service"; import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service"; import { AbstractStorageService } from "../../../platform/abstractions/storage.service"; @@ -30,6 +29,7 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr import { DEVICE_TRUST_DISK_LOCAL, StateProvider, UserKeyDefinition } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { UserKey, DeviceKey } from "../../../types/key"; +import { KeyGenerationService } from "../../crypto"; import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service"; import { EncryptService } from "../../crypto/abstractions/encrypt.service"; import { EncString } from "../../crypto/models/enc-string"; diff --git a/libs/common/src/key-management/device-trust/services/device-trust.service.spec.ts b/libs/common/src/key-management/device-trust/services/device-trust.service.spec.ts index 50a6b0efa21..7ed28518012 100644 --- a/libs/common/src/key-management/device-trust/services/device-trust.service.spec.ts +++ b/libs/common/src/key-management/device-trust/services/device-trust.service.spec.ts @@ -25,7 +25,6 @@ import { DeviceType } from "../../../enums"; import { AppIdService } from "../../../platform/abstractions/app-id.service"; import { ConfigService } from "../../../platform/abstractions/config/config.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; -import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service"; import { LogService } from "../../../platform/abstractions/log.service"; import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service"; import { AbstractStorageService } from "../../../platform/abstractions/storage.service"; @@ -37,6 +36,7 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr import { CsprngArray } from "../../../types/csprng"; import { UserId } from "../../../types/guid"; import { DeviceKey, UserKey } from "../../../types/key"; +import { KeyGenerationService } from "../../crypto"; import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service"; import { EncryptService } from "../../crypto/abstractions/encrypt.service"; import { EncString } from "../../crypto/models/enc-string"; diff --git a/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts b/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts index 3db2a7ecd79..67961616034 100644 --- a/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts +++ b/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts @@ -18,9 +18,9 @@ import { TokenService } from "../../../auth/services/token.service"; import { LogService } from "../../../platform/abstractions/log.service"; import { Utils } from "../../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; -import { KeyGenerationService } from "../../../platform/services/key-generation.service"; import { OrganizationId, UserId } from "../../../types/guid"; import { MasterKey, UserKey } from "../../../types/key"; +import { KeyGenerationService } from "../../crypto"; import { EncString } from "../../crypto/models/enc-string"; import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service"; import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request"; diff --git a/libs/common/src/key-management/key-connector/services/key-connector.service.ts b/libs/common/src/key-management/key-connector/services/key-connector.service.ts index 0c4f4090e61..a6207ab92e2 100644 --- a/libs/common/src/key-management/key-connector/services/key-connector.service.ts +++ b/libs/common/src/key-management/key-connector/services/key-connector.service.ts @@ -23,13 +23,13 @@ import { Organization } from "../../../admin-console/models/domain/organization" import { TokenService } from "../../../auth/abstractions/token.service"; import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response"; import { KeysRequest } from "../../../models/request/keys.request"; -import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service"; import { LogService } from "../../../platform/abstractions/log.service"; import { Utils } from "../../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { KEY_CONNECTOR_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { MasterKey } from "../../../types/key"; +import { KeyGenerationService } from "../../crypto"; import { InternalMasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service"; import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request"; diff --git a/libs/common/src/key-management/master-password/services/master-password.service.spec.ts b/libs/common/src/key-management/master-password/services/master-password.service.spec.ts index a09de9008d1..693a4fb39a6 100644 --- a/libs/common/src/key-management/master-password/services/master-password.service.spec.ts +++ b/libs/common/src/key-management/master-password/services/master-password.service.spec.ts @@ -13,13 +13,13 @@ import { mockAccountServiceWith, } from "../../../../spec"; import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; -import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service"; import { LogService } from "../../../platform/abstractions/log.service"; import { StateService } from "../../../platform/abstractions/state.service"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { MasterKey, UserKey } from "../../../types/key"; +import { KeyGenerationService } from "../../crypto"; import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service"; import { EncryptService } from "../../crypto/abstractions/encrypt.service"; import { EncString } from "../../crypto/models/enc-string"; diff --git a/libs/common/src/key-management/master-password/services/master-password.service.ts b/libs/common/src/key-management/master-password/services/master-password.service.ts index 75e5032e004..41cfb268f44 100644 --- a/libs/common/src/key-management/master-password/services/master-password.service.ts +++ b/libs/common/src/key-management/master-password/services/master-password.service.ts @@ -11,7 +11,6 @@ import { KdfConfig } from "@bitwarden/key-management"; import { PureCrypto } from "@bitwarden/sdk-internal"; import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; -import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service"; import { LogService } from "../../../platform/abstractions/log.service"; import { StateService } from "../../../platform/abstractions/state.service"; import { EncryptionType } from "../../../platform/enums"; @@ -24,6 +23,7 @@ import { } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { MasterKey, UserKey } from "../../../types/key"; +import { KeyGenerationService } from "../../crypto"; import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service"; import { EncryptService } from "../../crypto/abstractions/encrypt.service"; import { EncryptedString, EncString } from "../../crypto/models/enc-string"; diff --git a/libs/common/src/key-management/pin/pin.service.implementation.ts b/libs/common/src/key-management/pin/pin.service.implementation.ts index f926f4a4af2..da46cd3bc76 100644 --- a/libs/common/src/key-management/pin/pin.service.implementation.ts +++ b/libs/common/src/key-management/pin/pin.service.implementation.ts @@ -9,11 +9,11 @@ import { AccountService } from "../../auth/abstractions/account.service"; import { CryptoFunctionService } from "../../key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service"; import { EncString, EncryptedString } from "../../key-management/crypto/models/enc-string"; -import { KeyGenerationService } from "../../platform/abstractions/key-generation.service"; import { LogService } from "../../platform/abstractions/log.service"; import { PIN_DISK, PIN_MEMORY, StateProvider, UserKeyDefinition } from "../../platform/state"; import { UserId } from "../../types/guid"; import { PinKey, UserKey } from "../../types/key"; +import { KeyGenerationService } from "../crypto"; import { PinServiceAbstraction } from "./pin.service.abstraction"; diff --git a/libs/common/src/key-management/pin/pin.service.spec.ts b/libs/common/src/key-management/pin/pin.service.spec.ts index 3d7dbaa4718..b014c26c7dc 100644 --- a/libs/common/src/key-management/pin/pin.service.spec.ts +++ b/libs/common/src/key-management/pin/pin.service.spec.ts @@ -4,12 +4,12 @@ import { mock } from "jest-mock-extended"; import { DEFAULT_KDF_CONFIG, KdfConfigService } from "@bitwarden/key-management"; import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec"; -import { KeyGenerationService } from "../../platform/abstractions/key-generation.service"; import { LogService } from "../../platform/abstractions/log.service"; import { Utils } from "../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { UserId } from "../../types/guid"; import { PinKey, UserKey } from "../../types/key"; +import { KeyGenerationService } from "../crypto"; import { CryptoFunctionService } from "../crypto/abstractions/crypto-function.service"; import { EncryptService } from "../crypto/abstractions/encrypt.service"; import { EncString } from "../crypto/models/enc-string"; diff --git a/libs/common/src/platform/abstractions/key-generation.service.ts b/libs/common/src/platform/abstractions/key-generation.service.ts index 91c630ed638..8a230fb5b86 100644 --- a/libs/common/src/platform/abstractions/key-generation.service.ts +++ b/libs/common/src/platform/abstractions/key-generation.service.ts @@ -1,66 +1,2 @@ -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { KdfConfig } from "@bitwarden/key-management"; - -import { CsprngArray } from "../../types/csprng"; -import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; - -export abstract class KeyGenerationService { - /** - * Generates a key of the given length suitable for use in AES encryption - * @param bitLength Length of key. - * 256 bits = 32 bytes - * 512 bits = 64 bytes - * @returns Generated key. - */ - abstract createKey(bitLength: 256 | 512): Promise; - /** - * Generates key material from CSPRNG and derives a 64 byte key from it. - * Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869} - * for details. - * @param bitLength Length of key material. - * @param purpose Purpose for the key derivation function. - * Different purposes results in different keys, even with the same material. - * @param salt Optional. If not provided will be generated from CSPRNG. - * @returns An object containing the salt, key material, and derived key. - */ - abstract createKeyWithPurpose( - bitLength: 128 | 192 | 256 | 512, - purpose: string, - salt?: string, - ): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }>; - /** - * Derives a 64 byte key from key material. - * @remark The key material should be generated from {@link createKey}, or {@link createKeyWithPurpose}. - * Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869} for details. - * @param material key material. - * @param salt Salt for the key derivation function. - * @param purpose Purpose for the key derivation function. - * Different purposes results in different keys, even with the same material. - * @returns 64 byte derived key. - */ - abstract deriveKeyFromMaterial( - material: CsprngArray, - salt: string, - purpose: string, - ): Promise; - /** - * Derives a 32 byte key from a password using a key derivation function. - * @param password Password to derive the key from. - * @param salt Salt for the key derivation function. - * @param kdfConfig Configuration for the key derivation function. - * @returns 32 byte derived key. - */ - abstract deriveKeyFromPassword( - password: string | Uint8Array, - salt: string | Uint8Array, - kdfConfig: KdfConfig, - ): Promise; - - /** - * Derives a 64 byte key from a 32 byte key using a key derivation function. - * @param key 32 byte key. - * @returns 64 byte derived key. - */ - abstract stretchKey(key: SymmetricCryptoKey): Promise; -} +/** Temporary re-export. This should not be used for new imports */ +export { KeyGenerationService } from "../../key-management/crypto/key-generation/key-generation.service"; diff --git a/libs/common/src/platform/services/key-generation.service.ts b/libs/common/src/platform/services/key-generation.service.ts index d25be087b06..55d1f96e7df 100644 --- a/libs/common/src/platform/services/key-generation.service.ts +++ b/libs/common/src/platform/services/key-generation.service.ts @@ -1,92 +1,2 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { KdfConfig } from "@bitwarden/key-management"; -import { PureCrypto } from "@bitwarden/sdk-internal"; - -import { CryptoFunctionService } from "../../key-management/crypto/abstractions/crypto-function.service"; -import { CsprngArray } from "../../types/csprng"; -import { KeyGenerationService as KeyGenerationServiceAbstraction } from "../abstractions/key-generation.service"; -import { SdkLoadService } from "../abstractions/sdk/sdk-load.service"; -import { EncryptionType } from "../enums"; -import { Utils } from "../misc/utils"; -import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; - -export class KeyGenerationService implements KeyGenerationServiceAbstraction { - constructor(private cryptoFunctionService: CryptoFunctionService) {} - - async createKey(bitLength: 256 | 512): Promise { - const key = await this.cryptoFunctionService.aesGenerateKey(bitLength); - return new SymmetricCryptoKey(key); - } - - async createKeyWithPurpose( - bitLength: 128 | 192 | 256 | 512, - purpose: string, - salt?: string, - ): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }> { - if (salt == null) { - const bytes = await this.cryptoFunctionService.randomBytes(32); - salt = Utils.fromBufferToUtf8(bytes); - } - const material = await this.cryptoFunctionService.aesGenerateKey(bitLength); - const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256"); - return { salt, material, derivedKey: new SymmetricCryptoKey(key) }; - } - - async deriveKeyFromMaterial( - material: CsprngArray, - salt: string, - purpose: string, - ): Promise { - const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256"); - return new SymmetricCryptoKey(key); - } - - async deriveKeyFromPassword( - password: string | Uint8Array, - salt: string | Uint8Array, - kdfConfig: KdfConfig, - ): Promise { - if (typeof password === "string") { - password = new TextEncoder().encode(password); - } - if (typeof salt === "string") { - salt = new TextEncoder().encode(salt); - } - - await SdkLoadService.Ready; - return new SymmetricCryptoKey( - PureCrypto.derive_kdf_material(password, salt, kdfConfig.toSdkConfig()), - ); - } - - async stretchKey(key: SymmetricCryptoKey): Promise { - // The key to be stretched is actually usually the output of a KDF, and not actually meant for AesCbc256_B64 encryption, - // but has the same key length. Only 256-bit key materials should be stretched. - if (key.inner().type != EncryptionType.AesCbc256_B64) { - throw new Error("Key passed into stretchKey is not a 256-bit key."); - } - - const newKey = new Uint8Array(64); - // Master key and pin key are always 32 bytes - const encKey = await this.cryptoFunctionService.hkdfExpand( - key.inner().encryptionKey, - "enc", - 32, - "sha256", - ); - const macKey = await this.cryptoFunctionService.hkdfExpand( - key.inner().encryptionKey, - "mac", - 32, - "sha256", - ); - - newKey.set(new Uint8Array(encKey)); - newKey.set(new Uint8Array(macKey), 32); - - return new SymmetricCryptoKey(newKey); - } -} +/** Temporary re-export. This should not be used for new imports */ +export { DefaultKeyGenerationService as KeyGenerationService } from "../../key-management/crypto/key-generation/default-key-generation.service"; diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 04cf6289879..4d872e2cd5c 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -2,6 +2,7 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject, bufferCount, firstValueFrom, lastValueFrom, of, take } from "rxjs"; import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { @@ -12,7 +13,6 @@ import { FakeMasterPasswordService } from "@bitwarden/common/key-management/mast import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeoutStringType } from "@bitwarden/common/key-management/vault-timeout"; import { VAULT_TIMEOUT } from "@bitwarden/common/key-management/vault-timeout/services/vault-timeout-settings.state"; -import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index f55a8211dbe..4942279e436 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -19,6 +19,7 @@ import { ProfileOrganizationResponse } from "@bitwarden/common/admin-console/mod import { ProfileProviderOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-provider-organization.response"; import { ProfileProviderResponse } from "@bitwarden/common/admin-console/models/response/profile-provider.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { @@ -29,7 +30,6 @@ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key- import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeoutStringType } from "@bitwarden/common/key-management/vault-timeout"; import { VAULT_TIMEOUT } from "@bitwarden/common/key-management/vault-timeout/services/vault-timeout-settings.state"; -import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; From 0c166b3f94fa36a917dbfbf256ae3396833bf1f4 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Mon, 18 Aug 2025 10:17:05 -0400 Subject: [PATCH 08/30] remove BlockBrowserInjectionsByDomain feature flag (#16008) --- .../background/overlay.background.spec.ts | 2 +- .../popup/settings/autofill.component.html | 4 ++-- .../popup/settings/autofill.component.ts | 6 ------ .../services/autofill.service.spec.ts | 2 +- .../browser/src/background/main.background.ts | 5 +---- .../browser-script-injector.service.spec.ts | 8 ++----- .../src/popup/services/services.module.ts | 2 +- .../service-container/service-container.ts | 5 +---- .../src/services/jslib-services.module.ts | 2 +- .../services/domain-settings.service.spec.ts | 7 +------ .../services/domain-settings.service.ts | 21 +++++-------------- libs/common/src/enums/feature-flag.enum.ts | 2 -- 12 files changed, 16 insertions(+), 50 deletions(-) diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 8bee4a4675d..691ea1870d0 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -156,7 +156,7 @@ describe("OverlayBackground", () => { fakeStateProvider = new FakeStateProvider(accountService); showFaviconsMock$ = new BehaviorSubject(true); neverDomainsMock$ = new BehaviorSubject({}); - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService); + domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); domainSettingsService.showFavicons$ = showFaviconsMock$; domainSettingsService.neverDomains$ = neverDomainsMock$; logService = mock(); diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.html b/apps/browser/src/autofill/popup/settings/autofill.component.html index 985a0962c95..9ba27d87ece 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.html +++ b/apps/browser/src/autofill/popup/settings/autofill.component.html @@ -213,7 +213,7 @@ - +

{{ "additionalOptions" | i18n }}

@@ -276,7 +276,7 @@
- + {{ "blockedDomains" | i18n }} diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.ts b/apps/browser/src/autofill/popup/settings/autofill.component.ts index dcce3f37368..3d2d605c13f 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.ts +++ b/apps/browser/src/autofill/popup/settings/autofill.component.ts @@ -44,7 +44,6 @@ import { DisablePasswordManagerUri, InlineMenuVisibilitySetting, } from "@bitwarden/common/autofill/types"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { UriMatchStrategy, UriMatchStrategySetting, @@ -110,7 +109,6 @@ export class AutofillComponent implements OnInit { protected defaultBrowserAutofillDisabled: boolean = false; protected inlineMenuVisibility: InlineMenuVisibilitySetting = AutofillOverlayVisibility.OnFieldFocus; - protected blockBrowserInjectionsByDomainEnabled: boolean = false; protected browserClientVendor: BrowserClientVendor = BrowserClientVendors.Unknown; protected disablePasswordManagerURI: DisablePasswordManagerUri = DisablePasswordManagerUris.Unknown; @@ -222,10 +220,6 @@ export class AutofillComponent implements OnInit { this.autofillSettingsService.inlineMenuVisibility$, ); - this.blockBrowserInjectionsByDomainEnabled = await this.configService.getFeatureFlag( - FeatureFlag.BlockBrowserInjectionsByDomain, - ); - this.showInlineMenuIdentities = await firstValueFrom( this.autofillSettingsService.showInlineMenuIdentities$, ); diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index ef7a7ed47d4..80cce5228d3 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -138,7 +138,7 @@ describe("AutofillService", () => { userNotificationsSettings, messageListener, ); - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService); + domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); jest.spyOn(BrowserApi, "tabSendMessage"); }); diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 0b55ebb7357..ae21b9d2c9b 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -872,10 +872,7 @@ export default class MainBackground { this.userVerificationApiService = new UserVerificationApiService(this.apiService); - this.domainSettingsService = new DefaultDomainSettingsService( - this.stateProvider, - this.configService, - ); + this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); this.themeStateService = new DefaultThemeStateService(this.globalStateProvider); diff --git a/apps/browser/src/platform/services/browser-script-injector.service.spec.ts b/apps/browser/src/platform/services/browser-script-injector.service.spec.ts index b177497305b..21f6debc02f 100644 --- a/apps/browser/src/platform/services/browser-script-injector.service.spec.ts +++ b/apps/browser/src/platform/services/browser-script-injector.service.spec.ts @@ -1,11 +1,10 @@ -import { mock, MockProxy } from "jest-mock-extended"; +import { mock } from "jest-mock-extended"; import { of } from "rxjs"; import { DomainSettingsService, DefaultDomainSettingsService, } from "@bitwarden/common/autofill/services/domain-settings.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -54,14 +53,11 @@ describe("ScriptInjectorService", () => { const mockUserId = Utils.newGuid() as UserId; const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); - let configService: MockProxy; let domainSettingsService: DomainSettingsService; beforeEach(() => { jest.spyOn(BrowserApi, "getTab").mockImplementation(async () => tabMock); - configService = mock(); - configService.getFeatureFlag$.mockImplementation(() => of(false)); - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService); + domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); domainSettingsService.blockedInteractionsUris$ = of({}); scriptInjectorService = new BrowserScriptInjectorService( diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 5950bdc214c..76d61eac90d 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -352,7 +352,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: DomainSettingsService, useClass: DefaultDomainSettingsService, - deps: [StateProvider, ConfigService], + deps: [StateProvider], }), safeProvider({ provide: AbstractStorageService, diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index e98481ee0fe..d93e9838868 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -532,10 +532,7 @@ export class ServiceContainer { this.authService, ); - this.domainSettingsService = new DefaultDomainSettingsService( - this.stateProvider, - this.configService, - ); + this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); this.fileUploadService = new FileUploadService(this.logService, this.apiService); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 11948a04047..3a6e7439ccc 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -520,7 +520,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: DomainSettingsService, useClass: DefaultDomainSettingsService, - deps: [StateProvider, ConfigService], + deps: [StateProvider], }), safeProvider({ provide: CipherServiceAbstraction, diff --git a/libs/common/src/autofill/services/domain-settings.service.spec.ts b/libs/common/src/autofill/services/domain-settings.service.spec.ts index 36f7d0eacec..12a34b70913 100644 --- a/libs/common/src/autofill/services/domain-settings.service.spec.ts +++ b/libs/common/src/autofill/services/domain-settings.service.spec.ts @@ -1,8 +1,6 @@ -import { MockProxy, mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; import { FakeStateProvider, FakeAccountService, mockAccountServiceWith } from "../../../spec"; -import { ConfigService } from "../../platform/abstractions/config/config.service"; import { Utils } from "../../platform/misc/utils"; import { UserId } from "../../types/guid"; @@ -10,7 +8,6 @@ import { DefaultDomainSettingsService, DomainSettingsService } from "./domain-se describe("DefaultDomainSettingsService", () => { let domainSettingsService: DomainSettingsService; - let configService: MockProxy; const mockUserId = Utils.newGuid() as UserId; const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); @@ -22,9 +19,7 @@ describe("DefaultDomainSettingsService", () => { ]; beforeEach(() => { - configService = mock(); - configService.getFeatureFlag$.mockImplementation(() => of(false)); - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService); + domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); jest.spyOn(domainSettingsService, "getUrlEquivalentDomains"); domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); diff --git a/libs/common/src/autofill/services/domain-settings.service.ts b/libs/common/src/autofill/services/domain-settings.service.ts index b2833b9ee25..bc86f9b4d64 100644 --- a/libs/common/src/autofill/services/domain-settings.service.ts +++ b/libs/common/src/autofill/services/domain-settings.service.ts @@ -1,15 +1,13 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { map, Observable, switchMap, of } from "rxjs"; +import { map, Observable } from "rxjs"; -import { FeatureFlag } from "../../enums/feature-flag.enum"; import { NeverDomains, EquivalentDomains, UriMatchStrategySetting, UriMatchStrategy, } from "../../models/domain/domain-service"; -import { ConfigService } from "../../platform/abstractions/config/config.service"; import { Utils } from "../../platform/misc/utils"; import { DOMAIN_SETTINGS_DISK, @@ -111,10 +109,7 @@ export class DefaultDomainSettingsService implements DomainSettingsService { private defaultUriMatchStrategyState: ActiveUserState; readonly defaultUriMatchStrategy$: Observable; - constructor( - private stateProvider: StateProvider, - private configService: ConfigService, - ) { + constructor(private stateProvider: StateProvider) { this.showFaviconsState = this.stateProvider.getGlobal(SHOW_FAVICONS); this.showFavicons$ = this.showFaviconsState.state$.pipe(map((x) => x ?? true)); @@ -123,15 +118,9 @@ export class DefaultDomainSettingsService implements DomainSettingsService { // Needs to be global to prevent pre-login injections this.blockedInteractionsUrisState = this.stateProvider.getGlobal(BLOCKED_INTERACTIONS_URIS); - - this.blockedInteractionsUris$ = this.configService - .getFeatureFlag$(FeatureFlag.BlockBrowserInjectionsByDomain) - .pipe( - switchMap((featureIsEnabled) => - featureIsEnabled ? this.blockedInteractionsUrisState.state$ : of({} as NeverDomains), - ), - map((disabledUris) => (Object.keys(disabledUris).length ? disabledUris : {})), - ); + this.blockedInteractionsUris$ = this.blockedInteractionsUrisState.state$.pipe( + map((x) => x ?? ({} as NeverDomains)), + ); this.equivalentDomainsState = this.stateProvider.getActive(EQUIVALENT_DOMAINS); this.equivalentDomains$ = this.equivalentDomainsState.state$.pipe(map((x) => x ?? null)); diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index c030562e04b..15ecd9fb63e 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -17,7 +17,6 @@ export enum FeatureFlag { PM14938_BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals", /* Autofill */ - BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain", EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill", NotificationRefresh = "notification-refresh", UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection", @@ -76,7 +75,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.CreateDefaultLocation]: FALSE, /* Autofill */ - [FeatureFlag.BlockBrowserInjectionsByDomain]: FALSE, [FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE, [FeatureFlag.NotificationRefresh]: FALSE, [FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE, From df7f1a8d49bc833c6d551f330c1788d798564d4b Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Mon, 18 Aug 2025 09:52:28 -0500 Subject: [PATCH 09/30] [PM-22415] Tax ID notifications for Organizations and Providers (#15996) * [NO LOGIC] Rename BillableEntity to BitwardenSubscriber This helps us maintain paraody with server where we call this choice type ISubscriber. I chose BitwardenSubscriber to avoid overlap with RxJS * [NO LOGIC] Move subscriber-billing.client to clients folder * [NO LOGIC] Move organization warnings under organization folder * Move getWarnings from OrganizationBillingApiService to new OrganizationBillingClient I'd like us to move away from stashing so much in libs and utilizing the JsLibServicesModule when it's not necessary to do so. These are invocations used exclusively by the Web Vault and, until that changes, they should be treated as such * Refactor OrganizationWarningsService There was a case added to the Inactive Subscription warning for a free trial, but free trials do not represent inactive subscriptions so this was semantically incorrect. This creates another method that pulls the free trial warning and shows a dialog asking the user to subscribe if they're on one. * Implement Tax ID Warnings throughout Admin Console and Provider Portal * Fix linting error * Jimmy's feedback --- .../collections/vault.component.ts | 17 +- .../organization-layout.component.html | 6 + .../layouts/organization-layout.component.ts | 27 +- .../members/members.component.ts | 13 +- .../organizations/members/members.module.ts | 2 +- .../organizations/organization.module.ts | 2 + apps/web/src/app/billing/clients/index.ts | 2 + .../clients/organization-billing.client.ts | 22 + .../subscriber-billing.client.ts} | 42 +- .../account-payment-details.component.html | 4 +- .../account-payment-details.component.ts | 12 +- ...rganization-payment-details.component.html | 7 +- .../organization-payment-details.component.ts | 193 +++-- .../warnings/components/index.ts | 2 + ...ganization-free-trial-warning.component.ts | 33 +- ...tion-reseller-renewal-warning.component.ts | 8 +- .../warnings/organization-warnings.module.ts | 12 + .../warnings/services/index.ts | 0 .../organization-warnings.service.spec.ts | 682 ++++++++++++++++++ .../services/organization-warnings.service.ts | 153 ++-- .../organizations/warnings/types/index.ts | 1 + .../warnings/types/organization-warnings.ts | 19 +- .../add-account-credit-dialog.component.ts | 26 +- .../change-payment-method-dialog.component.ts | 16 +- .../display-account-credit.component.ts | 16 +- .../display-billing-address.component.ts | 57 +- .../display-payment-method.component.ts | 11 +- .../edit-billing-address-dialog.component.ts | 75 +- .../enter-billing-address.component.ts | 57 +- ...require-payment-method-dialog.component.ts | 14 +- .../submit-payment-method-dialog.component.ts | 10 +- .../verify-bank-account.component.ts | 12 +- apps/web/src/app/billing/services/index.ts | 1 - ...able-entity.ts => bitwarden-subscriber.ts} | 10 +- apps/web/src/app/billing/types/index.ts | 2 +- .../app/billing/warnings/components/index.ts | 3 +- .../components/tax-id-warning.component.ts | 286 ++++++++ .../organization-warnings.service.spec.ts | 358 --------- .../src/app/billing/warnings/types/index.ts | 2 +- .../warnings/types/organization-warnings.ts | 11 - .../warnings/types/tax-id-warning-type.ts | 19 + apps/web/src/locales/en/messages.json | 47 ++ .../providers/providers-layout.component.html | 9 + .../providers/providers-layout.component.ts | 38 +- .../providers/providers.module.ts | 2 + .../provider-payment-details.component.html | 7 +- .../provider-payment-details.component.ts | 100 ++- .../provider-warnings.service.spec.ts | 187 ----- .../services/provider-warnings.service.ts | 104 --- .../warnings/provider-warnings.module.ts | 10 + .../providers/warnings/services/index.ts | 1 + .../provider-warnings.service.spec.ts | 416 +++++++++++ .../services/provider-warnings.service.ts | 175 +++++ .../billing/providers/warnings/types/index.ts | 0 .../warnings/types/provider-warnings.ts | 39 + ...ization-billing-api.service.abstraction.ts | 3 - .../organization-billing-api.service.ts | 13 - libs/common/src/enums/feature-flag.enum.ts | 2 + 58 files changed, 2422 insertions(+), 976 deletions(-) create mode 100644 apps/web/src/app/billing/clients/index.ts create mode 100644 apps/web/src/app/billing/clients/organization-billing.client.ts rename apps/web/src/app/billing/{services/billing.client.ts => clients/subscriber-billing.client.ts} (72%) create mode 100644 apps/web/src/app/billing/organizations/warnings/components/index.ts rename apps/web/src/app/billing/{ => organizations}/warnings/components/organization-free-trial-warning.component.ts (54%) rename apps/web/src/app/billing/{ => organizations}/warnings/components/organization-reseller-renewal-warning.component.ts (82%) create mode 100644 apps/web/src/app/billing/organizations/warnings/organization-warnings.module.ts rename apps/web/src/app/billing/{ => organizations}/warnings/services/index.ts (100%) create mode 100644 apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts rename apps/web/src/app/billing/{ => organizations}/warnings/services/organization-warnings.service.ts (63%) create mode 100644 apps/web/src/app/billing/organizations/warnings/types/index.ts rename libs/common/src/billing/models/response/organization-warnings.response.ts => apps/web/src/app/billing/organizations/warnings/types/organization-warnings.ts (80%) rename apps/web/src/app/billing/types/{billable-entity.ts => bitwarden-subscriber.ts} (67%) create mode 100644 apps/web/src/app/billing/warnings/components/tax-id-warning.component.ts delete mode 100644 apps/web/src/app/billing/warnings/services/organization-warnings.service.spec.ts delete mode 100644 apps/web/src/app/billing/warnings/types/organization-warnings.ts create mode 100644 apps/web/src/app/billing/warnings/types/tax-id-warning-type.ts delete mode 100644 bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.spec.ts delete mode 100644 bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.ts create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/warnings/provider-warnings.module.ts create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/warnings/services/index.ts create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.spec.ts create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.ts create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/warnings/types/index.ts create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/warnings/types/provider-warnings.ts diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index 5d2460abdc1..87f309c6f66 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -79,8 +79,11 @@ import { DecryptionFailureDialogComponent, PasswordRepromptService, } from "@bitwarden/vault"; -import { OrganizationResellerRenewalWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components/organization-reseller-renewal-warning.component"; -import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/warnings/services/organization-warnings.service"; +import { + OrganizationFreeTrialWarningComponent, + OrganizationResellerRenewalWarningComponent, +} from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { VaultItemsComponent } from "@bitwarden/web-vault/app/vault/components/vault-items/vault-items.component"; import { BillingNotificationService } from "../../../billing/services/billing-notification.service"; @@ -90,7 +93,6 @@ import { } from "../../../billing/services/reseller-warning.service"; import { TrialFlowService } from "../../../billing/services/trial-flow.service"; import { FreeTrial } from "../../../billing/types/free-trial"; -import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components/organization-free-trial-warning.component"; import { SharedModule } from "../../../shared"; import { AssignCollectionsWebComponent } from "../../../vault/components/assign-collections"; import { @@ -674,6 +676,15 @@ export class VaultComponent implements OnInit, OnDestroy { ) .subscribe(); + organization$ + .pipe( + switchMap((organization) => + this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization), + ), + takeUntil(this.destroy$), + ) + .subscribe(); + const freeTrial$ = combineLatest([ organization$, this.hasSubscription$.pipe(filter((hasSubscription) => hasSubscription !== null)), diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index cbb4e1cf064..be9a85ffe4b 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -150,6 +150,12 @@ > {{ "accessingUsingProvider" | i18n: organization.providerName }} + + diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts index 89f62ed8975..0123ea4fb57 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts @@ -28,6 +28,10 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getById } from "@bitwarden/common/platform/misc"; import { BannerModule, IconModule, AdminConsoleLogo } from "@bitwarden/components"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; +import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types"; +import { TaxIdWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components"; +import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types"; import { FreeFamiliesPolicyService } from "../../../billing/services/free-families-policy.service"; import { OrgSwitcherComponent } from "../../../layouts/org-switcher/org-switcher.component"; @@ -44,6 +48,8 @@ import { WebLayoutModule } from "../../../layouts/web-layout.module"; IconModule, OrgSwitcherComponent, BannerModule, + TaxIdWarningComponent, + TaxIdWarningComponent, ], }) export class OrganizationLayoutComponent implements OnInit { @@ -58,7 +64,6 @@ export class OrganizationLayoutComponent implements OnInit { showPaymentAndHistory$: Observable; hideNewOrgButton$: Observable; organizationIsUnmanaged$: Observable; - enterpriseOrganization$: Observable; protected isBreadcrumbEventLogsEnabled$: Observable; protected showSponsoredFamiliesDropdown$: Observable; @@ -69,6 +74,9 @@ export class OrganizationLayoutComponent implements OnInit { textKey: string; }>; + protected subscriber$: Observable; + protected getTaxIdWarning$: () => Observable; + constructor( private route: ActivatedRoute, private organizationService: OrganizationService, @@ -79,6 +87,7 @@ export class OrganizationLayoutComponent implements OnInit { private accountService: AccountService, private freeFamiliesPolicyService: FreeFamiliesPolicyService, private organizationBillingService: OrganizationBillingServiceAbstraction, + private organizationWarningsService: OrganizationWarningsService, ) {} async ngOnInit() { @@ -150,6 +159,20 @@ export class OrganizationLayoutComponent implements OnInit { : { route: "billing/payment-method", textKey: "paymentMethod" }, ), ); + + this.subscriber$ = this.organization$.pipe( + map((organization) => ({ + type: "organization", + data: organization, + })), + ); + + this.getTaxIdWarning$ = () => + this.organization$.pipe( + switchMap((organization) => + this.organizationWarningsService.getTaxIdWarning$(organization), + ), + ); } canShowVaultTab(organization: Organization): boolean { @@ -179,4 +202,6 @@ export class OrganizationLayoutComponent implements OnInit { getReportTabLabel(organization: Organization): string { return organization.useEvents ? "reporting" : "reports"; } + + refreshTaxIdWarning = () => this.organizationWarningsService.refreshTaxIdWarning(); } diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index b4542be8d26..dedf13720bf 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -10,10 +10,10 @@ import { from, lastValueFrom, map, + merge, Observable, shareReplay, switchMap, - tap, } from "rxjs"; import { @@ -57,12 +57,12 @@ import { OrganizationId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { ChangePlanDialogResultType, openChangePlanDialog, } from "../../../billing/organizations/change-plan-dialog.component"; -import { OrganizationWarningsService } from "../../../billing/warnings/services"; import { BaseMembersComponent } from "../../common/base-members.component"; import { PeopleTableDataSource } from "../../common/people-table-data-source"; import { GroupApiService } from "../core"; @@ -253,11 +253,16 @@ export class MembersComponent extends BaseMembersComponent this.showUserManagementControls$ = organization$.pipe( map((organization) => organization.canManageUsers), ); + organization$ .pipe( + switchMap((organization) => + merge( + this.organizationWarningsService.showInactiveSubscriptionDialog$(organization), + this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization), + ), + ), takeUntilDestroyed(), - tap((org) => (this.organization = org)), - switchMap((org) => this.organizationWarningsService.showInactiveSubscriptionDialog$(org)), ) .subscribe(); } diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index d9c5ae356a2..efc091cb335 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -4,8 +4,8 @@ import { NgModule } from "@angular/core"; import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component"; import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; import { ScrollLayoutDirective } from "@bitwarden/components"; +import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; -import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components"; import { LooseComponentsModule } from "../../../shared"; import { SharedOrganizationModule } from "../shared"; diff --git a/apps/web/src/app/admin-console/organizations/organization.module.ts b/apps/web/src/app/admin-console/organizations/organization.module.ts index 687361760c9..d956174149b 100644 --- a/apps/web/src/app/admin-console/organizations/organization.module.ts +++ b/apps/web/src/app/admin-console/organizations/organization.module.ts @@ -2,6 +2,7 @@ import { ScrollingModule } from "@angular/cdk/scrolling"; import { NgModule } from "@angular/core"; import { ScrollLayoutDirective } from "@bitwarden/components"; +import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; import { LooseComponentsModule } from "../../shared"; @@ -21,6 +22,7 @@ import { AccessSelectorModule } from "./shared/components/access-selector"; LooseComponentsModule, ScrollingModule, ScrollLayoutDirective, + OrganizationWarningsModule, ], declarations: [GroupsComponent, GroupAddEditComponent], }) diff --git a/apps/web/src/app/billing/clients/index.ts b/apps/web/src/app/billing/clients/index.ts new file mode 100644 index 00000000000..ff962abcbf3 --- /dev/null +++ b/apps/web/src/app/billing/clients/index.ts @@ -0,0 +1,2 @@ +export * from "./organization-billing.client"; +export * from "./subscriber-billing.client"; diff --git a/apps/web/src/app/billing/clients/organization-billing.client.ts b/apps/web/src/app/billing/clients/organization-billing.client.ts new file mode 100644 index 00000000000..a8b3b31a4a4 --- /dev/null +++ b/apps/web/src/app/billing/clients/organization-billing.client.ts @@ -0,0 +1,22 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrganizationWarningsResponse } from "@bitwarden/web-vault/app/billing/organizations/warnings/types"; + +@Injectable() +export class OrganizationBillingClient { + constructor(private apiService: ApiService) {} + + getWarnings = async (organizationId: OrganizationId): Promise => { + const response = await this.apiService.send( + "GET", + `/organizations/${organizationId}/billing/vnext/warnings`, + null, + true, + true, + ); + + return new OrganizationWarningsResponse(response); + }; +} diff --git a/apps/web/src/app/billing/services/billing.client.ts b/apps/web/src/app/billing/clients/subscriber-billing.client.ts similarity index 72% rename from apps/web/src/app/billing/services/billing.client.ts rename to apps/web/src/app/billing/clients/subscriber-billing.client.ts index 69f82eab19a..18ca215ef0c 100644 --- a/apps/web/src/app/billing/services/billing.client.ts +++ b/apps/web/src/app/billing/clients/subscriber-billing.client.ts @@ -10,7 +10,7 @@ import { MaskedPaymentMethodResponse, TokenizedPaymentMethod, } from "../payment/types"; -import { BillableEntity } from "../types"; +import { BitwardenSubscriber } from "../types"; type Result = | { @@ -23,28 +23,28 @@ type Result = }; @Injectable() -export class BillingClient { +export class SubscriberBillingClient { constructor(private apiService: ApiService) {} - private getEndpoint = (entity: BillableEntity): string => { - switch (entity.type) { + private getEndpoint = (subscriber: BitwardenSubscriber): string => { + switch (subscriber.type) { case "account": { return "/account/billing/vnext"; } case "organization": { - return `/organizations/${entity.data.id}/billing/vnext`; + return `/organizations/${subscriber.data.id}/billing/vnext`; } case "provider": { - return `/providers/${entity.data.id}/billing/vnext`; + return `/providers/${subscriber.data.id}/billing/vnext`; } } }; addCreditWithBitPay = async ( - owner: BillableEntity, + subscriber: BitwardenSubscriber, credit: { amount: number; redirectUrl: string }, ): Promise> => { - const path = `${this.getEndpoint(owner)}/credit/bitpay`; + const path = `${this.getEndpoint(subscriber)}/credit/bitpay`; try { const data = await this.apiService.send("POST", path, credit, true, true); return { @@ -62,29 +62,31 @@ export class BillingClient { } }; - getBillingAddress = async (owner: BillableEntity): Promise => { - const path = `${this.getEndpoint(owner)}/address`; + getBillingAddress = async (subscriber: BitwardenSubscriber): Promise => { + const path = `${this.getEndpoint(subscriber)}/address`; const data = await this.apiService.send("GET", path, null, true, true); return data ? new BillingAddressResponse(data) : null; }; - getCredit = async (owner: BillableEntity): Promise => { - const path = `${this.getEndpoint(owner)}/credit`; + getCredit = async (subscriber: BitwardenSubscriber): Promise => { + const path = `${this.getEndpoint(subscriber)}/credit`; const data = await this.apiService.send("GET", path, null, true, true); return data ? (data as number) : null; }; - getPaymentMethod = async (owner: BillableEntity): Promise => { - const path = `${this.getEndpoint(owner)}/payment-method`; + getPaymentMethod = async ( + subscriber: BitwardenSubscriber, + ): Promise => { + const path = `${this.getEndpoint(subscriber)}/payment-method`; const data = await this.apiService.send("GET", path, null, true, true); return data ? new MaskedPaymentMethodResponse(data).value : null; }; updateBillingAddress = async ( - owner: BillableEntity, + subscriber: BitwardenSubscriber, billingAddress: BillingAddress, ): Promise> => { - const path = `${this.getEndpoint(owner)}/address`; + const path = `${this.getEndpoint(subscriber)}/address`; try { const data = await this.apiService.send("PUT", path, billingAddress, true, true); return { @@ -103,11 +105,11 @@ export class BillingClient { }; updatePaymentMethod = async ( - owner: BillableEntity, + subscriber: BitwardenSubscriber, paymentMethod: TokenizedPaymentMethod, billingAddress: Pick | null, ): Promise> => { - const path = `${this.getEndpoint(owner)}/payment-method`; + const path = `${this.getEndpoint(subscriber)}/payment-method`; try { const request = { ...paymentMethod, @@ -130,10 +132,10 @@ export class BillingClient { }; verifyBankAccount = async ( - owner: BillableEntity, + subscriber: BitwardenSubscriber, descriptorCode: string, ): Promise> => { - const path = `${this.getEndpoint(owner)}/payment-method/verify-bank-account`; + const path = `${this.getEndpoint(subscriber)}/payment-method/verify-bank-account`; try { const data = await this.apiService.send("POST", path, { descriptorCode }, true, true); return { diff --git a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html index c10590d8b1b..5bb47cd8a2e 100644 --- a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html +++ b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html @@ -12,13 +12,13 @@ } @else { diff --git a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts index 4a4d0f60c0b..9f46d9d3909 100644 --- a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts +++ b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts @@ -20,13 +20,13 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; +import { SubscriberBillingClient } from "../../clients"; import { DisplayAccountCreditComponent, DisplayPaymentMethodComponent, } from "../../payment/components"; import { MaskedPaymentMethod } from "../../payment/types"; -import { BillingClient } from "../../services"; -import { accountToBillableEntity, BillableEntity } from "../../types"; +import { mapAccountToSubscriber, BitwardenSubscriber } from "../../types"; class RedirectError { constructor( @@ -36,7 +36,7 @@ class RedirectError { } type View = { - account: BillableEntity; + account: BitwardenSubscriber; paymentMethod: MaskedPaymentMethod | null; credit: number | null; }; @@ -50,7 +50,7 @@ type View = { HeaderModule, SharedModule, ], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class AccountPaymentDetailsComponent { private viewState$ = new BehaviorSubject(null); @@ -68,7 +68,7 @@ export class AccountPaymentDetailsComponent { }), ), ), - accountToBillableEntity, + mapAccountToSubscriber, switchMap(async (account) => { const [paymentMethod, credit] = await Promise.all([ this.billingClient.getPaymentMethod(account), @@ -100,7 +100,7 @@ export class AccountPaymentDetailsComponent { constructor( private accountService: AccountService, private activatedRoute: ActivatedRoute, - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, private configService: ConfigService, private router: Router, ) {} diff --git a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html index 17f4349fdd5..cd31f1f33be 100644 --- a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html +++ b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html @@ -21,19 +21,20 @@ } @else { diff --git a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts index e357444b943..d1dfea40fe2 100644 --- a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts +++ b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts @@ -1,8 +1,9 @@ -import { Component, OnInit, ViewChild } from "@angular/core"; +import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { BehaviorSubject, catchError, + combineLatest, EMPTY, filter, firstValueFrom, @@ -11,8 +12,12 @@ import { map, merge, Observable, + of, shareReplay, + Subject, switchMap, + take, + takeUntil, tap, } from "rxjs"; @@ -26,19 +31,26 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { DialogService } from "@bitwarden/components"; - -import { HeaderModule } from "../../../layouts/header/header.module"; -import { SharedModule } from "../../../shared"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { ChangePaymentMethodDialogComponent, DisplayAccountCreditComponent, DisplayBillingAddressComponent, DisplayPaymentMethodComponent, -} from "../../payment/components"; -import { BillingAddress, MaskedPaymentMethod } from "../../payment/types"; -import { BillingClient } from "../../services"; -import { BillableEntity, organizationToBillableEntity } from "../../types"; -import { OrganizationFreeTrialWarningComponent } from "../../warnings/components"; +} from "@bitwarden/web-vault/app/billing/payment/components"; +import { + BillingAddress, + MaskedPaymentMethod, +} from "@bitwarden/web-vault/app/billing/payment/types"; +import { + BitwardenSubscriber, + mapOrganizationToSubscriber, +} from "@bitwarden/web-vault/app/billing/types"; +import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types"; +import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; class RedirectError { constructor( @@ -48,93 +60,100 @@ class RedirectError { } type View = { - organization: BillableEntity; + organization: BitwardenSubscriber; paymentMethod: MaskedPaymentMethod | null; billingAddress: BillingAddress | null; credit: number | null; + taxIdWarning: TaxIdWarningType | null; }; @Component({ templateUrl: "./organization-payment-details.component.html", standalone: true, imports: [ - DisplayBillingAddressComponent, DisplayAccountCreditComponent, + DisplayBillingAddressComponent, DisplayPaymentMethodComponent, HeaderModule, OrganizationFreeTrialWarningComponent, SharedModule, ], - providers: [BillingClient], }) -export class OrganizationPaymentDetailsComponent implements OnInit { - @ViewChild(OrganizationFreeTrialWarningComponent) - organizationFreeTrialWarningComponent!: OrganizationFreeTrialWarningComponent; - +export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy { private viewState$ = new BehaviorSubject(null); - private load$: Observable = this.accountService.activeAccount$ - .pipe( - getUserId, - switchMap((userId) => - this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(this.activatedRoute.snapshot.params.organizationId)), - ), - ) - .pipe( - switchMap((organization) => - this.configService - .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) - .pipe( - map((managePaymentDetailsOutsideCheckout) => { - if (!managePaymentDetailsOutsideCheckout) { - throw new RedirectError(["../payment-method"], this.activatedRoute); - } - return organization; - }), - ), - ), - organizationToBillableEntity, - switchMap(async (organization) => { - const [paymentMethod, billingAddress, credit] = await Promise.all([ - this.billingClient.getPaymentMethod(organization), - this.billingClient.getBillingAddress(organization), - this.billingClient.getCredit(organization), - ]); + protected organization$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.organizationService + .organizations$(userId) + .pipe(getOrganizationById(this.activatedRoute.snapshot.params.organizationId)), + ), + filter((organization): organization is Organization => !!organization), + ); - return { - organization, - paymentMethod, - billingAddress, - credit, - }; - }), - catchError((error: unknown) => { - if (error instanceof RedirectError) { - return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe( - switchMap(() => EMPTY), - ); - } - throw error; - }), - ); + private load$: Observable = this.organization$.pipe( + switchMap((organization) => + this.configService + .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) + .pipe( + map((managePaymentDetailsOutsideCheckout) => { + if (!managePaymentDetailsOutsideCheckout) { + throw new RedirectError(["../payment-method"], this.activatedRoute); + } + return organization; + }), + ), + ), + mapOrganizationToSubscriber, + switchMap(async (organization) => { + const getTaxIdWarning = firstValueFrom( + this.organizationWarningsService.getTaxIdWarning$(organization.data as Organization), + ); + + const [paymentMethod, billingAddress, credit, taxIdWarning] = await Promise.all([ + this.subscriberBillingClient.getPaymentMethod(organization), + this.subscriberBillingClient.getBillingAddress(organization), + this.subscriberBillingClient.getCredit(organization), + getTaxIdWarning, + ]); + + return { + organization, + paymentMethod, + billingAddress, + credit, + taxIdWarning, + }; + }), + catchError((error: unknown) => { + if (error instanceof RedirectError) { + return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe( + switchMap(() => EMPTY), + ); + } + throw error; + }), + ); view$: Observable = merge( this.load$.pipe(tap((view) => this.viewState$.next(view))), this.viewState$.pipe(filter((view): view is View => view !== null)), ).pipe(shareReplay({ bufferSize: 1, refCount: true })); - organization$ = this.view$.pipe(map((view) => view.organization.data as Organization)); + private destroy$ = new Subject(); + + protected enableTaxIdWarning!: boolean; constructor( private accountService: AccountService, private activatedRoute: ActivatedRoute, - private billingClient: BillingClient, private configService: ConfigService, private dialogService: DialogService, private organizationService: OrganizationService, + private organizationWarningsService: OrganizationWarningsService, private router: Router, + private subscriberBillingClient: SubscriberBillingClient, ) {} async ngOnInit() { @@ -145,24 +164,66 @@ export class OrganizationPaymentDetailsComponent implements OnInit { history.replaceState({ ...history.state, launchPaymentModalAutomatically: false }, ""); await this.changePaymentMethod(); } + + this.enableTaxIdWarning = await this.configService.getFeatureFlag( + FeatureFlag.PM22415_TaxIDWarnings, + ); + + if (this.enableTaxIdWarning) { + this.organizationWarningsService.taxIdWarningRefreshed$ + .pipe( + switchMap((warning) => + combineLatest([ + of(warning), + this.organization$.pipe(take(1)).pipe( + mapOrganizationToSubscriber, + switchMap((organization) => + this.subscriberBillingClient.getBillingAddress(organization), + ), + ), + ]), + ), + takeUntil(this.destroy$), + ) + .subscribe(([taxIdWarning, billingAddress]) => { + if (this.viewState$.value) { + this.viewState$.next({ + ...this.viewState$.value, + taxIdWarning, + billingAddress, + }); + } + }); + } + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } changePaymentMethod = async () => { const view = await firstValueFrom(this.view$); const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, { data: { - owner: view.organization, + subscriber: view.organization, }, }); const result = await lastValueFrom(dialogRef.closed); if (result?.type === "success") { await this.setPaymentMethod(result.paymentMethod); - this.organizationFreeTrialWarningComponent.refresh(); + this.organizationWarningsService.refreshFreeTrialWarning(); } }; setBillingAddress = (billingAddress: BillingAddress) => { if (this.viewState$.value) { + if ( + this.enableTaxIdWarning && + this.viewState$.value.billingAddress?.taxId !== billingAddress.taxId + ) { + this.organizationWarningsService.refreshTaxIdWarning(); + } this.viewState$.next({ ...this.viewState$.value, billingAddress, @@ -174,7 +235,7 @@ export class OrganizationPaymentDetailsComponent implements OnInit { if (this.viewState$.value) { const billingAddress = this.viewState$.value.billingAddress ?? - (await this.billingClient.getBillingAddress(this.viewState$.value.organization)); + (await this.subscriberBillingClient.getBillingAddress(this.viewState$.value.organization)); this.viewState$.next({ ...this.viewState$.value, diff --git a/apps/web/src/app/billing/organizations/warnings/components/index.ts b/apps/web/src/app/billing/organizations/warnings/components/index.ts new file mode 100644 index 00000000000..1e1e0682e62 --- /dev/null +++ b/apps/web/src/app/billing/organizations/warnings/components/index.ts @@ -0,0 +1,2 @@ +export * from "./organization-free-trial-warning.component"; +export * from "./organization-reseller-renewal-warning.component"; diff --git a/apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts b/apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts similarity index 54% rename from apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts rename to apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts index a7ce53c9998..4925e4bc01d 100644 --- a/apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts +++ b/apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts @@ -1,12 +1,9 @@ -import { AsyncPipe } from "@angular/common"; -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { Observable, Subject } from "rxjs"; -import { takeUntil } from "rxjs/operators"; +import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Observable } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { OrganizationId } from "@bitwarden/common/types/guid"; -import { AnchorLinkDirective, BannerComponent } from "@bitwarden/components"; -import { I18nPipe } from "@bitwarden/ui-common"; +import { BannerModule } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { OrganizationWarningsService } from "../services"; import { OrganizationFreeTrialWarning } from "../types"; @@ -37,33 +34,17 @@ import { OrganizationFreeTrialWarning } from "../types"; } `, - imports: [AnchorLinkDirective, AsyncPipe, BannerComponent, I18nPipe], + imports: [BannerModule, SharedModule], }) -export class OrganizationFreeTrialWarningComponent implements OnInit, OnDestroy { +export class OrganizationFreeTrialWarningComponent implements OnInit { @Input({ required: true }) organization!: Organization; @Output() clicked = new EventEmitter(); - warning$!: Observable; - private destroy$ = new Subject(); + warning$!: Observable; constructor(private organizationWarningsService: OrganizationWarningsService) {} ngOnInit() { this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization); - this.organizationWarningsService - .refreshWarningsForOrganization$(this.organization.id as OrganizationId) - .pipe(takeUntil(this.destroy$)) - .subscribe(() => { - this.refresh(); - }); } - - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } - - refresh = () => { - this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization, true); - }; } diff --git a/apps/web/src/app/billing/warnings/components/organization-reseller-renewal-warning.component.ts b/apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts similarity index 82% rename from apps/web/src/app/billing/warnings/components/organization-reseller-renewal-warning.component.ts rename to apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts index f45dd443dda..4eba9f3daf5 100644 --- a/apps/web/src/app/billing/warnings/components/organization-reseller-renewal-warning.component.ts +++ b/apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts @@ -1,9 +1,9 @@ -import { AsyncPipe } from "@angular/common"; import { Component, Input, OnInit } from "@angular/core"; import { Observable } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { BannerComponent } from "@bitwarden/components"; +import { BannerModule } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { OrganizationWarningsService } from "../services"; import { OrganizationResellerRenewalWarning } from "../types"; @@ -25,12 +25,12 @@ import { OrganizationResellerRenewalWarning } from "../types"; } `, - imports: [AsyncPipe, BannerComponent], + imports: [BannerModule, SharedModule], }) export class OrganizationResellerRenewalWarningComponent implements OnInit { @Input({ required: true }) organization!: Organization; - warning$!: Observable; + warning$!: Observable; constructor(private organizationWarningsService: OrganizationWarningsService) {} diff --git a/apps/web/src/app/billing/organizations/warnings/organization-warnings.module.ts b/apps/web/src/app/billing/organizations/warnings/organization-warnings.module.ts new file mode 100644 index 00000000000..6defee7e78b --- /dev/null +++ b/apps/web/src/app/billing/organizations/warnings/organization-warnings.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from "@angular/core"; + +import { + OrganizationBillingClient, + SubscriberBillingClient, +} from "@bitwarden/web-vault/app/billing/clients"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; + +@NgModule({ + providers: [OrganizationBillingClient, OrganizationWarningsService, SubscriberBillingClient], +}) +export class OrganizationWarningsModule {} diff --git a/apps/web/src/app/billing/warnings/services/index.ts b/apps/web/src/app/billing/organizations/warnings/services/index.ts similarity index 100% rename from apps/web/src/app/billing/warnings/services/index.ts rename to apps/web/src/app/billing/organizations/warnings/services/index.ts diff --git a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts new file mode 100644 index 00000000000..c7a297cc28b --- /dev/null +++ b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts @@ -0,0 +1,682 @@ +jest.mock("@bitwarden/web-vault/app/billing/organizations/change-plan-dialog.component", () => ({ + ChangePlanDialogResultType: { + Submitted: "submitted", + Cancelled: "cancelled", + }, + openChangePlanDialog: jest.fn(), +})); + +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogRef, DialogService } from "@bitwarden/components"; +import { OrganizationBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { + ChangePlanDialogResultType, + openChangePlanDialog, +} from "@bitwarden/web-vault/app/billing/organizations/change-plan-dialog.component"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services/organization-warnings.service"; +import { OrganizationWarningsResponse } from "@bitwarden/web-vault/app/billing/organizations/warnings/types"; +import { + TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE, + TrialPaymentDialogComponent, + TrialPaymentDialogResultType, +} from "@bitwarden/web-vault/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component"; +import { TaxIdWarningTypes } from "@bitwarden/web-vault/app/billing/warnings/types"; + +describe("OrganizationWarningsService", () => { + let service: OrganizationWarningsService; + let configService: MockProxy; + let dialogService: MockProxy; + let i18nService: MockProxy; + let organizationApiService: MockProxy; + let organizationBillingClient: MockProxy; + let router: MockProxy; + + const organization = { + id: "org-id-123", + name: "Test Organization", + providerName: "Test Reseller Inc", + productTierType: ProductTierType.Enterprise, + } as Organization; + + const format = (date: Date): string => + date.toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + }); + + beforeEach(() => { + configService = mock(); + dialogService = mock(); + i18nService = mock(); + organizationApiService = mock(); + organizationBillingClient = mock(); + router = mock(); + + (openChangePlanDialog as jest.Mock).mockReset(); + + i18nService.t.mockImplementation((key: string, ...args: any[]) => { + switch (key) { + case "freeTrialEndPromptCount": + return `Your free trial ends in ${args[0]} days.`; + case "freeTrialEndPromptTomorrowNoOrgName": + return "Your free trial ends tomorrow."; + case "freeTrialEndingTodayWithoutOrgName": + return "Your free trial ends today."; + case "resellerRenewalWarningMsg": + return `Your subscription will renew soon. To ensure uninterrupted service, contact ${args[0]} to confirm your renewal before ${args[1]}.`; + case "resellerOpenInvoiceWarningMgs": + return `An invoice for your subscription was issued on ${args[1]}. To ensure uninterrupted service, contact ${args[0]} to confirm your renewal before ${args[2]}.`; + case "resellerPastDueWarningMsg": + return `The invoice for your subscription has not been paid. To ensure uninterrupted service, contact ${args[0]} to confirm your renewal before ${args[1]}.`; + case "suspendedOrganizationTitle": + return `${args[0]} subscription suspended`; + case "close": + return "Close"; + case "continue": + return "Continue"; + default: + return key; + } + }); + + TestBed.configureTestingModule({ + providers: [ + OrganizationWarningsService, + { provide: ConfigService, useValue: configService }, + { provide: DialogService, useValue: dialogService }, + { provide: I18nService, useValue: i18nService }, + { provide: OrganizationApiServiceAbstraction, useValue: organizationApiService }, + { provide: OrganizationBillingClient, useValue: organizationBillingClient }, + { provide: Router, useValue: router }, + ], + }); + + service = TestBed.inject(OrganizationWarningsService); + }); + + describe("getFreeTrialWarning$", () => { + it("should return null when no free trial warning exists", (done) => { + organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); + + service.getFreeTrialWarning$(organization).subscribe((result) => { + expect(result).toBeNull(); + done(); + }); + }); + + it("should return warning with count message when remaining trial days >= 2", (done) => { + const warning = { remainingTrialDays: 5 }; + organizationBillingClient.getWarnings.mockResolvedValue({ + freeTrial: warning, + } as OrganizationWarningsResponse); + + service.getFreeTrialWarning$(organization).subscribe((result) => { + expect(result).toEqual({ + organization: organization, + message: "Your free trial ends in 5 days.", + }); + expect(i18nService.t).toHaveBeenCalledWith("freeTrialEndPromptCount", 5); + done(); + }); + }); + + it("should return warning with tomorrow message when remaining trial days = 1", (done) => { + const warning = { remainingTrialDays: 1 }; + organizationBillingClient.getWarnings.mockResolvedValue({ + freeTrial: warning, + } as OrganizationWarningsResponse); + + service.getFreeTrialWarning$(organization).subscribe((result) => { + expect(result).toEqual({ + organization: organization, + message: "Your free trial ends tomorrow.", + }); + expect(i18nService.t).toHaveBeenCalledWith("freeTrialEndPromptTomorrowNoOrgName"); + done(); + }); + }); + + it("should return warning with today message when remaining trial days = 0", (done) => { + const warning = { remainingTrialDays: 0 }; + organizationBillingClient.getWarnings.mockResolvedValue({ + freeTrial: warning, + } as OrganizationWarningsResponse); + + service.getFreeTrialWarning$(organization).subscribe((result) => { + expect(result).toEqual({ + organization: organization, + message: "Your free trial ends today.", + }); + expect(i18nService.t).toHaveBeenCalledWith("freeTrialEndingTodayWithoutOrgName"); + done(); + }); + }); + + it("should refresh warning when refreshFreeTrialWarning is called", (done) => { + const initialWarning = { remainingTrialDays: 3 }; + const refreshedWarning = { remainingTrialDays: 2 }; + let invocationCount = 0; + + organizationBillingClient.getWarnings + .mockResolvedValueOnce({ + freeTrial: initialWarning, + } as OrganizationWarningsResponse) + .mockResolvedValueOnce({ + freeTrial: refreshedWarning, + } as OrganizationWarningsResponse); + + const subscription = service.getFreeTrialWarning$(organization).subscribe((result) => { + invocationCount++; + + if (invocationCount === 1) { + expect(result).toEqual({ + organization: organization, + message: "Your free trial ends in 3 days.", + }); + } else if (invocationCount === 2) { + expect(result).toEqual({ + organization: organization, + message: "Your free trial ends in 2 days.", + }); + subscription.unsubscribe(); + done(); + } + }); + + setTimeout(() => { + service.refreshFreeTrialWarning(); + }, 10); + }); + }); + + describe("getResellerRenewalWarning$", () => { + it("should return null when no reseller renewal warning exists", (done) => { + organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); + + service.getResellerRenewalWarning$(organization).subscribe((result) => { + expect(result).toBeNull(); + done(); + }); + }); + + it("should return upcoming warning with correct type and message", (done) => { + const renewalDate = new Date(2024, 11, 31); + const warning = { + type: "upcoming" as const, + upcoming: { renewalDate }, + }; + organizationBillingClient.getWarnings.mockResolvedValue({ + resellerRenewal: warning, + } as OrganizationWarningsResponse); + + service.getResellerRenewalWarning$(organization).subscribe((result) => { + const expectedFormattedDate = format(renewalDate); + + expect(result).toEqual({ + type: "info", + message: `Your subscription will renew soon. To ensure uninterrupted service, contact Test Reseller Inc to confirm your renewal before ${expectedFormattedDate}.`, + }); + expect(i18nService.t).toHaveBeenCalledWith( + "resellerRenewalWarningMsg", + "Test Reseller Inc", + expectedFormattedDate, + ); + done(); + }); + }); + + it("should return issued warning with correct type and message", (done) => { + const issuedDate = new Date(2024, 10, 15); + const dueDate = new Date(2024, 11, 15); + const warning = { + type: "issued" as const, + issued: { issuedDate, dueDate }, + }; + organizationBillingClient.getWarnings.mockResolvedValue({ + resellerRenewal: warning, + } as OrganizationWarningsResponse); + + service.getResellerRenewalWarning$(organization).subscribe((result) => { + const expectedIssuedDate = format(issuedDate); + const expectedDueDate = format(dueDate); + + expect(result).toEqual({ + type: "info", + message: `An invoice for your subscription was issued on ${expectedIssuedDate}. To ensure uninterrupted service, contact Test Reseller Inc to confirm your renewal before ${expectedDueDate}.`, + }); + expect(i18nService.t).toHaveBeenCalledWith( + "resellerOpenInvoiceWarningMgs", + "Test Reseller Inc", + expectedIssuedDate, + expectedDueDate, + ); + done(); + }); + }); + + it("should return past_due warning with correct type and message", (done) => { + const suspensionDate = new Date(2024, 11, 1); + const warning = { + type: "past_due" as const, + pastDue: { suspensionDate }, + }; + organizationBillingClient.getWarnings.mockResolvedValue({ + resellerRenewal: warning, + } as OrganizationWarningsResponse); + + service.getResellerRenewalWarning$(organization).subscribe((result) => { + const expectedSuspensionDate = format(suspensionDate); + + expect(result).toEqual({ + type: "warning", + message: `The invoice for your subscription has not been paid. To ensure uninterrupted service, contact Test Reseller Inc to confirm your renewal before ${expectedSuspensionDate}.`, + }); + expect(i18nService.t).toHaveBeenCalledWith( + "resellerPastDueWarningMsg", + "Test Reseller Inc", + expectedSuspensionDate, + ); + done(); + }); + }); + }); + + describe("getTaxIdWarning$", () => { + it("should return null when no tax ID warning exists", (done) => { + organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); + + service.getTaxIdWarning$(organization).subscribe((result) => { + expect(result).toBeNull(); + done(); + }); + }); + + it("should return tax_id_missing type when tax ID is missing", (done) => { + const warning = { type: TaxIdWarningTypes.Missing }; + organizationBillingClient.getWarnings.mockResolvedValue({ + taxId: warning, + } as OrganizationWarningsResponse); + + service.getTaxIdWarning$(organization).subscribe((result) => { + expect(result).toBe(TaxIdWarningTypes.Missing); + done(); + }); + }); + + it("should return tax_id_pending_verification type when tax ID verification is pending", (done) => { + const warning = { type: TaxIdWarningTypes.PendingVerification }; + organizationBillingClient.getWarnings.mockResolvedValue({ + taxId: warning, + } as OrganizationWarningsResponse); + + service.getTaxIdWarning$(organization).subscribe((result) => { + expect(result).toBe(TaxIdWarningTypes.PendingVerification); + done(); + }); + }); + + it("should return tax_id_failed_verification type when tax ID verification failed", (done) => { + const warning = { type: TaxIdWarningTypes.FailedVerification }; + organizationBillingClient.getWarnings.mockResolvedValue({ + taxId: warning, + } as OrganizationWarningsResponse); + + service.getTaxIdWarning$(organization).subscribe((result) => { + expect(result).toBe(TaxIdWarningTypes.FailedVerification); + done(); + }); + }); + + it("should refresh warning and update taxIdWarningRefreshedSubject when refreshTaxIdWarning is called", (done) => { + const initialWarning = { type: TaxIdWarningTypes.Missing }; + const refreshedWarning = { type: TaxIdWarningTypes.FailedVerification }; + let invocationCount = 0; + + organizationBillingClient.getWarnings + .mockResolvedValueOnce({ + taxId: initialWarning, + } as OrganizationWarningsResponse) + .mockResolvedValueOnce({ + taxId: refreshedWarning, + } as OrganizationWarningsResponse); + + const subscription = service.getTaxIdWarning$(organization).subscribe((result) => { + invocationCount++; + + if (invocationCount === 1) { + expect(result).toBe(TaxIdWarningTypes.Missing); + } else if (invocationCount === 2) { + expect(result).toBe(TaxIdWarningTypes.FailedVerification); + subscription.unsubscribe(); + done(); + } + }); + + setTimeout(() => { + service.refreshTaxIdWarning(); + }, 10); + }); + + it("should update taxIdWarningRefreshedSubject with warning type when refresh returns a warning", (done) => { + const refreshedWarning = { type: TaxIdWarningTypes.Missing }; + let refreshedCount = 0; + + organizationBillingClient.getWarnings + .mockResolvedValueOnce({} as OrganizationWarningsResponse) + .mockResolvedValueOnce({ + taxId: refreshedWarning, + } as OrganizationWarningsResponse); + + const taxIdSubscription = service.taxIdWarningRefreshed$.subscribe((refreshedType) => { + refreshedCount++; + if (refreshedCount === 2) { + expect(refreshedType).toBe(TaxIdWarningTypes.Missing); + taxIdSubscription.unsubscribe(); + done(); + } + }); + + service.getTaxIdWarning$(organization).subscribe(); + + setTimeout(() => { + service.refreshTaxIdWarning(); + }, 10); + }); + + it("should update taxIdWarningRefreshedSubject with null when refresh returns no warning", (done) => { + const initialWarning = { type: TaxIdWarningTypes.Missing }; + let refreshedCount = 0; + + organizationBillingClient.getWarnings + .mockResolvedValueOnce({ + taxId: initialWarning, + } as OrganizationWarningsResponse) + .mockResolvedValueOnce({} as OrganizationWarningsResponse); + + const taxIdSubscription = service.taxIdWarningRefreshed$.subscribe((refreshedType) => { + refreshedCount++; + if (refreshedCount === 2) { + expect(refreshedType).toBeNull(); + taxIdSubscription.unsubscribe(); + done(); + } + }); + + service.getTaxIdWarning$(organization).subscribe(); + + setTimeout(() => { + service.refreshTaxIdWarning(); + }, 10); + }); + }); + + describe("showInactiveSubscriptionDialog$", () => { + it("should not show dialog when no inactive subscription warning exists", (done) => { + organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + done(); + }, + }); + }); + + it("should show contact provider dialog for contact_provider resolution", (done) => { + const warning = { resolution: "contact_provider" }; + organizationBillingClient.getWarnings.mockResolvedValue({ + inactiveSubscription: warning, + } as OrganizationWarningsResponse); + + dialogService.openSimpleDialog.mockResolvedValue(true); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: "Test Organization subscription suspended", + content: { + key: "suspendedManagedOrgMessage", + placeholders: ["Test Reseller Inc"], + }, + type: "danger", + acceptButtonText: "Close", + cancelButtonText: null, + }); + done(); + }, + }); + }); + + it("should show add payment method dialog and navigate when confirmed", (done) => { + const warning = { resolution: "add_payment_method" }; + organizationBillingClient.getWarnings.mockResolvedValue({ + inactiveSubscription: warning, + } as OrganizationWarningsResponse); + + dialogService.openSimpleDialog.mockResolvedValue(true); + configService.getFeatureFlag.mockResolvedValue(false); + router.navigate.mockResolvedValue(true); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: "Test Organization subscription suspended", + content: { key: "suspendedOwnerOrgMessage" }, + type: "danger", + acceptButtonText: "Continue", + cancelButtonText: "Close", + }); + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, + ); + expect(router.navigate).toHaveBeenCalledWith( + ["organizations", "org-id-123", "billing", "payment-method"], + { state: { launchPaymentModalAutomatically: true } }, + ); + done(); + }, + }); + }); + + it("should navigate to payment-details when feature flag is enabled", (done) => { + const warning = { resolution: "add_payment_method" }; + organizationBillingClient.getWarnings.mockResolvedValue({ + inactiveSubscription: warning, + } as OrganizationWarningsResponse); + + dialogService.openSimpleDialog.mockResolvedValue(true); + configService.getFeatureFlag.mockResolvedValue(true); + router.navigate.mockResolvedValue(true); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(router.navigate).toHaveBeenCalledWith( + ["organizations", "org-id-123", "billing", "payment-details"], + { state: { launchPaymentModalAutomatically: true } }, + ); + done(); + }, + }); + }); + + it("should not navigate when add payment method dialog is cancelled", (done) => { + const warning = { resolution: "add_payment_method" }; + organizationBillingClient.getWarnings.mockResolvedValue({ + inactiveSubscription: warning, + } as OrganizationWarningsResponse); + + dialogService.openSimpleDialog.mockResolvedValue(false); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(dialogService.openSimpleDialog).toHaveBeenCalled(); + expect(configService.getFeatureFlag).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }, + }); + }); + + it("should open change plan dialog for resubscribe resolution", (done) => { + const warning = { resolution: "resubscribe" }; + const subscription = { id: "sub-123" } as OrganizationSubscriptionResponse; + + organizationBillingClient.getWarnings.mockResolvedValue({ + inactiveSubscription: warning, + } as OrganizationWarningsResponse); + + organizationApiService.getSubscription.mockResolvedValue(subscription); + + const mockDialogRef = { + closed: of("submitted"), + } as DialogRef; + + (openChangePlanDialog as jest.Mock).mockReturnValue(mockDialogRef); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(organizationApiService.getSubscription).toHaveBeenCalledWith(organization.id); + expect(openChangePlanDialog).toHaveBeenCalledWith(dialogService, { + data: { + organizationId: organization.id, + subscription: subscription, + productTierType: organization.productTierType, + }, + }); + done(); + }, + }); + }); + + it("should show contact owner dialog for contact_owner resolution", (done) => { + const warning = { resolution: "contact_owner" }; + organizationBillingClient.getWarnings.mockResolvedValue({ + inactiveSubscription: warning, + } as OrganizationWarningsResponse); + + dialogService.openSimpleDialog.mockResolvedValue(true); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: "Test Organization subscription suspended", + content: { key: "suspendedUserOrgMessage" }, + type: "danger", + acceptButtonText: "Close", + cancelButtonText: null, + }); + done(); + }, + }); + }); + }); + + describe("showSubscribeBeforeFreeTrialEndsDialog$", () => { + it("should not show dialog when no free trial warning exists", (done) => { + organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); + + service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({ + complete: () => { + expect(organizationApiService.getSubscription).not.toHaveBeenCalled(); + done(); + }, + }); + }); + + it("should open trial payment dialog when free trial warning exists", (done) => { + const warning = { remainingTrialDays: 2 }; + const subscription = { id: "sub-123" } as OrganizationSubscriptionResponse; + + organizationBillingClient.getWarnings.mockResolvedValue({ + freeTrial: warning, + } as OrganizationWarningsResponse); + + organizationApiService.getSubscription.mockResolvedValue(subscription); + + const mockDialogRef = { + closed: of(TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.CLOSED), + } as DialogRef; + + const openSpy = jest + .spyOn(TrialPaymentDialogComponent, "open") + .mockReturnValue(mockDialogRef); + + service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({ + complete: () => { + expect(organizationApiService.getSubscription).toHaveBeenCalledWith(organization.id); + expect(openSpy).toHaveBeenCalledWith(dialogService, { + data: { + organizationId: organization.id, + subscription: subscription, + productTierType: organization.productTierType, + }, + }); + done(); + }, + }); + }); + + it("should refresh free trial warning when dialog result is SUBMITTED", (done) => { + const warning = { remainingTrialDays: 1 }; + const subscription = { id: "sub-456" } as OrganizationSubscriptionResponse; + + organizationBillingClient.getWarnings.mockResolvedValue({ + freeTrial: warning, + } as OrganizationWarningsResponse); + + organizationApiService.getSubscription.mockResolvedValue(subscription); + + const mockDialogRef = { + closed: of(TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED), + } as DialogRef; + + jest.spyOn(TrialPaymentDialogComponent, "open").mockReturnValue(mockDialogRef); + + const refreshTriggerSpy = jest.spyOn(service["refreshFreeTrialWarningTrigger"], "next"); + + service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({ + complete: () => { + expect(refreshTriggerSpy).toHaveBeenCalled(); + done(); + }, + }); + }); + + it("should not refresh free trial warning when dialog result is CLOSED", (done) => { + const warning = { remainingTrialDays: 3 }; + const subscription = { id: "sub-789" } as OrganizationSubscriptionResponse; + + organizationBillingClient.getWarnings.mockResolvedValue({ + freeTrial: warning, + } as OrganizationWarningsResponse); + + organizationApiService.getSubscription.mockResolvedValue(subscription); + + const mockDialogRef = { + closed: of(TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.CLOSED), + } as DialogRef; + + jest.spyOn(TrialPaymentDialogComponent, "open").mockReturnValue(mockDialogRef); + const refreshSpy = jest.spyOn(service, "refreshFreeTrialWarning"); + + service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({ + complete: () => { + expect(refreshSpy).not.toHaveBeenCalled(); + done(); + }, + }); + }); + }); +}); diff --git a/apps/web/src/app/billing/warnings/services/organization-warnings.service.ts b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts similarity index 63% rename from apps/web/src/app/billing/warnings/services/organization-warnings.service.ts rename to apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts index 78c17a5d384..5b466dfe41d 100644 --- a/apps/web/src/app/billing/warnings/services/organization-warnings.service.ts +++ b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts @@ -1,26 +1,39 @@ -import { Location } from "@angular/common"; import { Injectable } from "@angular/core"; import { Router } from "@angular/router"; -import { filter, from, lastValueFrom, map, Observable, Subject, switchMap, takeWhile } from "rxjs"; +import { + BehaviorSubject, + filter, + from, + lastValueFrom, + map, + merge, + Observable, + Subject, + switchMap, + tap, +} from "rxjs"; import { take } from "rxjs/operators"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; -import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SyncService } from "@bitwarden/common/platform/sync"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; +import { OrganizationBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types"; -import { openChangePlanDialog } from "../../organizations/change-plan-dialog.component"; import { TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE, TrialPaymentDialogComponent, -} from "../../shared/trial-payment-dialog/trial-payment-dialog.component"; -import { OrganizationFreeTrialWarning, OrganizationResellerRenewalWarning } from "../types"; +} from "../../../shared/trial-payment-dialog/trial-payment-dialog.component"; +import { openChangePlanDialog } from "../../change-plan-dialog.component"; +import { + OrganizationFreeTrialWarning, + OrganizationResellerRenewalWarning, + OrganizationWarningsResponse, +} from "../types"; const format = (date: Date) => date.toLocaleDateString("en-US", { @@ -29,28 +42,39 @@ const format = (date: Date) => year: "numeric", }); -@Injectable({ providedIn: "root" }) +@Injectable() export class OrganizationWarningsService { private cache$ = new Map>(); - private refreshWarnings$ = new Subject(); + + private refreshFreeTrialWarningTrigger = new Subject(); + private refreshTaxIdWarningTrigger = new Subject(); + + private taxIdWarningRefreshedSubject = new BehaviorSubject(null); + taxIdWarningRefreshed$ = this.taxIdWarningRefreshedSubject.asObservable(); constructor( private configService: ConfigService, private dialogService: DialogService, private i18nService: I18nService, private organizationApiService: OrganizationApiServiceAbstraction, - private organizationBillingApiService: OrganizationBillingApiServiceAbstraction, + private organizationBillingClient: OrganizationBillingClient, private router: Router, - private location: Location, - protected syncService: SyncService, ) {} getFreeTrialWarning$ = ( organization: Organization, - bypassCache: boolean = false, - ): Observable => - this.getWarning$(organization, (response) => response.freeTrial, bypassCache).pipe( + ): Observable => + merge( + this.getWarning$(organization, (response) => response.freeTrial), + this.refreshFreeTrialWarningTrigger.pipe( + switchMap(() => this.getWarning$(organization, (response) => response.freeTrial, true)), + ), + ).pipe( map((warning) => { + if (!warning) { + return null; + } + const { remainingTrialDays } = warning; if (remainingTrialDays >= 2) { @@ -76,10 +100,12 @@ export class OrganizationWarningsService { getResellerRenewalWarning$ = ( organization: Organization, - bypassCache: boolean = false, - ): Observable => - this.getWarning$(organization, (response) => response.resellerRenewal, bypassCache).pipe( - map((warning): OrganizationResellerRenewalWarning | null => { + ): Observable => + this.getWarning$(organization, (response) => response.resellerRenewal).pipe( + map((warning) => { + if (!warning) { + return null; + } switch (warning.type) { case "upcoming": { return { @@ -114,14 +140,27 @@ export class OrganizationWarningsService { } } }), - filter((result): result is NonNullable => result !== null), ); - showInactiveSubscriptionDialog$ = ( - organization: Organization, - bypassCache: boolean = false, - ): Observable => - this.getWarning$(organization, (response) => response.inactiveSubscription, bypassCache).pipe( + getTaxIdWarning$ = (organization: Organization): Observable => + merge( + this.getWarning$(organization, (response) => response.taxId), + this.refreshTaxIdWarningTrigger.pipe( + switchMap(() => + this.getWarning$(organization, (response) => response.taxId, true).pipe( + tap((warning) => this.taxIdWarningRefreshedSubject.next(warning ? warning.type : null)), + ), + ), + ), + ).pipe(map((warning) => (warning ? warning.type : null))); + + refreshFreeTrialWarning = () => this.refreshFreeTrialWarningTrigger.next(); + + refreshTaxIdWarning = () => this.refreshTaxIdWarningTrigger.next(); + + showInactiveSubscriptionDialog$ = (organization: Organization): Observable => + this.getWarning$(organization, (response) => response.inactiveSubscription).pipe( + filter((warning) => warning !== null), switchMap(async (warning) => { switch (warning.resolution) { case "contact_provider": { @@ -183,43 +222,43 @@ export class OrganizationWarningsService { }); break; } - case "add_payment_method_optional_trial": { - const organizationSubscriptionResponse = - await this.organizationApiService.getSubscription(organization.id); - - const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, { - data: { - organizationId: organization.id, - subscription: organizationSubscriptionResponse, - productTierType: organization?.productTierType, - }, - }); - const result = await lastValueFrom(dialogRef.closed); - if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) { - this.refreshWarnings$.next(organization.id as OrganizationId); - } - } } }), ); - refreshWarningsForOrganization$(organizationId: OrganizationId): Observable { - return this.refreshWarnings$.pipe( - filter((id) => id === organizationId), - map((): void => void 0), - ); - } + showSubscribeBeforeFreeTrialEndsDialog$ = (organization: Organization): Observable => + this.getWarning$(organization, (response) => response.freeTrial).pipe( + filter((warning) => warning !== null), + switchMap(async () => { + const organizationSubscriptionResponse = await this.organizationApiService.getSubscription( + organization.id, + ); - private getResponse$ = ( + const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, { + data: { + organizationId: organization.id, + subscription: organizationSubscriptionResponse, + productTierType: organization?.productTierType, + }, + }); + const result = await lastValueFrom(dialogRef.closed); + if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) { + this.refreshFreeTrialWarningTrigger.next(); + } + }), + ); + + private readThroughWarnings$ = ( organization: Organization, bypassCache: boolean = false, ): Observable => { - const existing = this.cache$.get(organization.id as OrganizationId); + const organizationId = organization.id as OrganizationId; + const existing = this.cache$.get(organizationId); if (existing && !bypassCache) { return existing; } - const response$ = from(this.organizationBillingApiService.getWarnings(organization.id)); - this.cache$.set(organization.id as OrganizationId, response$); + const response$ = from(this.organizationBillingClient.getWarnings(organizationId)); + this.cache$.set(organizationId, response$); return response$; }; @@ -227,10 +266,12 @@ export class OrganizationWarningsService { organization: Organization, extract: (response: OrganizationWarningsResponse) => T | null | undefined, bypassCache: boolean = false, - ): Observable => - this.getResponse$(organization, bypassCache).pipe( - map(extract), - takeWhile((warning): warning is T => !!warning), + ): Observable => + this.readThroughWarnings$(organization, bypassCache).pipe( + map((response) => { + const value = extract(response); + return value ? value : null; + }), take(1), ); } diff --git a/apps/web/src/app/billing/organizations/warnings/types/index.ts b/apps/web/src/app/billing/organizations/warnings/types/index.ts new file mode 100644 index 00000000000..fc0c7d278ed --- /dev/null +++ b/apps/web/src/app/billing/organizations/warnings/types/index.ts @@ -0,0 +1 @@ +export * from "./organization-warnings"; diff --git a/libs/common/src/billing/models/response/organization-warnings.response.ts b/apps/web/src/app/billing/organizations/warnings/types/organization-warnings.ts similarity index 80% rename from libs/common/src/billing/models/response/organization-warnings.response.ts rename to apps/web/src/app/billing/organizations/warnings/types/organization-warnings.ts index ff70298101e..0c0097d5b09 100644 --- a/libs/common/src/billing/models/response/organization-warnings.response.ts +++ b/apps/web/src/app/billing/organizations/warnings/types/organization-warnings.ts @@ -1,9 +1,22 @@ -import { BaseResponse } from "../../../models/response/base.response"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { TaxIdWarningResponse } from "@bitwarden/web-vault/app/billing/warnings/types"; + +export type OrganizationFreeTrialWarning = { + organization: Pick; + message: string; +}; + +export type OrganizationResellerRenewalWarning = { + type: "info" | "warning"; + message: string; +}; export class OrganizationWarningsResponse extends BaseResponse { freeTrial?: FreeTrialWarningResponse; inactiveSubscription?: InactiveSubscriptionWarningResponse; resellerRenewal?: ResellerRenewalWarningResponse; + taxId?: TaxIdWarningResponse; constructor(response: any) { super(response); @@ -21,6 +34,10 @@ export class OrganizationWarningsResponse extends BaseResponse { if (resellerWarning) { this.resellerRenewal = new ResellerRenewalWarningResponse(resellerWarning); } + const taxIdWarning = this.getResponseProperty("TaxId"); + if (taxIdWarning) { + this.taxId = new TaxIdWarningResponse(taxIdWarning); + } } } diff --git a/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts b/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts index 90705abddd3..a83a00e8158 100644 --- a/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts @@ -14,13 +14,13 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; type DialogParams = { - owner: BillableEntity; + subscriber: BitwardenSubscriber; }; type DialogResult = "cancelled" | "error" | "launched"; @@ -125,7 +125,7 @@ const positiveNumberValidator = `, standalone: true, imports: [SharedModule], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class AddAccountCreditDialogComponent { @ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm!: ElementRef; @@ -143,22 +143,22 @@ export class AddAccountCreditDialogComponent { protected payPalCustom$ = this.configService.cloudRegion$.pipe( map((cloudRegion) => { - switch (this.dialogParams.owner.type) { + switch (this.dialogParams.subscriber.type) { case "account": { - return `user_id:${this.dialogParams.owner.data.id},account_credit:1,region:${cloudRegion}`; + return `user_id:${this.dialogParams.subscriber.data.id},account_credit:1,region:${cloudRegion}`; } case "organization": { - return `organization_id:${this.dialogParams.owner.data.id},account_credit:1,region:${cloudRegion}`; + return `organization_id:${this.dialogParams.subscriber.data.id},account_credit:1,region:${cloudRegion}`; } case "provider": { - return `provider_id:${this.dialogParams.owner.data.id},account_credit:1,region:${cloudRegion}`; + return `provider_id:${this.dialogParams.subscriber.data.id},account_credit:1,region:${cloudRegion}`; } } }), ); constructor( - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, private configService: ConfigService, @Inject(DIALOG_DATA) private dialogParams: DialogParams, private dialogRef: DialogRef, @@ -175,7 +175,7 @@ export class AddAccountCreditDialogComponent { } if (this.formGroup.value.paymentMethod === "bitPay") { - const result = await this.billingClient.addCreditWithBitPay(this.dialogParams.owner, { + const result = await this.billingClient.addCreditWithBitPay(this.dialogParams.subscriber, { amount: this.amount!, redirectUrl: this.redirectUrl, }); @@ -225,13 +225,13 @@ export class AddAccountCreditDialogComponent { } get payPalSubject(): string { - switch (this.dialogParams.owner.type) { + switch (this.dialogParams.subscriber.type) { case "account": { - return this.dialogParams.owner.data.email; + return this.dialogParams.subscriber.data.email; } case "organization": case "provider": { - return this.dialogParams.owner.data.name; + return this.dialogParams.subscriber.data.name; } } } diff --git a/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts index 15c63d8f99f..4d2fadaa894 100644 --- a/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts @@ -3,10 +3,10 @@ import { Component, Inject } from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; import { @@ -15,7 +15,7 @@ import { } from "./submit-payment-method-dialog.component"; type DialogParams = { - owner: BillableEntity; + subscriber: BitwardenSubscriber; }; @Component({ @@ -28,7 +28,7 @@ type DialogParams = {
@@ -51,20 +51,20 @@ type DialogParams = { `, standalone: true, imports: [EnterPaymentMethodComponent, SharedModule], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class ChangePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent { - protected override owner: BillableEntity; + protected override subscriber: BitwardenSubscriber; constructor( - billingClient: BillingClient, + billingClient: SubscriberBillingClient, @Inject(DIALOG_DATA) protected dialogParams: DialogParams, dialogRef: DialogRef, i18nService: I18nService, toastService: ToastService, ) { super(billingClient, dialogRef, i18nService, toastService); - this.owner = this.dialogParams.owner; + this.subscriber = this.dialogParams.subscriber; } static open = (dialogService: DialogService, dialogConfig: DialogConfig) => diff --git a/apps/web/src/app/billing/payment/components/display-account-credit.component.ts b/apps/web/src/app/billing/payment/components/display-account-credit.component.ts index 7cbe3a27f30..f6aa0ef58bb 100644 --- a/apps/web/src/app/billing/payment/components/display-account-credit.component.ts +++ b/apps/web/src/app/billing/payment/components/display-account-credit.component.ts @@ -3,10 +3,10 @@ import { Component, Input } from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.component"; @@ -23,14 +23,14 @@ import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.com `, standalone: true, imports: [SharedModule], - providers: [BillingClient, CurrencyPipe], + providers: [SubscriberBillingClient, CurrencyPipe], }) export class DisplayAccountCreditComponent { - @Input({ required: true }) owner!: BillableEntity; + @Input({ required: true }) subscriber!: BitwardenSubscriber; @Input({ required: true }) credit!: number | null; constructor( - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, private currencyPipe: CurrencyPipe, private dialogService: DialogService, private i18nService: I18nService, @@ -38,8 +38,8 @@ export class DisplayAccountCreditComponent { ) {} addAccountCredit = async () => { - if (this.owner.type !== "account") { - const billingAddress = await this.billingClient.getBillingAddress(this.owner); + if (this.subscriber.type !== "account") { + const billingAddress = await this.billingClient.getBillingAddress(this.subscriber); if (!billingAddress) { this.toastService.showToast({ variant: "error", @@ -51,7 +51,7 @@ export class DisplayAccountCreditComponent { AddAccountCreditDialogComponent.open(this.dialogService, { data: { - owner: this.owner, + subscriber: this.subscriber, }, }); }; diff --git a/apps/web/src/app/billing/payment/components/display-billing-address.component.ts b/apps/web/src/app/billing/payment/components/display-billing-address.component.ts index f0a11321e5d..03d21a79003 100644 --- a/apps/web/src/app/billing/payment/components/display-billing-address.component.ts +++ b/apps/web/src/app/billing/payment/components/display-billing-address.component.ts @@ -2,23 +2,38 @@ import { Component, EventEmitter, Input, Output } from "@angular/core"; import { lastValueFrom } from "rxjs"; import { DialogService } from "@bitwarden/components"; - -import { SharedModule } from "../../../shared"; -import { BillableEntity } from "../../types"; -import { AddressPipe } from "../pipes"; -import { BillingAddress } from "../types"; - -import { EditBillingAddressDialogComponent } from "./edit-billing-address-dialog.component"; +import { EditBillingAddressDialogComponent } from "@bitwarden/web-vault/app/billing/payment/components/edit-billing-address-dialog.component"; +import { AddressPipe } from "@bitwarden/web-vault/app/billing/payment/pipes"; +import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types"; +import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types"; +import { + TaxIdWarningType, + TaxIdWarningTypes, +} from "@bitwarden/web-vault/app/billing/warnings/types"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; @Component({ selector: "app-display-billing-address", template: ` -

{{ "billingAddress" | i18n }}

+

+ {{ "billingAddress" | i18n }} + @if (showMissingTaxIdBadge) { + {{ "missingTaxId" | i18n }} + } +

@if (billingAddress) {

{{ billingAddress | address }}

@if (billingAddress.taxId) { -

{{ "taxId" | i18n: billingAddress.taxId.value }}

+

+ {{ "taxId" | i18n: billingAddress.taxId.value }} + @if (showTaxIdPendingVerificationBadge) { + {{ "pendingVerification" | i18n }} + } + @if (showUnverifiedTaxIdBadge) { + {{ "unverified" | i18n }} + } +

} } @else {

{{ "noBillingAddress" | i18n }}

@@ -33,8 +48,9 @@ import { EditBillingAddressDialogComponent } from "./edit-billing-address-dialog imports: [AddressPipe, SharedModule], }) export class DisplayBillingAddressComponent { - @Input({ required: true }) owner!: BillableEntity; + @Input({ required: true }) subscriber!: BitwardenSubscriber; @Input({ required: true }) billingAddress!: BillingAddress | null; + @Input() taxIdWarning?: TaxIdWarningType; @Output() updated = new EventEmitter(); constructor(private dialogService: DialogService) {} @@ -42,8 +58,9 @@ export class DisplayBillingAddressComponent { editBillingAddress = async (): Promise => { const dialogRef = EditBillingAddressDialogComponent.open(this.dialogService, { data: { - owner: this.owner, + subscriber: this.subscriber, billingAddress: this.billingAddress, + taxIdWarning: this.taxIdWarning, }, }); @@ -53,4 +70,22 @@ export class DisplayBillingAddressComponent { this.updated.emit(result.billingAddress); } }; + + get showMissingTaxIdBadge(): boolean { + return this.subscriber.type !== "account" && this.taxIdWarning === TaxIdWarningTypes.Missing; + } + + get showTaxIdPendingVerificationBadge(): boolean { + return ( + this.subscriber.type !== "account" && + this.taxIdWarning === TaxIdWarningTypes.PendingVerification + ); + } + + get showUnverifiedTaxIdBadge(): boolean { + return ( + this.subscriber.type !== "account" && + this.taxIdWarning === TaxIdWarningTypes.FailedVerification + ); + } } diff --git a/apps/web/src/app/billing/payment/components/display-payment-method.component.ts b/apps/web/src/app/billing/payment/components/display-payment-method.component.ts index 769472bcfcf..df42d04b802 100644 --- a/apps/web/src/app/billing/payment/components/display-payment-method.component.ts +++ b/apps/web/src/app/billing/payment/components/display-payment-method.component.ts @@ -4,7 +4,7 @@ import { lastValueFrom } from "rxjs"; import { DialogService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { MaskedPaymentMethod } from "../types"; import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dialog.component"; @@ -19,7 +19,10 @@ import { VerifyBankAccountComponent } from "./verify-bank-account.component"; @switch (paymentMethod.type) { @case ("bankAccount") { @if (!paymentMethod.verified) { - + } @@ -63,7 +66,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account.component"; imports: [SharedModule, VerifyBankAccountComponent], }) export class DisplayPaymentMethodComponent { - @Input({ required: true }) owner!: BillableEntity; + @Input({ required: true }) subscriber!: BitwardenSubscriber; @Input({ required: true }) paymentMethod!: MaskedPaymentMethod | null; @Output() updated = new EventEmitter(); @@ -82,7 +85,7 @@ export class DisplayPaymentMethodComponent { changePaymentMethod = async (): Promise => { const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, { data: { - owner: this.owner, + subscriber: this.subscriber, }, }); diff --git a/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts b/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts index c844d08df58..de2f2f94497 100644 --- a/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts @@ -3,18 +3,31 @@ import { Component, Inject } from "@angular/core"; import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; - -import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; -import { BillingAddress, getTaxIdTypeForCountry } from "../types"; +import { + CalloutTypes, + DialogConfig, + DialogRef, + DialogService, + ToastService, +} from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { + BillingAddress, + getTaxIdTypeForCountry, +} from "@bitwarden/web-vault/app/billing/payment/types"; +import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types"; +import { + TaxIdWarningType, + TaxIdWarningTypes, +} from "@bitwarden/web-vault/app/billing/warnings/types"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { EnterBillingAddressComponent } from "./enter-billing-address.component"; type DialogParams = { - owner: BillableEntity; + subscriber: BitwardenSubscriber; billingAddress: BillingAddress | null; + taxIdWarning?: TaxIdWarningType; }; type DialogResult = @@ -30,11 +43,18 @@ type DialogResult = {{ "editBillingAddress" | i18n }}
+ @let callout = taxIdWarningCallout; + @if (callout) { + + {{ callout.message }} + + } @@ -57,13 +77,13 @@ type DialogResult = `, standalone: true, imports: [EnterBillingAddressComponent, SharedModule], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class EditBillingAddressDialogComponent { protected formGroup = EnterBillingAddressComponent.getFormGroup(); constructor( - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, @Inject(DIALOG_DATA) protected dialogParams: DialogParams, private dialogRef: DialogRef, private i18nService: I18nService, @@ -93,7 +113,7 @@ export class EditBillingAddressDialogComponent { : { ...addressFields, taxId: null }; const result = await this.billingClient.updateBillingAddress( - this.dialogParams.owner, + this.dialogParams.subscriber, billingAddress, ); @@ -125,7 +145,7 @@ export class EditBillingAddressDialogComponent { }; get supportsTaxId(): boolean { - switch (this.dialogParams.owner.type) { + switch (this.dialogParams.subscriber.type) { case "account": { return false; } @@ -134,7 +154,7 @@ export class EditBillingAddressDialogComponent { ProductTierType.TeamsStarter, ProductTierType.Teams, ProductTierType.Enterprise, - ].includes(this.dialogParams.owner.data.productTierType); + ].includes(this.dialogParams.subscriber.data.productTierType); } case "provider": { return true; @@ -142,6 +162,37 @@ export class EditBillingAddressDialogComponent { } } + get taxIdWarningCallout(): { + type: CalloutTypes; + title: string; + message: string; + } | null { + if ( + !this.supportsTaxId || + !this.dialogParams.taxIdWarning || + this.dialogParams.taxIdWarning === TaxIdWarningTypes.PendingVerification + ) { + return null; + } + + switch (this.dialogParams.taxIdWarning) { + case TaxIdWarningTypes.Missing: { + return { + type: "warning", + title: this.i18nService.t("missingTaxIdCalloutTitle"), + message: this.i18nService.t("missingTaxIdCalloutDescription"), + }; + } + case TaxIdWarningTypes.FailedVerification: { + return { + type: "warning", + title: this.i18nService.t("unverifiedTaxIdCalloutTitle"), + message: this.i18nService.t("unverifiedTaxIdCalloutDescription"), + }; + } + } + } + static open = (dialogService: DialogService, dialogConfig: DialogConfig) => dialogService.open(EditBillingAddressDialogComponent, dialogConfig); } diff --git a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts index ab59e965b4e..7659b7ed5ca 100644 --- a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts +++ b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts @@ -3,9 +3,14 @@ import { FormControl, FormGroup, Validators } from "@angular/forms"; import { map, Observable, startWith, Subject, takeUntil } from "rxjs"; import { ControlsOf } from "@bitwarden/angular/types/controls-of"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + TaxIdWarningType, + TaxIdWarningTypes, +} from "@bitwarden/web-vault/app/billing/warnings/types"; import { SharedModule } from "../../../shared"; -import { BillingAddress, selectableCountries, taxIdTypes } from "../types"; +import { BillingAddress, getTaxIdTypeForCountry, selectableCountries, taxIdTypes } from "../types"; export interface BillingAddressControls { country: string; @@ -28,6 +33,7 @@ type Scenario = type: "update"; existing?: BillingAddress; supportsTaxId: boolean; + taxIdWarning?: TaxIdWarningType; }; @Component({ @@ -110,7 +116,7 @@ type Scenario =
@if (supportsTaxId$ | async) { -
+
{{ "taxIdNumber" | i18n }} + @let hint = taxIdWarningHint; + @if (hint) { + {{ hint }} + }
} @@ -137,6 +154,8 @@ export class EnterBillingAddressComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); + constructor(private i18nService: I18nService) {} + ngOnInit() { switch (this.scenario.type) { case "checkout": { @@ -185,6 +204,40 @@ export class EnterBillingAddressComponent implements OnInit, OnDestroy { this.group.controls.state.disable(); }; + get taxIdWarningHint() { + if ( + this.scenario.type === "checkout" || + !this.scenario.supportsTaxId || + !this.group.value.country || + this.scenario.taxIdWarning !== TaxIdWarningTypes.FailedVerification + ) { + return null; + } + + const taxIdType = getTaxIdTypeForCountry(this.group.value.country); + + if (!taxIdType) { + return null; + } + + const checkInputFormat = this.i18nService.t("checkInputFormat"); + + switch (taxIdType.code) { + case "au_abn": { + const exampleFormat = this.i18nService.t("exampleTaxIdFormat", "ABN", taxIdType.example); + return `${checkInputFormat} ${exampleFormat}`; + } + case "eu_vat": { + const exampleFormat = this.i18nService.t("exampleTaxIdFormat", "EU VAT", taxIdType.example); + return `${checkInputFormat} ${exampleFormat}`; + } + case "gb_vat": { + const exampleFormat = this.i18nService.t("exampleTaxIdFormat", "GB VAT", taxIdType.example); + return `${checkInputFormat} ${exampleFormat}`; + } + } + } + static getFormGroup = (): BillingAddressFormGroup => new FormGroup({ country: new FormControl("", { diff --git a/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts index 72585badca0..b1ca1922775 100644 --- a/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts @@ -9,10 +9,10 @@ import { DialogService, ToastService, } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; import { @@ -21,7 +21,7 @@ import { } from "./submit-payment-method-dialog.component"; type DialogParams = { - owner: BillableEntity; + subscriber: BitwardenSubscriber; callout: { type: CalloutTypes; title: string; @@ -53,20 +53,20 @@ type DialogParams = { `, standalone: true, imports: [EnterPaymentMethodComponent, SharedModule], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class RequirePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent { - protected override owner: BillableEntity; + protected override subscriber: BitwardenSubscriber; constructor( - billingClient: BillingClient, + billingClient: SubscriberBillingClient, @Inject(DIALOG_DATA) protected dialogParams: DialogParams, dialogRef: DialogRef, i18nService: I18nService, toastService: ToastService, ) { super(billingClient, dialogRef, i18nService, toastService); - this.owner = this.dialogParams.owner; + this.subscriber = this.dialogParams.subscriber; } static open = (dialogService: DialogService, dialogConfig: DialogConfig) => diff --git a/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts index 0a0a5bf26d9..62d2b775eb5 100644 --- a/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts @@ -2,9 +2,9 @@ import { Component, ViewChild } from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogRef, ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { MaskedPaymentMethod } from "../types"; import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; @@ -20,10 +20,10 @@ export abstract class SubmitPaymentMethodDialogComponent { private enterPaymentMethodComponent!: EnterPaymentMethodComponent; protected formGroup = EnterPaymentMethodComponent.getFormGroup(); - protected abstract owner: BillableEntity; + protected abstract subscriber: BitwardenSubscriber; protected constructor( - protected billingClient: BillingClient, + protected billingClient: SubscriberBillingClient, protected dialogRef: DialogRef, protected i18nService: I18nService, protected toastService: ToastService, @@ -43,7 +43,7 @@ export abstract class SubmitPaymentMethodDialogComponent { : null; const result = await this.billingClient.updatePaymentMethod( - this.owner, + this.subscriber, paymentMethod, billingAddress, ); diff --git a/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts b/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts index f79e9a1b5fc..b1a2814daf2 100644 --- a/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts +++ b/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts @@ -3,10 +3,10 @@ import { FormControl, FormGroup, Validators } from "@angular/forms"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { MaskedPaymentMethod } from "../types"; @Component({ @@ -32,10 +32,10 @@ import { MaskedPaymentMethod } from "../types"; `, standalone: true, imports: [SharedModule], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class VerifyBankAccountComponent { - @Input({ required: true }) owner!: BillableEntity; + @Input({ required: true }) subscriber!: BitwardenSubscriber; @Output() verified = new EventEmitter(); protected formGroup = new FormGroup({ @@ -47,7 +47,7 @@ export class VerifyBankAccountComponent { }); constructor( - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, private i18nService: I18nService, private toastService: ToastService, ) {} @@ -60,7 +60,7 @@ export class VerifyBankAccountComponent { } const result = await this.billingClient.verifyBankAccount( - this.owner, + this.subscriber, this.formGroup.value.descriptorCode!, ); diff --git a/apps/web/src/app/billing/services/index.ts b/apps/web/src/app/billing/services/index.ts index dcd2c05034a..e291ca6a454 100644 --- a/apps/web/src/app/billing/services/index.ts +++ b/apps/web/src/app/billing/services/index.ts @@ -1,4 +1,3 @@ -export * from "./billing.client"; export * from "./billing-services.module"; export * from "./braintree.service"; export * from "./stripe.service"; diff --git a/apps/web/src/app/billing/types/billable-entity.ts b/apps/web/src/app/billing/types/bitwarden-subscriber.ts similarity index 67% rename from apps/web/src/app/billing/types/billable-entity.ts rename to apps/web/src/app/billing/types/bitwarden-subscriber.ts index 79ed12a4161..3454d6a9651 100644 --- a/apps/web/src/app/billing/types/billable-entity.ts +++ b/apps/web/src/app/billing/types/bitwarden-subscriber.ts @@ -4,12 +4,14 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; -export type BillableEntity = +export type BitwardenSubscriber = | { type: "account"; data: Account } | { type: "organization"; data: Organization } | { type: "provider"; data: Provider }; -export const accountToBillableEntity = map((account) => { +export type NonIndividualSubscriber = Exclude; + +export const mapAccountToSubscriber = map((account) => { if (!account) { throw new Error("Account not found"); } @@ -19,7 +21,7 @@ export const accountToBillableEntity = map((acco }; }); -export const organizationToBillableEntity = map( +export const mapOrganizationToSubscriber = map( (organization) => { if (!organization) { throw new Error("Organization not found"); @@ -31,7 +33,7 @@ export const organizationToBillableEntity = map((provider) => { +export const mapProviderToSubscriber = map((provider) => { if (!provider) { throw new Error("Organization not found"); } diff --git a/apps/web/src/app/billing/types/index.ts b/apps/web/src/app/billing/types/index.ts index 1278e0f2e14..50c007677f3 100644 --- a/apps/web/src/app/billing/types/index.ts +++ b/apps/web/src/app/billing/types/index.ts @@ -1,2 +1,2 @@ -export * from "./billable-entity"; +export * from "./bitwarden-subscriber"; export * from "./free-trial"; diff --git a/apps/web/src/app/billing/warnings/components/index.ts b/apps/web/src/app/billing/warnings/components/index.ts index 1e1e0682e62..5edefadb1ee 100644 --- a/apps/web/src/app/billing/warnings/components/index.ts +++ b/apps/web/src/app/billing/warnings/components/index.ts @@ -1,2 +1 @@ -export * from "./organization-free-trial-warning.component"; -export * from "./organization-reseller-renewal-warning.component"; +export * from "./tax-id-warning.component"; diff --git a/apps/web/src/app/billing/warnings/components/tax-id-warning.component.ts b/apps/web/src/app/billing/warnings/components/tax-id-warning.component.ts new file mode 100644 index 00000000000..7527ef8f0b7 --- /dev/null +++ b/apps/web/src/app/billing/warnings/components/tax-id-warning.component.ts @@ -0,0 +1,286 @@ +import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { + BehaviorSubject, + combineLatest, + filter, + firstValueFrom, + lastValueFrom, + map, + Observable, + switchMap, +} from "rxjs"; + +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { BannerModule, DialogService } from "@bitwarden/components"; +import { BILLING_DISK, StateProvider, UserKeyDefinition } from "@bitwarden/state"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { EditBillingAddressDialogComponent } from "@bitwarden/web-vault/app/billing/payment/components"; +import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types"; +import { + TaxIdWarningType, + TaxIdWarningTypes, +} from "@bitwarden/web-vault/app/billing/warnings/types"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +type DismissalCounts = { + [TaxIdWarningTypes.Missing]?: number; + [TaxIdWarningTypes.FailedVerification]?: number; +}; + +const DISMISSALS_COUNT_KEY = new UserKeyDefinition( + BILLING_DISK, + "taxIdWarningDismissalCounts", + { + deserializer: (dismissalCounts) => dismissalCounts, + clearOn: [], + }, +); + +type DismissedThisSession = { + [TaxIdWarningTypes.Missing]?: boolean; + [TaxIdWarningTypes.FailedVerification]?: boolean; +}; + +const DISMISSED_THIS_SESSION_KEY = new UserKeyDefinition( + BILLING_DISK, + "taxIdWarningDismissedThisSession", + { + deserializer: (dismissedThisSession) => dismissedThisSession, + clearOn: ["logout"], + }, +); + +type Dismissals = { + [TaxIdWarningTypes.Missing]: { + count: number; + dismissedThisSession: boolean; + }; + [TaxIdWarningTypes.FailedVerification]: { + count: number; + dismissedThisSession: boolean; + }; +}; + +const shouldShowWarning = ( + warning: Exclude, + dismissals: Dismissals, +) => { + const dismissalsForType = dismissals[warning]; + if (dismissalsForType.dismissedThisSession) { + return false; + } + return dismissalsForType.count < 3; +}; + +type View = { + message: string; + callToAction: string; +}; + +type GetWarning$ = () => Observable; + +@Component({ + selector: "app-tax-id-warning", + template: ` + @if (enableTaxIdWarning$ | async) { + @let view = view$ | async; + + @if (view) { + + {{ view.message }} + + {{ view.callToAction }} + + + } + } + `, + imports: [BannerModule, SharedModule], +}) +export class TaxIdWarningComponent implements OnInit { + @Input({ required: true }) subscriber!: NonIndividualSubscriber; + @Input({ required: true }) getWarning$!: GetWarning$; + @Output() billingAddressUpdated = new EventEmitter(); + + protected enableTaxIdWarning$ = this.configService.getFeatureFlag$( + FeatureFlag.PM22415_TaxIDWarnings, + ); + + protected userId$ = this.accountService.activeAccount$.pipe( + filter((account): account is Account => account !== null), + getUserId, + ); + + protected dismissals$: Observable = this.userId$.pipe( + switchMap((userId) => + combineLatest([ + this.stateProvider.getUser(userId, DISMISSALS_COUNT_KEY).state$.pipe( + map((dismissalCounts) => { + if (!dismissalCounts) { + return { + [TaxIdWarningTypes.Missing]: 0, + [TaxIdWarningTypes.FailedVerification]: 0, + }; + } + return { + [TaxIdWarningTypes.Missing]: dismissalCounts[TaxIdWarningTypes.Missing] ?? 0, + [TaxIdWarningTypes.FailedVerification]: + dismissalCounts[TaxIdWarningTypes.FailedVerification] ?? 0, + }; + }), + ), + this.stateProvider.getUser(userId, DISMISSED_THIS_SESSION_KEY).state$.pipe( + map((dismissedThisSession) => { + if (!dismissedThisSession) { + return { + [TaxIdWarningTypes.Missing]: false, + [TaxIdWarningTypes.FailedVerification]: false, + }; + } + return { + [TaxIdWarningTypes.Missing]: dismissedThisSession[TaxIdWarningTypes.Missing] ?? false, + [TaxIdWarningTypes.FailedVerification]: + dismissedThisSession[TaxIdWarningTypes.FailedVerification] ?? false, + }; + }), + ), + ]), + ), + map(([dismissalCounts, dismissedThisSession]) => ({ + [TaxIdWarningTypes.Missing]: { + count: dismissalCounts[TaxIdWarningTypes.Missing], + dismissedThisSession: dismissedThisSession[TaxIdWarningTypes.Missing], + }, + [TaxIdWarningTypes.FailedVerification]: { + count: dismissalCounts[TaxIdWarningTypes.FailedVerification], + dismissedThisSession: dismissedThisSession[TaxIdWarningTypes.FailedVerification], + }, + })), + ); + + protected getWarningSubject = new BehaviorSubject(null); + + protected warning$ = this.getWarningSubject.pipe(switchMap(() => this.getWarning$())); + + protected view$: Observable = combineLatest([this.warning$, this.dismissals$]).pipe( + map(([warning, dismissals]) => { + if (!warning || warning === TaxIdWarningTypes.PendingVerification) { + return null; + } + + if (!shouldShowWarning(warning, dismissals)) { + return null; + } + + switch (warning) { + case TaxIdWarningTypes.Missing: { + return { + message: this.i18nService.t("missingTaxIdWarning"), + callToAction: this.i18nService.t("addTaxId"), + }; + } + case TaxIdWarningTypes.FailedVerification: { + return { + message: this.i18nService.t("unverifiedTaxIdWarning"), + callToAction: this.i18nService.t("editTaxId"), + }; + } + } + }), + ); + + constructor( + private accountService: AccountService, + private configService: ConfigService, + private dialogService: DialogService, + private i18nService: I18nService, + private subscriberBillingClient: SubscriberBillingClient, + private stateProvider: StateProvider, + ) {} + + ngOnInit() { + this.getWarningSubject.next(this.getWarning$); + } + + editBillingAddress = async () => { + const billingAddress = await this.subscriberBillingClient.getBillingAddress(this.subscriber); + const warning = (await firstValueFrom(this.warning$)) ?? undefined; + + const dialogRef = EditBillingAddressDialogComponent.open(this.dialogService, { + data: { + subscriber: this.subscriber, + billingAddress, + taxIdWarning: warning, + }, + }); + + const result = await lastValueFrom(dialogRef.closed); + + if (result?.type === "success") { + this.billingAddressUpdated.emit(); + } + }; + + trackDismissal = async () => { + const warning = await firstValueFrom(this.warning$); + if (!warning || warning === TaxIdWarningTypes.PendingVerification) { + return; + } + const userId = await firstValueFrom(this.userId$); + const updateDismissalCounts = this.stateProvider + .getUser(userId, DISMISSALS_COUNT_KEY) + .update((dismissalCounts) => { + if (!dismissalCounts) { + return { + [warning]: 1, + }; + } + const dismissalsByType = dismissalCounts[warning]; + if (!dismissalsByType) { + return { + ...dismissalCounts, + [warning]: 1, + }; + } + return { + ...dismissalCounts, + [warning]: dismissalsByType + 1, + }; + }); + const updateDismissedThisSession = this.stateProvider + .getUser(userId, DISMISSED_THIS_SESSION_KEY) + .update((dismissedThisSession) => { + if (!dismissedThisSession) { + return { + [warning]: true, + }; + } + const dismissedThisSessionByType = dismissedThisSession[warning]; + if (!dismissedThisSessionByType) { + return { + ...dismissedThisSession, + }; + } + return { + ...dismissedThisSession, + [warning]: dismissedThisSessionByType, + }; + }); + await Promise.all([updateDismissalCounts, updateDismissedThisSession]); + }; +} diff --git a/apps/web/src/app/billing/warnings/services/organization-warnings.service.spec.ts b/apps/web/src/app/billing/warnings/services/organization-warnings.service.spec.ts deleted file mode 100644 index c75dde0c9e5..00000000000 --- a/apps/web/src/app/billing/warnings/services/organization-warnings.service.spec.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { Router } from "@angular/router"; -import { mock, MockProxy } from "jest-mock-extended"; -import { firstValueFrom, lastValueFrom } from "rxjs"; - -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; -import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { DialogService, SimpleDialogOptions } from "@bitwarden/components"; - -import { OrganizationWarningsService } from "./organization-warnings.service"; - -// Skipped since Angular complains about `TypeError: Cannot read properties of undefined (reading 'ngModule')` -// which is typically a sign of circular dependencies. The problem seems to be originating from `ChangePlanDialogComponent`. -describe.skip("OrganizationWarningsService", () => { - let dialogService: MockProxy; - let i18nService: MockProxy; - let organizationApiService: MockProxy; - let organizationBillingApiService: MockProxy; - let router: MockProxy; - - let organizationWarningsService: OrganizationWarningsService; - - const respond = (responseBody: any) => - Promise.resolve(new OrganizationWarningsResponse(responseBody)); - - const empty = () => Promise.resolve(new OrganizationWarningsResponse({})); - - beforeEach(() => { - dialogService = mock(); - i18nService = mock(); - organizationApiService = mock(); - organizationBillingApiService = mock(); - router = mock(); - - organizationWarningsService = new OrganizationWarningsService( - dialogService, - i18nService, - organizationApiService, - organizationBillingApiService, - router, - ); - }); - - describe("cache$", () => { - it("should only request warnings once for a specific organization and replay the cached result for multiple subscriptions", async () => { - const response1 = respond({ - freeTrial: { - remainingTrialDays: 1, - }, - }); - - const organization1 = { - id: "1", - name: "Test", - } as Organization; - - const response2 = respond({ - freeTrial: { - remainingTrialDays: 2, - }, - }); - - const organization2 = { - id: "2", - name: "Test", - } as Organization; - - organizationBillingApiService.getWarnings.mockImplementation((id) => { - if (id === organization1.id) { - return response1; - } - - if (id === organization2.id) { - return response2; - } - - return empty(); - }); - - const oneDayRemainingTranslation = "oneDayRemaining"; - const twoDaysRemainingTranslation = "twoDaysRemaining"; - - i18nService.t.mockImplementation((id, p1) => { - if (id === "freeTrialEndPromptTomorrowNoOrgName") { - return oneDayRemainingTranslation; - } - - if (id === "freeTrialEndPromptCount" && p1 === 2) { - return twoDaysRemainingTranslation; - } - - return ""; - }); - - const organization1Subscription1 = await firstValueFrom( - organizationWarningsService.getFreeTrialWarning$(organization1), - ); - - const organization1Subscription2 = await firstValueFrom( - organizationWarningsService.getFreeTrialWarning$(organization1), - ); - - expect(organization1Subscription1).toEqual({ - organization: organization1, - message: oneDayRemainingTranslation, - }); - - expect(organization1Subscription2).toEqual(organization1Subscription1); - - const organization2Subscription1 = await firstValueFrom( - organizationWarningsService.getFreeTrialWarning$(organization2), - ); - - const organization2Subscription2 = await firstValueFrom( - organizationWarningsService.getFreeTrialWarning$(organization2), - ); - - expect(organization2Subscription1).toEqual({ - organization: organization2, - message: twoDaysRemainingTranslation, - }); - - expect(organization2Subscription2).toEqual(organization2Subscription1); - - expect(organizationBillingApiService.getWarnings).toHaveBeenCalledTimes(2); - }); - }); - - describe("getFreeTrialWarning$", () => { - it("should not emit a free trial warning when none is included in the warnings response", (done) => { - const organization = { - id: "1", - name: "Test", - } as Organization; - - organizationBillingApiService.getWarnings.mockReturnValue(empty()); - - const warning$ = organizationWarningsService.getFreeTrialWarning$(organization); - - warning$.subscribe({ - next: () => { - fail("Observable should not emit a value."); - }, - complete: () => { - done(); - }, - }); - }); - - it("should emit a free trial warning when one is included in the warnings response", async () => { - const response = respond({ - freeTrial: { - remainingTrialDays: 1, - }, - }); - - const organization = { - id: "1", - name: "Test", - } as Organization; - - organizationBillingApiService.getWarnings.mockImplementation((id) => { - if (id === organization.id) { - return response; - } else { - return empty(); - } - }); - - const translation = "translation"; - i18nService.t.mockImplementation((id) => { - if (id === "freeTrialEndPromptTomorrowNoOrgName") { - return translation; - } else { - return ""; - } - }); - - const warning = await firstValueFrom( - organizationWarningsService.getFreeTrialWarning$(organization), - ); - - expect(warning).toEqual({ - organization, - message: translation, - }); - }); - }); - - describe("getResellerRenewalWarning$", () => { - it("should not emit a reseller renewal warning when none is included in the warnings response", (done) => { - const organization = { - id: "1", - name: "Test", - } as Organization; - - organizationBillingApiService.getWarnings.mockReturnValue(empty()); - - const warning$ = organizationWarningsService.getResellerRenewalWarning$(organization); - - warning$.subscribe({ - next: () => { - fail("Observable should not emit a value."); - }, - complete: () => { - done(); - }, - }); - }); - - it("should emit a reseller renewal warning when one is included in the warnings response", async () => { - const response = respond({ - resellerRenewal: { - type: "upcoming", - upcoming: { - renewalDate: "2026-01-01T00:00:00.000Z", - }, - }, - }); - - const organization = { - id: "1", - name: "Test", - providerName: "Provider", - } as Organization; - - organizationBillingApiService.getWarnings.mockImplementation((id) => { - if (id === organization.id) { - return response; - } else { - return empty(); - } - }); - - const formattedDate = new Date("2026-01-01T00:00:00.000Z").toLocaleDateString("en-US", { - month: "short", - day: "2-digit", - year: "numeric", - }); - - const translation = "translation"; - i18nService.t.mockImplementation((id, p1, p2) => { - if ( - id === "resellerRenewalWarningMsg" && - p1 === organization.providerName && - p2 === formattedDate - ) { - return translation; - } else { - return ""; - } - }); - - const warning = await firstValueFrom( - organizationWarningsService.getResellerRenewalWarning$(organization), - ); - - expect(warning).toEqual({ - type: "info", - message: translation, - }); - }); - }); - - describe("showInactiveSubscriptionDialog$", () => { - it("should not emit the opening of a dialog for an inactive subscription warning when the warning is not included in the warnings response", (done) => { - const organization = { - id: "1", - name: "Test", - } as Organization; - - organizationBillingApiService.getWarnings.mockReturnValue(empty()); - - const warning$ = organizationWarningsService.showInactiveSubscriptionDialog$(organization); - - warning$.subscribe({ - next: () => { - fail("Observable should not emit a value."); - }, - complete: () => { - done(); - }, - }); - }); - - it("should emit the opening of a dialog for an inactive subscription warning when the warning is included in the warnings response", async () => { - const response = respond({ - inactiveSubscription: { - resolution: "add_payment_method", - }, - }); - - const organization = { - id: "1", - name: "Test", - providerName: "Provider", - } as Organization; - - organizationBillingApiService.getWarnings.mockImplementation((id) => { - if (id === organization.id) { - return response; - } else { - return empty(); - } - }); - - const titleTranslation = "title"; - const continueTranslation = "continue"; - const closeTranslation = "close"; - - i18nService.t.mockImplementation((id, param) => { - if (id === "suspendedOrganizationTitle" && param === organization.name) { - return titleTranslation; - } - if (id === "continue") { - return continueTranslation; - } - if (id === "close") { - return closeTranslation; - } - return ""; - }); - - const expectedOptions = { - title: titleTranslation, - content: { - key: "suspendedOwnerOrgMessage", - }, - type: "danger", - acceptButtonText: continueTranslation, - cancelButtonText: closeTranslation, - } as SimpleDialogOptions; - - dialogService.openSimpleDialog.mockImplementation((options) => { - if (JSON.stringify(options) == JSON.stringify(expectedOptions)) { - return Promise.resolve(true); - } else { - return Promise.resolve(false); - } - }); - - const observable$ = organizationWarningsService.showInactiveSubscriptionDialog$(organization); - - const routerNavigateSpy = jest.spyOn(router, "navigate").mockResolvedValue(true); - - await lastValueFrom(observable$); - - expect(routerNavigateSpy).toHaveBeenCalledWith( - ["organizations", `${organization.id}`, "billing", "payment-method"], - { - state: { launchPaymentModalAutomatically: true }, - }, - ); - }); - }); -}); diff --git a/apps/web/src/app/billing/warnings/types/index.ts b/apps/web/src/app/billing/warnings/types/index.ts index fc0c7d278ed..1d7b17fcd28 100644 --- a/apps/web/src/app/billing/warnings/types/index.ts +++ b/apps/web/src/app/billing/warnings/types/index.ts @@ -1 +1 @@ -export * from "./organization-warnings"; +export * from "./tax-id-warning-type"; diff --git a/apps/web/src/app/billing/warnings/types/organization-warnings.ts b/apps/web/src/app/billing/warnings/types/organization-warnings.ts deleted file mode 100644 index 96bf5aff6f1..00000000000 --- a/apps/web/src/app/billing/warnings/types/organization-warnings.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; - -export type OrganizationFreeTrialWarning = { - organization: Pick; - message: string; -}; - -export type OrganizationResellerRenewalWarning = { - type: "info" | "warning"; - message: string; -}; diff --git a/apps/web/src/app/billing/warnings/types/tax-id-warning-type.ts b/apps/web/src/app/billing/warnings/types/tax-id-warning-type.ts new file mode 100644 index 00000000000..86bc76708aa --- /dev/null +++ b/apps/web/src/app/billing/warnings/types/tax-id-warning-type.ts @@ -0,0 +1,19 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export const TaxIdWarningTypes = { + Missing: "tax_id_missing", + PendingVerification: "tax_id_pending_verification", + FailedVerification: "tax_id_failed_verification", +} as const; + +export type TaxIdWarningType = (typeof TaxIdWarningTypes)[keyof typeof TaxIdWarningTypes]; + +export class TaxIdWarningResponse extends BaseResponse { + type: TaxIdWarningType; + + constructor(response: any) { + super(response); + + this.type = this.getResponseProperty("Type"); + } +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index fb85d4e3dd9..bbd89c1d288 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -11016,5 +11016,52 @@ }, "showLess": { "message": "Show less" + }, + "missingTaxId": { + "message": "Missing Tax ID" + }, + "missingTaxIdWarning": { + "message": "Action required: You're missing a Tax ID number in payment details. If a Tax ID is not added, your invoices may include additional tax." + }, + "addTaxId": { + "message": "Add a Tax ID" + }, + "missingTaxIdCalloutTitle": { + "message": "Action required: Missing Tax ID" + }, + "missingTaxIdCalloutDescription": { + "message": "If a Tax ID is not added, your invoices may include additional tax." + }, + "unverifiedTaxIdWarning": { + "message": "Action required: Your Tax ID number is unverified. If your Tax ID is left unverified, your invoices may include additional tax." + }, + "editTaxId": { + "message": "Edit your Tax ID" + }, + "unverifiedTaxIdCalloutTitle": { + "message": "Tax ID unverified" + }, + "unverifiedTaxIdCalloutDescription": { + "message": "Check your Tax ID to verify the format is correct and there are no typos." + }, + "pendingVerification": { + "message": "Pending verification" + }, + "checkInputFormat": { + "message": "Check input format for typos." + }, + "exampleTaxIdFormat": { + "message": "Example $CODE$ format: $EXAMPLE$", + "placeholders": { + "code": { + "content": "$1", + "example": "ABN" + }, + "example": { + "content": "$2", + "example": "92873837267" + } + } } } + diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html index b61b1ce7840..31e56836375 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html @@ -55,5 +55,14 @@ > + + + + + diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts index 52260168d4c..5e2e9a14f2d 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts @@ -18,15 +18,24 @@ import { ProviderPortalLogo, BusinessUnitPortalLogo, } from "@bitwarden/components"; +import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types"; +import { TaxIdWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components"; +import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types"; import { WebLayoutModule } from "@bitwarden/web-vault/app/layouts/web-layout.module"; -import { ProviderWarningsService } from "../../billing/providers/services/provider-warnings.service"; +import { ProviderWarningsService } from "../../billing/providers/warnings/services"; @Component({ selector: "providers-layout", templateUrl: "providers-layout.component.html", - imports: [CommonModule, RouterModule, JslibModule, WebLayoutModule, IconModule], - providers: [ProviderWarningsService], + imports: [ + CommonModule, + RouterModule, + JslibModule, + WebLayoutModule, + IconModule, + TaxIdWarningComponent, + ], }) export class ProvidersLayoutComponent implements OnInit, OnDestroy { protected readonly logo = ProviderPortalLogo; @@ -43,6 +52,9 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy { protected managePaymentDetailsOutsideCheckout$: Observable; protected providerPortalTakeover$: Observable; + protected subscriber$: Observable; + protected getTaxIdWarning$: () => Observable; + constructor( private route: ActivatedRoute, private providerService: ProviderService, @@ -90,10 +102,10 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy { FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, ); - providerId$ + this.provider$ .pipe( - switchMap((providerId) => - this.providerWarningsService.showProviderSuspendedDialog$(providerId), + switchMap((provider) => + this.providerWarningsService.showProviderSuspendedDialog$(provider), ), takeUntil(this.destroy$), ) @@ -102,6 +114,18 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy { this.providerPortalTakeover$ = this.configService.getFeatureFlag$( FeatureFlag.PM21821_ProviderPortalTakeover, ); + + this.subscriber$ = this.provider$.pipe( + map((provider) => ({ + type: "provider", + data: provider, + })), + ); + + this.getTaxIdWarning$ = () => + this.provider$.pipe( + switchMap((provider) => this.providerWarningsService.getTaxIdWarning$(provider)), + ); } ngOnDestroy() { @@ -116,4 +140,6 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy { showSettingsTab(provider: Provider) { return provider.isProviderAdmin; } + + refreshTaxIdWarning = () => this.providerWarningsService.refreshTaxIdWarning(); } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts index 01f1facfc15..263b90f5b32 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts @@ -21,6 +21,7 @@ import { } from "../../billing/providers"; import { AddExistingOrganizationDialogComponent } from "../../billing/providers/clients/add-existing-organization-dialog.component"; import { SetupBusinessUnitComponent } from "../../billing/providers/setup/setup-business-unit.component"; +import { ProviderWarningsModule } from "../../billing/providers/warnings/provider-warnings.module"; import { AddOrganizationComponent } from "./clients/add-organization.component"; import { CreateOrganizationComponent } from "./clients/create-organization.component"; @@ -55,6 +56,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr CardComponent, ScrollLayoutDirective, PaymentComponent, + ProviderWarningsModule, ], declarations: [ AcceptProviderComponent, diff --git a/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.html b/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.html index 375faab8d34..fa45bbb32d3 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.html @@ -13,19 +13,20 @@ } @else { diff --git a/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts index dbf948518a2..7782c89a5bd 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts @@ -1,22 +1,30 @@ -import { Component } from "@angular/core"; +import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { BehaviorSubject, + combineLatest, EMPTY, filter, + firstValueFrom, from, map, merge, Observable, + of, shareReplay, + Subject, switchMap, + take, + takeUntil, tap, } from "rxjs"; import { catchError } from "rxjs/operators"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { DisplayAccountCreditComponent, DisplayBillingAddressComponent, @@ -26,11 +34,16 @@ import { BillingAddress, MaskedPaymentMethod, } from "@bitwarden/web-vault/app/billing/payment/types"; -import { BillingClient } from "@bitwarden/web-vault/app/billing/services"; -import { BillableEntity, providerToBillableEntity } from "@bitwarden/web-vault/app/billing/types"; +import { + BitwardenSubscriber, + mapProviderToSubscriber, +} from "@bitwarden/web-vault/app/billing/types"; +import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types"; import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { ProviderWarningsService } from "../warnings/services"; + class RedirectError { constructor( public path: string[], @@ -39,29 +52,31 @@ class RedirectError { } type View = { - provider: BillableEntity; + provider: BitwardenSubscriber; paymentMethod: MaskedPaymentMethod | null; billingAddress: BillingAddress | null; credit: number | null; + taxIdWarning: TaxIdWarningType | null; }; @Component({ templateUrl: "./provider-payment-details.component.html", - standalone: true, imports: [ - DisplayBillingAddressComponent, DisplayAccountCreditComponent, + DisplayBillingAddressComponent, DisplayPaymentMethodComponent, HeaderModule, SharedModule, ], - providers: [BillingClient], }) -export class ProviderPaymentDetailsComponent { +export class ProviderPaymentDetailsComponent implements OnInit, OnDestroy { private viewState$ = new BehaviorSubject(null); - private load$: Observable = this.activatedRoute.params.pipe( + private provider$ = this.activatedRoute.params.pipe( switchMap(({ providerId }) => this.providerService.get$(providerId)), + ); + + private load$: Observable = this.provider$.pipe( switchMap((provider) => this.configService .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) @@ -74,12 +89,17 @@ export class ProviderPaymentDetailsComponent { }), ), ), - providerToBillableEntity, + mapProviderToSubscriber, switchMap(async (provider) => { - const [paymentMethod, billingAddress, credit] = await Promise.all([ + const getTaxIdWarning = firstValueFrom( + this.providerWarningsService.getTaxIdWarning$(provider.data as Provider), + ); + + const [paymentMethod, billingAddress, credit, taxIdWarning] = await Promise.all([ this.billingClient.getPaymentMethod(provider), this.billingClient.getBillingAddress(provider), this.billingClient.getCredit(provider), + getTaxIdWarning, ]); return { @@ -87,6 +107,7 @@ export class ProviderPaymentDetailsComponent { paymentMethod, billingAddress, credit, + taxIdWarning, }; }), shareReplay({ bufferSize: 1, refCount: false }), @@ -105,16 +126,64 @@ export class ProviderPaymentDetailsComponent { this.viewState$.pipe(filter((view): view is View => view !== null)), ).pipe(shareReplay({ bufferSize: 1, refCount: true })); + private destroy$ = new Subject(); + + protected enableTaxIdWarning!: boolean; + constructor( private activatedRoute: ActivatedRoute, - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, private configService: ConfigService, private providerService: ProviderService, + private providerWarningsService: ProviderWarningsService, private router: Router, + private subscriberBillingClient: SubscriberBillingClient, ) {} + async ngOnInit() { + this.enableTaxIdWarning = await this.configService.getFeatureFlag( + FeatureFlag.PM22415_TaxIDWarnings, + ); + + if (this.enableTaxIdWarning) { + this.providerWarningsService.taxIdWarningRefreshed$ + .pipe( + switchMap((warning) => + combineLatest([ + of(warning), + this.provider$.pipe(take(1)).pipe( + mapProviderToSubscriber, + switchMap((provider) => this.subscriberBillingClient.getBillingAddress(provider)), + ), + ]), + ), + takeUntil(this.destroy$), + ) + .subscribe(([taxIdWarning, billingAddress]) => { + if (this.viewState$.value) { + this.viewState$.next({ + ...this.viewState$.value, + taxIdWarning, + billingAddress, + }); + } + }); + } + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + setBillingAddress = (billingAddress: BillingAddress) => { if (this.viewState$.value) { + if ( + this.enableTaxIdWarning && + this.viewState$.value.billingAddress?.taxId !== billingAddress.taxId + ) { + this.providerWarningsService.refreshTaxIdWarning(); + } this.viewState$.next({ ...this.viewState$.value, billingAddress, @@ -122,11 +191,16 @@ export class ProviderPaymentDetailsComponent { } }; - setPaymentMethod = (paymentMethod: MaskedPaymentMethod) => { + setPaymentMethod = async (paymentMethod: MaskedPaymentMethod) => { if (this.viewState$.value) { + const billingAddress = + this.viewState$.value.billingAddress ?? + (await this.subscriberBillingClient.getBillingAddress(this.viewState$.value.provider)); + this.viewState$.next({ ...this.viewState$.value, paymentMethod, + billingAddress, }); } }; diff --git a/bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.spec.ts b/bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.spec.ts deleted file mode 100644 index b2b92c26e1f..00000000000 --- a/bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.spec.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { TestBed } from "@angular/core/testing"; -import { ActivatedRoute, Router } from "@angular/router"; -import { mock, MockProxy } from "jest-mock-extended"; -import { of } from "rxjs"; - -import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; -import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; -import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; -import { ProviderSubscriptionResponse } from "@bitwarden/common/billing/models/response/provider-subscription-response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SyncService } from "@bitwarden/common/platform/sync"; -import { DialogRef, DialogService } from "@bitwarden/components"; -import { - RequirePaymentMethodDialogComponent, - SubmitPaymentMethodDialogResult, -} from "@bitwarden/web-vault/app/billing/payment/components"; - -import { ProviderWarningsService } from "./provider-warnings.service"; - -describe("ProviderWarningsService", () => { - let service: ProviderWarningsService; - let configService: MockProxy; - let dialogService: MockProxy; - let providerService: MockProxy; - let billingApiService: MockProxy; - let i18nService: MockProxy; - let router: MockProxy; - let syncService: MockProxy; - - beforeEach(() => { - billingApiService = mock(); - configService = mock(); - dialogService = mock(); - i18nService = mock(); - providerService = mock(); - router = mock(); - syncService = mock(); - - TestBed.configureTestingModule({ - providers: [ - ProviderWarningsService, - { provide: ActivatedRoute, useValue: {} }, - { provide: BillingApiServiceAbstraction, useValue: billingApiService }, - { provide: ConfigService, useValue: configService }, - { provide: DialogService, useValue: dialogService }, - { provide: I18nService, useValue: i18nService }, - { provide: ProviderService, useValue: providerService }, - { provide: Router, useValue: router }, - { provide: SyncService, useValue: syncService }, - ], - }); - - service = TestBed.inject(ProviderWarningsService); - }); - - it("should create the service", () => { - expect(service).toBeTruthy(); - }); - - describe("showProviderSuspendedDialog$", () => { - const providerId = "test-provider-id"; - - it("should not show any dialog when the 'pm-21821-provider-portal-takeover' flag is disabled", (done) => { - const provider = { enabled: false } as Provider; - const subscription = { status: "unpaid" } as ProviderSubscriptionResponse; - - providerService.get$.mockReturnValue(of(provider)); - billingApiService.getProviderSubscription.mockResolvedValue(subscription); - configService.getFeatureFlag$.mockReturnValue(of(false)); - - const requirePaymentMethodDialogComponentOpenSpy = jest.spyOn( - RequirePaymentMethodDialogComponent, - "open", - ); - - service.showProviderSuspendedDialog$(providerId).subscribe(() => { - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); - expect(requirePaymentMethodDialogComponentOpenSpy).not.toHaveBeenCalled(); - done(); - }); - }); - - it("should not show any dialog when the provider is enabled", (done) => { - const provider = { enabled: true } as Provider; - const subscription = { status: "unpaid" } as ProviderSubscriptionResponse; - - providerService.get$.mockReturnValue(of(provider)); - billingApiService.getProviderSubscription.mockResolvedValue(subscription); - configService.getFeatureFlag$.mockReturnValue(of(true)); - - const requirePaymentMethodDialogComponentOpenSpy = jest.spyOn( - RequirePaymentMethodDialogComponent, - "open", - ); - - service.showProviderSuspendedDialog$(providerId).subscribe(() => { - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); - expect(requirePaymentMethodDialogComponentOpenSpy).not.toHaveBeenCalled(); - done(); - }); - }); - - it("should show the require payment method dialog for an admin of a provider with an unpaid subscription", (done) => { - const provider = { - enabled: false, - type: ProviderUserType.ProviderAdmin, - name: "Test Provider", - } as Provider; - const subscription = { - status: "unpaid", - cancelAt: "2024-12-31", - } as ProviderSubscriptionResponse; - - providerService.get$.mockReturnValue(of(provider)); - billingApiService.getProviderSubscription.mockResolvedValue(subscription); - configService.getFeatureFlag$.mockReturnValue(of(true)); - - const dialogRef = { - closed: of({ type: "success" }), - } as DialogRef; - jest.spyOn(RequirePaymentMethodDialogComponent, "open").mockReturnValue(dialogRef); - - service.showProviderSuspendedDialog$(providerId).subscribe(() => { - expect(RequirePaymentMethodDialogComponent.open).toHaveBeenCalled(); - expect(syncService.fullSync).toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalled(); - done(); - }); - }); - - it("should show the simple, unpaid invoices dialog for a service user of a provider with an unpaid subscription", (done) => { - const provider = { - enabled: false, - type: ProviderUserType.ServiceUser, - name: "Test Provider", - } as Provider; - const subscription = { status: "unpaid" } as ProviderSubscriptionResponse; - - providerService.get$.mockReturnValue(of(provider)); - billingApiService.getProviderSubscription.mockResolvedValue(subscription); - dialogService.openSimpleDialog.mockResolvedValue(true); - configService.getFeatureFlag$.mockReturnValue(of(true)); - - i18nService.t.mockImplementation((key: string) => key); - - service.showProviderSuspendedDialog$(providerId).subscribe(() => { - expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ - type: "danger", - title: "unpaidInvoices", - content: "unpaidInvoicesForServiceUser", - disableClose: true, - }); - done(); - }); - }); - - it("should show the provider suspended dialog to all users of a provider that's suspended, but not unpaid", (done) => { - const provider = { - enabled: false, - name: "Test Provider", - } as Provider; - const subscription = { status: "active" } as ProviderSubscriptionResponse; - - providerService.get$.mockReturnValue(of(provider)); - billingApiService.getProviderSubscription.mockResolvedValue(subscription); - dialogService.openSimpleDialog.mockResolvedValue(true); - configService.getFeatureFlag$.mockReturnValue(of(true)); - - i18nService.t.mockImplementation((key: string) => key); - - service.showProviderSuspendedDialog$(providerId).subscribe(() => { - expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ - type: "danger", - title: "providerSuspended", - content: "restoreProviderPortalAccessViaCustomerSupport", - disableClose: false, - acceptButtonText: "contactSupportShort", - cancelButtonText: null, - acceptAction: expect.any(Function), - }); - done(); - }); - }); - }); -}); diff --git a/bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.ts b/bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.ts deleted file mode 100644 index 87e6d9351ab..00000000000 --- a/bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Injectable } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { combineLatest, from, lastValueFrom, Observable, switchMap } from "rxjs"; - -import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; -import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SyncService } from "@bitwarden/common/platform/sync"; -import { DialogService } from "@bitwarden/components"; -import { RequirePaymentMethodDialogComponent } from "@bitwarden/web-vault/app/billing/payment/components"; - -@Injectable() -export class ProviderWarningsService { - constructor( - private activatedRoute: ActivatedRoute, - private billingApiService: BillingApiServiceAbstraction, - private configService: ConfigService, - private dialogService: DialogService, - private i18nService: I18nService, - private providerService: ProviderService, - private router: Router, - private syncService: SyncService, - ) {} - - showProviderSuspendedDialog$ = (providerId: string): Observable => - combineLatest([ - this.configService.getFeatureFlag$(FeatureFlag.PM21821_ProviderPortalTakeover), - this.providerService.get$(providerId), - from(this.billingApiService.getProviderSubscription(providerId)), - ]).pipe( - switchMap(async ([providerPortalTakeover, provider, subscription]) => { - if (!providerPortalTakeover || provider.enabled) { - return; - } - - if (subscription.status === "unpaid") { - switch (provider.type) { - case ProviderUserType.ProviderAdmin: { - const cancelAt = subscription.cancelAt - ? new Date(subscription.cancelAt).toLocaleDateString("en-US", { - month: "short", - day: "2-digit", - year: "numeric", - }) - : null; - - const dialogRef = RequirePaymentMethodDialogComponent.open(this.dialogService, { - data: { - owner: { - type: "provider", - data: provider, - }, - callout: { - type: "danger", - title: this.i18nService.t("unpaidInvoices"), - message: this.i18nService.t( - "restoreProviderPortalAccessViaPaymentMethod", - cancelAt ?? undefined, - ), - }, - }, - }); - - const result = await lastValueFrom(dialogRef.closed); - - if (result?.type === "success") { - await this.syncService.fullSync(true); - await this.router.navigate(["."], { - relativeTo: this.activatedRoute, - onSameUrlNavigation: "reload", - }); - } - break; - } - case ProviderUserType.ServiceUser: { - await this.dialogService.openSimpleDialog({ - type: "danger", - title: this.i18nService.t("unpaidInvoices"), - content: this.i18nService.t("unpaidInvoicesForServiceUser"), - disableClose: true, - }); - break; - } - } - } else { - await this.dialogService.openSimpleDialog({ - type: "danger", - title: this.i18nService.t("providerSuspended", provider.name), - content: this.i18nService.t("restoreProviderPortalAccessViaCustomerSupport"), - disableClose: false, - acceptButtonText: this.i18nService.t("contactSupportShort"), - cancelButtonText: null, - acceptAction: async () => { - window.open("https://bitwarden.com/contact/", "_blank"); - return Promise.resolve(); - }, - }); - } - }), - ); -} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/warnings/provider-warnings.module.ts b/bitwarden_license/bit-web/src/app/billing/providers/warnings/provider-warnings.module.ts new file mode 100644 index 00000000000..88418d1ae08 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/warnings/provider-warnings.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from "@angular/core"; + +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; + +import { ProviderWarningsService } from "./services"; + +@NgModule({ + providers: [ProviderWarningsService, SubscriberBillingClient], +}) +export class ProviderWarningsModule {} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/index.ts b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/index.ts new file mode 100644 index 00000000000..08302c082d0 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/index.ts @@ -0,0 +1 @@ +export * from "./provider-warnings.service"; diff --git a/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.spec.ts b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.spec.ts new file mode 100644 index 00000000000..0eb25bff524 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.spec.ts @@ -0,0 +1,416 @@ +import { TestBed } from "@angular/core/testing"; +import { ActivatedRoute, Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { ProviderId } from "@bitwarden/common/types/guid"; +import { DialogRef, DialogService } from "@bitwarden/components"; +import { RequirePaymentMethodDialogComponent } from "@bitwarden/web-vault/app/billing/payment/components"; +import { TaxIdWarningTypes } from "@bitwarden/web-vault/app/billing/warnings/types"; + +import { ProviderWarningsResponse } from "../types/provider-warnings"; + +import { ProviderWarningsService } from "./provider-warnings.service"; + +describe("ProviderWarningsService", () => { + let service: ProviderWarningsService; + let activatedRoute: MockProxy; + let apiService: MockProxy; + let configService: MockProxy; + let dialogService: MockProxy; + let i18nService: MockProxy; + let router: MockProxy; + let syncService: MockProxy; + + const provider = { + id: "provider-id-123", + name: "Test Provider", + } as Provider; + + const formatDate = (date: Date): string => + date.toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + }); + + beforeEach(() => { + activatedRoute = mock(); + apiService = mock(); + configService = mock(); + dialogService = mock(); + i18nService = mock(); + router = mock(); + syncService = mock(); + + i18nService.t.mockImplementation((key: string, ...args: any[]) => { + switch (key) { + case "unpaidInvoices": + return "Unpaid invoices"; + case "restoreProviderPortalAccessViaPaymentMethod": + return `To restore access to the provider portal, add a valid payment method. Your subscription will be cancelled on ${args[0]}.`; + case "unpaidInvoicesForServiceUser": + return "There are unpaid invoices on this account. Contact your administrator to restore access to the provider portal."; + case "providerSuspended": + return `${args[0]} subscription suspended`; + case "restoreProviderPortalAccessViaCustomerSupport": + return "To restore access to the provider portal, contact our support team."; + case "contactSupportShort": + return "Contact Support"; + default: + return key; + } + }); + + TestBed.configureTestingModule({ + providers: [ + ProviderWarningsService, + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: ApiService, useValue: apiService }, + { provide: ConfigService, useValue: configService }, + { provide: DialogService, useValue: dialogService }, + { provide: I18nService, useValue: i18nService }, + { provide: Router, useValue: router }, + { provide: SyncService, useValue: syncService }, + ], + }); + + service = TestBed.inject(ProviderWarningsService); + }); + + describe("getTaxIdWarning$", () => { + it("should return null when no tax ID warning exists", (done) => { + apiService.send.mockResolvedValue({}); + + service.getTaxIdWarning$(provider).subscribe((result) => { + expect(result).toBeNull(); + done(); + }); + }); + + it("should return tax_id_missing type when tax ID is missing", (done) => { + const warning = { Type: TaxIdWarningTypes.Missing }; + apiService.send.mockResolvedValue({ + TaxId: warning, + }); + + service.getTaxIdWarning$(provider).subscribe((result) => { + expect(result).toBe(TaxIdWarningTypes.Missing); + done(); + }); + }); + + it("should return tax_id_pending_verification type when tax ID verification is pending", (done) => { + const warning = { Type: TaxIdWarningTypes.PendingVerification }; + apiService.send.mockResolvedValue({ + TaxId: warning, + }); + + service.getTaxIdWarning$(provider).subscribe((result) => { + expect(result).toBe(TaxIdWarningTypes.PendingVerification); + done(); + }); + }); + + it("should return tax_id_failed_verification type when tax ID verification failed", (done) => { + const warning = { Type: TaxIdWarningTypes.FailedVerification }; + apiService.send.mockResolvedValue({ + TaxId: warning, + }); + + service.getTaxIdWarning$(provider).subscribe((result) => { + expect(result).toBe(TaxIdWarningTypes.FailedVerification); + done(); + }); + }); + + it("should refresh warning and update taxIdWarningRefreshedSubject when refreshTaxIdWarning is called", (done) => { + const initialWarning = { Type: TaxIdWarningTypes.Missing }; + const refreshedWarning = { Type: TaxIdWarningTypes.FailedVerification }; + let invocationCount = 0; + + apiService.send + .mockResolvedValueOnce({ + TaxId: initialWarning, + }) + .mockResolvedValueOnce({ + TaxId: refreshedWarning, + }); + + const subscription = service.getTaxIdWarning$(provider).subscribe((result) => { + invocationCount++; + + if (invocationCount === 1) { + expect(result).toBe(TaxIdWarningTypes.Missing); + } else if (invocationCount === 2) { + expect(result).toBe(TaxIdWarningTypes.FailedVerification); + subscription.unsubscribe(); + done(); + } + }); + + setTimeout(() => { + service.refreshTaxIdWarning(); + }, 10); + }); + + it("should update taxIdWarningRefreshedSubject with warning type when refresh returns a warning", (done) => { + const refreshedWarning = { Type: TaxIdWarningTypes.Missing }; + let refreshedCount = 0; + + apiService.send.mockResolvedValueOnce({}).mockResolvedValueOnce({ + TaxId: refreshedWarning, + }); + + const taxIdSubscription = service.taxIdWarningRefreshed$.subscribe((refreshedType) => { + refreshedCount++; + if (refreshedCount === 2) { + expect(refreshedType).toBe(TaxIdWarningTypes.Missing); + taxIdSubscription.unsubscribe(); + done(); + } + }); + + service.getTaxIdWarning$(provider).subscribe(); + + setTimeout(() => { + service.refreshTaxIdWarning(); + }, 10); + }); + + it("should update taxIdWarningRefreshedSubject with null when refresh returns no warning", (done) => { + const initialWarning = { Type: TaxIdWarningTypes.Missing }; + let refreshedCount = 0; + + apiService.send + .mockResolvedValueOnce({ + TaxId: initialWarning, + }) + .mockResolvedValueOnce({}); + + const taxIdSubscription = service.taxIdWarningRefreshed$.subscribe((refreshedType) => { + refreshedCount++; + if (refreshedCount === 2) { + expect(refreshedType).toBeNull(); + taxIdSubscription.unsubscribe(); + done(); + } + }); + + service.getTaxIdWarning$(provider).subscribe(); + + setTimeout(() => { + service.refreshTaxIdWarning(); + }, 10); + }); + }); + + describe("showProviderSuspendedDialog$", () => { + it("should not show dialog when feature flag is disabled", (done) => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + apiService.send.mockResolvedValue({ + Suspension: { Resolution: "add_payment_method" }, + }); + + service.showProviderSuspendedDialog$(provider).subscribe({ + complete: () => { + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + done(); + }, + }); + }); + + it("should not show dialog when no suspension warning exists", (done) => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + apiService.send.mockResolvedValue({}); + + service.showProviderSuspendedDialog$(provider).subscribe({ + complete: () => { + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + done(); + }, + }); + }); + + it("should show add payment method dialog with cancellation date", (done) => { + const cancelsAt = new Date(2024, 11, 31); + configService.getFeatureFlag$.mockReturnValue(of(true)); + apiService.send.mockResolvedValue({ + Suspension: { + Resolution: "add_payment_method", + SubscriptionCancelsAt: cancelsAt.toISOString(), + }, + }); + + const mockDialogRef = { + closed: of({ type: "success" }), + } as DialogRef; + + jest.spyOn(RequirePaymentMethodDialogComponent, "open").mockReturnValue(mockDialogRef); + syncService.fullSync.mockResolvedValue(true); + router.navigate.mockResolvedValue(true); + + service.showProviderSuspendedDialog$(provider).subscribe({ + complete: () => { + const expectedDate = formatDate(cancelsAt); + expect(RequirePaymentMethodDialogComponent.open).toHaveBeenCalledWith(dialogService, { + data: { + subscriber: { + type: "provider", + data: provider, + }, + callout: { + type: "danger", + title: "Unpaid invoices", + message: `To restore access to the provider portal, add a valid payment method. Your subscription will be cancelled on ${expectedDate}.`, + }, + }, + }); + expect(syncService.fullSync).toHaveBeenCalledWith(true); + expect(router.navigate).toHaveBeenCalledWith(["."], { + relativeTo: activatedRoute, + onSameUrlNavigation: "reload", + }); + done(); + }, + }); + }); + + it("should show add payment method dialog without cancellation date", (done) => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + apiService.send.mockResolvedValue({ + Suspension: { + Resolution: "add_payment_method", + }, + }); + + const mockDialogRef = { + closed: of({ type: "cancelled" }), + } as DialogRef; + + jest.spyOn(RequirePaymentMethodDialogComponent, "open").mockReturnValue(mockDialogRef); + + service.showProviderSuspendedDialog$(provider).subscribe({ + complete: () => { + expect(RequirePaymentMethodDialogComponent.open).toHaveBeenCalledWith(dialogService, { + data: { + subscriber: { + type: "provider", + data: provider, + }, + callout: { + type: "danger", + title: "Unpaid invoices", + message: + "To restore access to the provider portal, add a valid payment method. Your subscription will be cancelled on undefined.", + }, + }, + }); + expect(syncService.fullSync).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }, + }); + }); + + it("should show contact administrator dialog for contact_administrator resolution", (done) => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + apiService.send.mockResolvedValue({ + Suspension: { + Resolution: "contact_administrator", + }, + }); + + dialogService.openSimpleDialog.mockResolvedValue(true); + + service.showProviderSuspendedDialog$(provider).subscribe({ + complete: () => { + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + type: "danger", + title: "Unpaid invoices", + content: + "There are unpaid invoices on this account. Contact your administrator to restore access to the provider portal.", + disableClose: true, + }); + done(); + }, + }); + }); + + it("should show contact support dialog with action for contact_support resolution", (done) => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + apiService.send.mockResolvedValue({ + Suspension: { + Resolution: "contact_support", + }, + }); + + dialogService.openSimpleDialog.mockResolvedValue(true); + const openSpy = jest.spyOn(window, "open").mockImplementation(); + + service.showProviderSuspendedDialog$(provider).subscribe({ + complete: () => { + const dialogCall = dialogService.openSimpleDialog.mock.calls[0][0]; + expect(dialogCall).toEqual({ + type: "danger", + title: "Test Provider subscription suspended", + content: "To restore access to the provider portal, contact our support team.", + acceptButtonText: "Contact Support", + cancelButtonText: null, + acceptAction: expect.any(Function), + }); + + if (dialogCall.acceptAction) { + void dialogCall.acceptAction().then(() => { + expect(openSpy).toHaveBeenCalledWith("https://bitwarden.com/contact/", "_blank"); + openSpy.mockRestore(); + done(); + }); + } else { + fail("acceptAction should be defined"); + } + }, + }); + }); + }); + + describe("fetchWarnings", () => { + it("should fetch warnings from correct API endpoint", async () => { + const mockResponse = { TaxId: { Type: TaxIdWarningTypes.Missing } }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await service.fetchWarnings(provider.id as ProviderId); + + expect(apiService.send).toHaveBeenCalledWith( + "GET", + `/providers/${provider.id}/billing/vnext/warnings`, + null, + true, + true, + ); + expect(result).toBeInstanceOf(ProviderWarningsResponse); + expect(result.taxId?.type).toBe(TaxIdWarningTypes.Missing); + }); + + it("should handle API response with suspension warning", async () => { + const cancelsAt = new Date(2024, 11, 31); + const mockResponse = { + Suspension: { + Resolution: "add_payment_method", + SubscriptionCancelsAt: cancelsAt.toISOString(), + }, + }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await service.fetchWarnings(provider.id as ProviderId); + + expect(result.suspension?.resolution).toBe("add_payment_method"); + expect(result.suspension?.subscriptionCancelsAt).toEqual(cancelsAt); + }); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.ts b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.ts new file mode 100644 index 00000000000..89ddf4b4788 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.ts @@ -0,0 +1,175 @@ +import { Injectable } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { + BehaviorSubject, + combineLatest, + from, + lastValueFrom, + map, + merge, + Observable, + Subject, + switchMap, + take, + tap, +} from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { ProviderId } from "@bitwarden/common/types/guid"; +import { DialogService } from "@bitwarden/components"; +import { RequirePaymentMethodDialogComponent } from "@bitwarden/web-vault/app/billing/payment/components"; +import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types"; + +import { ProviderWarningsResponse } from "../types/provider-warnings"; + +@Injectable() +export class ProviderWarningsService { + private cache$ = new Map>(); + + private refreshTaxIdWarningTrigger = new Subject(); + + private taxIdWarningRefreshedSubject = new BehaviorSubject(null); + taxIdWarningRefreshed$ = this.taxIdWarningRefreshedSubject.asObservable(); + + constructor( + private activatedRoute: ActivatedRoute, + private apiService: ApiService, + private configService: ConfigService, + private dialogService: DialogService, + private i18nService: I18nService, + private router: Router, + private syncService: SyncService, + ) {} + + getTaxIdWarning$ = (provider: Provider): Observable => + merge( + this.getWarning$(provider, (response) => response.taxId), + this.refreshTaxIdWarningTrigger.pipe( + switchMap(() => + this.getWarning$(provider, (response) => response.taxId, true).pipe( + tap((warning) => this.taxIdWarningRefreshedSubject.next(warning ? warning.type : null)), + ), + ), + ), + ).pipe(map((warning) => (warning ? warning.type : null))); + + refreshTaxIdWarning = () => this.refreshTaxIdWarningTrigger.next(); + + showProviderSuspendedDialog$ = (provider: Provider): Observable => + combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.PM21821_ProviderPortalTakeover), + this.getWarning$(provider, (response) => response.suspension), + ]).pipe( + switchMap(async ([providerPortalTakeover, warning]) => { + if (!providerPortalTakeover || !warning) { + return; + } + + switch (warning.resolution) { + case "add_payment_method": { + const cancelAt = warning.subscriptionCancelsAt + ? new Date(warning.subscriptionCancelsAt).toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + }) + : null; + + const dialogRef = RequirePaymentMethodDialogComponent.open(this.dialogService, { + data: { + subscriber: { + type: "provider", + data: provider, + }, + callout: { + type: "danger", + title: this.i18nService.t("unpaidInvoices"), + message: this.i18nService.t( + "restoreProviderPortalAccessViaPaymentMethod", + cancelAt ?? undefined, + ), + }, + }, + }); + + const result = await lastValueFrom(dialogRef.closed); + + if (result?.type === "success") { + await this.syncService.fullSync(true); + await this.router.navigate(["."], { + relativeTo: this.activatedRoute, + onSameUrlNavigation: "reload", + }); + } + break; + } + case "contact_administrator": { + await this.dialogService.openSimpleDialog({ + type: "danger", + title: this.i18nService.t("unpaidInvoices"), + content: this.i18nService.t("unpaidInvoicesForServiceUser"), + disableClose: true, + }); + break; + } + case "contact_support": { + await this.dialogService.openSimpleDialog({ + type: "danger", + title: this.i18nService.t("providerSuspended", provider.name), + content: this.i18nService.t("restoreProviderPortalAccessViaCustomerSupport"), + acceptButtonText: this.i18nService.t("contactSupportShort"), + cancelButtonText: null, + acceptAction: async () => { + window.open("https://bitwarden.com/contact/", "_blank"); + return Promise.resolve(); + }, + }); + } + } + }), + ); + + fetchWarnings = async (providerId: ProviderId): Promise => { + const response = await this.apiService.send( + "GET", + `/providers/${providerId}/billing/vnext/warnings`, + null, + true, + true, + ); + + return new ProviderWarningsResponse(response); + }; + + private readThroughWarnings$ = ( + provider: Provider, + bypassCache: boolean = false, + ): Observable => { + const providerId = provider.id as ProviderId; + const existing = this.cache$.get(providerId); + if (existing && !bypassCache) { + return existing; + } + const response$ = from(this.fetchWarnings(providerId)); + this.cache$.set(providerId, response$); + return response$; + }; + + private getWarning$ = ( + provider: Provider, + extract: (response: ProviderWarningsResponse) => T | null | undefined, + bypassCache: boolean = false, + ): Observable => + this.readThroughWarnings$(provider, bypassCache).pipe( + map((response) => { + const value = extract(response); + return value ? value : null; + }), + take(1), + ); +} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/warnings/types/index.ts b/bitwarden_license/bit-web/src/app/billing/providers/warnings/types/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/bitwarden_license/bit-web/src/app/billing/providers/warnings/types/provider-warnings.ts b/bitwarden_license/bit-web/src/app/billing/providers/warnings/types/provider-warnings.ts new file mode 100644 index 00000000000..9c8700150ea --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/warnings/types/provider-warnings.ts @@ -0,0 +1,39 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { TaxIdWarningResponse } from "@bitwarden/web-vault/app/billing/warnings/types"; + +type ProviderSuspensionResolution = + | "add_payment_method" + | "contact_administrator" + | "contact_support"; + +export class ProviderWarningsResponse extends BaseResponse { + suspension?: SuspensionWarningResponse; + taxId?: TaxIdWarningResponse; + + constructor(response: any) { + super(response); + const suspension = this.getResponseProperty("Suspension"); + if (suspension) { + this.suspension = new SuspensionWarningResponse(suspension); + } + const taxId = this.getResponseProperty("TaxId"); + if (taxId) { + this.taxId = new TaxIdWarningResponse(taxId); + } + } +} + +class SuspensionWarningResponse extends BaseResponse { + resolution: ProviderSuspensionResolution; + subscriptionCancelsAt?: Date; + + constructor(response: any) { + super(response); + + this.resolution = this.getResponseProperty("Resolution"); + const subscriptionCancelsAt = this.getResponseProperty("SubscriptionCancelsAt"); + if (subscriptionCancelsAt) { + this.subscriptionCancelsAt = new Date(subscriptionCancelsAt); + } + } +} diff --git a/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts index 29301e626b9..f89025e7d4a 100644 --- a/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts @@ -1,5 +1,4 @@ import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request"; -import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; import { BillingInvoiceResponse, @@ -18,8 +17,6 @@ export abstract class OrganizationBillingApiServiceAbstraction { startAfter?: string, ) => Promise; - abstract getWarnings: (id: string) => Promise; - abstract setupBusinessUnit: ( id: string, request: { diff --git a/libs/common/src/billing/services/organization/organization-billing-api.service.ts b/libs/common/src/billing/services/organization/organization-billing-api.service.ts index e9456f61026..40424c236e7 100644 --- a/libs/common/src/billing/services/organization/organization-billing-api.service.ts +++ b/libs/common/src/billing/services/organization/organization-billing-api.service.ts @@ -1,5 +1,4 @@ import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request"; -import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; import { ApiService } from "../../../abstractions/api.service"; import { OrganizationBillingApiServiceAbstraction } from "../../abstractions/organizations/organization-billing-api.service.abstraction"; @@ -53,18 +52,6 @@ export class OrganizationBillingApiService implements OrganizationBillingApiServ return r?.map((i: any) => new BillingTransactionResponse(i)) || []; } - async getWarnings(id: string): Promise { - const response = await this.apiService.send( - "GET", - `/organizations/${id}/billing/warnings`, - null, - true, - true, - ); - - return new OrganizationWarningsResponse(response); - } - async setupBusinessUnit( id: string, request: { diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 15ecd9fb63e..5a4e5ff5dde 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -32,6 +32,7 @@ export enum FeatureFlag { AllowTrialLengthZero = "pm-20322-allow-trial-length-0", PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout", PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover", + PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", @@ -108,6 +109,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.AllowTrialLengthZero]: FALSE, [FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE, [FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE, + [FeatureFlag.PM22415_TaxIDWarnings]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, From d01db53f693b37d07d84b7de58722d9c258a56c4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 11:30:09 -0400 Subject: [PATCH 10/30] [deps]: Update actions/download-artifact action to v5 (#16060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel García --- .github/workflows/build-browser.yml | 2 +- .github/workflows/build-cli.yml | 2 +- .github/workflows/build-desktop.yml | 4 ++-- .github/workflows/release-desktop-beta.yml | 2 +- .github/workflows/test.yml | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index be140b9a20e..43661d50910 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -232,7 +232,7 @@ jobs: npm --version - name: Download browser source - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: browser-source-${{ env._BUILD_NUMBER }}.zip diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 73b765f207a..22ba3a3e7be 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -499,7 +499,7 @@ jobs: echo "BW Package Version: $_PACKAGE_VERSION" - name: Get bw linux cli - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: bw-linux-${{ env._PACKAGE_VERSION }}.zip path: apps/cli/dist/snap diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 366d439fb45..e6c77b366b1 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -1079,7 +1079,7 @@ jobs: run: npm run build - name: Download Browser artifact - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: path: ${{ github.workspace }}/browser-build-artifacts @@ -1344,7 +1344,7 @@ jobs: run: npm run build - name: Download Browser artifact - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: path: ${{ github.workspace }}/browser-build-artifacts diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml index e3eb9090cb7..eb6af20f9ee 100644 --- a/.github/workflows/release-desktop-beta.yml +++ b/.github/workflows/release-desktop-beta.yml @@ -1035,7 +1035,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Download all artifacts - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: path: apps/desktop/artifacts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a8bfd368884..64c4e0dff13 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -170,13 +170,13 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Download jest coverage - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: jest-coverage path: ./ - name: Download rust coverage - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: rust-coverage path: ./apps/desktop/desktop_native From ea305a0f7199c624d1ad96b649828dfd0cd42aea Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 18:35:17 +0200 Subject: [PATCH 11/30] [deps] Platform: Update nx monorepo to v21.3.11 (#15286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel García --- package-lock.json | 1608 ++++++++++++++++++++++++++++++++++++++++----- package.json | 10 +- 2 files changed, 1449 insertions(+), 169 deletions(-) diff --git a/package-lock.json b/package-lock.json index c8d06230f67..4157e5ab9c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,10 +31,10 @@ "@microsoft/signalr": "8.0.7", "@microsoft/signalr-protocol-msgpack": "8.0.7", "@ng-select/ng-select": "14.9.0", - "@nx/devkit": "21.1.2", - "@nx/eslint": "21.1.2", - "@nx/jest": "21.1.2", - "@nx/js": "21.1.2", + "@nx/devkit": "21.3.11", + "@nx/eslint": "21.3.11", + "@nx/jest": "21.3.11", + "@nx/js": "21.3.11", "big-integer": "1.6.52", "braintree-web-drop-in": "1.44.0", "buffer": "6.0.3", @@ -156,7 +156,7 @@ "json5": "2.2.3", "lint-staged": "16.0.0", "mini-css-extract-plugin": "2.9.2", - "nx": "21.1.2", + "nx": "21.3.11", "postcss": "8.5.3", "postcss-loader": "8.1.1", "prettier": "3.6.2", @@ -2838,6 +2838,15 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.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", @@ -3029,12 +3038,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", - "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/types": "^7.28.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -4494,33 +4503,33 @@ } }, "node_modules/@babel/traverse": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", - "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/parser": "^7.27.4", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/types": "^7.28.0", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse/node_modules/@babel/generator": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz", - "integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.5", - "@babel/types": "^7.27.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -4528,9 +4537,9 @@ } }, "node_modules/@babel/types": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", - "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -7187,7 +7196,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -7205,7 +7213,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -7218,7 +7225,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -7231,14 +7237,12 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "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, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -7256,7 +7260,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -7272,7 +7275,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -7380,6 +7382,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -7489,10 +7492,20 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, "license": "MIT", "dependencies": { "@jest/fake-timers": "^29.7.0", @@ -7508,6 +7521,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, "license": "MIT", "dependencies": { "expect": "^29.7.0", @@ -7521,6 +7535,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, "license": "MIT", "dependencies": { "jest-get-type": "^29.6.3" @@ -7533,6 +7548,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -7546,10 +7562,20 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/get-type": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.0.1.tgz", + "integrity": "sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/globals": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", @@ -7561,10 +7587,33 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern/node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/reporters": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", @@ -7608,6 +7657,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -7619,6 +7669,7 @@ "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, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -7639,6 +7690,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -7651,6 +7703,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "devOptional": true, "license": "MIT", "dependencies": { "@sinclair/typebox": "^0.27.8" @@ -7659,10 +7712,62 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/snapshot-utils": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.0.5.tgz", + "integrity": "sha512-XcCQ5qWHLvi29UUrowgDFvV4t7ETxX91CbDczMnoqXPOIcZOxyNdSjm6kV5XMc8+HkxfRegU/MUmnTbJRzGrUQ==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils/node_modules/@sinclair/typebox": { + "version": "0.34.38", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", + "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", + "license": "MIT" + }, "node_modules/@jest/source-map": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.18", @@ -7677,6 +7782,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, "license": "MIT", "dependencies": { "@jest/console": "^29.7.0", @@ -7692,6 +7798,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, "license": "MIT", "dependencies": { "@jest/test-result": "^29.7.0", @@ -7707,6 +7814,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", @@ -7733,12 +7841,14 @@ "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, "license": "MIT" }, "node_modules/@jest/types": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "devOptional": true, "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", @@ -7753,17 +7863,13 @@ } }, "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==", + "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==", "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -7775,15 +7881,6 @@ "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==", - "license": "MIT", - "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", @@ -7802,9 +7899,9 @@ "license": "MIT" }, "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==", + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -9189,9 +9286,9 @@ } }, "node_modules/@nx/devkit": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-21.1.2.tgz", - "integrity": "sha512-1dgjwSsNDdp/VXydZnSfzfVwySEB3C9yjzeIw6+3+nRvZfH16a7ggZE7MF5sJTq4d+01hAgIDz3KyvGa6Jf73g==", + "version": "21.3.11", + "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-21.3.11.tgz", + "integrity": "sha512-JOV8TAa9K5+ZwTA/EUi0g5qcKEg5vmi0AyOUsrNUHlv3BgQnwZtPLDDTPPZ+ezq24o6YzgwueZWj3CLEdMHEDg==", "license": "MIT", "dependencies": { "ejs": "^3.1.7", @@ -9204,7 +9301,7 @@ "yargs-parser": "21.1.1" }, "peerDependencies": { - "nx": "21.1.2" + "nx": "21.3.11" } }, "node_modules/@nx/devkit/node_modules/ignore": { @@ -9217,16 +9314,16 @@ } }, "node_modules/@nx/eslint": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/@nx/eslint/-/eslint-21.1.2.tgz", - "integrity": "sha512-Mp8u0RlkhxYtZ47d2ou6t8XIpRy7N/n23OzikqMro4Wt/DK1irGyShSoNIqdGdwalAE5MG1OFXspttXB+y/wOQ==", + "version": "21.3.11", + "resolved": "https://registry.npmjs.org/@nx/eslint/-/eslint-21.3.11.tgz", + "integrity": "sha512-9jeD8QuU3OMcItjtw0QHl5cwohLeA9R+lajNJoOjS2tUGXTHWb8NOcEZBXWMcML+eV1iloIDW8/P4jV4BYqP2w==", "license": "MIT", "dependencies": { - "@nx/devkit": "21.1.2", - "@nx/js": "21.1.2", + "@nx/devkit": "21.3.11", + "@nx/js": "21.3.11", "semver": "^7.5.3", "tslib": "^2.3.0", - "typescript": "~5.7.2" + "typescript": "~5.8.2" }, "peerDependencies": { "@zkochan/js-yaml": "0.0.7", @@ -9239,9 +9336,9 @@ } }, "node_modules/@nx/eslint/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==", + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -9252,20 +9349,20 @@ } }, "node_modules/@nx/jest": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/@nx/jest/-/jest-21.1.2.tgz", - "integrity": "sha512-y4VZita9LFb6XajulRIwjMcqHU6/f73C4SNSH6IM5BYmkN68ovICmzTGvoaL7wGTaYrA4Moh/WoKwEwQWKxRPQ==", + "version": "21.3.11", + "resolved": "https://registry.npmjs.org/@nx/jest/-/jest-21.3.11.tgz", + "integrity": "sha512-PkdNWeoUY81zr+jtUapBdvvh26lWYIhDNyUwTjIBFajX8EAlhJpvShKHs7QObmrwOMLMXwLHKINiSCw9rueOBQ==", "license": "MIT", "dependencies": { - "@jest/reporters": "^29.4.1", - "@jest/test-result": "^29.4.1", - "@nx/devkit": "21.1.2", - "@nx/js": "21.1.2", + "@jest/reporters": "^30.0.2", + "@jest/test-result": "^30.0.2", + "@nx/devkit": "21.3.11", + "@nx/js": "21.3.11", "@phenomnomnominal/tsquery": "~5.0.1", "identity-obj-proxy": "3.0.0", - "jest-config": "^29.4.1", - "jest-resolve": "^29.4.1", - "jest-util": "^29.4.1", + "jest-config": "^30.0.2", + "jest-resolve": "^30.0.2", + "jest-util": "^30.0.2", "minimatch": "9.0.3", "picocolors": "^1.1.0", "resolve.exports": "2.0.3", @@ -9274,10 +9371,1039 @@ "yargs-parser": "21.1.1" } }, + "node_modules/@nx/jest/node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "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/@nx/jest/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==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@nx/jest/node_modules/@babel/generator": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@nx/jest/node_modules/@jest/console": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.0.5.tgz", + "integrity": "sha512-xY6b0XiL0Nav3ReresUarwl2oIz1gTnxGbGpho9/rbUWsLH0f1OD/VT84xs8c7VmH7MChnLb0pag6PhZhAdDiA==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.0.5", + "jest-util": "30.0.5", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/@jest/environment": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.0.5.tgz", + "integrity": "sha512-aRX7WoaWx1oaOkDQvCWImVQ8XNtdv5sEWgk4gxR6NXb7WBUnL5sRak4WRzIQRZ1VTWPvV4VI4mgGjNL9TeKMYA==", + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-mock": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/@jest/expect": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.0.5.tgz", + "integrity": "sha512-6udac8KKrtTtC+AXZ2iUN/R7dp7Ydry+Fo6FPFnDG54wjVMnb6vW/XNlf7Xj8UDjAE3aAVAsR4KFyKk3TCXmTA==", + "license": "MIT", + "dependencies": { + "expect": "30.0.5", + "jest-snapshot": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/@jest/expect-utils": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.0.5.tgz", + "integrity": "sha512-F3lmTT7CXWYywoVUGTCmom0vXq3HTTkaZyTAzIy+bXSBizB7o5qzlC9VCtq0arOa8GqmNsbg/cE9C6HLn7Szew==", + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/@jest/fake-timers": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.0.5.tgz", + "integrity": "sha512-ZO5DHfNV+kgEAeP3gK3XlpJLL4U3Sz6ebl/n68Uwt64qFFs5bv4bfEEjyRGK5uM0C90ewooNgFuKMdkbEoMEXw==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.0.5", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/@jest/globals": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.0.5.tgz", + "integrity": "sha512-7oEJT19WW4oe6HR7oLRvHxwlJk2gev0U9px3ufs8sX9PoD1Eza68KF0/tlN7X0dq/WVsBScXQGgCldA1V9Y/jA==", + "license": "MIT", + "dependencies": { + "@jest/environment": "30.0.5", + "@jest/expect": "30.0.5", + "@jest/types": "30.0.5", + "jest-mock": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/@jest/reporters": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.0.5.tgz", + "integrity": "sha512-mafft7VBX4jzED1FwGC1o/9QUM2xebzavImZMeqnsklgcyxBto8mV4HzNSzUrryJ+8R9MFOM3HgYuDradWR+4g==", + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.0.5", + "@jest/test-result": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.0.5", + "jest-util": "30.0.5", + "jest-worker": "30.0.5", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@nx/jest/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/@jest/test-result": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.0.5.tgz", + "integrity": "sha512-wPyztnK0gbDMQAJZ43tdMro+qblDHH1Ru/ylzUo21TBKqt88ZqnKKK2m30LKmLLoKtR2lxdpCC/P3g1vfKcawQ==", + "license": "MIT", + "dependencies": { + "@jest/console": "30.0.5", + "@jest/types": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/@jest/test-sequencer": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.0.5.tgz", + "integrity": "sha512-Aea/G1egWoIIozmDD7PBXUOxkekXl7ueGzrsGGi1SbeKgQqCYCIf+wfbflEbf2LiPxL8j2JZGLyrzZagjvW4YQ==", + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.0.5", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.0.5", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/@jest/transform": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.0.5.tgz", + "integrity": "sha512-Vk8amLQCmuZyy6GbBht1Jfo9RSdBtg7Lks+B0PecnjI8J+PCLQPGh7uI8Q/2wwpW2gLdiAfiHNsmekKlywULqg==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.0.5", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.0", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.0.5", + "jest-regex-util": "30.0.1", + "jest-util": "30.0.5", + "micromatch": "^4.0.8", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/@sinclair/typebox": { + "version": "0.34.38", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", + "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", + "license": "MIT" + }, + "node_modules/@nx/jest/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@nx/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@nx/jest/node_modules/babel-jest": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.0.5.tgz", + "integrity": "sha512-mRijnKimhGDMsizTvBTWotwNpzrkHr+VvZUQBof2AufXKB8NXrL1W69TG20EvOz7aevx6FTJIaBuBkYxS8zolg==", + "license": "MIT", + "dependencies": { + "@jest/transform": "30.0.5", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.0", + "babel-preset-jest": "30.0.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0" + } + }, + "node_modules/@nx/jest/node_modules/babel-plugin-istanbul": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.0.tgz", + "integrity": "sha512-C5OzENSx/A+gt7t4VH1I2XsflxyPUmXRFPKBxt33xncdOmq7oROVM3bZv9Ysjjkv8OJYDMa+tKuKMvqU/H3xdw==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@nx/jest/node_modules/babel-plugin-jest-hoist": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.0.1.tgz", + "integrity": "sha512-zTPME3pI50NsFW8ZBaVIOeAxzEY7XHlmWeXXu9srI+9kNfzCUTy8MFan46xOGZY8NZThMqq+e3qZUKsvXbasnQ==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3", + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/babel-preset-jest": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.0.1.tgz", + "integrity": "sha512-+YHejD5iTWI46cZmcc/YtX4gaKBtdqCHCVfuVinizVpbmyjO3zYmeuyFdfA8duRqQZfgCAMlsfmkVbJ+e2MAJw==", + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.0.1", + "babel-preset-current-node-syntax": "^1.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0" + } + }, + "node_modules/@nx/jest/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@nx/jest/node_modules/ci-info": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", + "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@nx/jest/node_modules/cjs-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz", + "integrity": "sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==", + "license": "MIT" + }, + "node_modules/@nx/jest/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==", + "license": "MIT" + }, + "node_modules/@nx/jest/node_modules/expect": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.0.5.tgz", + "integrity": "sha512-P0te2pt+hHI5qLJkIR+iMvS+lYUZml8rKKsohVHAGY+uClp9XVbdyYNJOIjSRpHVp8s8YqxJCiHUkSYZGr8rtQ==", + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.0.5", + "@jest/get-type": "30.0.1", + "jest-matcher-utils": "30.0.5", + "jest-message-util": "30.0.5", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/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==", + "license": "ISC", + "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/@nx/jest/node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@nx/jest/node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@nx/jest/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==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/@nx/jest/node_modules/jest-circus": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.0.5.tgz", + "integrity": "sha512-h/sjXEs4GS+NFFfqBDYT7y5Msfxh04EwWLhQi0F8kuWpe+J/7tICSlswU8qvBqumR3kFgHbfu7vU6qruWWBPug==", + "license": "MIT", + "dependencies": { + "@jest/environment": "30.0.5", + "@jest/expect": "30.0.5", + "@jest/test-result": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.0.5", + "jest-matcher-utils": "30.0.5", + "jest-message-util": "30.0.5", + "jest-runtime": "30.0.5", + "jest-snapshot": "30.0.5", + "jest-util": "30.0.5", + "p-limit": "^3.1.0", + "pretty-format": "30.0.5", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/jest-config": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.0.5.tgz", + "integrity": "sha512-aIVh+JNOOpzUgzUnPn5FLtyVnqc3TQHVMupYtyeURSb//iLColiMIR8TxCIDKyx9ZgjKnXGucuW68hCxgbrwmA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.0.1", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.0.5", + "@jest/types": "30.0.5", + "babel-jest": "30.0.5", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-circus": "30.0.5", + "jest-docblock": "30.0.1", + "jest-environment-node": "30.0.5", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.0.5", + "jest-runner": "30.0.5", + "jest-util": "30.0.5", + "jest-validate": "30.0.5", + "micromatch": "^4.0.8", + "parse-json": "^5.2.0", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/@nx/jest/node_modules/jest-diff": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.5.tgz", + "integrity": "sha512-1UIqE9PoEKaHcIKvq2vbibrCog4Y8G0zmOxgQUVEiTqwR5hJVMCoDsN1vFvI5JvwD37hjueZ1C4l2FyGnfpE0A==", + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.0.1", + "chalk": "^4.1.2", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/jest-docblock": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.0.1.tgz", + "integrity": "sha512-/vF78qn3DYphAaIc3jy4gA7XSAz167n9Bm/wn/1XhTLW7tTBIzXtCJpb/vcmc73NIIeeohCbdL94JasyXUZsGA==", + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/jest-each": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.0.5.tgz", + "integrity": "sha512-dKjRsx1uZ96TVyejD3/aAWcNKy6ajMaN531CwWIsrazIqIoXI9TnnpPlkrEYku/8rkS3dh2rbH+kMOyiEIv0xQ==", + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1", + "@jest/types": "30.0.5", + "chalk": "^4.1.2", + "jest-util": "30.0.5", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/jest-environment-node": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.0.5.tgz", + "integrity": "sha512-ppYizXdLMSvciGsRsMEnv/5EFpvOdXBaXRBzFUDPWrsfmog4kYrOGWXarLllz6AXan6ZAA/kYokgDWuos1IKDA==", + "license": "MIT", + "dependencies": { + "@jest/environment": "30.0.5", + "@jest/fake-timers": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-mock": "30.0.5", + "jest-util": "30.0.5", + "jest-validate": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/jest-haste-map": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.0.5.tgz", + "integrity": "sha512-dkmlWNlsTSR0nH3nRfW5BKbqHefLZv0/6LCccG0xFCTWcJu8TuEwG+5Cm75iBfjVoockmO6J35o5gxtFSn5xeg==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.0.5", + "jest-worker": "30.0.5", + "micromatch": "^4.0.8", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/@nx/jest/node_modules/jest-leak-detector": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.0.5.tgz", + "integrity": "sha512-3Uxr5uP8jmHMcsOtYMRB/zf1gXN3yUIc+iPorhNETG54gErFIiUhLvyY/OggYpSMOEYqsmRxmuU4ZOoX5jpRFg==", + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/jest-matcher-utils": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.0.5.tgz", + "integrity": "sha512-uQgGWt7GOrRLP1P7IwNWwK1WAQbq+m//ZY0yXygyfWp0rJlksMSLQAA4wYQC3b6wl3zfnchyTx+k3HZ5aPtCbQ==", + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1", + "chalk": "^4.1.2", + "jest-diff": "30.0.5", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/jest-message-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.5.tgz", + "integrity": "sha512-NAiDOhsK3V7RU0Aa/HnrQo+E4JlbarbmI3q6Pi4KcxicdtjV82gcIUrejOtczChtVQR4kddu1E1EJlW6EN9IyA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.0.5", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/jest-mock": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", + "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-util": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/jest-resolve": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.0.5.tgz", + "integrity": "sha512-d+DjBQ1tIhdz91B79mywH5yYu76bZuE96sSbxj8MkjWVx5WNdt1deEFRONVL4UkKLSrAbMkdhb24XN691yDRHg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.0.5", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.0.5", + "jest-validate": "30.0.5", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/jest-runner": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.0.5.tgz", + "integrity": "sha512-JcCOucZmgp+YuGgLAXHNy7ualBx4wYSgJVWrYMRBnb79j9PD0Jxh0EHvR5Cx/r0Ce+ZBC4hCdz2AzFFLl9hCiw==", + "license": "MIT", + "dependencies": { + "@jest/console": "30.0.5", + "@jest/environment": "30.0.5", + "@jest/test-result": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.0.1", + "jest-environment-node": "30.0.5", + "jest-haste-map": "30.0.5", + "jest-leak-detector": "30.0.5", + "jest-message-util": "30.0.5", + "jest-resolve": "30.0.5", + "jest-runtime": "30.0.5", + "jest-util": "30.0.5", + "jest-watcher": "30.0.5", + "jest-worker": "30.0.5", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/jest-runtime": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.0.5.tgz", + "integrity": "sha512-7oySNDkqpe4xpX5PPiJTe5vEa+Ak/NnNz2bGYZrA1ftG3RL3EFlHaUkA1Cjx+R8IhK0Vg43RML5mJedGTPNz3A==", + "license": "MIT", + "dependencies": { + "@jest/environment": "30.0.5", + "@jest/fake-timers": "30.0.5", + "@jest/globals": "30.0.5", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.0.5", + "jest-message-util": "30.0.5", + "jest-mock": "30.0.5", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.0.5", + "jest-snapshot": "30.0.5", + "jest-util": "30.0.5", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/jest-snapshot": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.0.5.tgz", + "integrity": "sha512-T00dWU/Ek3LqTp4+DcW6PraVxjk28WY5Ua/s+3zUKSERZSNyxTqhDXCWKG5p2HAJ+crVQ3WJ2P9YVHpj1tkW+g==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.0.5", + "@jest/get-type": "30.0.1", + "@jest/snapshot-utils": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", + "babel-preset-current-node-syntax": "^1.1.0", + "chalk": "^4.1.2", + "expect": "30.0.5", + "graceful-fs": "^4.2.11", + "jest-diff": "30.0.5", + "jest-matcher-utils": "30.0.5", + "jest-message-util": "30.0.5", + "jest-util": "30.0.5", + "pretty-format": "30.0.5", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/jest-validate": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.0.5.tgz", + "integrity": "sha512-ouTm6VFHaS2boyl+k4u+Qip4TSH7Uld5tyD8psQ8abGgt2uYYB8VwVfAHWHjHc0NWmGGbwO5h0sCPOGHHevefw==", + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1", + "@jest/types": "30.0.5", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/jest-watcher": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.0.5.tgz", + "integrity": "sha512-z9slj/0vOwBDBjN3L4z4ZYaA+pG56d6p3kTUhFRYGvXbXMWhXmb/FIxREZCD06DYUwDKKnj2T80+Pb71CQ0KEg==", + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.0.5", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/jest-worker": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.0.5.tgz", + "integrity": "sha512-ojRXsWzEP16NdUuBw/4H/zkZdHOa7MMYCk4E430l+8fELeLg/mqmMlRhjL7UNZvQrDmnovWZV4DxX03fZF48fQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.0.5", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/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==", + "license": "ISC" + }, + "node_modules/@nx/jest/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==", + "license": "BlueOak-1.0.0", + "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/@nx/jest/node_modules/pretty-format": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@nx/jest/node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/@nx/jest/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/@nx/jest/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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@nx/jest/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/@nx/jest/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==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@nx/jest/node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/@nx/js": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/@nx/js/-/js-21.1.2.tgz", - "integrity": "sha512-ZF6Zf4Ys+RBvH0GoQHio94C/0N07Px/trAvseMuQ8PKc0tSkXycu/EBc1uAZQvgJThR5o3diAKtIQug77pPYMQ==", + "version": "21.3.11", + "resolved": "https://registry.npmjs.org/@nx/js/-/js-21.3.11.tgz", + "integrity": "sha512-aN8g1TP3FMN6MFLvMrZNaoqSwAkBFH1PunKQV17w4nlPkimWICaCP2DhY5W3VoOpjQBbhQoqrRt4mVfgnEpyvA==", "license": "MIT", "dependencies": { "@babel/core": "^7.23.2", @@ -9287,8 +10413,8 @@ "@babel/preset-env": "^7.23.2", "@babel/preset-typescript": "^7.22.5", "@babel/runtime": "^7.22.6", - "@nx/devkit": "21.1.2", - "@nx/workspace": "21.1.2", + "@nx/devkit": "21.3.11", + "@nx/workspace": "21.3.11", "@zkochan/js-yaml": "0.0.7", "babel-plugin-const-enum": "^1.0.1", "babel-plugin-macros": "^3.1.0", @@ -9427,9 +10553,9 @@ } }, "node_modules/@nx/nx-darwin-arm64": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-21.1.2.tgz", - "integrity": "sha512-9dO32jd+h7SrvQafJph6b7Bsmp2IotTE0w7dAGb4MGBQni3JWCXaxlMMpWUZXWW1pM5uIkFJO5AASW4UOI7w2w==", + "version": "21.3.11", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-21.3.11.tgz", + "integrity": "sha512-qXZrW6kfsfGG9n4cWugR2v8ys7P1SsbQuFahlbNSTd7g+ZxozaOnc7tyxW9XuY84KQ35HwP/QSu1E13fK5CXwQ==", "cpu": [ "arm64" ], @@ -9440,9 +10566,9 @@ ] }, "node_modules/@nx/nx-darwin-x64": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-21.1.2.tgz", - "integrity": "sha512-5sf+4PRVg9pDVgD53NE1hoPz4lC8Ni34UovQsOrZgDvwU5mqPbIhTzVYRDH86i/086AcCvjT5tEt7rEcuRwlKw==", + "version": "21.3.11", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-21.3.11.tgz", + "integrity": "sha512-6NJEIGRITpFZYptJtr/wdnVuidAS/wONMMSwX5rgAqh5A9teI0vxZVOgG6n5f6NQyqEDvZ9ytcIvLsQWA4kJFg==", "cpu": [ "x64" ], @@ -9453,9 +10579,9 @@ ] }, "node_modules/@nx/nx-freebsd-x64": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-21.1.2.tgz", - "integrity": "sha512-E5HR44fimXlQuAgn/tP9esmvxbzt/92AIl0PBT6L3Juh/xYiXKWhda63H4+UNT8AcLRxVXwfZrGPuGCDs+7y/Q==", + "version": "21.3.11", + "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-21.3.11.tgz", + "integrity": "sha512-9VZOM9mutzuZCUgijHXrIl3NgKt2CWuH/awLqDS8ijhLs6WfI5TYTa+mFwx90dfZZ4y/jy6XWXa2Ee3OShf7Hg==", "cpu": [ "x64" ], @@ -9466,9 +10592,9 @@ ] }, "node_modules/@nx/nx-linux-arm-gnueabihf": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-21.1.2.tgz", - "integrity": "sha512-V4n6DE+r12gwJHFjZs+e2GmWYZdhpgA2DYWbsYWRYb1XQCNUg4vPzt+YFzWZ+K2o91k93EBnlLfrag7CqxUslw==", + "version": "21.3.11", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-21.3.11.tgz", + "integrity": "sha512-a05tAySKDEWt0TGoSnWp/l5+HL/CDJQkHfI9pXho85oDSkVRzhOInAn1EeZB/F+Q3PnJFsMHMhbuu2/nm3uYJA==", "cpu": [ "arm" ], @@ -9479,9 +10605,9 @@ ] }, "node_modules/@nx/nx-linux-arm64-gnu": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-21.1.2.tgz", - "integrity": "sha512-NFhsp27O+mS3r7PWLmJgyZy42WQ72c2pTQSpYfhaBbZPTI5DqBHdANa0sEPmV+ON24qkl5CZKvsmhzjsNmyW6A==", + "version": "21.3.11", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-21.3.11.tgz", + "integrity": "sha512-MPeivf0ptNpzQYvww6zHIqVbE5dTT2isl/WqzGyy7NgSeYDpFXmouDCQaeKxo5WytMVRCvCw/NnWTQuCK6TjnA==", "cpu": [ "arm64" ], @@ -9492,9 +10618,9 @@ ] }, "node_modules/@nx/nx-linux-arm64-musl": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-21.1.2.tgz", - "integrity": "sha512-BgS9npARwcnw+hoaRsbas6vdBAJRBAj5qSeL57LO8Dva+e/6PYqoNyVJ0BgJ98xPXDpzM/NnpeRsndQGpLyhDw==", + "version": "21.3.11", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-21.3.11.tgz", + "integrity": "sha512-/hJpc4VJsbxDEreXt5Ka9HJ3TBEHgIa9y/i+H9MmWOeapCdH1Edhx58Heuv9OaX7kK8Y8q0cSicv0dJCghiTjA==", "cpu": [ "arm64" ], @@ -9505,9 +10631,9 @@ ] }, "node_modules/@nx/nx-linux-x64-gnu": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-21.1.2.tgz", - "integrity": "sha512-tjBINbymQgxnIlNK/m6B0P5eiGRSHSYPNkFdh3+sra80AP/ymHGLRxxZy702Ga2xg8RVr9zEvuXYHI+QBa1YmA==", + "version": "21.3.11", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-21.3.11.tgz", + "integrity": "sha512-pTBHuloqTxpTHa/fdKjHkFFsfW16mEcTp37HDtoQpjPfcd9nO8CYO8OClaewr9khNqCnSbCLfSoIg/alnb7BWw==", "cpu": [ "x64" ], @@ -9518,9 +10644,9 @@ ] }, "node_modules/@nx/nx-linux-x64-musl": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-21.1.2.tgz", - "integrity": "sha512-+0V0YAOWMh1wvpQZuayQ7y+sj2MhE3l7z0JMD9SX/4xv9zLOWGv+EiUmN/fGoU/mwsSkH2wTCo6G6quKF1E8jQ==", + "version": "21.3.11", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-21.3.11.tgz", + "integrity": "sha512-OhFjURB68rd6xld8t8fiNpopF2E7v+8/jfbpsku9c0gdV2UhzoxCeZwooe7qhQjCcjVO8JNOs4dAf7qs1VtpMw==", "cpu": [ "x64" ], @@ -9531,9 +10657,9 @@ ] }, "node_modules/@nx/nx-win32-arm64-msvc": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-21.1.2.tgz", - "integrity": "sha512-E+ECMQIMJ6R47BMW5YpDyOhTqczvFaL8k24umRkcvlRh3SraczyxBVPkYHDukDp7tCeIszc5EvdWc83C3W8U4w==", + "version": "21.3.11", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-21.3.11.tgz", + "integrity": "sha512-pGE2Td13oEj7aeogwCL+2fjmpabQVSduKfGOTlt4YoMlM0w0bXYSWqwiGBMKbMA50qkhnVapwwkuWF38PgCIxg==", "cpu": [ "arm64" ], @@ -9544,9 +10670,9 @@ ] }, "node_modules/@nx/nx-win32-x64-msvc": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-21.1.2.tgz", - "integrity": "sha512-J9rNTBOS7Ld6CybU/cou1Fg52AHSYsiwpZISM2RNM0XIoVSDk3Jsvh4OJgS2rvV0Sp/cgDg3ieOMAreekH+TKw==", + "version": "21.3.11", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-21.3.11.tgz", + "integrity": "sha512-KJqLL/Zyx96hs+7pKbo/fsU7ZTFSLeZLnYQu05o6fvJJ5I1+p85t212/7vkbKKWJncyMospQdzLr3zLG3A/u8A==", "cpu": [ "x64" ], @@ -9557,16 +10683,16 @@ ] }, "node_modules/@nx/workspace": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/@nx/workspace/-/workspace-21.1.2.tgz", - "integrity": "sha512-I4e/X/GN0Vx3FDZv/7bFYmXfOPmcMI3cDO/rg+TqudsuxVM7tJ7+8jtwdpU4I2IEpI6oU9FZ7Fu9R2uNqL5rrQ==", + "version": "21.3.11", + "resolved": "https://registry.npmjs.org/@nx/workspace/-/workspace-21.3.11.tgz", + "integrity": "sha512-DD2iu9Ip/faNQ5MXZk+UbbBxGofYKjzHsXKRvMNQ/OAVzP/u9z2CPXEmRKlRAEQoy1lInmyopwfEUWwK1v4x0g==", "license": "MIT", "dependencies": { - "@nx/devkit": "21.1.2", + "@nx/devkit": "21.3.11", "@zkochan/js-yaml": "0.0.7", "chalk": "^4.1.0", "enquirer": "~2.3.6", - "nx": "21.1.2", + "nx": "21.3.11", "picomatch": "4.0.2", "tslib": "^2.3.0", "yargs-parser": "21.1.1" @@ -9950,13 +11076,24 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "license": "MIT", "optional": true, "engines": { "node": ">=14" } }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "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", @@ -10727,6 +11864,7 @@ "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "devOptional": true, "license": "MIT" }, "node_modules/@sindresorhus/is": { @@ -10768,6 +11906,7 @@ "version": "10.3.0", "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.0" @@ -12358,6 +13497,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -13506,6 +14646,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.9.1.tgz", @@ -13513,7 +14659,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13527,7 +14672,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13541,7 +14685,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13555,7 +14698,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13569,7 +14711,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13583,7 +14724,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13597,7 +14737,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13611,7 +14750,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13625,7 +14763,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13639,7 +14776,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13653,7 +14789,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13667,7 +14802,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13681,7 +14815,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13695,7 +14828,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13709,7 +14841,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13723,7 +14854,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -13737,7 +14867,6 @@ "version": "0.2.11", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", "integrity": "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -13753,7 +14882,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13767,7 +14895,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13781,7 +14908,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15322,6 +16448,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, "license": "MIT", "dependencies": { "@jest/transform": "^29.7.0", @@ -15375,6 +16502,7 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", @@ -15391,6 +16519,7 @@ "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, "license": "BSD-3-Clause", "dependencies": { "@babel/core": "^7.12.3", @@ -15407,6 +16536,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -15416,6 +16546,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.3.3", @@ -15563,6 +16694,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, "license": "MIT", "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", @@ -16834,6 +17966,7 @@ "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "devOptional": true, "funding": [ { "type": "github", @@ -16849,6 +17982,7 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, "license": "MIT" }, "node_modules/clean-css": { @@ -18465,6 +19599,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -18955,7 +20090,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, "node_modules/ee-first": { @@ -19659,7 +20793,7 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -19700,7 +20834,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "debug": "^4.3.4" @@ -20414,6 +21548,16 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "license": "MIT", "engines": { "node": ">= 0.8.0" } @@ -20445,6 +21589,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, "license": "MIT", "dependencies": { "@jest/expect-utils": "^29.7.0", @@ -21083,7 +22228,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -23907,6 +25051,7 @@ "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, "license": "BSD-3-Clause", "dependencies": { "debug": "^4.1.1", @@ -23921,6 +25066,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -24041,6 +25187,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", @@ -24072,6 +25219,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -24084,6 +25232,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", @@ -24098,6 +25247,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, "license": "MIT" }, "node_modules/jest-cli": { @@ -24138,6 +25288,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", @@ -24183,6 +25334,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -24195,6 +25347,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -24206,6 +25359,7 @@ "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, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -24226,6 +25380,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -24238,6 +25393,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", @@ -24252,12 +25408,14 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, "license": "MIT" }, "node_modules/jest-diff": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -24273,6 +25431,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -24285,6 +25444,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", @@ -24299,12 +25459,14 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, "license": "MIT" }, "node_modules/jest-docblock": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, "license": "MIT", "dependencies": { "detect-newline": "^3.0.0" @@ -24317,6 +25479,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -24333,6 +25496,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -24345,6 +25509,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", @@ -24359,6 +25524,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, "license": "MIT" }, "node_modules/jest-environment-jsdom": { @@ -24614,6 +25780,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", @@ -24631,6 +25798,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "devOptional": true, "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -24640,6 +25808,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "devOptional": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -24691,6 +25860,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, "license": "MIT", "dependencies": { "jest-get-type": "^29.6.3", @@ -24704,6 +25874,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -24716,6 +25887,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", @@ -24730,12 +25902,14 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, "license": "MIT" }, "node_modules/jest-matcher-utils": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -24751,6 +25925,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -24763,6 +25938,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", @@ -24777,12 +25953,14 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, "license": "MIT" }, "node_modules/jest-message-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.12.13", @@ -24803,6 +25981,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -24815,6 +25994,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", @@ -24829,12 +26009,14 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, "license": "MIT" }, "node_modules/jest-mock": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -25089,6 +26271,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "devOptional": true, "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -25098,6 +26281,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "devOptional": true, "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -25132,6 +26316,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, "license": "MIT", "dependencies": { "@jest/console": "^29.7.0", @@ -25164,6 +26349,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -25173,6 +26359,7 @@ "version": "0.5.13", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -25183,6 +26370,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", @@ -25216,6 +26404,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -25227,6 +26416,7 @@ "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, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -25247,6 +26437,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -25269,6 +26460,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", @@ -25300,6 +26492,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -25312,6 +26505,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", @@ -25326,12 +26520,14 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, "license": "MIT" }, "node_modules/jest-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "devOptional": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -25349,6 +26545,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -25361,6 +26558,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "devOptional": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -25378,6 +26576,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -25390,6 +26589,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -25402,6 +26602,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "devOptional": true, "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", @@ -25416,6 +26617,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "devOptional": true, "license": "MIT" }, "node_modules/jest-watch-typeahead": { @@ -25539,6 +26741,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, "license": "MIT", "dependencies": { "@jest/test-result": "^29.7.0", @@ -25558,6 +26761,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -25573,6 +26777,7 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "devOptional": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -28506,7 +29711,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -29046,7 +30250,6 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.4.tgz", "integrity": "sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg==", - "dev": true, "license": "MIT", "bin": { "napi-postinstall": "lib/cli.js" @@ -30180,9 +31383,9 @@ "license": "MIT" }, "node_modules/nx": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/nx/-/nx-21.1.2.tgz", - "integrity": "sha512-oczAEOOkQHElxCXs2g2jXDRabDRsmub/h5SAgqAUDSJ2CRnYGVVlgZX7l+o+A9kSqfONyLy5FlJ1pSWlvPuG4w==", + "version": "21.3.11", + "resolved": "https://registry.npmjs.org/nx/-/nx-21.3.11.tgz", + "integrity": "sha512-nj2snZ3mHZnbHcoB3NUdxbch9L1sQKV1XccLs1B79fmI/N5oOgWgctm/bWoZH2UH5b4A8ZLAMTsC6YnSJGbcaw==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -30202,7 +31405,7 @@ "flat": "^5.0.2", "front-matter": "^4.0.2", "ignore": "^5.0.4", - "jest-diff": "^29.4.1", + "jest-diff": "^30.0.2", "jsonc-parser": "3.2.0", "lines-and-columns": "2.0.3", "minimatch": "9.0.3", @@ -30227,16 +31430,16 @@ "nx-cloud": "bin/nx-cloud.js" }, "optionalDependencies": { - "@nx/nx-darwin-arm64": "21.1.2", - "@nx/nx-darwin-x64": "21.1.2", - "@nx/nx-freebsd-x64": "21.1.2", - "@nx/nx-linux-arm-gnueabihf": "21.1.2", - "@nx/nx-linux-arm64-gnu": "21.1.2", - "@nx/nx-linux-arm64-musl": "21.1.2", - "@nx/nx-linux-x64-gnu": "21.1.2", - "@nx/nx-linux-x64-musl": "21.1.2", - "@nx/nx-win32-arm64-msvc": "21.1.2", - "@nx/nx-win32-x64-msvc": "21.1.2" + "@nx/nx-darwin-arm64": "21.3.11", + "@nx/nx-darwin-x64": "21.3.11", + "@nx/nx-freebsd-x64": "21.3.11", + "@nx/nx-linux-arm-gnueabihf": "21.3.11", + "@nx/nx-linux-arm64-gnu": "21.3.11", + "@nx/nx-linux-arm64-musl": "21.3.11", + "@nx/nx-linux-x64-gnu": "21.3.11", + "@nx/nx-linux-x64-musl": "21.3.11", + "@nx/nx-win32-arm64-msvc": "21.3.11", + "@nx/nx-win32-x64-msvc": "21.3.11" }, "peerDependencies": { "@swc-node/register": "^1.8.0", @@ -30251,6 +31454,36 @@ } } }, + "node_modules/nx/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/nx/node_modules/@sinclair/typebox": { + "version": "0.34.38", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", + "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", + "license": "MIT" + }, + "node_modules/nx/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/nx/node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -30308,6 +31541,21 @@ "node": ">=8" } }, + "node_modules/nx/node_modules/jest-diff": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.5.tgz", + "integrity": "sha512-1UIqE9PoEKaHcIKvq2vbibrCog4Y8G0zmOxgQUVEiTqwR5hJVMCoDsN1vFvI5JvwD37hjueZ1C4l2FyGnfpE0A==", + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.0.1", + "chalk": "^4.1.2", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/nx/node_modules/jsonc-parser": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", @@ -30353,6 +31601,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nx/node_modules/pretty-format": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/nx/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/nx/node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -31165,7 +32433,6 @@ "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, "license": "BlueOak-1.0.0" }, "node_modules/pacote": { @@ -32761,6 +34028,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, "funding": [ { "type": "individual", @@ -34550,7 +35818,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -35301,7 +36568,6 @@ "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, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -35388,7 +36654,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -35641,6 +36906,21 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "license": "MIT" }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -37631,7 +38911,6 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.9.1.tgz", "integrity": "sha512-4AZVxP05JGN6DwqIkSP4VKLOcwQa5l37SWHF/ahcuqBMbfxbpN1L1QKafEhWCziHhzKex9H/AR09H0OuVyU+9g==", - "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -39712,7 +40991,6 @@ "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, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -39736,6 +41014,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", @@ -39749,6 +41028,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, "license": "ISC" }, "node_modules/ws": { diff --git a/package.json b/package.json index eddeab90db8..bd407623b7a 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "json5": "2.2.3", "lint-staged": "16.0.0", "mini-css-extract-plugin": "2.9.2", - "nx": "21.1.2", + "nx": "21.3.11", "postcss": "8.5.3", "postcss-loader": "8.1.1", "prettier": "3.6.2", @@ -166,10 +166,10 @@ "@microsoft/signalr": "8.0.7", "@microsoft/signalr-protocol-msgpack": "8.0.7", "@ng-select/ng-select": "14.9.0", - "@nx/devkit": "21.1.2", - "@nx/eslint": "21.1.2", - "@nx/jest": "21.1.2", - "@nx/js": "21.1.2", + "@nx/devkit": "21.3.11", + "@nx/eslint": "21.3.11", + "@nx/jest": "21.3.11", + "@nx/js": "21.3.11", "big-integer": "1.6.52", "braintree-web-drop-in": "1.44.0", "buffer": "6.0.3", From 939fd402c390b0d14d33d8f15a2fa7c1772d487b Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 18 Aug 2025 12:37:25 -0400 Subject: [PATCH 12/30] [PM-24677] Slim StateService down so it can be moved to state lib (#16021) * Slim StateService down so it can be moved to state lib * Fix accidental import changes * Add `switchAccount` assertion * Needs to use mock --- .../browser/main-context-menu-handler.spec.ts | 14 +- .../browser/main-context-menu-handler.ts | 12 +- .../browser/src/background/main.background.ts | 29 +- .../sync/foreground-sync.service.spec.ts | 6 +- .../platform/sync/foreground-sync.service.ts | 6 +- apps/browser/src/popup/app.component.ts | 6 +- .../src/popup/services/init.service.ts | 4 +- .../src/popup/services/services.module.ts | 3 +- apps/cli/src/base-program.ts | 2 +- apps/cli/src/oss-serve-configurator.ts | 15 +- apps/cli/src/program.ts | 17 +- .../service-container/service-container.ts | 32 +- apps/cli/src/tools/generate.command.ts | 21 +- apps/desktop/src/app/app.component.ts | 3 + apps/desktop/src/app/services/init.service.ts | 4 +- .../key-management/electron-key.service.ts | 2 +- apps/web/src/app/app.component.ts | 3 + .../app/auth/verify-email-token.component.ts | 7 +- apps/web/src/app/core/init.service.ts | 6 +- libs/angular/src/services/injection-tokens.ts | 2 - .../src/services/jslib-services.module.ts | 34 +- .../login-strategies/login.strategy.spec.ts | 20 +- .../common/login-strategies/login.strategy.ts | 14 - .../user-api-login.strategy.spec.ts | 2 +- .../services/vault-timeout.service.spec.ts | 13 +- .../services/vault-timeout.service.ts | 13 +- .../platform/abstractions/state.service.ts | 64 +- .../src/platform/factories/account-factory.ts | 13 - .../factories/global-state-factory.ts | 15 - .../src/platform/factories/state-factory.ts | 29 - .../models/domain/account-keys.spec.ts | 42 -- .../models/domain/account-profile.spec.ts | 9 - .../platform/models/domain/account.spec.ts | 19 - .../src/platform/models/domain/account.ts | 136 ---- .../models/domain/encryption-pair.spec.ts | 41 -- .../src/platform/models/domain/state.spec.ts | 31 - .../src/platform/models/domain/state.ts | 46 -- .../src/platform/services/state.service.ts | 659 ------------------ .../src/platform/sync/core-sync.service.ts | 12 +- .../sync/default-sync.service.spec.ts | 4 - .../src/platform/sync/default-sync.service.ts | 6 +- .../src/vault/services/cipher.service.spec.ts | 5 - .../src/vault/services/cipher.service.ts | 2 - libs/key-management/src/key.service.ts | 2 +- libs/state/src/index.ts | 1 + .../state/src/legacy/default-state.service.ts | 107 +++ .../src/legacy}/global-state.ts | 0 libs/state/src/legacy/index.ts | 2 + libs/state/src/legacy/state.service.ts | 25 + 49 files changed, 286 insertions(+), 1274 deletions(-) delete mode 100644 libs/common/src/platform/factories/account-factory.ts delete mode 100644 libs/common/src/platform/factories/global-state-factory.ts delete mode 100644 libs/common/src/platform/factories/state-factory.ts delete mode 100644 libs/common/src/platform/models/domain/account-keys.spec.ts delete mode 100644 libs/common/src/platform/models/domain/account-profile.spec.ts delete mode 100644 libs/common/src/platform/models/domain/account.spec.ts delete mode 100644 libs/common/src/platform/models/domain/account.ts delete mode 100644 libs/common/src/platform/models/domain/encryption-pair.spec.ts delete mode 100644 libs/common/src/platform/models/domain/state.spec.ts delete mode 100644 libs/common/src/platform/models/domain/state.ts delete mode 100644 libs/common/src/platform/services/state.service.ts create mode 100644 libs/state/src/legacy/default-state.service.ts rename libs/{common/src/platform/models/domain => state/src/legacy}/global-state.ts (100%) create mode 100644 libs/state/src/legacy/index.ts create mode 100644 libs/state/src/legacy/state.service.ts diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts index 901d6595fc8..1348928b7e9 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts @@ -2,6 +2,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AUTOFILL_CARD_ID, AUTOFILL_ID, @@ -17,7 +18,6 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; @@ -67,7 +67,7 @@ const createCipher = (data?: { }; describe("context-menu", () => { - let stateService: MockProxy; + let tokenService: MockProxy; let autofillSettingsService: MockProxy; let i18nService: MockProxy; let logService: MockProxy; @@ -85,7 +85,7 @@ describe("context-menu", () => { let sut: MainContextMenuHandler; beforeEach(() => { - stateService = mock(); + tokenService = mock(); autofillSettingsService = mock(); i18nService = mock(); logService = mock(); @@ -109,7 +109,7 @@ describe("context-menu", () => { i18nService.t.mockImplementation((key) => key); sut = new MainContextMenuHandler( - stateService, + tokenService, autofillSettingsService, i18nService, logService, @@ -276,7 +276,7 @@ describe("context-menu", () => { it("removes menu items that require code injection", async () => { billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true)); autofillSettingsService.enableContextMenu$ = of(true); - stateService.getIsAuthenticated.mockResolvedValue(true); + tokenService.hasAccessToken$.mockReturnValue(of(true)); const optionId = "1"; await sut.loadOptions("TEST_TITLE", optionId, createCipher()); @@ -317,7 +317,7 @@ describe("context-menu", () => { }); it("Loads context menu items that ask the user to unlock their vault if they are authed", async () => { - stateService.getIsAuthenticated.mockResolvedValue(true); + tokenService.hasAccessToken$.mockReturnValue(of(true)); await sut.noAccess(); @@ -325,7 +325,7 @@ describe("context-menu", () => { }); it("Loads context menu items that ask the user to login to their vault if they are not authed", async () => { - stateService.getIsAuthenticated.mockResolvedValue(false); + tokenService.hasAccessToken$.mockReturnValue(of(false)); await sut.noAccess(); diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.ts index abfa2465c51..00ff55f5517 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.ts @@ -1,8 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, map } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AUTOFILL_CARD_ID, AUTOFILL_ID, @@ -23,7 +24,6 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -152,7 +152,7 @@ export class MainContextMenuHandler { ]; constructor( - private stateService: StateService, + private tokenService: TokenService, private autofillSettingsService: AutofillSettingsServiceAbstraction, private i18nService: I18nService, private logService: LogService, @@ -343,7 +343,11 @@ export class MainContextMenuHandler { async noAccess() { if (await this.init()) { - const authed = await this.stateService.getIsAuthenticated(); + const userId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + const authed = + userId != null && (await firstValueFrom(this.tokenService.hasAccessToken$(userId))); this.loadOptions( this.i18nService.t(authed ? "unlockVaultMenu" : "loginToVaultMenu"), NOOP_COMMAND_SUFFIX, diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index ae21b9d2c9b..1558d9926fa 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -111,14 +111,11 @@ import { ObservableStorageService, } from "@bitwarden/common/platform/abstractions/storage.service"; import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/platform/abstractions/system.service"; -import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { IpcService } from "@bitwarden/common/platform/ipc"; import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; // eslint-disable-next-line no-restricted-imports -- Used for dependency creation import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; import { Lazy } from "@bitwarden/common/platform/misc/lazy"; -import { Account } from "@bitwarden/common/platform/models/domain/account"; -import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { NotificationsService } from "@bitwarden/common/platform/notifications"; // eslint-disable-next-line no-restricted-imports -- Needed for service creation @@ -143,11 +140,11 @@ import { MigrationRunner } from "@bitwarden/common/platform/services/migration-r import { DefaultSdkClientFactory } from "@bitwarden/common/platform/services/sdk/default-sdk-client-factory"; import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service"; import { NoopSdkClientFactory } from "@bitwarden/common/platform/services/sdk/noop-sdk-client-factory"; -import { StateService } from "@bitwarden/common/platform/services/state.service"; import { SystemService } from "@bitwarden/common/platform/services/system.service"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; import { ActiveUserStateProvider, + DefaultStateService, DerivedStateProvider, GlobalStateProvider, SingleUserStateProvider, @@ -387,6 +384,7 @@ export default class MainBackground { activeUserStateProvider: ActiveUserStateProvider; derivedStateProvider: DerivedStateProvider; stateProvider: StateProvider; + migrationRunner: MigrationRunner; taskSchedulerService: BrowserTaskSchedulerService; fido2Background: Fido2BackgroundAbstraction; individualVaultExportService: IndividualVaultExportServiceAbstraction; @@ -592,8 +590,9 @@ export default class MainBackground { this.globalStateProvider, this.singleUserStateProvider, ); + const activeUserAccessor = new DefaultActiveUserAccessor(this.accountService); this.activeUserStateProvider = new DefaultActiveUserStateProvider( - new DefaultActiveUserAccessor(this.accountService), + activeUserAccessor, this.singleUserStateProvider, ); this.derivedStateProvider = new InlineDerivedStateProvider(); @@ -639,23 +638,17 @@ export default class MainBackground { this.taskSchedulerService, ); - const migrationRunner = new MigrationRunner( + this.migrationRunner = new MigrationRunner( this.storageService, this.logService, new MigrationBuilderService(), ClientType.Browser, ); - this.stateService = new StateService( + this.stateService = new DefaultStateService( this.storageService, this.secureStorageService, - this.memoryStorageService, - this.logService, - new StateFactory(GlobalState, Account), - this.accountService, - this.environmentService, - this.tokenService, - migrationRunner, + activeUserAccessor, ); this.masterPasswordService = new MasterPasswordService( @@ -887,7 +880,6 @@ export default class MainBackground { this.apiService, this.i18nService, this.searchService, - this.stateService, this.autofillSettingsService, this.encryptService, this.cipherFileUploadService, @@ -946,6 +938,7 @@ export default class MainBackground { this.messagingService, this.searchService, this.stateService, + this.tokenService, this.authService, this.vaultTimeoutSettingsService, this.stateEventRunnerService, @@ -989,7 +982,6 @@ export default class MainBackground { this.sendService, this.logService, this.keyConnectorService, - this.stateService, this.providerService, this.folderApiService, this.organizationService, @@ -1320,7 +1312,7 @@ export default class MainBackground { ); this.mainContextMenuHandler = new MainContextMenuHandler( - this.stateService, + this.tokenService, this.autofillSettingsService, this.i18nService, this.logService, @@ -1387,7 +1379,7 @@ export default class MainBackground { await this.sdkLoadService.loadAndInit(); // Only the "true" background should run migrations - await this.stateService.init({ runMigrations: true }); + await this.migrationRunner.run(); // This is here instead of in in the InitService b/c we don't plan for // side effects to run in the Browser InitService. @@ -1607,6 +1599,7 @@ export default class MainBackground { const needStorageReseed = await this.needsStorageReseed(userBeingLoggedOut); await this.stateService.clean({ userId: userBeingLoggedOut }); + await this.tokenService.clearAccessToken(userBeingLoggedOut); await this.accountService.clean(userBeingLoggedOut); await this.stateEventRunnerService.handleEvent("logout", userBeingLoggedOut); diff --git a/apps/browser/src/platform/sync/foreground-sync.service.spec.ts b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts index 34ee4fa0f77..f8b4050a5ce 100644 --- a/apps/browser/src/platform/sync/foreground-sync.service.spec.ts +++ b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts @@ -4,8 +4,8 @@ import { Subject } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SyncOptions } from "@bitwarden/common/platform/sync/sync.service"; @@ -22,7 +22,7 @@ import { FullSyncFinishedMessage } from "./sync-service.listener"; describe("ForegroundSyncService", () => { const userId = Utils.newGuid() as UserId; - const stateService = mock(); + const tokenService = mock(); const folderService = mock(); const folderApiService = mock(); const messageSender = mock(); @@ -38,7 +38,7 @@ describe("ForegroundSyncService", () => { const stateProvider = new FakeStateProvider(accountService); const sut = new ForegroundSyncService( - stateService, + tokenService, folderService, folderApiService, messageSender, diff --git a/apps/browser/src/platform/sync/foreground-sync.service.ts b/apps/browser/src/platform/sync/foreground-sync.service.ts index 2ac75bbec2c..01b1f35239b 100644 --- a/apps/browser/src/platform/sync/foreground-sync.service.ts +++ b/apps/browser/src/platform/sync/foreground-sync.service.ts @@ -4,8 +4,8 @@ import { CollectionService } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { CommandDefinition, MessageListener, @@ -31,7 +31,7 @@ export const DO_FULL_SYNC = new CommandDefinition("doFullSync") export class ForegroundSyncService extends CoreSyncService { constructor( - stateService: StateService, + tokenService: TokenService, folderService: InternalFolderService, folderApiService: FolderApiServiceAbstraction, messageSender: MessageSender, @@ -47,7 +47,7 @@ export class ForegroundSyncService extends CoreSyncService { stateProvider: StateProvider, ) { super( - stateService, + tokenService, folderService, folderApiService, messageSender, diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 6a26476de43..fa1e6c237c9 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -28,13 +28,13 @@ import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n"; import { LogoutReason, UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -102,7 +102,7 @@ export class AppComponent implements OnInit, OnDestroy { private authService: AuthService, private i18nService: I18nService, private router: Router, - private stateService: StateService, + private readonly tokenService: TokenService, private vaultBrowserStateService: VaultBrowserStateService, private cipherService: CipherService, private changeDetectorRef: ChangeDetectorRef, @@ -321,7 +321,7 @@ export class AppComponent implements OnInit, OnDestroy { } private async clearComponentStates() { - if (!(await this.stateService.getIsAuthenticated())) { + if (!(await firstValueFrom(this.tokenService.hasAccessToken$(this.activeUserId)))) { return; } diff --git a/apps/browser/src/popup/services/init.service.ts b/apps/browser/src/popup/services/init.service.ts index 9e750ae7341..1930dbd1d4b 100644 --- a/apps/browser/src/popup/services/init.service.ts +++ b/apps/browser/src/popup/services/init.service.ts @@ -8,6 +8,7 @@ import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { BrowserApi } from "../../platform/browser/browser-api"; import BrowserPopupUtils from "../../platform/browser/browser-popup-utils"; @@ -27,13 +28,14 @@ export class InitService { private themingService: AbstractThemingService, private sdkLoadService: SdkLoadService, private viewCacheService: PopupViewCacheService, + private readonly migrationRunner: MigrationRunner, @Inject(DOCUMENT) private document: Document, ) {} init() { return async () => { await this.sdkLoadService.loadAndInit(); - await this.stateService.init({ runMigrations: false }); // Browser background is responsible for migrations + await this.migrationRunner.waitForCompletion(); // Browser background is responsible for migrations await this.i18nService.init(); this.twoFactorService.init(); await this.viewCacheService.init(); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 76d61eac90d..f531ebd5ca7 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -47,6 +47,7 @@ import { import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AutofillSettingsService, @@ -333,7 +334,7 @@ const safeProviders: SafeProvider[] = [ provide: SyncService, useClass: ForegroundSyncService, deps: [ - StateService, + TokenService, InternalFolderService, FolderApiServiceAbstraction, MessageSender, diff --git a/apps/cli/src/base-program.ts b/apps/cli/src/base-program.ts index 5719f78c1b9..5957f08de89 100644 --- a/apps/cli/src/base-program.ts +++ b/apps/cli/src/base-program.ts @@ -129,7 +129,7 @@ export abstract class BaseProgram { if (!userId) { fail(); } - const authed = await this.serviceContainer.stateService.getIsAuthenticated({ userId }); + const authed = await firstValueFrom(this.serviceContainer.tokenService.hasAccessToken$(userId)); if (!authed) { fail(); } diff --git a/apps/cli/src/oss-serve-configurator.ts b/apps/cli/src/oss-serve-configurator.ts index df46e22f84d..6ae2776eae7 100644 --- a/apps/cli/src/oss-serve-configurator.ts +++ b/apps/cli/src/oss-serve-configurator.ts @@ -107,7 +107,8 @@ export class OssServeConfigurator { ); this.generateCommand = new GenerateCommand( this.serviceContainer.passwordGenerationService, - this.serviceContainer.stateService, + this.serviceContainer.tokenService, + this.serviceContainer.accountService, ); this.syncCommand = new SyncCommand(this.serviceContainer.syncService); this.statusCommand = new StatusCommand( @@ -417,14 +418,18 @@ export class OssServeConfigurator { } protected async errorIfLocked(res: koa.Response) { - const authed = await this.serviceContainer.stateService.getIsAuthenticated(); + const userId = await firstValueFrom( + this.serviceContainer.accountService.activeAccount$.pipe(map((account) => account?.id)), + ); + + const authed = + userId != null || + (await firstValueFrom(this.serviceContainer.tokenService.hasAccessToken$(userId))); + if (!authed) { this.processResponse(res, Response.error("You are not logged in.")); return true; } - const userId = await firstValueFrom( - this.serviceContainer.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); if (await this.serviceContainer.keyService.hasUserKey(userId)) { return false; } diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 468901282b4..4d541739aab 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import * as chalk from "chalk"; import { program, Command, OptionValues } from "commander"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, of, switchMap } from "rxjs"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; @@ -129,7 +129,17 @@ export class Program extends BaseProgram { "Path to a file containing your password as its first line", ) .option("--check", "Check login status.", async () => { - const authed = await this.serviceContainer.stateService.getIsAuthenticated(); + const authed = await firstValueFrom( + this.serviceContainer.accountService.activeAccount$.pipe( + switchMap((account) => { + if (account == null) { + return of(false); + } + + return this.serviceContainer.tokenService.hasAccessToken$(account.id); + }), + ), + ); if (authed) { const res = new MessageResponse("You are logged in!", null); this.processResponse(Response.success(res), true); @@ -350,7 +360,8 @@ export class Program extends BaseProgram { .action(async (options) => { const command = new GenerateCommand( this.serviceContainer.passwordGenerationService, - this.serviceContainer.stateService, + this.serviceContainer.tokenService, + this.serviceContainer.accountService, ); const response = await command.run(options); this.processResponse(response); diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index d93e9838868..e82ceb5a6e9 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -88,10 +88,7 @@ import { import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { KeySuffixOptions, LogLevelType } from "@bitwarden/common/platform/enums"; -import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { MessageSender } from "@bitwarden/common/platform/messaging"; -import { Account } from "@bitwarden/common/platform/models/domain/account"; -import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { TaskSchedulerService, DefaultTaskSchedulerService, @@ -108,16 +105,17 @@ import { MigrationRunner } from "@bitwarden/common/platform/services/migration-r import { DefaultSdkClientFactory } from "@bitwarden/common/platform/services/sdk/default-sdk-client-factory"; import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service"; import { NoopSdkClientFactory } from "@bitwarden/common/platform/services/sdk/noop-sdk-client-factory"; -import { StateService } from "@bitwarden/common/platform/services/state.service"; import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; import { ActiveUserStateProvider, + DefaultStateService, DerivedStateProvider, GlobalStateProvider, SingleUserStateProvider, StateEventRunnerService, StateProvider, + StateService, } from "@bitwarden/common/platform/state"; /* eslint-disable import/no-restricted-paths -- We need the implementation to inject, but generally these should not be accessed */ import { DefaultActiveUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-active-user-state.provider"; @@ -212,6 +210,7 @@ export class ServiceContainer { secureStorageService: NodeEnvSecureStorageService; memoryStorageService: MemoryStorageService; memoryStorageForStateProviders: MemoryStorageServiceForStateProviders; + migrationRunner: MigrationRunner; i18nService: I18nService; platformUtilsService: CliPlatformUtilsService; keyService: KeyService; @@ -379,8 +378,10 @@ export class ServiceContainer { this.singleUserStateProvider, ); + const activeUserAccessor = new DefaultActiveUserAccessor(this.accountService); + this.activeUserStateProvider = new DefaultActiveUserStateProvider( - new DefaultActiveUserAccessor(this.accountService), + activeUserAccessor, this.singleUserStateProvider, ); @@ -412,23 +413,17 @@ export class ServiceContainer { logoutCallback, ); - const migrationRunner = new MigrationRunner( + this.migrationRunner = new MigrationRunner( this.storageService, this.logService, new MigrationBuilderService(), ClientType.Cli, ); - this.stateService = new StateService( + this.stateService = new DefaultStateService( this.storageService, this.secureStorageService, - this.memoryStorageService, - this.logService, - new StateFactory(GlobalState, Account), - this.accountService, - this.environmentService, - this.tokenService, - migrationRunner, + activeUserAccessor, ); this.kdfConfigService = new DefaultKdfConfigService(this.stateProvider); @@ -713,7 +708,6 @@ export class ServiceContainer { this.apiService, this.i18nService, this.searchService, - this.stateService, this.autofillSettingsService, this.encryptService, this.cipherFileUploadService, @@ -764,6 +758,7 @@ export class ServiceContainer { this.messagingService, this.searchService, this.stateService, + this.tokenService, this.authService, this.vaultTimeoutSettingsService, this.stateEventRunnerService, @@ -790,7 +785,6 @@ export class ServiceContainer { this.sendService, this.logService, this.keyConnectorService, - this.stateService, this.providerService, this.folderApiService, this.organizationService, @@ -903,7 +897,8 @@ export class ServiceContainer { await this.stateEventRunnerService.handleEvent("logout", userId as UserId); - await this.stateService.clean(); + await this.stateService.clean({ userId: userId }); + await this.tokenService.clearAccessToken(userId); await this.accountService.clean(userId as UserId); await this.accountService.switchAccount(null); process.env.BW_SESSION = undefined; @@ -917,7 +912,8 @@ export class ServiceContainer { await this.sdkLoadService.loadAndInit(); await this.storageService.init(); - await this.stateService.init(); + + await this.migrationRunner.run(); this.containerService.attachToGlobal(global); await this.i18nService.init(); this.twoFactorService.init(); diff --git a/apps/cli/src/tools/generate.command.ts b/apps/cli/src/tools/generate.command.ts index 64c6118a0c6..1d8a8690ab3 100644 --- a/apps/cli/src/tools/generate.command.ts +++ b/apps/cli/src/tools/generate.command.ts @@ -1,6 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { firstValueFrom, of, switchMap } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { DefaultPasswordGenerationOptions, DefaultPassphraseGenerationOptions, @@ -17,7 +20,8 @@ import { CliUtils } from "../utils"; export class GenerateCommand { constructor( private passwordGenerationService: PasswordGenerationServiceAbstraction, - private stateService: StateService, + private tokenService: TokenService, + private accountService: AccountService, ) {} async run(cmdOptions: Record): Promise { @@ -38,7 +42,18 @@ export class GenerateCommand { ambiguous: !normalizedOptions.ambiguous, }; - const enforcedOptions = (await this.stateService.getIsAuthenticated()) + const shouldEnforceOptions = await firstValueFrom( + this.accountService.activeAccount$.pipe( + switchMap((account) => { + if (account == null) { + return of(false); + } + + return this.tokenService.hasAccessToken$(account.id); + }), + ), + ); + const enforcedOptions = shouldEnforceOptions ? (await this.passwordGenerationService.enforcePasswordGeneratorPoliciesOnOptions(options))[0] : options; diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index b0c5eb03723..04651ed0c10 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -40,6 +40,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -175,6 +176,7 @@ export class AppComponent implements OnInit, OnDestroy { private readonly destroyRef: DestroyRef, private readonly documentLangSetter: DocumentLangSetter, private restrictedItemTypesService: RestrictedItemTypesService, + private readonly tokenService: TokenService, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); @@ -684,6 +686,7 @@ export class AppComponent implements OnInit, OnDestroy { await this.stateEventRunnerService.handleEvent("logout", userBeingLoggedOut); await this.stateService.clean({ userId: userBeingLoggedOut }); + await this.tokenService.clearAccessToken(userBeingLoggedOut); await this.accountService.clean(userBeingLoggedOut); // HACK: Wait for the user logging outs authentication status to transition to LoggedOut diff --git a/apps/desktop/src/app/services/init.service.ts b/apps/desktop/src/app/services/init.service.ts index 698427c1e57..2c68821b6c7 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -15,6 +15,7 @@ import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk- import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { NotificationsService } from "@bitwarden/common/platform/notifications"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; +import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/platform/sync"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; @@ -52,6 +53,7 @@ export class InitService { private autotypeService: DesktopAutotypeService, private sdkLoadService: SdkLoadService, @Inject(DOCUMENT) private document: Document, + private readonly migrationRunner: MigrationRunner, ) {} init() { @@ -59,7 +61,7 @@ export class InitService { await this.sdkLoadService.loadAndInit(); await this.sshAgentService.init(); this.nativeMessagingService.init(); - await this.stateService.init({ runMigrations: false }); // Desktop will run them in main process + await this.migrationRunner.waitForCompletion(); // Desktop will run migrations in the main process const accounts = await firstValueFrom(this.accountService.accounts$); const setUserKeyInMemoryPromises = []; diff --git a/apps/desktop/src/key-management/electron-key.service.ts b/apps/desktop/src/key-management/electron-key.service.ts index 48ccd3f27fd..562662f6696 100644 --- a/apps/desktop/src/key-management/electron-key.service.ts +++ b/apps/desktop/src/key-management/electron-key.service.ts @@ -65,7 +65,7 @@ export class ElectronKeyService extends DefaultKeyService { protected override async getKeyFromStorage( keySuffix: KeySuffixOptions, - userId?: UserId, + userId: UserId, ): Promise { return await super.getKeyFromStorage(keySuffix, userId); } diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 694d0c6eb9a..ae20670c2dd 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -12,6 +12,7 @@ import { EventUploadService } from "@bitwarden/common/abstractions/event/event-u import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; @@ -89,6 +90,7 @@ export class AppComponent implements OnDestroy, OnInit { private deviceTrustToastService: DeviceTrustToastService, private readonly destoryRef: DestroyRef, private readonly documentLangSetter: DocumentLangSetter, + private readonly tokenService: TokenService, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); @@ -297,6 +299,7 @@ export class AppComponent implements OnDestroy, OnInit { await this.searchService.clearIndex(userId); this.authService.logOut(async () => { await this.stateService.clean({ userId: userId }); + await this.tokenService.clearAccessToken(userId); await this.accountService.clean(userId); await this.accountService.switchAccount(null); diff --git a/apps/web/src/app/auth/verify-email-token.component.ts b/apps/web/src/app/auth/verify-email-token.component.ts index 9e44cc7a713..2c4fa7f447c 100644 --- a/apps/web/src/app/auth/verify-email-token.component.ts +++ b/apps/web/src/app/auth/verify-email-token.component.ts @@ -2,14 +2,15 @@ // @ts-strict-ignore import { Component, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { first } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { VerifyEmailRequest } from "@bitwarden/common/models/request/verify-email.request"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { ToastService } from "@bitwarden/components"; @Component({ @@ -25,7 +26,7 @@ export class VerifyEmailTokenComponent implements OnInit { private route: ActivatedRoute, private apiService: ApiService, private logService: LogService, - private stateService: StateService, + private tokenService: TokenService, private toastService: ToastService, ) {} @@ -37,7 +38,7 @@ export class VerifyEmailTokenComponent implements OnInit { await this.apiService.postAccountVerifyEmailToken( new VerifyEmailRequest(qParams.userId, qParams.token), ); - if (await this.stateService.getIsAuthenticated()) { + if (await firstValueFrom(this.tokenService.hasAccessToken$(qParams.userId))) { await this.apiService.refreshIdentityToken(); } this.toastService.showToast({ diff --git a/apps/web/src/app/core/init.service.ts b/apps/web/src/app/core/init.service.ts index f4d05171d56..57d9918aad7 100644 --- a/apps/web/src/app/core/init.service.ts +++ b/apps/web/src/app/core/init.service.ts @@ -11,10 +11,10 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { DefaultVaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; -import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { IpcService } from "@bitwarden/common/platform/ipc"; import { NotificationsService } from "@bitwarden/common/platform/notifications"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; +import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; import { TaskService } from "@bitwarden/common/vault/tasks"; @@ -31,7 +31,6 @@ export class InitService { private i18nService: I18nServiceAbstraction, private eventUploadService: EventUploadServiceAbstraction, private twoFactorService: TwoFactorServiceAbstraction, - private stateService: StateServiceAbstraction, private keyService: KeyServiceAbstraction, private themingService: AbstractThemingService, private encryptService: EncryptService, @@ -41,13 +40,14 @@ export class InitService { private ipcService: IpcService, private sdkLoadService: SdkLoadService, private taskService: TaskService, + private readonly migrationRunner: MigrationRunner, @Inject(DOCUMENT) private document: Document, ) {} init() { return async () => { await this.sdkLoadService.loadAndInit(); - await this.stateService.init(); + await this.migrationRunner.run(); const activeAccount = await firstValueFrom(this.accountService.activeAccount$); if (activeAccount) { diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index 2122506890a..6bf3ab77252 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -13,7 +13,6 @@ import { ObservableStorageService, } from "@bitwarden/common/platform/abstractions/storage.service"; import { Theme } from "@bitwarden/common/platform/enums"; -import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { Message } from "@bitwarden/common/platform/messaging"; import { HttpOperations } from "@bitwarden/common/services/api.service"; import { SafeInjectionToken } from "@bitwarden/ui-common"; @@ -33,7 +32,6 @@ export const OBSERVABLE_DISK_LOCAL_STORAGE = new SafeInjectionToken< >("OBSERVABLE_DISK_LOCAL_STORAGE"); export const MEMORY_STORAGE = new SafeInjectionToken("MEMORY_STORAGE"); export const SECURE_STORAGE = new SafeInjectionToken("SECURE_STORAGE"); -export const STATE_FACTORY = new SafeInjectionToken("STATE_FACTORY"); export const LOGOUT_CALLBACK = new SafeInjectionToken< (logoutReason: LogoutReason, userId?: string) => Promise >("LOGOUT_CALLBACK"); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 3a6e7439ccc..d6e4e901b50 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -196,13 +196,10 @@ import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.serv import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/platform/abstractions/validation.service"; -import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; // eslint-disable-next-line no-restricted-imports -- Used for dependency injection import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags"; -import { Account } from "@bitwarden/common/platform/models/domain/account"; -import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { NotificationsService } from "@bitwarden/common/platform/notifications"; // eslint-disable-next-line no-restricted-imports -- Needed for service creation import { @@ -228,13 +225,13 @@ import { FileUploadService } from "@bitwarden/common/platform/services/file-uplo import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service"; -import { StateService } from "@bitwarden/common/platform/services/state.service"; import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; import { ValidationService } from "@bitwarden/common/platform/services/validation.service"; import { ActiveUserAccessor, ActiveUserStateProvider, + DefaultStateService, DerivedStateProvider, GlobalStateProvider, SingleUserStateProvider, @@ -371,12 +368,10 @@ import { LOCKED_CALLBACK, LOG_MAC_FAILURES, LOGOUT_CALLBACK, - MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE, OBSERVABLE_MEMORY_STORAGE, REFRESH_ACCESS_TOKEN_ERROR_CALLBACK, SECURE_STORAGE, - STATE_FACTORY, SUPPORTS_SECURE_STORAGE, SYSTEM_LANGUAGE, SYSTEM_THEME_OBSERVABLE, @@ -414,10 +409,6 @@ const safeProviders: SafeProvider[] = [ useFactory: (window: Window) => window.navigator.language, deps: [WINDOW], }), - safeProvider({ - provide: STATE_FACTORY, - useValue: new StateFactory(GlobalState, Account), - }), // TODO: PM-21212 - Deprecate LogoutCallback in favor of LogoutService safeProvider({ provide: LOGOUT_CALLBACK, @@ -530,7 +521,6 @@ const safeProviders: SafeProvider[] = [ apiService: ApiServiceAbstraction, i18nService: I18nServiceAbstraction, searchService: SearchServiceAbstraction, - stateService: StateServiceAbstraction, autofillSettingsService: AutofillSettingsServiceAbstraction, encryptService: EncryptService, fileUploadService: CipherFileUploadServiceAbstraction, @@ -547,7 +537,6 @@ const safeProviders: SafeProvider[] = [ apiService, i18nService, searchService, - stateService, autofillSettingsService, encryptService, fileUploadService, @@ -564,7 +553,6 @@ const safeProviders: SafeProvider[] = [ ApiServiceAbstraction, I18nServiceAbstraction, SearchServiceAbstraction, - StateServiceAbstraction, AutofillSettingsServiceAbstraction, EncryptService, CipherFileUploadServiceAbstraction, @@ -801,7 +789,6 @@ const safeProviders: SafeProvider[] = [ InternalSendService, LogService, KeyConnectorServiceAbstraction, - StateServiceAbstraction, ProviderServiceAbstraction, FolderApiServiceAbstraction, InternalOrganizationServiceAbstraction, @@ -849,6 +836,7 @@ const safeProviders: SafeProvider[] = [ MessagingServiceAbstraction, SearchServiceAbstraction, StateServiceAbstraction, + TokenServiceAbstraction, AuthServiceAbstraction, VaultTimeoutSettingsService, StateEventRunnerService, @@ -868,24 +856,10 @@ const safeProviders: SafeProvider[] = [ useClass: SsoLoginService, deps: [StateProvider, LogService], }), - safeProvider({ - provide: STATE_FACTORY, - useValue: new StateFactory(GlobalState, Account), - }), safeProvider({ provide: StateServiceAbstraction, - useClass: StateService, - deps: [ - AbstractStorageService, - SECURE_STORAGE, - MEMORY_STORAGE, - LogService, - STATE_FACTORY, - AccountServiceAbstraction, - EnvironmentService, - TokenServiceAbstraction, - MigrationRunner, - ], + useClass: DefaultStateService, + deps: [AbstractStorageService, SECURE_STORAGE, ActiveUserAccessor], }), safeProvider({ provide: IndividualVaultExportServiceAbstraction, diff --git a/libs/auth/src/common/login-strategies/login.strategy.spec.ts b/libs/auth/src/common/login-strategies/login.strategy.spec.ts index 1a6592887ba..222fb5592aa 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.spec.ts @@ -32,7 +32,6 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { Account, AccountProfile } from "@bitwarden/common/platform/models/domain/account"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { @@ -243,18 +242,8 @@ describe("LoginStrategy", () => { refreshToken, ); - expect(stateService.addAccount).toHaveBeenCalledWith( - new Account({ - profile: { - ...new AccountProfile(), - ...{ - userId: userId, - name: name, - email: email, - }, - }, - }), - ); + expect(environmentService.seedUserEnvironment).toHaveBeenCalled(); + expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith( UserDecryptionOptions.fromResponse(idTokenResponse), ); @@ -388,7 +377,8 @@ describe("LoginStrategy", () => { const result = await passwordLoginStrategy.logIn(credentials); - expect(stateService.addAccount).not.toHaveBeenCalled(); + expect(environmentService.seedUserEnvironment).not.toHaveBeenCalled(); + expect(accountService.mock.switchAccount).not.toHaveBeenCalled(); expect(messagingService.send).not.toHaveBeenCalled(); expect(tokenService.clearTwoFactorToken).toHaveBeenCalled(); @@ -422,7 +412,7 @@ describe("LoginStrategy", () => { const result = await passwordLoginStrategy.logIn(credentials); - expect(stateService.addAccount).not.toHaveBeenCalled(); + expect(environmentService.seedUserEnvironment).not.toHaveBeenCalled(); expect(messagingService.send).not.toHaveBeenCalled(); const expected = new AuthResult(); diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index 53e34147d9f..4c7a38254d7 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -32,7 +32,6 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { EncryptionType } from "@bitwarden/common/platform/enums"; -import { Account, AccountProfile } from "@bitwarden/common/platform/models/domain/account"; import { UserId } from "@bitwarden/common/types/guid"; import { KeyService, @@ -198,19 +197,6 @@ export abstract class LoginStrategy { await this.accountService.switchAccount(userId); - await this.stateService.addAccount( - new Account({ - profile: { - ...new AccountProfile(), - ...{ - userId, - name: accountInformation.name, - email: accountInformation.email, - }, - }, - }), - ); - await this.verifyAccountAdded(userId); // We must set user decryption options before retrieving vault timeout settings diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts index 7114afbf94f..a6446401f70 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts @@ -170,7 +170,7 @@ describe("UserApiLoginStrategy", () => { mockVaultTimeoutAction, mockVaultTimeout, ); - expect(stateService.addAccount).toHaveBeenCalled(); + expect(environmentService.seedUserEnvironment).toHaveBeenCalled(); }); it("sets the encrypted user key and private key from the identity token response", async () => { diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts index 9963e7d24f8..26d263d7e72 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts @@ -12,15 +12,16 @@ import { LogoutReason } from "@bitwarden/auth/common"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { BiometricsService } from "@bitwarden/key-management"; +import { StateService } from "@bitwarden/state"; import { FakeAccountService, mockAccountServiceWith } from "../../../../spec"; import { AccountInfo } from "../../../auth/abstractions/account.service"; import { AuthService } from "../../../auth/abstractions/auth.service"; +import { TokenService } from "../../../auth/abstractions/token.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { LogService } from "../../../platform/abstractions/log.service"; import { MessagingService } from "../../../platform/abstractions/messaging.service"; import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service"; -import { StateService } from "../../../platform/abstractions/state.service"; import { Utils } from "../../../platform/misc/utils"; import { TaskSchedulerService } from "../../../platform/scheduling"; import { StateEventRunnerService } from "../../../platform/state"; @@ -45,6 +46,7 @@ describe("VaultTimeoutService", () => { let messagingService: MockProxy; let searchService: MockProxy; let stateService: MockProxy; + let tokenService: MockProxy; let authService: MockProxy; let vaultTimeoutSettingsService: MockProxy; let stateEventRunnerService: MockProxy; @@ -71,6 +73,7 @@ describe("VaultTimeoutService", () => { messagingService = mock(); searchService = mock(); stateService = mock(); + tokenService = mock(); authService = mock(); vaultTimeoutSettingsService = mock(); stateEventRunnerService = mock(); @@ -99,6 +102,7 @@ describe("VaultTimeoutService", () => { messagingService, searchService, stateService, + tokenService, authService, vaultTimeoutSettingsService, stateEventRunnerService, @@ -141,9 +145,8 @@ describe("VaultTimeoutService", () => { authService.getAuthStatus.mockImplementation((userId) => { return Promise.resolve(accounts[userId]?.authStatus); }); - stateService.getIsAuthenticated.mockImplementation((options) => { - // Just like actual state service, if no userId is given fallback to active userId - return Promise.resolve(accounts[options.userId ?? globalSetups?.userId]?.isAuthenticated); + tokenService.hasAccessToken$.mockImplementation((userId) => { + return of(accounts[userId]?.isAuthenticated ?? false); }); vaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation((userId) => { @@ -201,7 +204,7 @@ describe("VaultTimeoutService", () => { const expectUserToHaveLocked = (userId: string) => { // This does NOT assert all the things that the lock process does - expect(stateService.getIsAuthenticated).toHaveBeenCalledWith({ userId: userId }); + expect(tokenService.hasAccessToken$).toHaveBeenCalledWith(userId); expect(vaultTimeoutSettingsService.availableVaultTimeoutActions$).toHaveBeenCalledWith(userId); expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(null, { userId: userId }); expect(masterPasswordService.mock.clearMasterKey).toHaveBeenCalledWith(userId); diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts index 6d71bad0b0a..98f6f76fbe7 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts @@ -14,6 +14,7 @@ import { BiometricsService } from "@bitwarden/key-management"; import { AccountService } from "../../../auth/abstractions/account.service"; import { AuthService } from "../../../auth/abstractions/auth.service"; +import { TokenService } from "../../../auth/abstractions/token.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { LogService } from "../../../platform/abstractions/log.service"; import { MessagingService } from "../../../platform/abstractions/messaging.service"; @@ -43,6 +44,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { private messagingService: MessagingService, private searchService: SearchService, private stateService: StateService, + private tokenService: TokenService, private authService: AuthService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private stateEventRunnerService: StateEventRunnerService, @@ -108,7 +110,10 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { async lock(userId?: UserId): Promise { await this.biometricService.setShouldAutopromptNow(false); - const authed = await this.stateService.getIsAuthenticated({ userId: userId }); + const lockingUserId = + userId ?? (await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)))); + + const authed = await firstValueFrom(this.tokenService.hasAccessToken$(lockingUserId)); if (!authed) { return; } @@ -121,12 +126,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { await this.logOut(userId); } - const currentUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); - - const lockingUserId = userId ?? currentUserId; - // HACK: Start listening for the transition of the locking user from something to the locked state. // This is very much a hack to ensure that the authentication status to retrievable right after // it does its work. Particularly the `lockedCallback` and `"locked"` message. Instead diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 4c1c000284e..612b801d535 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -1,63 +1 @@ -import { BiometricKey } from "../../auth/types/biometric-key"; -import { Account } from "../models/domain/account"; -import { StorageOptions } from "../models/domain/storage-options"; - -/** - * Options for customizing the initiation behavior. - */ -export type InitOptions = { - /** - * Whether or not to run state migrations as part of the init process. Defaults to true. - * - * If false, the init method will instead wait for migrations to complete before doing its - * other init operations. Make sure migrations have either already completed, or will complete - * before calling {@link StateService.init} with `runMigrations: false`. - */ - runMigrations?: boolean; -}; - -export abstract class StateService { - abstract addAccount(account: T): Promise; - abstract clean(options?: StorageOptions): Promise; - abstract init(initOptions?: InitOptions): Promise; - - /** - * Gets the user's auto key - */ - abstract getUserKeyAutoUnlock(options?: StorageOptions): Promise; - /** - * Sets the user's auto key - */ - abstract setUserKeyAutoUnlock(value: string | null, options?: StorageOptions): Promise; - /** - * Gets the user's biometric key - */ - abstract getUserKeyBiometric(options?: StorageOptions): Promise; - /** - * Checks if the user has a biometric key available - */ - abstract hasUserKeyBiometric(options?: StorageOptions): Promise; - /** - * Sets the user's biometric key - */ - abstract setUserKeyBiometric(value: BiometricKey, options?: StorageOptions): Promise; - /** - * @deprecated For backwards compatible purposes only, use DesktopAutofillSettingsService - */ - abstract setEnableDuckDuckGoBrowserIntegration( - value: boolean, - options?: StorageOptions, - ): Promise; - abstract getDuckDuckGoSharedKey(options?: StorageOptions): Promise; - abstract setDuckDuckGoSharedKey(value: string, options?: StorageOptions): Promise; - - /** - * @deprecated Use `TokenService.hasAccessToken$()` or `AuthService.authStatusFor$` instead. - */ - abstract getIsAuthenticated(options?: StorageOptions): Promise; - - /** - * @deprecated Use `AccountService.activeAccount$` instead. - */ - abstract getUserId(options?: StorageOptions): Promise; -} +export { StateService } from "@bitwarden/state"; diff --git a/libs/common/src/platform/factories/account-factory.ts b/libs/common/src/platform/factories/account-factory.ts deleted file mode 100644 index 1fe5aee369c..00000000000 --- a/libs/common/src/platform/factories/account-factory.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Account } from "../models/domain/account"; - -export class AccountFactory { - private accountConstructor: new (init: Partial) => T; - - constructor(accountConstructor: new (init: Partial) => T) { - this.accountConstructor = accountConstructor; - } - - create(args: Partial) { - return new this.accountConstructor(args); - } -} diff --git a/libs/common/src/platform/factories/global-state-factory.ts b/libs/common/src/platform/factories/global-state-factory.ts deleted file mode 100644 index b52b022fd18..00000000000 --- a/libs/common/src/platform/factories/global-state-factory.ts +++ /dev/null @@ -1,15 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { GlobalState } from "../models/domain/global-state"; - -export class GlobalStateFactory { - private globalStateConstructor: new (init: Partial) => T; - - constructor(globalStateConstructor: new (init: Partial) => T) { - this.globalStateConstructor = globalStateConstructor; - } - - create(args?: Partial) { - return new this.globalStateConstructor(args); - } -} diff --git a/libs/common/src/platform/factories/state-factory.ts b/libs/common/src/platform/factories/state-factory.ts deleted file mode 100644 index fcdd3220c2b..00000000000 --- a/libs/common/src/platform/factories/state-factory.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Account } from "../models/domain/account"; -import { GlobalState } from "../models/domain/global-state"; - -import { AccountFactory } from "./account-factory"; -import { GlobalStateFactory } from "./global-state-factory"; - -export class StateFactory< - TGlobal extends GlobalState = GlobalState, - TAccount extends Account = Account, -> { - private globalStateFactory: GlobalStateFactory; - private accountFactory: AccountFactory; - - constructor( - globalStateConstructor: new (init: Partial) => TGlobal, - accountConstructor: new (init: Partial) => TAccount, - ) { - this.globalStateFactory = new GlobalStateFactory(globalStateConstructor); - this.accountFactory = new AccountFactory(accountConstructor); - } - - createGlobal(args: Partial): TGlobal { - return this.globalStateFactory.create(args); - } - - createAccount(args: Partial): TAccount { - return this.accountFactory.create(args); - } -} diff --git a/libs/common/src/platform/models/domain/account-keys.spec.ts b/libs/common/src/platform/models/domain/account-keys.spec.ts deleted file mode 100644 index 6bdb08edd51..00000000000 --- a/libs/common/src/platform/models/domain/account-keys.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { makeStaticByteArray } from "../../../../spec"; -import { Utils } from "../../misc/utils"; - -import { AccountKeys, EncryptionPair } from "./account"; - -describe("AccountKeys", () => { - describe("toJSON", () => { - it("should serialize itself", () => { - const keys = new AccountKeys(); - const buffer = makeStaticByteArray(64); - keys.publicKey = buffer; - - const bufferSpy = jest.spyOn(Utils, "fromBufferToByteString"); - keys.toJSON(); - expect(bufferSpy).toHaveBeenCalledWith(buffer); - }); - - it("should serialize public key as a string", () => { - const keys = new AccountKeys(); - keys.publicKey = Utils.fromByteStringToArray("hello"); - const json = JSON.stringify(keys); - expect(json).toContain('"publicKey":"hello"'); - }); - }); - - describe("fromJSON", () => { - it("should deserialize public key to a buffer", () => { - const keys = AccountKeys.fromJSON({ - publicKey: "hello", - }); - expect(keys.publicKey).toEqual(Utils.fromByteStringToArray("hello")); - }); - - it("should deserialize privateKey", () => { - const spy = jest.spyOn(EncryptionPair, "fromJSON"); - AccountKeys.fromJSON({ - privateKey: { encrypted: "encrypted", decrypted: "decrypted" }, - } as any); - expect(spy).toHaveBeenCalled(); - }); - }); -}); diff --git a/libs/common/src/platform/models/domain/account-profile.spec.ts b/libs/common/src/platform/models/domain/account-profile.spec.ts deleted file mode 100644 index 7c6deda34eb..00000000000 --- a/libs/common/src/platform/models/domain/account-profile.spec.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { AccountProfile } from "./account"; - -describe("AccountProfile", () => { - describe("fromJSON", () => { - it("should deserialize to an instance of itself", () => { - expect(AccountProfile.fromJSON({})).toBeInstanceOf(AccountProfile); - }); - }); -}); diff --git a/libs/common/src/platform/models/domain/account.spec.ts b/libs/common/src/platform/models/domain/account.spec.ts deleted file mode 100644 index 307fde62f93..00000000000 --- a/libs/common/src/platform/models/domain/account.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Account, AccountKeys, AccountProfile } from "./account"; - -describe("Account", () => { - describe("fromJSON", () => { - it("should deserialize to an instance of itself", () => { - expect(Account.fromJSON({})).toBeInstanceOf(Account); - }); - - it("should call all the sub-fromJSONs", () => { - const keysSpy = jest.spyOn(AccountKeys, "fromJSON"); - const profileSpy = jest.spyOn(AccountProfile, "fromJSON"); - - Account.fromJSON({}); - - expect(keysSpy).toHaveBeenCalled(); - expect(profileSpy).toHaveBeenCalled(); - }); - }); -}); diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts deleted file mode 100644 index b9d10f47e97..00000000000 --- a/libs/common/src/platform/models/domain/account.ts +++ /dev/null @@ -1,136 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Jsonify } from "type-fest"; - -import { DeepJsonify } from "../../../types/deep-jsonify"; -import { Utils } from "../../misc/utils"; - -import { SymmetricCryptoKey } from "./symmetric-crypto-key"; - -export class EncryptionPair { - encrypted?: TEncrypted; - decrypted?: TDecrypted; - - toJSON() { - return { - encrypted: this.encrypted, - decrypted: - this.decrypted instanceof ArrayBuffer - ? Utils.fromBufferToByteString(this.decrypted) - : this.decrypted, - }; - } - - static fromJSON( - obj: { encrypted?: Jsonify; decrypted?: string | Jsonify }, - decryptedFromJson?: (decObj: Jsonify | string) => TDecrypted, - encryptedFromJson?: (encObj: Jsonify) => TEncrypted, - ) { - if (obj == null) { - return null; - } - - const pair = new EncryptionPair(); - if (obj?.encrypted != null) { - pair.encrypted = encryptedFromJson - ? encryptedFromJson(obj.encrypted) - : (obj.encrypted as TEncrypted); - } - if (obj?.decrypted != null) { - pair.decrypted = decryptedFromJson - ? decryptedFromJson(obj.decrypted) - : (obj.decrypted as TDecrypted); - } - return pair; - } -} - -export class AccountKeys { - publicKey?: Uint8Array; - - /** @deprecated July 2023, left for migration purposes*/ - cryptoSymmetricKey?: EncryptionPair = new EncryptionPair< - string, - SymmetricCryptoKey - >(); - - toJSON() { - // If you pass undefined into fromBufferToByteString, you will get an empty string back - // which will cause all sorts of headaches down the line when you try to getPublicKey - // and expect a Uint8Array and get an empty string instead. - return Utils.merge(this, { - publicKey: this.publicKey ? Utils.fromBufferToByteString(this.publicKey) : undefined, - }); - } - - static fromJSON(obj: DeepJsonify): AccountKeys { - if (obj == null) { - return null; - } - return Object.assign(new AccountKeys(), obj, { - cryptoSymmetricKey: EncryptionPair.fromJSON( - obj?.cryptoSymmetricKey, - SymmetricCryptoKey.fromJSON, - ), - publicKey: Utils.fromByteStringToArray(obj?.publicKey), - }); - } - - static initRecordEncryptionPairsFromJSON(obj: any) { - return EncryptionPair.fromJSON(obj, (decObj: any) => { - if (obj == null) { - return null; - } - - const record: Record = {}; - for (const id in decObj) { - record[id] = SymmetricCryptoKey.fromJSON(decObj[id]); - } - return record; - }); - } -} - -export class AccountProfile { - name?: string; - email?: string; - emailVerified?: boolean; - userId?: string; - - static fromJSON(obj: Jsonify): AccountProfile { - if (obj == null) { - return null; - } - - return Object.assign(new AccountProfile(), obj); - } -} - -export class Account { - keys?: AccountKeys = new AccountKeys(); - profile?: AccountProfile = new AccountProfile(); - - constructor(init: Partial) { - Object.assign(this, { - keys: { - ...new AccountKeys(), - ...init?.keys, - }, - profile: { - ...new AccountProfile(), - ...init?.profile, - }, - }); - } - - static fromJSON(json: Jsonify): Account { - if (json == null) { - return null; - } - - return Object.assign(new Account({}), json, { - keys: AccountKeys.fromJSON(json?.keys), - profile: AccountProfile.fromJSON(json?.profile), - }); - } -} diff --git a/libs/common/src/platform/models/domain/encryption-pair.spec.ts b/libs/common/src/platform/models/domain/encryption-pair.spec.ts deleted file mode 100644 index 1418c125ed6..00000000000 --- a/libs/common/src/platform/models/domain/encryption-pair.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Utils } from "../../misc/utils"; - -import { EncryptionPair } from "./account"; - -describe("EncryptionPair", () => { - describe("toJSON", () => { - it("should populate decryptedSerialized for buffer arrays", () => { - const pair = new EncryptionPair(); - pair.decrypted = Utils.fromByteStringToArray("hello").buffer; - const json = pair.toJSON(); - expect(json.decrypted).toEqual("hello"); - }); - - it("should populate decryptedSerialized for TypesArrays", () => { - const pair = new EncryptionPair(); - pair.decrypted = Utils.fromByteStringToArray("hello"); - const json = pair.toJSON(); - expect(json.decrypted).toEqual(new Uint8Array([104, 101, 108, 108, 111])); - }); - - it("should serialize encrypted and decrypted", () => { - const pair = new EncryptionPair(); - pair.encrypted = "hello"; - pair.decrypted = "world"; - const json = pair.toJSON(); - expect(json.encrypted).toEqual("hello"); - expect(json.decrypted).toEqual("world"); - }); - }); - - describe("fromJSON", () => { - it("should deserialize encrypted and decrypted", () => { - const pair = EncryptionPair.fromJSON({ - encrypted: "hello", - decrypted: "world", - }); - expect(pair.encrypted).toEqual("hello"); - expect(pair.decrypted).toEqual("world"); - }); - }); -}); diff --git a/libs/common/src/platform/models/domain/state.spec.ts b/libs/common/src/platform/models/domain/state.spec.ts deleted file mode 100644 index 55d17bded3f..00000000000 --- a/libs/common/src/platform/models/domain/state.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Account } from "./account"; -import { State } from "./state"; - -describe("state", () => { - describe("fromJSON", () => { - it("should deserialize to an instance of itself", () => { - expect(State.fromJSON({}, () => new Account({}))).toBeInstanceOf(State); - }); - - it("should always assign an object to accounts", () => { - const state = State.fromJSON({}, () => new Account({})); - expect(state.accounts).not.toBeNull(); - expect(state.accounts).toEqual({}); - }); - - it("should build an account map", () => { - const accountsSpy = jest.spyOn(Account, "fromJSON"); - const state = State.fromJSON( - { - accounts: { - userId: {}, - }, - }, - Account.fromJSON, - ); - - expect(state.accounts["userId"]).toBeInstanceOf(Account); - expect(accountsSpy).toHaveBeenCalled(); - }); - }); -}); diff --git a/libs/common/src/platform/models/domain/state.ts b/libs/common/src/platform/models/domain/state.ts deleted file mode 100644 index d9f5849a3ca..00000000000 --- a/libs/common/src/platform/models/domain/state.ts +++ /dev/null @@ -1,46 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Jsonify } from "type-fest"; - -import { Account } from "./account"; -import { GlobalState } from "./global-state"; - -export class State< - TGlobalState extends GlobalState = GlobalState, - TAccount extends Account = Account, -> { - accounts: { [userId: string]: TAccount } = {}; - globals: TGlobalState; - - constructor(globals: TGlobalState) { - this.globals = globals; - } - - // TODO, make Jsonify work. It currently doesn't because Globals doesn't implement Jsonify. - static fromJSON( - obj: any, - accountDeserializer: (json: Jsonify) => TAccount, - ): State { - if (obj == null) { - return null; - } - - return Object.assign(new State(null), obj, { - accounts: State.buildAccountMapFromJSON(obj?.accounts, accountDeserializer), - }); - } - - private static buildAccountMapFromJSON( - jsonAccounts: { [userId: string]: Jsonify }, - accountDeserializer: (json: Jsonify) => TAccount, - ) { - if (!jsonAccounts) { - return {}; - } - const accounts: { [userId: string]: TAccount } = {}; - for (const userId in jsonAccounts) { - accounts[userId] = accountDeserializer(jsonAccounts[userId]); - } - return accounts; - } -} diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts deleted file mode 100644 index 284c8a7f2dc..00000000000 --- a/libs/common/src/platform/services/state.service.ts +++ /dev/null @@ -1,659 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { firstValueFrom, map } from "rxjs"; -import { Jsonify, JsonValue } from "type-fest"; - -import { AccountService } from "../../auth/abstractions/account.service"; -import { TokenService } from "../../auth/abstractions/token.service"; -import { BiometricKey } from "../../auth/types/biometric-key"; -import { UserId } from "../../types/guid"; -import { EnvironmentService } from "../abstractions/environment.service"; -import { LogService } from "../abstractions/log.service"; -import { - InitOptions, - StateService as StateServiceAbstraction, -} from "../abstractions/state.service"; -import { AbstractStorageService } from "../abstractions/storage.service"; -import { HtmlStorageLocation, StorageLocation } from "../enums"; -import { StateFactory } from "../factories/state-factory"; -import { Account } from "../models/domain/account"; -import { GlobalState } from "../models/domain/global-state"; -import { State } from "../models/domain/state"; -import { StorageOptions } from "../models/domain/storage-options"; - -import { MigrationRunner } from "./migration-runner"; - -const keys = { - state: "state", - stateVersion: "stateVersion", - global: "global", - tempAccountSettings: "tempAccountSettings", // used to hold account specific settings (i.e clear clipboard) between initial migration and first account authentication -}; - -const partialKeys = { - userAutoKey: "_user_auto", - userBiometricKey: "_user_biometric", - - autoKey: "_masterkey_auto", - masterKey: "_masterkey", -}; - -const DDG_SHARED_KEY = "DuckDuckGoSharedKey"; - -export class StateService< - TGlobalState extends GlobalState = GlobalState, - TAccount extends Account = Account, -> implements StateServiceAbstraction -{ - private hasBeenInited = false; - protected isRecoveredSession = false; - - // default account serializer, must be overridden by child class - protected accountDeserializer = Account.fromJSON as (json: Jsonify) => TAccount; - - constructor( - protected storageService: AbstractStorageService, - protected secureStorageService: AbstractStorageService, - protected memoryStorageService: AbstractStorageService, - protected logService: LogService, - protected stateFactory: StateFactory, - protected accountService: AccountService, - protected environmentService: EnvironmentService, - protected tokenService: TokenService, - private migrationRunner: MigrationRunner, - ) {} - - async init(initOptions: InitOptions = {}): Promise { - // Deconstruct and apply defaults - const { runMigrations = true } = initOptions; - if (this.hasBeenInited) { - return; - } - - if (runMigrations) { - await this.migrationRunner.run(); - } else { - // It may have been requested to not run the migrations but we should defensively not - // continue this method until migrations have a chance to be completed elsewhere. - await this.migrationRunner.waitForCompletion(); - } - - await this.state().then(async (state) => { - if (state == null) { - await this.setState(new State(this.createGlobals())); - } else { - this.isRecoveredSession = true; - } - }); - await this.initAccountState(); - - this.hasBeenInited = true; - } - - async initAccountState() { - if (this.isRecoveredSession) { - return; - } - - // Get all likely authenticated accounts - const authenticatedAccounts = await firstValueFrom( - this.accountService.accounts$.pipe(map((accounts) => Object.keys(accounts))), - ); - - await this.updateState(async (state) => { - for (const i in authenticatedAccounts) { - state = await this.syncAccountFromDisk(authenticatedAccounts[i]); - } - - return state; - }); - } - - async syncAccountFromDisk(userId: string): Promise> { - if (userId == null) { - return; - } - const diskAccount = await this.getAccountFromDisk({ userId: userId }); - const state = await this.updateState(async (state) => { - if (state.accounts == null) { - state.accounts = {}; - } - state.accounts[userId] = this.createAccount(); - - if (diskAccount == null) { - // Return early because we can't set the diskAccount.profile - // if diskAccount itself is null - return state; - } - - state.accounts[userId].profile = diskAccount.profile; - return state; - }); - - return state; - } - - async addAccount(account: TAccount) { - await this.updateState(async (state) => { - state.accounts[account.profile.userId] = account; - return state; - }); - await this.scaffoldNewAccountStorage(account); - } - - async clean(options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); - await this.deAuthenticateAccount(options.userId); - - await this.removeAccountFromDisk(options?.userId); - await this.removeAccountFromMemory(options?.userId); - } - - /** - * user key when using the "never" option of vault timeout - */ - async getUserKeyAutoUnlock(options?: StorageOptions): Promise { - options = this.reconcileOptions( - this.reconcileOptions(options, { keySuffix: "auto" }), - await this.defaultSecureStorageOptions(), - ); - if (options?.userId == null) { - return null; - } - return await this.secureStorageService.get( - `${options.userId}${partialKeys.userAutoKey}`, - options, - ); - } - - /** - * user key when using the "never" option of vault timeout - */ - async setUserKeyAutoUnlock(value: string | null, options?: StorageOptions): Promise { - options = this.reconcileOptions( - this.reconcileOptions(options, { keySuffix: "auto" }), - await this.defaultSecureStorageOptions(), - ); - if (options?.userId == null) { - return; - } - await this.saveSecureStorageKey(partialKeys.userAutoKey, value, options); - } - - /** - * User's encrypted symmetric key when using biometrics - */ - async getUserKeyBiometric(options?: StorageOptions): Promise { - options = this.reconcileOptions( - this.reconcileOptions(options, { keySuffix: "biometric" }), - await this.defaultSecureStorageOptions(), - ); - if (options?.userId == null) { - return null; - } - return await this.secureStorageService.get( - `${options.userId}${partialKeys.userBiometricKey}`, - options, - ); - } - - async hasUserKeyBiometric(options?: StorageOptions): Promise { - options = this.reconcileOptions( - this.reconcileOptions(options, { keySuffix: "biometric" }), - await this.defaultSecureStorageOptions(), - ); - if (options?.userId == null) { - return false; - } - return await this.secureStorageService.has( - `${options.userId}${partialKeys.userBiometricKey}`, - options, - ); - } - - async setUserKeyBiometric(value: BiometricKey, options?: StorageOptions): Promise { - options = this.reconcileOptions( - this.reconcileOptions(options, { keySuffix: "biometric" }), - await this.defaultSecureStorageOptions(), - ); - if (options?.userId == null) { - return; - } - await this.saveSecureStorageKey(partialKeys.userBiometricKey, value, options); - } - - async getDuckDuckGoSharedKey(options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); - if (options?.userId == null) { - return null; - } - return await this.secureStorageService.get(DDG_SHARED_KEY, options); - } - - async setDuckDuckGoSharedKey(value: string, options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); - if (options?.userId == null) { - return; - } - value == null - ? await this.secureStorageService.remove(DDG_SHARED_KEY, options) - : await this.secureStorageService.save(DDG_SHARED_KEY, value, options); - } - - async setEnableDuckDuckGoBrowserIntegration( - value: boolean, - options?: StorageOptions, - ): Promise { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - globals.enableDuckDuckGoBrowserIntegration = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - - /** - * @deprecated Use UserKey instead - */ - async getEncryptedCryptoSymmetricKey(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.keys.cryptoSymmetricKey.encrypted; - } - - async getIsAuthenticated(options?: StorageOptions): Promise { - return ( - (await this.tokenService.getAccessToken(options?.userId as UserId)) != null && - (await this.getUserId(options)) != null - ); - } - - async getUserId(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.profile?.userId; - } - - protected async getGlobals(options: StorageOptions): Promise { - let globals: TGlobalState; - if (this.useMemory(options.storageLocation)) { - globals = await this.getGlobalsFromMemory(); - } - - if (this.useDisk && globals == null) { - globals = await this.getGlobalsFromDisk(options); - } - - if (globals == null) { - globals = this.createGlobals(); - } - - return globals; - } - - protected async saveGlobals(globals: TGlobalState, options: StorageOptions) { - return this.useMemory(options.storageLocation) - ? this.saveGlobalsToMemory(globals) - : await this.saveGlobalsToDisk(globals, options); - } - - protected async getGlobalsFromMemory(): Promise { - return (await this.state()).globals; - } - - protected async getGlobalsFromDisk(options: StorageOptions): Promise { - return await this.storageService.get(keys.global, options); - } - - protected async saveGlobalsToMemory(globals: TGlobalState): Promise { - await this.updateState(async (state) => { - state.globals = globals; - return state; - }); - } - - protected async saveGlobalsToDisk(globals: TGlobalState, options: StorageOptions): Promise { - if (options.useSecureStorage) { - await this.secureStorageService.save(keys.global, globals, options); - } else { - await this.storageService.save(keys.global, globals, options); - } - } - - protected async getAccount(options: StorageOptions): Promise { - try { - let account: TAccount; - if (this.useMemory(options.storageLocation)) { - account = await this.getAccountFromMemory(options); - } - - if (this.useDisk(options.storageLocation) && account == null) { - account = await this.getAccountFromDisk(options); - } - - return account; - } catch (e) { - this.logService.error(e); - } - } - - protected async getAccountFromMemory(options: StorageOptions): Promise { - const userId = - options.userId ?? - (await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - )); - - return await this.state().then(async (state) => { - if (state.accounts == null) { - return null; - } - return state.accounts[userId]; - }); - } - - protected async getAccountFromDisk(options: StorageOptions): Promise { - const userId = - options.userId ?? - (await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - )); - - if (userId == null) { - return null; - } - - const account = options?.useSecureStorage - ? ((await this.secureStorageService.get(options.userId, options)) ?? - (await this.storageService.get( - options.userId, - this.reconcileOptions(options, { htmlStorageLocation: HtmlStorageLocation.Local }), - ))) - : await this.storageService.get(options.userId, options); - return account; - } - - protected useMemory(storageLocation: StorageLocation) { - return storageLocation === StorageLocation.Memory || storageLocation === StorageLocation.Both; - } - - protected useDisk(storageLocation: StorageLocation) { - return storageLocation === StorageLocation.Disk || storageLocation === StorageLocation.Both; - } - - protected async saveAccount( - account: TAccount, - options: StorageOptions = { - storageLocation: StorageLocation.Both, - useSecureStorage: false, - }, - ) { - return this.useMemory(options.storageLocation) - ? await this.saveAccountToMemory(account) - : await this.saveAccountToDisk(account, options); - } - - protected async saveAccountToDisk(account: TAccount, options: StorageOptions): Promise { - const storageLocation = options.useSecureStorage - ? this.secureStorageService - : this.storageService; - - await storageLocation.save(`${options.userId}`, account, options); - } - - protected async saveAccountToMemory(account: TAccount): Promise { - if ((await this.getAccountFromMemory({ userId: account.profile.userId })) !== null) { - await this.updateState((state) => { - return new Promise((resolve) => { - state.accounts[account.profile.userId] = account; - resolve(state); - }); - }); - } - } - - protected async scaffoldNewAccountStorage(account: TAccount): Promise { - // We don't want to manipulate the referenced in memory account - const deepClone = JSON.parse(JSON.stringify(account)); - await this.scaffoldNewAccountLocalStorage(deepClone); - await this.scaffoldNewAccountSessionStorage(deepClone); - await this.scaffoldNewAccountMemoryStorage(deepClone); - } - - // TODO: There is a tech debt item for splitting up these methods - only Web uses multiple storage locations in its storageService. - // For now these methods exist with some redundancy to facilitate this special web requirement. - protected async scaffoldNewAccountLocalStorage(account: TAccount): Promise { - await this.saveAccount( - account, - this.reconcileOptions( - { userId: account.profile.userId }, - await this.defaultOnDiskLocalOptions(), - ), - ); - } - - protected async scaffoldNewAccountMemoryStorage(account: TAccount): Promise { - await this.storageService.save( - account.profile.userId, - account, - await this.defaultOnDiskMemoryOptions(), - ); - await this.saveAccount( - account, - this.reconcileOptions( - { userId: account.profile.userId }, - await this.defaultOnDiskMemoryOptions(), - ), - ); - } - - protected async scaffoldNewAccountSessionStorage(account: TAccount): Promise { - await this.storageService.save( - account.profile.userId, - account, - await this.defaultOnDiskMemoryOptions(), - ); - await this.saveAccount( - account, - this.reconcileOptions({ userId: account.profile.userId }, await this.defaultOnDiskOptions()), - ); - } - - protected reconcileOptions( - requestedOptions: StorageOptions, - defaultOptions: StorageOptions, - ): StorageOptions { - if (requestedOptions == null) { - return defaultOptions; - } - requestedOptions.userId = requestedOptions?.userId ?? defaultOptions.userId; - requestedOptions.storageLocation = - requestedOptions?.storageLocation ?? defaultOptions.storageLocation; - requestedOptions.useSecureStorage = - requestedOptions?.useSecureStorage ?? defaultOptions.useSecureStorage; - requestedOptions.htmlStorageLocation = - requestedOptions?.htmlStorageLocation ?? defaultOptions.htmlStorageLocation; - requestedOptions.keySuffix = requestedOptions?.keySuffix ?? defaultOptions.keySuffix; - return requestedOptions; - } - - protected async defaultInMemoryOptions(): Promise { - const userId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); - - return { - storageLocation: StorageLocation.Memory, - userId, - }; - } - - protected async defaultOnDiskOptions(): Promise { - const userId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); - - return { - storageLocation: StorageLocation.Disk, - htmlStorageLocation: HtmlStorageLocation.Session, - userId, - useSecureStorage: false, - }; - } - - protected async defaultOnDiskLocalOptions(): Promise { - const userId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); - - return { - storageLocation: StorageLocation.Disk, - htmlStorageLocation: HtmlStorageLocation.Local, - userId, - useSecureStorage: false, - }; - } - - protected async defaultOnDiskMemoryOptions(): Promise { - const userId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); - - return { - storageLocation: StorageLocation.Disk, - htmlStorageLocation: HtmlStorageLocation.Memory, - userId, - useSecureStorage: false, - }; - } - - protected async defaultSecureStorageOptions(): Promise { - const userId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); - - return { - storageLocation: StorageLocation.Disk, - useSecureStorage: true, - userId, - }; - } - - protected async getActiveUserIdFromStorage(): Promise { - return await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))); - } - - protected async removeAccountFromLocalStorage(userId: string = null): Promise { - userId ??= await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); - - const storedAccount = await this.getAccount( - this.reconcileOptions({ userId: userId }, await this.defaultOnDiskLocalOptions()), - ); - await this.saveAccount( - this.resetAccount(storedAccount), - this.reconcileOptions({ userId: userId }, await this.defaultOnDiskLocalOptions()), - ); - } - - protected async removeAccountFromSessionStorage(userId: string = null): Promise { - userId ??= await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); - - const storedAccount = await this.getAccount( - this.reconcileOptions({ userId: userId }, await this.defaultOnDiskOptions()), - ); - await this.saveAccount( - this.resetAccount(storedAccount), - this.reconcileOptions({ userId: userId }, await this.defaultOnDiskOptions()), - ); - } - - protected async removeAccountFromSecureStorage(userId: string = null): Promise { - userId ??= await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); - - await this.setUserKeyAutoUnlock(null, { userId: userId }); - await this.setUserKeyBiometric(null, { userId: userId }); - } - - protected async removeAccountFromMemory(userId: string = null): Promise { - userId ??= await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); - - await this.updateState(async (state) => { - delete state.accounts[userId]; - return state; - }); - } - - // settings persist even on reset, and are not affected by this method - protected resetAccount(account: TAccount) { - // All settings have been moved to StateProviders - return this.createAccount(); - } - - protected createAccount(init: Partial = null): TAccount { - return this.stateFactory.createAccount(init); - } - - protected createGlobals(init: Partial = null): TGlobalState { - return this.stateFactory.createGlobal(init); - } - - protected async deAuthenticateAccount(userId: string): Promise { - // We must have a manual call to clear tokens as we can't leverage state provider to clean - // up our data as we have secure storage in the mix. - await this.tokenService.clearTokens(userId as UserId); - } - - protected async removeAccountFromDisk(userId: string) { - await this.removeAccountFromSessionStorage(userId); - await this.removeAccountFromLocalStorage(userId); - await this.removeAccountFromSecureStorage(userId); - } - - protected async saveSecureStorageKey( - key: string, - value: T | null, - options?: StorageOptions, - ) { - return value == null - ? await this.secureStorageService.remove(`${options.userId}${key}`, options) - : await this.secureStorageService.save(`${options.userId}${key}`, value, options); - } - - protected async state(): Promise> { - let state = await this.memoryStorageService.get>(keys.state); - if (this.memoryStorageService.valuesRequireDeserialization) { - state = State.fromJSON(state, this.accountDeserializer); - } - return state; - } - - private async setState( - state: State, - ): Promise> { - await this.memoryStorageService.save(keys.state, state); - return state; - } - - protected async updateState( - stateUpdater: (state: State) => Promise>, - ): Promise> { - return await this.state().then(async (state) => { - const updatedState = await stateUpdater(state); - if (updatedState == null) { - throw new Error("Attempted to update state to null value"); - } - - return await this.setState(updatedState); - }); - } -} diff --git a/libs/common/src/platform/sync/core-sync.service.ts b/libs/common/src/platform/sync/core-sync.service.ts index 40419a343da..45a127c599a 100644 --- a/libs/common/src/platform/sync/core-sync.service.ts +++ b/libs/common/src/platform/sync/core-sync.service.ts @@ -9,6 +9,7 @@ import { CollectionService } from "@bitwarden/admin-console/common"; import { ApiService } from "../../abstractions/api.service"; import { AccountService } from "../../auth/abstractions/account.service"; import { AuthService } from "../../auth/abstractions/auth.service"; +import { TokenService } from "../../auth/abstractions/token.service"; import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { SyncCipherNotification, @@ -26,7 +27,6 @@ import { SyncService } from "../../vault/abstractions/sync/sync.service.abstract import { CipherData } from "../../vault/models/data/cipher.data"; import { FolderData } from "../../vault/models/data/folder.data"; import { LogService } from "../abstractions/log.service"; -import { StateService } from "../abstractions/state.service"; import { MessageSender } from "../messaging"; import { StateProvider, SYNC_DISK, UserKeyDefinition } from "../state"; @@ -44,7 +44,7 @@ export abstract class CoreSyncService implements SyncService { syncInProgress = false; constructor( - protected readonly stateService: StateService, + readonly tokenService: TokenService, protected readonly folderService: InternalFolderService, protected readonly folderApiService: FolderApiServiceAbstraction, protected readonly messageSender: MessageSender, @@ -256,7 +256,13 @@ export abstract class CoreSyncService implements SyncService { async syncDeleteSend(notification: SyncSendNotification): Promise { this.syncStarted(); - if (await this.stateService.getIsAuthenticated()) { + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + if ( + activeUserId != null && + (await firstValueFrom(this.tokenService.hasAccessToken$(activeUserId))) + ) { await this.sendService.delete(notification.id); this.messageSender.send("syncedDeletedSend", { sendId: notification.id }); // TODO: Update syncCompleted userId when send service allows modification of non-active users diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts index fc6b9481bd5..8929e74c635 100644 --- a/libs/common/src/platform/sync/default-sync.service.spec.ts +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -36,7 +36,6 @@ import { CipherService } from "../../vault/abstractions/cipher.service"; import { FolderApiServiceAbstraction } from "../../vault/abstractions/folder/folder-api.service.abstraction"; import { InternalFolderService } from "../../vault/abstractions/folder/folder.service.abstraction"; import { LogService } from "../abstractions/log.service"; -import { StateService } from "../abstractions/state.service"; import { MessageSender } from "../messaging"; import { StateProvider } from "../state"; @@ -57,7 +56,6 @@ describe("DefaultSyncService", () => { let sendService: MockProxy; let logService: MockProxy; let keyConnectorService: MockProxy; - let stateService: MockProxy; let providerService: MockProxy; let folderApiService: MockProxy; let organizationService: MockProxy; @@ -86,7 +84,6 @@ describe("DefaultSyncService", () => { sendService = mock(); logService = mock(); keyConnectorService = mock(); - stateService = mock(); providerService = mock(); folderApiService = mock(); organizationService = mock(); @@ -113,7 +110,6 @@ describe("DefaultSyncService", () => { sendService, logService, keyConnectorService, - stateService, providerService, folderApiService, organizationService, diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index 99e87383657..9ef7b432d9c 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -53,7 +53,6 @@ import { FolderData } from "../../vault/models/data/folder.data"; import { CipherResponse } from "../../vault/models/response/cipher.response"; import { FolderResponse } from "../../vault/models/response/folder.response"; import { LogService } from "../abstractions/log.service"; -import { StateService } from "../abstractions/state.service"; import { MessageSender } from "../messaging"; import { StateProvider } from "../state"; @@ -87,7 +86,6 @@ export class DefaultSyncService extends CoreSyncService { sendService: InternalSendService, logService: LogService, private keyConnectorService: KeyConnectorService, - stateService: StateService, private providerService: ProviderService, folderApiService: FolderApiServiceAbstraction, private organizationService: InternalOrganizationServiceAbstraction, @@ -96,12 +94,12 @@ export class DefaultSyncService extends CoreSyncService { private avatarService: AvatarService, private logoutCallback: (logoutReason: LogoutReason, userId?: UserId) => Promise, private billingAccountProfileStateService: BillingAccountProfileStateService, - private tokenService: TokenService, + tokenService: TokenService, authService: AuthService, stateProvider: StateProvider, ) { super( - stateService, + tokenService, folderService, folderApiService, messageSender, diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index be72d618dee..2088f50d1cc 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -20,7 +20,6 @@ import { EncString } from "../../key-management/crypto/models/enc-string"; import { UriMatchStrategy } from "../../models/domain/domain-service"; import { ConfigService } from "../../platform/abstractions/config/config.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; -import { StateService } from "../../platform/abstractions/state.service"; import { Utils } from "../../platform/misc/utils"; import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; @@ -94,7 +93,6 @@ let accountService: FakeAccountService; describe("Cipher Service", () => { const keyService = mock(); - const stateService = mock(); const autofillSettingsService = mock(); const domainSettingsService = mock(); const apiService = mock(); @@ -127,7 +125,6 @@ describe("Cipher Service", () => { apiService, i18nService, searchService, - stateService, autofillSettingsService, encryptService, cipherFileUploadService, @@ -470,8 +467,6 @@ describe("Cipher Service", () => { searchService.indexedEntityId$.mockReturnValue(of(null)); - stateService.getUserId.mockResolvedValue(mockUserId); - const keys = { userKey: originalUserKey } as CipherDecryptionKeys; keyService.cipherDecryptionKeys$.mockReturnValue(of(keys)); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 96a9a309c54..2f225d4dfc5 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -31,7 +31,6 @@ import { ListResponse } from "../../models/response/list.response"; import { View } from "../../models/view/view"; import { ConfigService } from "../../platform/abstractions/config/config.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; -import { StateService } from "../../platform/abstractions/state.service"; import { Utils } from "../../platform/misc/utils"; import Domain from "../../platform/models/domain/domain-base"; import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer"; @@ -110,7 +109,6 @@ export class CipherService implements CipherServiceAbstraction { private apiService: ApiService, private i18nService: I18nService, private searchService: SearchService, - private stateService: StateService, private autofillSettingsService: AutofillSettingsServiceAbstraction, private encryptService: EncryptService, private cipherFileUploadService: CipherFileUploadService, diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index 4942279e436..92bee383a0b 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -732,7 +732,7 @@ export class DefaultKeyService implements KeyServiceAbstraction { protected async getKeyFromStorage( keySuffix: KeySuffixOptions, - userId?: UserId, + userId: UserId, ): Promise { if (keySuffix === KeySuffixOptions.Auto) { const userKey = await this.stateService.getUserKeyAutoUnlock({ userId: userId }); diff --git a/libs/state/src/index.ts b/libs/state/src/index.ts index d74e7fc137d..71563ecb764 100644 --- a/libs/state/src/index.ts +++ b/libs/state/src/index.ts @@ -2,3 +2,4 @@ export * from "./core"; export * from "./state-migrations"; export * from "./types/state"; +export * from "./legacy"; diff --git a/libs/state/src/legacy/default-state.service.ts b/libs/state/src/legacy/default-state.service.ts new file mode 100644 index 00000000000..b1c5ddb3a0b --- /dev/null +++ b/libs/state/src/legacy/default-state.service.ts @@ -0,0 +1,107 @@ +import { firstValueFrom } from "rxjs"; + +import { StorageService } from "@bitwarden/storage-core"; +import { UserId } from "@bitwarden/user-core"; + +import { ActiveUserAccessor } from "../core"; + +import { GlobalState } from "./global-state"; +import { RequiredUserId, StateService } from "./state.service"; + +const keys = { + global: "global", +}; + +const partialKeys = { + userAutoKey: "_user_auto", + userBiometricKey: "_user_biometric", +}; + +const DDG_SHARED_KEY = "DuckDuckGoSharedKey"; + +export class DefaultStateService implements StateService { + constructor( + private readonly storageService: StorageService, + private readonly secureStorageService: StorageService, + private readonly activeUserAccessor: ActiveUserAccessor, + ) {} + + async clean(options: RequiredUserId): Promise { + await this.setUserKeyAutoUnlock(null, options); + await this.clearUserKeyBiometric(options.userId); + } + + /** + * user key when using the "never" option of vault timeout + */ + async getUserKeyAutoUnlock(options: RequiredUserId): Promise { + if (options.userId == null) { + return null; + } + return await this.secureStorageService.get( + `${options.userId}${partialKeys.userAutoKey}`, + { + userId: options.userId, + keySuffix: "auto", + }, + ); + } + + /** + * user key when using the "never" option of vault timeout + */ + async setUserKeyAutoUnlock(value: string | null, options: RequiredUserId): Promise { + if (options.userId == null) { + return; + } + await this.saveSecureStorageKey(partialKeys.userAutoKey, value, options.userId, "auto"); + } + + private async clearUserKeyBiometric(userId: UserId): Promise { + if (userId == null) { + return; + } + await this.saveSecureStorageKey(partialKeys.userBiometricKey, null, userId, "biometric"); + } + + async getDuckDuckGoSharedKey(): Promise { + const userId = await this.getActiveUserIdFromStorage(); + if (userId == null) { + return null; + } + return await this.secureStorageService.get(DDG_SHARED_KEY); + } + + async setDuckDuckGoSharedKey(value: string): Promise { + const userId = await this.getActiveUserIdFromStorage(); + if (userId == null) { + return; + } + value == null + ? await this.secureStorageService.remove(DDG_SHARED_KEY) + : await this.secureStorageService.save(DDG_SHARED_KEY, value); + } + + async setEnableDuckDuckGoBrowserIntegration(value: boolean): Promise { + const globals = (await this.storageService.get(keys.global)) ?? new GlobalState(); + globals.enableDuckDuckGoBrowserIntegration = value; + await this.storageService.save(keys.global, globals); + } + + private async getActiveUserIdFromStorage(): Promise { + return await firstValueFrom(this.activeUserAccessor.activeUserId$); + } + + private async saveSecureStorageKey( + key: string, + value: string | null, + userId: UserId, + keySuffix: string, + ) { + return value == null + ? await this.secureStorageService.remove(`${userId}${key}`, { keySuffix: keySuffix }) + : await this.secureStorageService.save(`${userId}${key}`, value, { + keySuffix: keySuffix, + }); + } +} diff --git a/libs/common/src/platform/models/domain/global-state.ts b/libs/state/src/legacy/global-state.ts similarity index 100% rename from libs/common/src/platform/models/domain/global-state.ts rename to libs/state/src/legacy/global-state.ts diff --git a/libs/state/src/legacy/index.ts b/libs/state/src/legacy/index.ts new file mode 100644 index 00000000000..d25d4d4616a --- /dev/null +++ b/libs/state/src/legacy/index.ts @@ -0,0 +1,2 @@ +export { StateService } from "./state.service"; +export { DefaultStateService } from "./default-state.service"; diff --git a/libs/state/src/legacy/state.service.ts b/libs/state/src/legacy/state.service.ts new file mode 100644 index 00000000000..dd07a975895 --- /dev/null +++ b/libs/state/src/legacy/state.service.ts @@ -0,0 +1,25 @@ +import { UserId } from "@bitwarden/user-core"; + +export type RequiredUserId = { userId: UserId }; + +/** + * This class exists for various legacy reasons, there are likely better things to use than this service. + */ +export abstract class StateService { + abstract clean(options: RequiredUserId): Promise; + + /** + * Gets the user's auto key + */ + abstract getUserKeyAutoUnlock(options: RequiredUserId): Promise; + /** + * Sets the user's auto key + */ + abstract setUserKeyAutoUnlock(value: string | null, options: RequiredUserId): Promise; + /** + * @deprecated For backwards compatible purposes only, use DesktopAutofillSettingsService + */ + abstract setEnableDuckDuckGoBrowserIntegration(value: boolean): Promise; + abstract getDuckDuckGoSharedKey(): Promise; + abstract setDuckDuckGoSharedKey(value: string): Promise; +} From f2d2d0a767842a50534190159ff9a7e2d4973e11 Mon Sep 17 00:00:00 2001 From: Vicki League Date: Mon, 18 Aug 2025 12:46:19 -0400 Subject: [PATCH 13/30] [CL-653] Re-add mfa classes and braintree overrides (#16044) --- apps/web/src/scss/plugins.scss | 109 +++++++++++++++++++++++++++++++++ apps/web/src/scss/styles.scss | 1 + 2 files changed, 110 insertions(+) create mode 100644 apps/web/src/scss/plugins.scss diff --git a/apps/web/src/scss/plugins.scss b/apps/web/src/scss/plugins.scss new file mode 100644 index 00000000000..bdd368bc661 --- /dev/null +++ b/apps/web/src/scss/plugins.scss @@ -0,0 +1,109 @@ +#web-authn-frame { + height: 40px; + + iframe { + border: none; + height: 100%; + width: 100%; + } +} + +// MFA Types for logo styling with no dark theme alternative +$mfaTypes: 0, 2, 3, 4, 6; + +@each $mfaType in $mfaTypes { + .mfaType#{$mfaType} { + content: url("../images/two-factor/" + $mfaType + ".png"); + max-width: 120px; + } +} + +.mfaType0 { + content: url("../images/two-factor/0.png"); + max-width: 120px; + max-height: 62px; +} + +.mfaType1 { + max-width: 120px; + max-height: 62px; + + &:is(.theme_light *) { + content: url("../images/two-factor/1.png"); + } + + &:is(.theme_dark *) { + content: url("../images/two-factor/1-w.png"); + } +} + +.mfaType7 { + max-width: 120px; + + &:is(.theme_light *) { + content: url("../images/two-factor/7.png"); + } + + &:is(.theme_dark *) { + content: url("../images/two-factor/7-w.png"); + } +} + +// Braintree + +#bt-dropin-container { + min-height: 50px; + &:is(.theme_light *) { + background: url("../images/loading.svg") center center no-repeat; + } + + &:is(.theme_dark *) { + background: url("../images/loading-white.svg") center center no-repeat; + } +} + +.braintree-placeholder, +.braintree-sheet__header { + display: none; +} + +.braintree-sheet__content--button { + min-height: 0; + padding: 0; + text-align: left; +} + +.braintree-sheet__container { + margin-bottom: 0; +} + +.braintree-sheet { + border: none; +} + +// hide duplicate paypal iframe +.braintree-sheet__content--button + .braintree-sheet__button--paypal + iframe.zoid-prerender-frame.zoid-invisible { + display: none !important; +} + +[data-braintree-id="upper-container"]::before { + background-color: rgba(var(--color-background)); +} + +.card [data-braintree-id="upper-container"]::before { + background-color: rgba(var(--color-background-alt)); +} + +[data-braintree-id="paypal-button"] { + background-color: rgba(var(--color-background)); +} + +.card [data-braintree-id="paypal-button"] { + background-color: rgba(var(--color-background-alt)); +} + +.paypal-button-text { + color: rgba(var(--color-text-main)); +} diff --git a/apps/web/src/scss/styles.scss b/apps/web/src/scss/styles.scss index 68cbad19784..34247494459 100644 --- a/apps/web/src/scss/styles.scss +++ b/apps/web/src/scss/styles.scss @@ -8,3 +8,4 @@ @import "@angular/cdk/text-field-prebuilt.css"; @import "./vault-filters"; +@import "./plugins.scss"; From 827c4c0301739e9644de79880d0d671fccdb92af Mon Sep 17 00:00:00 2001 From: Will Martin Date: Mon, 18 Aug 2025 15:36:45 -0400 Subject: [PATCH 14/30] [PM-15847] `libs/components` strict migration (#15738) This PR migrates `libs/components` to use strict TypeScript. - Remove `@ts-strict-ignore` from each file in `libs/components` and resolved any new compilation errors - Converted ViewChild and ContentChild decorators to use the new signal-based queries using the [Angular signal queries migration](https://angular.dev/reference/migrations/signal-queries) - Made view/content children `required` where appropriate, eliminating the need for additional null checking. This helped simplify the strict migration. --- Co-authored-by: Vicki League --- .../popup/layout/popup-back.directive.ts | 23 +-- .../popup/layout/popup-layout.stories.ts | 2 +- .../product-switcher.stories.ts | 4 +- .../src/a11y/a11y-title.directive.ts | 43 ++---- .../anon-layout-wrapper.component.ts | 26 ++-- .../src/anon-layout/anon-layout.component.ts | 6 +- .../src/async-actions/bit-action.directive.ts | 6 +- .../src/async-actions/bit-submit.directive.ts | 8 +- .../async-actions/form-button.directive.ts | 2 - .../components/src/avatar/avatar.component.ts | 31 ++-- .../src/breadcrumbs/breadcrumb.component.ts | 7 +- .../breadcrumbs/breadcrumbs.component.html | 12 +- .../src/callout/callout.component.html | 2 +- .../src/callout/callout.component.ts | 8 +- .../src/checkbox/checkbox.component.ts | 13 +- .../src/chip-select/chip-select.component.ts | 45 +++--- .../simple-configurable-dialog.component.ts | 29 ++-- .../disclosure-trigger-for.directive.ts | 4 +- .../src/disclosure/disclosure.component.ts | 12 +- libs/components/src/drawer/drawer.stories.ts | 2 - .../form-control/form-control.abstraction.ts | 10 +- .../form-control/form-control.component.html | 4 +- .../form-control/form-control.component.ts | 14 +- .../src/form-control/label.component.ts | 4 +- .../forbidden-characters.validator.spec.ts | 4 +- .../bit-validators/trim.validator.spec.ts | 4 +- .../src/form-field/error-summary.component.ts | 6 +- .../src/form-field/error.component.ts | 6 +- .../src/form-field/form-field-control.ts | 23 ++- .../src/form-field/form-field.component.html | 10 +- .../src/form-field/form-field.component.ts | 40 ++--- .../src/form-field/form-field.stories.ts | 39 +---- .../password-input-toggle.directive.ts | 12 +- .../form-field/password-input-toggle.spec.ts | 2 +- .../src/icon-button/icon-button.component.ts | 4 +- .../src/input/autofocus.directive.ts | 10 +- libs/components/src/input/input.directive.ts | 15 +- .../src/item/item-content.component.ts | 9 +- .../src/menu/menu-trigger-for.directive.ts | 47 +++--- libs/components/src/menu/menu.component.ts | 16 +- libs/components/src/menu/menu.stories.ts | 2 +- .../multi-select/multi-select.component.ts | 34 ++-- .../src/navigation/nav-base.component.ts | 2 - .../src/navigation/nav-group.component.html | 2 +- .../src/navigation/nav-group.component.ts | 8 +- .../src/navigation/nav-logo.component.ts | 3 - .../src/navigation/side-nav.component.ts | 9 +- .../popover/popover-trigger-for.directive.ts | 18 ++- .../src/popover/popover.component.ts | 6 +- .../radio-button/radio-group.component.html | 4 +- .../src/radio-button/radio-group.component.ts | 24 +-- .../src/radio-button/radio-input.component.ts | 13 +- .../components/src/search/search.component.ts | 14 +- .../components/src/select/option.component.ts | 2 - .../components/src/select/select.component.ts | 39 ++--- .../src/shared/button-like.abstraction.ts | 6 +- .../src/shared/compact-mode.service.ts | 4 +- .../src/shared/focusable-element.ts | 4 +- .../src/table/sortable.component.ts | 6 +- .../components/src/table/table-data-source.ts | 10 +- .../src/table/table-scroll.component.html | 4 +- .../src/table/table-scroll.component.ts | 8 +- .../components/src/table/table.component.html | 2 +- libs/components/src/table/table.component.ts | 8 +- .../tabs/shared/tab-list-item.directive.ts | 4 +- .../src/tabs/tab-group/tab-body.component.ts | 4 +- .../tabs/tab-group/tab-group.component.html | 4 +- .../src/tabs/tab-group/tab-group.component.ts | 146 +++++++++--------- .../src/tabs/tab-group/tab.component.ts | 12 +- .../tabs/tab-nav-bar/tab-link.component.ts | 25 ++- .../tabs/tab-nav-bar/tab-nav-bar.component.ts | 21 +-- .../src/toast/toast-container.component.ts | 7 +- libs/components/src/toast/toast.service.ts | 4 +- .../src/toggle-group/toggle.component.ts | 16 +- .../src/typography/typography.directive.ts | 4 +- .../components/src/utils/i18n-mock.service.ts | 17 +- .../autofill-options/uri-option.component.ts | 2 +- 77 files changed, 450 insertions(+), 612 deletions(-) diff --git a/apps/browser/src/platform/popup/layout/popup-back.directive.ts b/apps/browser/src/platform/popup/layout/popup-back.directive.ts index ce8ebff5ec5..62d66ab87e5 100644 --- a/apps/browser/src/platform/popup/layout/popup-back.directive.ts +++ b/apps/browser/src/platform/popup/layout/popup-back.directive.ts @@ -1,8 +1,6 @@ -import { Directive, Optional } from "@angular/core"; +import { Directive, inject, model } from "@angular/core"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; -import { BitActionDirective, ButtonLikeAbstraction } from "@bitwarden/components"; +import { BitActionDirective, FunctionReturningAwaitable } from "@bitwarden/components"; import { PopupRouterCacheService } from "../view-cache/popup-router-cache.service"; @@ -11,15 +9,10 @@ import { PopupRouterCacheService } from "../view-cache/popup-router-cache.servic selector: "[popupBackAction]", }) export class PopupBackBrowserDirective extends BitActionDirective { - constructor( - buttonComponent: ButtonLikeAbstraction, - private router: PopupRouterCacheService, - @Optional() validationService?: ValidationService, - @Optional() logService?: LogService, - ) { - super(buttonComponent, validationService, logService); - - // override `bitAction` input; the parent handles the rest - this.handler.set(() => this.router.back()); - } + private routerCacheService = inject(PopupRouterCacheService); + // Override the required input to make it optional since we set it automatically + override readonly handler = model( + () => this.routerCacheService.back(), + { alias: "popupBackAction" }, + ); } diff --git a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts index e7f157328bb..aeeed6f65ce 100644 --- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts +++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts @@ -343,7 +343,7 @@ export default { generator: "Generator", send: "Send", settings: "Settings", - labelWithNotification: (label: string) => `${label}: New Notification`, + labelWithNotification: (label: string | undefined) => `${label}: New Notification`, }); }, }, diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts index 0b7304a3657..cc919a929a9 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts @@ -179,7 +179,7 @@ type Story = StoryObj< const Template: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -191,7 +191,7 @@ const Template: Story = {
- +
diff --git a/libs/components/src/a11y/a11y-title.directive.ts b/libs/components/src/a11y/a11y-title.directive.ts index 80486ab9bcf..8bcff2cff4e 100644 --- a/libs/components/src/a11y/a11y-title.directive.ts +++ b/libs/components/src/a11y/a11y-title.directive.ts @@ -1,39 +1,24 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Directive, ElementRef, Input, OnInit, Renderer2 } from "@angular/core"; +import { Directive, effect, ElementRef, input, Renderer2 } from "@angular/core"; @Directive({ selector: "[appA11yTitle]", }) -export class A11yTitleDirective implements OnInit { - // TODO: Skipped for signal migration because: - // Accessor inputs cannot be migrated as they are too complex. - @Input() set appA11yTitle(title: string) { - this.title = title; - this.setAttributes(); - } - - private title: string; - private originalTitle: string | null; - private originalAriaLabel: string | null; +export class A11yTitleDirective { + title = input.required({ alias: "appA11yTitle" }); constructor( private el: ElementRef, private renderer: Renderer2, - ) {} - - ngOnInit() { - this.originalTitle = this.el.nativeElement.getAttribute("title"); - this.originalAriaLabel = this.el.nativeElement.getAttribute("aria-label"); - this.setAttributes(); - } - - private setAttributes() { - if (this.originalTitle === null) { - this.renderer.setAttribute(this.el.nativeElement, "title", this.title); - } - if (this.originalAriaLabel === null) { - this.renderer.setAttribute(this.el.nativeElement, "aria-label", this.title); - } + ) { + const originalTitle = this.el.nativeElement.getAttribute("title"); + const originalAriaLabel = this.el.nativeElement.getAttribute("aria-label"); + effect(() => { + if (originalTitle === null) { + this.renderer.setAttribute(this.el.nativeElement, "title", this.title()); + } + if (originalAriaLabel === null) { + this.renderer.setAttribute(this.el.nativeElement, "aria-label", this.title()); + } + }); } } diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts index 4b570df9814..33b90f7eb8a 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts @@ -1,9 +1,7 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { ChangeDetectorRef, Component, OnInit, inject, DestroyRef } from "@angular/core"; +import { ChangeDetectorRef, Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router"; -import { filter, switchMap, tap } from "rxjs"; +import { Subject, filter, of, switchMap, tap } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -53,13 +51,15 @@ export interface AnonLayoutWrapperData { imports: [AnonLayoutComponent, RouterModule], }) export class AnonLayoutWrapperComponent implements OnInit { - protected pageTitle: string; - protected pageSubtitle: string; - protected pageIcon: Icon; - protected showReadonlyHostname: boolean; - protected maxWidth: AnonLayoutMaxWidth; - protected hideCardWrapper: boolean; - protected hideIcon: boolean = false; + private destroy$ = new Subject(); + + protected pageTitle?: string | null; + protected pageSubtitle?: string | null; + protected pageIcon?: Icon | null; + protected showReadonlyHostname?: boolean | null; + protected maxWidth?: AnonLayoutMaxWidth | null; + protected hideCardWrapper?: boolean | null; + protected hideIcon?: boolean | null; constructor( private router: Router, @@ -85,7 +85,7 @@ export class AnonLayoutWrapperComponent implements OnInit { filter((event) => event instanceof NavigationEnd), // reset page data on page changes tap(() => this.resetPageData()), - switchMap(() => this.route.firstChild?.data || null), + switchMap(() => this.route.firstChild?.data || of(null)), takeUntilDestroyed(this.destroyRef), ) .subscribe((firstChildRouteData: Data | null) => { @@ -93,7 +93,7 @@ export class AnonLayoutWrapperComponent implements OnInit { }); } - private setAnonLayoutWrapperDataFromRouteData(firstChildRouteData: Data | null) { + private setAnonLayoutWrapperDataFromRouteData(firstChildRouteData?: Data | null) { if (!firstChildRouteData) { return; } diff --git a/libs/components/src/anon-layout/anon-layout.component.ts b/libs/components/src/anon-layout/anon-layout.component.ts index 355f3aef6eb..8b002cae7f0 100644 --- a/libs/components/src/anon-layout/anon-layout.component.ts +++ b/libs/components/src/anon-layout/anon-layout.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CommonModule } from "@angular/common"; import { Component, @@ -56,8 +54,8 @@ export class AnonLayoutComponent implements OnInit, OnChanges { protected logo = BitwardenLogo; protected year: string; protected clientType: ClientType; - protected hostname: string; - protected version: string; + protected hostname?: string; + protected version?: string; protected hideYearAndVersion = false; diff --git a/libs/components/src/async-actions/bit-action.directive.ts b/libs/components/src/async-actions/bit-action.directive.ts index 2de8a16dd31..c89ba932583 100644 --- a/libs/components/src/async-actions/bit-action.directive.ts +++ b/libs/components/src/async-actions/bit-action.directive.ts @@ -1,6 +1,4 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Directive, HostListener, model, Optional, inject, DestroyRef } from "@angular/core"; +import { DestroyRef, Directive, HostListener, inject, model, Optional } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { BehaviorSubject, finalize, tap } from "rxjs"; @@ -38,7 +36,7 @@ export class BitActionDirective { disabled = false; - readonly handler = model(undefined, { alias: "bitAction" }); + readonly handler = model.required({ alias: "bitAction" }); private readonly destroyRef = inject(DestroyRef); diff --git a/libs/components/src/async-actions/bit-submit.directive.ts b/libs/components/src/async-actions/bit-submit.directive.ts index e7911196fc3..2d662493cd3 100644 --- a/libs/components/src/async-actions/bit-submit.directive.ts +++ b/libs/components/src/async-actions/bit-submit.directive.ts @@ -1,6 +1,4 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Directive, OnInit, Optional, input, inject, DestroyRef } from "@angular/core"; +import { DestroyRef, Directive, OnInit, Optional, inject, input } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormGroupDirective } from "@angular/forms"; import { BehaviorSubject, catchError, filter, of, switchMap } from "rxjs"; @@ -22,7 +20,7 @@ export class BitSubmitDirective implements OnInit { private _loading$ = new BehaviorSubject(false); private _disabled$ = new BehaviorSubject(false); - readonly handler = input(undefined, { alias: "bitSubmit" }); + readonly handler = input.required({ alias: "bitSubmit" }); readonly allowDisabledFormSubmit = input(false); @@ -63,7 +61,7 @@ export class BitSubmitDirective implements OnInit { ngOnInit(): void { this.formGroupDirective.statusChanges - .pipe(takeUntilDestroyed(this.destroyRef)) + ?.pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((c) => { if (this.allowDisabledFormSubmit()) { this._disabled$.next(false); diff --git a/libs/components/src/async-actions/form-button.directive.ts b/libs/components/src/async-actions/form-button.directive.ts index dc8c095fd18..a1d28f627d5 100644 --- a/libs/components/src/async-actions/form-button.directive.ts +++ b/libs/components/src/async-actions/form-button.directive.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Directive, Optional, input } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; diff --git a/libs/components/src/avatar/avatar.component.ts b/libs/components/src/avatar/avatar.component.ts index fca5a457fac..59a9492f8c8 100644 --- a/libs/components/src/avatar/avatar.component.ts +++ b/libs/components/src/avatar/avatar.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { NgClass } from "@angular/common"; import { Component, OnChanges, input } from "@angular/core"; import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; @@ -41,7 +39,7 @@ export class AvatarComponent implements OnChanges { private svgFontSize = 20; private svgFontWeight = 300; private svgSize = 48; - src: SafeResourceUrl; + src?: SafeResourceUrl; constructor(public sanitizer: DomSanitizer) {} @@ -56,8 +54,14 @@ export class AvatarComponent implements OnChanges { } private generate() { - let chars: string = null; - const upperCaseText = this.text()?.toUpperCase() ?? ""; + const color = this.color(); + const text = this.text(); + const id = this.id(); + if (!text && !color && !id) { + throw new Error("Must supply `text`, `color`, or `id` input."); + } + let chars: string | null = null; + const upperCaseText = text?.toUpperCase() ?? ""; chars = this.getFirstLetters(upperCaseText, this.svgCharCount); @@ -66,18 +70,17 @@ export class AvatarComponent implements OnChanges { } // If the chars contain an emoji, only show it. - if (chars.match(Utils.regexpEmojiPresentation)) { - chars = chars.match(Utils.regexpEmojiPresentation)[0]; + const emojiMatch = chars.match(Utils.regexpEmojiPresentation); + if (emojiMatch) { + chars = emojiMatch[0]; } let svg: HTMLElement; - let hexColor = this.color(); - - const id = this.id(); - if (!Utils.isNullOrWhitespace(this.color())) { + let hexColor = color ?? ""; + if (!Utils.isNullOrWhitespace(hexColor)) { svg = this.createSvgElement(this.svgSize, hexColor); - } else if (!Utils.isNullOrWhitespace(id)) { - hexColor = Utils.stringToColor(id.toString()); + } else if (!Utils.isNullOrWhitespace(id ?? "")) { + hexColor = Utils.stringToColor(id!.toString()); svg = this.createSvgElement(this.svgSize, hexColor); } else { hexColor = Utils.stringToColor(upperCaseText); @@ -95,7 +98,7 @@ export class AvatarComponent implements OnChanges { ); } - private getFirstLetters(data: string, count: number): string { + private getFirstLetters(data: string, count: number): string | null { const parts = data.split(" "); if (parts.length > 1) { let text = ""; diff --git a/libs/components/src/breadcrumbs/breadcrumb.component.ts b/libs/components/src/breadcrumbs/breadcrumb.component.ts index 54678f3e4ee..783cb2655f7 100644 --- a/libs/components/src/breadcrumbs/breadcrumb.component.ts +++ b/libs/components/src/breadcrumbs/breadcrumb.component.ts @@ -1,7 +1,4 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore - -import { Component, EventEmitter, Output, TemplateRef, ViewChild, input } from "@angular/core"; +import { Component, EventEmitter, Output, TemplateRef, input, viewChild } from "@angular/core"; import { QueryParamsHandling } from "@angular/router"; @Component({ @@ -20,7 +17,7 @@ export class BreadcrumbComponent { @Output() click = new EventEmitter(); - @ViewChild(TemplateRef, { static: true }) content: TemplateRef; + readonly content = viewChild(TemplateRef); onClick(args: unknown) { this.click.next(args); diff --git a/libs/components/src/breadcrumbs/breadcrumbs.component.html b/libs/components/src/breadcrumbs/breadcrumbs.component.html index 820b100afd3..d062e82548e 100644 --- a/libs/components/src/breadcrumbs/breadcrumbs.component.html +++ b/libs/components/src/breadcrumbs/breadcrumbs.component.html @@ -8,7 +8,7 @@ [queryParams]="breadcrumb.queryParams()" [queryParamsHandling]="breadcrumb.queryParamsHandling()" > - + } @else { } @if (!last) { @@ -46,11 +46,11 @@ [queryParams]="breadcrumb.queryParams()" [queryParamsHandling]="breadcrumb.queryParamsHandling()" > - + } @else { } } @@ -66,7 +66,7 @@ [queryParams]="breadcrumb.queryParams()" [queryParamsHandling]="breadcrumb.queryParamsHandling()" > - + } @else { } @if (!last) { diff --git a/libs/components/src/callout/callout.component.html b/libs/components/src/callout/callout.component.html index b990e57a767..b98679766d5 100644 --- a/libs/components/src/callout/callout.component.html +++ b/libs/components/src/callout/callout.component.html @@ -1,6 +1,6 @@
diff --git a/apps/web/src/app/billing/members/free-bitwarden-families.component.html b/apps/web/src/app/billing/members/free-bitwarden-families.component.html index ddf7c506745..697a5963a71 100644 --- a/apps/web/src/app/billing/members/free-bitwarden-families.component.html +++ b/apps/web/src/app/billing/members/free-bitwarden-families.component.html @@ -53,7 +53,7 @@ bitIconButton="bwi-ellipsis-v" buttonType="main" [bitMenuTriggerFor]="appListDropdown" - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" > @if (!isSelfHosted && !sponsoredFamily.validUntil) { diff --git a/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html b/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html index a09000ef55f..465a50ec8c3 100644 --- a/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html +++ b/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html @@ -33,7 +33,7 @@ showToast [valueLabel]="'billingSyncKey' | i18n" [appCopyClick]="clientSecret" - [appA11yTitle]="'copyValue' | i18n" + [label]="'copyValue' | i18n" >
diff --git a/apps/web/src/app/billing/organizations/billing-sync-key.component.html b/apps/web/src/app/billing/organizations/billing-sync-key.component.html index 9736351deca..94a81140344 100644 --- a/apps/web/src/app/billing/organizations/billing-sync-key.component.html +++ b/apps/web/src/app/billing/organizations/billing-sync-key.component.html @@ -33,7 +33,7 @@ bitIconButton="bwi-trash" bitFormButton [bitAction]="deleteConnection" - appA11yTitle="{{ 'delete' | i18n }}" + label="{{ 'delete' | i18n }}" > diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html index ace3d749a3f..f899b8eccb4 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -359,7 +359,7 @@ type="button" [bitIconButton]="totalOpened ? 'bwi-angle-down' : 'bwi-angle-up'" size="small" - aria-hidden="true" + [label]="totalOpened ? ('hidePricingSummary' | i18n) : ('showPricingSummary' | i18n)" >

diff --git a/apps/web/src/app/billing/organizations/change-plan.component.html b/apps/web/src/app/billing/organizations/change-plan.component.html index 75a12122d19..1cd15a7c836 100644 --- a/apps/web/src/app/billing/organizations/change-plan.component.html +++ b/apps/web/src/app/billing/organizations/change-plan.component.html @@ -8,7 +8,7 @@ type="button" size="small" class="tw-float-right" - appA11yTitle="{{ 'cancel' | i18n }}" + label="{{ 'cancel' | i18n }}" (click)="cancel()" >

{{ "changeBillingPlan" | i18n }}

diff --git a/apps/web/src/app/billing/settings/sponsoring-org-row.component.html b/apps/web/src/app/billing/settings/sponsoring-org-row.component.html index 1e5690cd85a..5167c0a5c32 100644 --- a/apps/web/src/app/billing/settings/sponsoring-org-row.component.html +++ b/apps/web/src/app/billing/settings/sponsoring-org-row.component.html @@ -12,7 +12,7 @@ bitIconButton="bwi-ellipsis-v" buttonType="main" [bitMenuTriggerFor]="appListDropdown" - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" >

diff --git a/apps/web/src/app/dirt/reports/reports-layout.component.html b/apps/web/src/app/dirt/reports/reports-layout.component.html index 283d9213cc7..a27556a7aa9 100644 --- a/apps/web/src/app/dirt/reports/reports-layout.component.html +++ b/apps/web/src/app/dirt/reports/reports-layout.component.html @@ -2,7 +2,7 @@
diff --git a/apps/web/src/app/layouts/header/web-header.stories.ts b/apps/web/src/app/layouts/header/web-header.stories.ts index 9715dbf8cd3..7abddf01f2b 100644 --- a/apps/web/src/app/layouts/header/web-header.stories.ts +++ b/apps/web/src/app/layouts/header/web-header.stories.ts @@ -48,7 +48,7 @@ class MockStateService { @Component({ selector: "product-switcher", - template: ``, + template: ``, standalone: false, }) class MockProductSwitcher {} diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.component.html b/apps/web/src/app/layouts/product-switcher/product-switcher.component.html index f1942a02c20..a44f05a7ed7 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.component.html +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.component.html @@ -3,7 +3,7 @@ bitIconButton="bwi bwi-fw bwi-filter" [bitMenuTriggerFor]="content?.menu" [buttonType]="buttonType" - [attr.aria-label]="'switchProducts' | i18n" + [label]="'switchProducts' | i18n" *ngIf="products$ | async" > diff --git a/apps/web/src/app/settings/domain-rules.component.html b/apps/web/src/app/settings/domain-rules.component.html index 8ebeecb429f..880e2a6da0f 100644 --- a/apps/web/src/app/settings/domain-rules.component.html +++ b/apps/web/src/app/settings/domain-rules.component.html @@ -32,7 +32,7 @@ type="button" buttonType="danger" (click)="remove(i)" - appA11yTitle="{{ 'remove' | i18n }}" + label="{{ 'remove' | i18n }}" >
diff --git a/apps/web/src/app/tools/send/send.component.html b/apps/web/src/app/tools/send/send.component.html index 042046b85ff..b79f50311ed 100644 --- a/apps/web/src/app/tools/send/send.component.html +++ b/apps/web/src/app/tools/send/send.component.html @@ -156,7 +156,7 @@ type="button" [bitMenuTriggerFor]="sendOptions" bitIconButton="bwi-ellipsis-v" - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" > @@ -101,7 +101,7 @@ bitIconButton="bwi-ellipsis-v" type="button" appStopProp - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" > diff --git a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html index ad2886b1e59..7cd5129d3f0 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html @@ -65,7 +65,7 @@ size="small" bitIconButton="bwi-ellipsis-v" type="button" - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" appStopProp > } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html index ef928903a72..5ddccf6a395 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html @@ -56,7 +56,7 @@ bitIconButton="bwi-ellipsis-v" size="small" type="button" - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" > @@ -67,8 +67,7 @@ bitIconButton="bwi-trash" buttonType="danger" size="default" - title="{{ 'delete' | i18n }}" - aria-label="Delete" + label="{{ 'delete' | i18n }}" [bitAction]="deleteDomain" type="submit" bitFormButton diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.html b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.html index 7ade2e6c63d..38cb077c623 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.html @@ -29,7 +29,7 @@ bitSuffix bitIconButton="bwi-clone" [bitAction]="copyScimUrl" - [appA11yTitle]="'copyScimUrl' | i18n" + [label]="'copyScimUrl' | i18n" > @@ -46,7 +46,7 @@ bitSuffix [bitIconButton]="showScimKey ? 'bwi-eye-slash' : 'bwi-eye'" [bitAction]="toggleScimKey" - [appA11yTitle]="'toggleVisibility' | i18n" + [label]="'toggleVisibility' | i18n" > {{ "scimApiKeyHelperText" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.html index 60993f5570c..08e694aa45a 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.html @@ -58,7 +58,7 @@ bitIconButton="bwi-trash" buttonType="danger" bitFormButton - [appA11yTitle]="'delete' | i18n" + [label]="'delete' | i18n" [bitAction]="delete" [disabled]="loading" > diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html index f203b7a934a..07ccd997b96 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html @@ -79,7 +79,7 @@ type="button" bitIconButton="bwi-ellipsis-v" size="small" - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" > @@ -193,7 +193,7 @@ bitSuffix type="button" [appCopyClick]="signedOutCallbackPath" - [appA11yTitle]="'copyValue' | i18n" + [label]="'copyValue' | i18n" > @@ -336,7 +336,7 @@ bitSuffix type="button" [appCopyClick]="spEntityId" - [appA11yTitle]="'copyValue' | i18n" + [label]="'copyValue' | i18n" > @@ -348,7 +348,7 @@ bitSuffix type="button" [appCopyClick]="spEntityIdStatic" - [appA11yTitle]="'copyValue' | i18n" + [label]="'copyValue' | i18n" > @@ -360,14 +360,14 @@ bitSuffix type="button" [appLaunchClick]="spMetadataUrl" - [appA11yTitle]="'launch' | i18n" + [label]="'launch' | i18n" > @@ -379,7 +379,7 @@ bitSuffix type="button" [appCopyClick]="spAcsUrl" - [appA11yTitle]="'copyValue' | i18n" + [label]="'copyValue' | i18n" > diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.html index 043ce65b961..2ab82bd837b 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.html @@ -86,7 +86,7 @@ type="button" bitIconButton="bwi-ellipsis-v" size="small" - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" > diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/section.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/overview/section.component.html index ab7c67c7b22..e7f9692beb3 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/section.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/section.component.html @@ -7,6 +7,7 @@ (click)="toggle()" [attr.aria-expanded]="open" [attr.aria-controls]="contentId" + [label]="'toggleVisibility' | i18n" >
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.html index 24168d0b025..3a2c858ac31 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.html @@ -95,7 +95,7 @@ buttonType="danger" bitIconButton="bwi-trash" bitFormButton - [appA11yTitle]="'delete' | i18n" + [label]="'delete' | i18n" [bitAction]="delete" > diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html index fbb0dd8888a..3399b550ba5 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html @@ -40,8 +40,7 @@ bitIconButton="bwi-ellipsis-v" buttonType="main" [bitMenuTriggerFor]="tableMenu" - [title]="'options' | i18n" - [attr.aria-label]="'options' | i18n" + [label]="'options' | i18n" > @@ -65,8 +64,7 @@ type="button" bitIconButton="bwi-ellipsis-v" buttonType="main" - [title]="'options' | i18n" - [attr.aria-label]="'options' | i18n" + [label]="'options' | i18n" [bitMenuTriggerFor]="tokenMenu" > diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/config/config.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/config/config.component.html index b17e47a39ec..11f8e0b9b77 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/config/config.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/config/config.component.html @@ -11,12 +11,19 @@ type="button" bitIconButton="bwi-clone" [bitAction]="copyIdentityUrl" + [label]="'copyCustomField' | i18n: identityUrl" > {{ "apiUrl" | i18n }} - +
@@ -27,6 +34,7 @@ type="button" bitIconButton="bwi-clone" [bitAction]="copyOrganizationId" + [label]="'copyCustomField' | i18n: organizationId" > diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html index bfb7b985423..3d7fc9715c3 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html @@ -39,8 +39,7 @@ type="button" bitIconButton="bwi-ellipsis-v" buttonType="main" - [title]="'options' | i18n" - [attr.aria-label]="'options' | i18n" + [label]="'options' | i18n" [bitMenuTriggerFor]="tableMenu" > @@ -72,8 +71,7 @@ type="button" bitIconButton="bwi-ellipsis-v" buttonType="main" - [title]="'options' | i18n" - [attr.aria-label]="'options' | i18n" + [label]="'options' | i18n" [bitMenuTriggerFor]="serviceAccountMenu" > diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html index c8a50175781..d01faae4e6e 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html @@ -67,8 +67,7 @@ buttonType="main" size="default" [disabled]="disabled" - [attr.title]="'remove' | i18n" - [attr.aria-label]="'remove' | i18n" + [label]="'remove' | i18n" (click)="selectionList.deselectItem(item.id); handleBlur()" > diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.html index bc1655d8b28..236af0d414c 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.html @@ -43,8 +43,7 @@ bitIconButton="bwi-ellipsis-v" buttonType="main" [bitMenuTriggerFor]="tableMenu" - [title]="'options' | i18n" - [attr.aria-label]="'options' | i18n" + [label]="'options' | i18n" *ngIf="showMenus" > @@ -77,8 +76,7 @@ bitIconButton="bwi-clone" buttonType="main" size="small" - [title]="'copyUuid' | i18n" - [attr.aria-label]="'copyUuid' | i18n" + [label]="'copyUuid' | i18n" (click)="copyProjectUuidToClipboard(project.id)" > @@ -94,8 +92,7 @@ bitIconButton="bwi-ellipsis-v" buttonType="main" [bitMenuTriggerFor]="projectMenu" - [title]="'options' | i18n" - [attr.aria-label]="'options' | i18n" + [label]="'options' | i18n" *ngIf="showMenus" > diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html index 859c7417eb8..e5d22a01502 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html @@ -45,8 +45,7 @@ type="button" bitIconButton="bwi-ellipsis-v" buttonType="main" - [title]="'options' | i18n" - [attr.aria-label]="'options' | i18n" + [label]="'options' | i18n" [bitMenuTriggerFor]="tableMenu" > @@ -78,8 +77,7 @@ bitIconButton="bwi-clone" buttonType="main" size="small" - [title]="'copyUuid' | i18n" - [attr.aria-label]="'copyUuid' | i18n" + [label]="'copyUuid' | i18n" (click)="copySecretUuidEvent.emit(secret.id)" > @@ -108,8 +106,7 @@ type="button" bitIconButton="bwi-ellipsis-v" buttonType="main" - [title]="'options' | i18n" - [attr.aria-label]="'options' | i18n" + [label]="'options' | i18n" [bitMenuTriggerFor]="secretMenu" > diff --git a/eslint.config.mjs b/eslint.config.mjs index c4018b7625e..c745f073fc8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,7 @@ import angularRxjs from "eslint-plugin-rxjs-angular"; import storybook from "eslint-plugin-storybook"; import platformPlugins from "./libs/eslint/platform/index.mjs"; +import componentPlugins from "./libs/eslint/components/index.mjs"; export default tseslint.config( ...storybook.configs["flat/recommended"], @@ -174,6 +175,7 @@ export default tseslint.config( plugins: { "@angular-eslint/template": angular.templatePlugin, tailwindcss: eslintPluginTailwindCSS, + "@bitwarden/components": componentPlugins, }, rules: { "@angular-eslint/template/button-has-type": "error", @@ -188,6 +190,10 @@ export default tseslint.config( "tailwindcss/enforces-negative-arbitrary-values": "error", "tailwindcss/enforces-shorthand": "error", "tailwindcss/no-contradicting-classname": "error", + "@bitwarden/components/require-label-on-biticonbutton": [ + "error", + { ignoreIfHas: ["bitPasswordInputToggle"] }, + ], }, }, diff --git a/libs/angular/src/vault/components/spotlight/spotlight.component.html b/libs/angular/src/vault/components/spotlight/spotlight.component.html index e445640cff9..0d0e95e191b 100644 --- a/libs/angular/src/vault/components/spotlight/spotlight.component.html +++ b/libs/angular/src/vault/components/spotlight/spotlight.component.html @@ -18,9 +18,8 @@ size="small" *ngIf="!persistent" (click)="handleDismiss()" - [attr.title]="'close' | i18n" - [attr.aria-label]="'close' | i18n" class="-tw-me-2" + [label]="'close' | i18n" > diff --git a/libs/auth/src/angular/input-password/input-password.component.html b/libs/auth/src/angular/input-password/input-password.component.html index d39215b2d68..d56fe6a27fc 100644 --- a/libs/auth/src/angular/input-password/input-password.component.html +++ b/libs/auth/src/angular/input-password/input-password.component.html @@ -45,7 +45,7 @@ type="button" bitIconButton="bwi-generate" bitSuffix - [appA11yTitle]="'generatePassword' | i18n" + [label]="'generatePassword' | i18n" (click)="generatePassword()" > - + ``` ## `[bitSubmit]` Disabled Form Submit diff --git a/libs/components/src/async-actions/in-forms.stories.ts b/libs/components/src/async-actions/in-forms.stories.ts index dd901cd2477..88383fe85a3 100644 --- a/libs/components/src/async-actions/in-forms.stories.ts +++ b/libs/components/src/async-actions/in-forms.stories.ts @@ -7,6 +7,7 @@ import { delay, of } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { A11yTitleDirective } from "../a11y"; import { ButtonModule } from "../button"; import { FormFieldModule } from "../form-field"; import { IconButtonModule } from "../icon-button"; @@ -28,20 +29,21 @@ const template = ` Email - + - + `; @Component({ selector: "app-promise-example", template, imports: [ + A11yTitleDirective, AsyncActionsModule, ButtonModule, FormFieldModule, @@ -86,6 +88,7 @@ class PromiseExampleComponent { selector: "app-observable-example", template, imports: [ + A11yTitleDirective, AsyncActionsModule, ButtonModule, FormFieldModule, diff --git a/libs/components/src/async-actions/standalone.mdx b/libs/components/src/async-actions/standalone.mdx index f484ea01c58..a781f40d852 100644 --- a/libs/components/src/async-actions/standalone.mdx +++ b/libs/components/src/async-actions/standalone.mdx @@ -63,7 +63,7 @@ from how click handlers are usually defined with the output syntax `(click)="han ```html -`; +`; ``` ## Stories diff --git a/libs/components/src/async-actions/standalone.stories.ts b/libs/components/src/async-actions/standalone.stories.ts index 1ed6f6c5a59..99cde70566b 100644 --- a/libs/components/src/async-actions/standalone.stories.ts +++ b/libs/components/src/async-actions/standalone.stories.ts @@ -16,7 +16,7 @@ const template = /*html*/ ` - `; + `; @Component({ template, diff --git a/libs/components/src/banner/banner.component.html b/libs/components/src/banner/banner.component.html index 581a56d86cb..bfde8135da9 100644 --- a/libs/components/src/banner/banner.component.html +++ b/libs/components/src/banner/banner.component.html @@ -19,8 +19,7 @@ buttonType="main" size="small" (click)="onClose.emit()" - [attr.title]="'close' | i18n" - [attr.aria-label]="'close' | i18n" + [label]="'close' | i18n" > } diff --git a/libs/components/src/breadcrumbs/breadcrumbs.component.html b/libs/components/src/breadcrumbs/breadcrumbs.component.html index d062e82548e..b63b21de76b 100644 --- a/libs/components/src/breadcrumbs/breadcrumbs.component.html +++ b/libs/components/src/breadcrumbs/breadcrumbs.component.html @@ -35,6 +35,7 @@ bitIconButton="bwi-ellipsis-h" [bitMenuTriggerFor]="overflowMenu" size="small" + [label]="'moreBreadcrumbs' | i18n" > @for (breadcrumb of overflow; track breadcrumb) { diff --git a/libs/components/src/breadcrumbs/breadcrumbs.component.ts b/libs/components/src/breadcrumbs/breadcrumbs.component.ts index a1a6e732459..3c24f91be99 100644 --- a/libs/components/src/breadcrumbs/breadcrumbs.component.ts +++ b/libs/components/src/breadcrumbs/breadcrumbs.component.ts @@ -2,6 +2,8 @@ import { CommonModule } from "@angular/common"; import { Component, ContentChildren, QueryList, input } from "@angular/core"; import { RouterModule } from "@angular/router"; +import { I18nPipe } from "@bitwarden/ui-common"; + import { IconButtonModule } from "../icon-button"; import { LinkModule } from "../link"; import { MenuModule } from "../menu"; @@ -16,7 +18,7 @@ import { BreadcrumbComponent } from "./breadcrumb.component"; @Component({ selector: "bit-breadcrumbs", templateUrl: "./breadcrumbs.component.html", - imports: [CommonModule, LinkModule, RouterModule, IconButtonModule, MenuModule], + imports: [I18nPipe, CommonModule, LinkModule, RouterModule, IconButtonModule, MenuModule], }) export class BreadcrumbsComponent { readonly show = input(3); diff --git a/libs/components/src/breadcrumbs/breadcrumbs.stories.ts b/libs/components/src/breadcrumbs/breadcrumbs.stories.ts index 98af3c0ae7b..893f645a913 100644 --- a/libs/components/src/breadcrumbs/breadcrumbs.stories.ts +++ b/libs/components/src/breadcrumbs/breadcrumbs.stories.ts @@ -2,9 +2,12 @@ import { Component, importProvidersFrom } from "@angular/core"; import { RouterModule } from "@angular/router"; import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + import { IconButtonModule } from "../icon-button"; import { LinkModule } from "../link"; import { MenuModule } from "../menu"; +import { I18nMockService } from "../utils"; import { BreadcrumbComponent } from "./breadcrumb.component"; import { BreadcrumbsComponent } from "./breadcrumbs.component"; @@ -26,6 +29,16 @@ export default { decorators: [ moduleMetadata({ imports: [LinkModule, MenuModule, IconButtonModule, RouterModule, BreadcrumbComponent], + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + moreBreadcrumbs: "More breadcrumbs", + }); + }, + }, + ], }), applicationConfig({ providers: [ diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index 94fc9ef06a0..9f140ca7624 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -37,8 +37,7 @@ buttonType="main" size="default" bitDialogClose - [attr.title]="'close' | i18n" - [attr.aria-label]="'close' | i18n" + [label]="'close' | i18n" > } diff --git a/libs/components/src/dialog/dialog/dialog.stories.ts b/libs/components/src/dialog/dialog/dialog.stories.ts index 71e07542ed8..f93ef1a2f25 100644 --- a/libs/components/src/dialog/dialog/dialog.stories.ts +++ b/libs/components/src/dialog/dialog/dialog.stories.ts @@ -101,8 +101,7 @@ export const Default: Story = { bitIconButton="bwi-trash" buttonType="danger" size="default" - title="Delete" - aria-label="Delete"> + label="Delete">
`, @@ -219,7 +218,7 @@ export const WithCards: Story = {

Foo

- + @@ -239,7 +238,7 @@ export const WithCards: Story = {

Bar

- + @@ -265,8 +264,7 @@ export const WithCards: Story = { bitIconButton="bwi-trash" buttonType="danger" size="default" - title="Delete" - aria-label="Delete"> + label="Delete"> diff --git a/libs/components/src/disclosure/disclosure.component.ts b/libs/components/src/disclosure/disclosure.component.ts index 703c815087b..2d73d7d8ad6 100644 --- a/libs/components/src/disclosure/disclosure.component.ts +++ b/libs/components/src/disclosure/disclosure.component.ts @@ -28,6 +28,7 @@ let nextId = 0; * bitIconButton="bwi-sliders" * [buttonType]="'muted'" * [bitDisclosureTriggerFor]="disclosureRef" + * [label]="'Settings' | i18n" * > * click button to hide this content * ``` diff --git a/libs/components/src/disclosure/disclosure.stories.ts b/libs/components/src/disclosure/disclosure.stories.ts index bb3680c1f3b..2e45964ccaa 100644 --- a/libs/components/src/disclosure/disclosure.stories.ts +++ b/libs/components/src/disclosure/disclosure.stories.ts @@ -27,7 +27,7 @@ export const DisclosureWithIconButton: Story = { render: (args) => ({ props: args, template: /*html*/ ` - click button to hide this content `, diff --git a/libs/components/src/drawer/drawer-header.component.html b/libs/components/src/drawer/drawer-header.component.html index 863b19edfb2..2723744eda3 100644 --- a/libs/components/src/drawer/drawer-header.component.html +++ b/libs/components/src/drawer/drawer-header.component.html @@ -5,11 +5,5 @@ {{ title() }} - + diff --git a/libs/components/src/form-field/form-field.stories.ts b/libs/components/src/form-field/form-field.stories.ts index e070765ec8a..7aeb2f63040 100644 --- a/libs/components/src/form-field/form-field.stories.ts +++ b/libs/components/src/form-field/form-field.stories.ts @@ -239,8 +239,8 @@ export const Readonly: Story = { Input - - + + @@ -261,7 +261,7 @@ export const Readonly: Story = { Input - + @@ -309,11 +309,11 @@ export const ButtonInputGroup: Story = {
- + - - - + + + `, }), @@ -326,11 +326,11 @@ export const DisabledButtonInputGroup: Story = { template: /*html*/ ` Label - + - - - + + + `, @@ -345,9 +345,9 @@ export const PartiallyDisabledButtonInputGroup: Story = { Label - - - + + + `, }), diff --git a/libs/components/src/form-field/password-input-toggle.spec.ts b/libs/components/src/form-field/password-input-toggle.spec.ts index 95110f2bd93..72f2481d789 100644 --- a/libs/components/src/form-field/password-input-toggle.spec.ts +++ b/libs/components/src/form-field/password-input-toggle.spec.ts @@ -20,7 +20,13 @@ import { BitPasswordInputToggleDirective } from "./password-input-toggle.directi Password - + `, diff --git a/libs/components/src/form-field/password-input-toggle.stories.ts b/libs/components/src/form-field/password-input-toggle.stories.ts index d46ec92ab37..3d50a4eb75a 100644 --- a/libs/components/src/form-field/password-input-toggle.stories.ts +++ b/libs/components/src/form-field/password-input-toggle.stories.ts @@ -48,7 +48,7 @@ export const Default: Story = { Password - + `, @@ -63,7 +63,7 @@ export const Binding: Story = { Password - +
diff --git a/libs/components/src/item/item.stories.ts b/libs/components/src/item/item.stories.ts index 15b81f3f67e..bc2ced7fd11 100644 --- a/libs/components/src/item/item.stories.ts +++ b/libs/components/src/item/item.stories.ts @@ -77,10 +77,10 @@ export const Default: Story = { - + - + @@ -150,10 +150,10 @@ export const TextOverflowTruncate: Story = { - + - + @@ -173,10 +173,10 @@ export const TextOverflowWrap: Story = { - + - + @@ -198,10 +198,10 @@ const multipleActionListTemplate = /*html*/ ` - + - + @@ -217,10 +217,10 @@ const multipleActionListTemplate = /*html*/ ` - + - + @@ -236,10 +236,10 @@ const multipleActionListTemplate = /*html*/ ` - + - + @@ -255,10 +255,10 @@ const multipleActionListTemplate = /*html*/ ` - + - + @@ -274,10 +274,10 @@ const multipleActionListTemplate = /*html*/ ` - + - + @@ -293,10 +293,10 @@ const multipleActionListTemplate = /*html*/ ` - + - + @@ -410,10 +410,10 @@ export const VirtualScrolling: Story = { - + - + @@ -440,10 +440,10 @@ export const WithoutBorderRadius: Story = { - + - + diff --git a/libs/components/src/navigation/nav-group.component.html b/libs/components/src/navigation/nav-group.component.html index e294f3cebe2..195569292f6 100644 --- a/libs/components/src/navigation/nav-group.component.html +++ b/libs/components/src/navigation/nav-group.component.html @@ -18,11 +18,10 @@ [buttonType]="'nav-contrast'" (click)="toggle($event)" size="small" - [title]="'toggleCollapse' | i18n" aria-haspopup="true" [attr.aria-expanded]="open().toString()" [attr.aria-controls]="contentId" - [attr.aria-label]="['toggleCollapse' | i18n, text()].join(' ')" + [label]="['toggleCollapse' | i18n, text()].join(' ')" > diff --git a/libs/components/src/navigation/nav-item.stories.ts b/libs/components/src/navigation/nav-item.stories.ts index c3f7e526ecb..dbd17695c8b 100644 --- a/libs/components/src/navigation/nav-item.stories.ts +++ b/libs/components/src/navigation/nav-item.stories.ts @@ -95,7 +95,7 @@ export const WithChildButtons: Story = { [bitIconButton]="'bwi-pencil-square'" [buttonType]="'nav-contrast'" size="small" - aria-label="option 2" + label="Edit" > `, diff --git a/libs/components/src/navigation/side-nav.component.html b/libs/components/src/navigation/side-nav.component.html index a5866b5e42e..1fdd40b1588 100644 --- a/libs/components/src/navigation/side-nav.component.html +++ b/libs/components/src/navigation/side-nav.component.html @@ -37,7 +37,7 @@ buttonType="nav-contrast" size="small" (click)="sideNavService.toggle()" - [attr.aria-label]="'toggleSideNavigation' | i18n" + [label]="'toggleSideNavigation' | i18n" [attr.aria-expanded]="data.open" aria-controls="bit-side-nav" > diff --git a/libs/components/src/popover/popover.component.html b/libs/components/src/popover/popover.component.html index 328da284732..756ac27b749 100644 --- a/libs/components/src/popover/popover.component.html +++ b/libs/components/src/popover/popover.component.html @@ -18,8 +18,7 @@ diff --git a/libs/components/src/section/section.mdx b/libs/components/src/section/section.mdx index 92798420479..81d98101c06 100644 --- a/libs/components/src/section/section.mdx +++ b/libs/components/src/section/section.mdx @@ -51,7 +51,7 @@ padding to align the header with the border radius of the card/item.

I'm a section header

- +

I'm card content

diff --git a/libs/components/src/section/section.stories.ts b/libs/components/src/section/section.stories.ts index f28cca0af7b..31cdd0a324c 100644 --- a/libs/components/src/section/section.stories.ts +++ b/libs/components/src/section/section.stories.ts @@ -69,7 +69,7 @@ export const HeaderVariants: Story = {

Title with icon button suffix

- + `, }), @@ -88,7 +88,7 @@ export const HeaderEndSlotVariants: Story = {

Title with end slot icon button

- + `, }), @@ -103,7 +103,7 @@ export const HeaderWithPadding: Story = {

Card as immediate sibling

- +

bit-section-header has padding

@@ -114,7 +114,7 @@ export const HeaderWithPadding: Story = {

Card nested in immediate sibling

- +
@@ -127,7 +127,7 @@ export const HeaderWithPadding: Story = {

Item as immediate sibling

- + bit-section-header has padding @@ -138,7 +138,7 @@ export const HeaderWithPadding: Story = {

Item nested in immediate sibling

- + @@ -160,7 +160,7 @@ export const HeaderWithoutPadding: Story = {

No card or item used

- +

just a div, so bit-section-header has no padding

@@ -171,7 +171,7 @@ export const HeaderWithoutPadding: Story = {

Card nested in non-immediate sibling

- +
a div here diff --git a/libs/components/src/stories/kitchen-sink/components/dialog-virtual-scroll-block.component.ts b/libs/components/src/stories/kitchen-sink/components/dialog-virtual-scroll-block.component.ts index 9eb68d3f23f..7f6a6c42f32 100644 --- a/libs/components/src/stories/kitchen-sink/components/dialog-virtual-scroll-block.component.ts +++ b/libs/components/src/stories/kitchen-sink/components/dialog-virtual-scroll-block.component.ts @@ -36,7 +36,7 @@ import { TableDataSource, TableModule } from "../../../table"; diff --git a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts index 316dbf22d66..f92a14330d6 100644 --- a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts +++ b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts @@ -80,7 +80,13 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module"; - +
diff --git a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-table.component.ts b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-table.component.ts index 8765eae9960..302d9f6c0a8 100644 --- a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-table.component.ts +++ b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-table.component.ts @@ -23,6 +23,7 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module"; type="button" bitIconButton="bwi-ellipsis-v" [bitMenuTriggerFor]="menu1" + label="Options" > Anchor link @@ -40,6 +41,7 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module"; type="button" bitIconButton="bwi-ellipsis-v" [bitMenuTriggerFor]="menu2" + label="Options" > Anchor link diff --git a/libs/components/src/toast/toast.component.html b/libs/components/src/toast/toast.component.html index a110336856e..36d58dcdda7 100644 --- a/libs/components/src/toast/toast.component.html +++ b/libs/components/src/toast/toast.component.html @@ -25,6 +25,7 @@ type="button" size="small" (click)="this.onClose.emit()" + [label]="'close' | i18n" >
diff --git a/libs/eslint/components/index.mjs b/libs/eslint/components/index.mjs new file mode 100644 index 00000000000..0a9b1d1481f --- /dev/null +++ b/libs/eslint/components/index.mjs @@ -0,0 +1,3 @@ +import requireLabelOnBiticonbutton from "./require-label-on-biticonbutton.mjs"; + +export default { rules: { "require-label-on-biticonbutton": requireLabelOnBiticonbutton } }; diff --git a/libs/eslint/components/require-label-on-biticonbutton.mjs b/libs/eslint/components/require-label-on-biticonbutton.mjs new file mode 100644 index 00000000000..be177c65017 --- /dev/null +++ b/libs/eslint/components/require-label-on-biticonbutton.mjs @@ -0,0 +1,51 @@ +export const errorMessage = + "Elements with 'bitIconButton' must also have a 'label' attribute for accessibility."; + +export default { + meta: { + type: "problem", + docs: { + description: + "Require a label attribute on elements with bitIconButton, except when ignored attributes are present", + category: "Best Practices", + recommended: false, + }, + schema: [ + { + type: "object", + properties: { + ignoreIfHas: { + type: "array", + items: { type: "string" }, + description: "Attributes that, if present, will skip the label requirement.", + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const [{ ignoreIfHas = [] } = {}] = context.options; + + return { + Element(node) { + const allAttrNames = [ + ...(node.attributes?.map((attr) => attr.name) ?? []), + ...(node.inputs?.map((input) => input.name) ?? []), + ...(node.templateAttrs?.map((attr) => attr.name) ?? []), + ]; + + const hasBitIconButton = allAttrNames.includes("bitIconButton"); + const hasLabel = allAttrNames.includes("label"); + const shouldIgnore = ignoreIfHas.some((attr) => allAttrNames.includes(attr)); + + if (hasBitIconButton && !shouldIgnore && !hasLabel) { + context.report({ + node, + message: errorMessage, + }); + } + }, + }; + }, +}; diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html index 398085c135c..b33b01d3b13 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html @@ -89,6 +89,7 @@ appStopClick bitSuffix (click)="generatePassword()" + [label]="'generatePassword' | i18n" > {{ "exportPasswordDescription" | i18n }} diff --git a/libs/tools/generator/components/src/credential-generator-history.component.html b/libs/tools/generator/components/src/credential-generator-history.component.html index 05199763a15..1f2f3d99e00 100644 --- a/libs/tools/generator/components/src/credential-generator-history.component.html +++ b/libs/tools/generator/components/src/credential-generator-history.component.html @@ -12,7 +12,7 @@ bitIconButton="bwi-clone" [appCopyClick]="credential.credential" [valueLabel]="getGeneratedValueText(credential)" - [appA11yTitle]="getCopyText(credential)" + [label]="getCopyText(credential)" showToast > {{ getCopyText(credential) }} diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 3f9813f4384..124de1e3c45 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -23,7 +23,7 @@ bitIconButton="bwi-generate" buttonType="main" (click)="generate(USER_REQUEST)" - [appA11yTitle]="credentialTypeGenerateLabel$ | async" + [label]="credentialTypeGenerateLabel$ | async" [disabled]="!(algorithm$ | async)" > {{ credentialTypeGenerateLabel$ | async }} @@ -33,7 +33,7 @@ bitIconButton="bwi-clone" buttonType="main" showToast - [appA11yTitle]="credentialTypeCopyLabel$ | async" + [label]="credentialTypeCopyLabel$ | async" [appCopyClick]="value$ | async" [valueLabel]="credentialTypeLabel$ | async" [disabled]="!(algorithm$ | async)" diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index ce0768fe128..9995613685b 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -20,7 +20,7 @@ bitIconButton="bwi-generate" buttonType="main" (click)="generate(USER_REQUEST)" - [appA11yTitle]="credentialTypeGenerateLabel$ | async" + [label]="credentialTypeGenerateLabel$ | async" [disabled]="!(algorithm$ | async)" > {{ credentialTypeGenerateLabel$ | async }} @@ -30,7 +30,7 @@ bitIconButton="bwi-clone" buttonType="main" showToast - [appA11yTitle]="credentialTypeCopyLabel$ | async" + [label]="credentialTypeCopyLabel$ | async" [appCopyClick]="value$ | async" [valueLabel]="credentialTypeLabel$ | async" [disabled]="!(algorithm$ | async)" diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index 51b998f1d56..0f3182118a1 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -8,7 +8,7 @@ bitIconButton="bwi-generate" buttonType="main" (click)="generate(USER_REQUEST)" - [appA11yTitle]="credentialTypeGenerateLabel$ | async" + [label]="credentialTypeGenerateLabel$ | async" [disabled]="!(algorithm$ | async)" > {{ credentialTypeGenerateLabel$ | async }} @@ -18,7 +18,7 @@ bitIconButton="bwi-clone" buttonType="main" showToast - [appA11yTitle]="credentialTypeCopyLabel$ | async" + [label]="credentialTypeCopyLabel$ | async" [appCopyClick]="value$ | async" [valueLabel]="credentialTypeLabel$ | async" [disabled]="!(algorithm$ | async)" diff --git a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.html b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.html index d3f3ebedf49..b0edbe12892 100644 --- a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.html +++ b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.html @@ -28,7 +28,7 @@ slot="end" bitIconButton="bwi-trash" [bitAction]="deleteSend" - appA11yTitle="{{ 'delete' | i18n }}" + label="{{ 'delete' | i18n }}" >
diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html index b9edf8eebcc..a271788b0ef 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html @@ -26,7 +26,7 @@ type="button" bitIconButton="bwi-generate" bitSuffix - [appA11yTitle]="'generatePassword' | i18n" + [label]="'generatePassword' | i18n" [disabled]="!config.areSendsAllowed" (click)="generatePassword()" data-testid="generate-password" @@ -36,7 +36,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copyPassword' | i18n" + [label]="'copyPassword' | i18n" [disabled]="!config.areSendsAllowed || !sendOptionsForm.get('password').value" [valueLabel]="'password' | i18n" [appCopyClick]="sendOptionsForm.get('password').value" @@ -50,7 +50,7 @@ type="button" buttonType="danger" bitIconButton="bwi-minus-circle" - [appA11yTitle]="'removePassword' | i18n" + [label]="'removePassword' | i18n" [bitAction]="removePassword" showToast > diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html index 214c978ad48..e650ca3a5df 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html @@ -30,7 +30,7 @@ showToast bitIconButton="bwi-clone" [appCopyClick]="sendLink" - [appA11yTitle]="'copySendLink' | i18n" + [label]="'copySendLink' | i18n" > diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html index 94ebfc3e5e6..3442375315a 100644 --- a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html @@ -48,7 +48,7 @@ size="small" type="button" (click)="copySendLink(send)" - appA11yTitle="{{ 'copyLink' | i18n }} - {{ send.name }}" + label="{{ 'copyLink' | i18n }} - {{ send.name }}" > @@ -57,7 +57,7 @@ size="small" type="button" (click)="deleteSend(send)" - appA11yTitle="{{ 'delete' | i18n }} - {{ send.name }}" + label="{{ 'delete' | i18n }} - {{ send.name }}" > diff --git a/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.html b/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.html index efbc1a0503c..71354a7c221 100644 --- a/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.html +++ b/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.html @@ -4,6 +4,6 @@ size="small" type="button" class="tw-border-transparent" - [appA11yTitle]="'deleteAttachmentName' | i18n: attachment.fileName" + [label]="'deleteAttachmentName' | i18n: attachment.fileName" [bitAction]="delete" > diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html index 2e88a68a0d4..2390512c92d 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html @@ -8,7 +8,7 @@ type="button" [bitIconButton]="showMatchDetection ? 'bwi-cog-f' : 'bwi-cog'" bitSuffix - [appA11yTitle]="toggleTitle" + [label]="toggleTitle" (click)="toggleMatchDetection()" data-testid="toggle-match-detection-button" > @@ -17,7 +17,7 @@ bitIconButton="bwi-minus-circle" buttonType="danger" bitSuffix - [appA11yTitle]="'deleteWebsite' | i18n" + [label]="'deleteWebsite' | i18n" *ngIf="canRemove" (click)="removeUri()" data-testid="remove-uri-button" @@ -29,7 +29,7 @@ bitIconButton="bwi-drag-and-drop" class="!tw-py-0 !tw-px-1" cdkDragHandle - [appA11yTitle]="'reorderToggleButton' | i18n: uriLabel" + [label]="'reorderToggleButton' | i18n: uriLabel" (keydown)="handleKeydown($event)" data-testid="reorder-toggle-button" *ngIf="canReorder" diff --git a/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html b/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html index f379f466b4a..4c0d567ad75 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html +++ b/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html @@ -40,7 +40,7 @@ buttonType="danger" class="tw-ml-auto" bitIconButton="bwi-trash" - [appA11yTitle]="'deleteCustomField' | i18n: customFieldForm.value.label" + [label]="'deleteCustomField' | i18n: customFieldForm.value.label" (click)="removeField()" > diff --git a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html index 98cc6489bbd..c47f332375b 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html +++ b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html @@ -89,7 +89,7 @@ diff --git a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html index b0c501c53ed..7fb19d3c4b2 100644 --- a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html +++ b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html @@ -15,7 +15,7 @@ bitSuffix *ngIf="loginDetailsForm.controls.username.enabled" data-testid="generate-username-button" - [appA11yTitle]="'generateUsername' | i18n" + [label]="'generateUsername' | i18n" [bitAction]="generateUsername" > @@ -47,7 +47,7 @@ loginDetailsForm.controls.password.value?.length > 0 " data-testid="check-password-button" - [appA11yTitle]="'checkPassword' | i18n" + [label]="'checkPassword' | i18n" [bitAction]="checkPassword" > @@ -86,7 +86,7 @@ *ngIf="loginDetailsForm.enabled && viewHiddenFields" [bitAction]="removePasskey" data-testid="remove-passkey-button" - [appA11yTitle]="'removePasskey' | i18n" + [label]="'removePasskey' | i18n" > @@ -125,7 +125,7 @@ *ngIf="canCaptureTotp" data-testid="capture-totp-button" [bitAction]="captureTotp" - [appA11yTitle]="'totpCapture' | i18n" + [label]="'totpCapture' | i18n" > diff --git a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html index de528267db0..ec9d715ff19 100644 --- a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html @@ -21,7 +21,7 @@ bitSuffix data-testid="import-privateKey" *ngIf="showImport" - appA11yTitle="{{ 'importSshKeyFromClipboard' | i18n }}" + label="{{ 'importSshKeyFromClipboard' | i18n }}" (click)="importSshKeyFromClipboard()" > diff --git a/libs/vault/src/cipher-view/additional-options/additional-options.component.html b/libs/vault/src/cipher-view/additional-options/additional-options.component.html index aa6d339dcd7..a53bfd239ca 100644 --- a/libs/vault/src/cipher-view/additional-options/additional-options.component.html +++ b/libs/vault/src/cipher-view/additional-options/additional-options.component.html @@ -14,7 +14,7 @@ [appCopyClick]="notes" showToast [valueLabel]="'note' | i18n" - [appA11yTitle]="'copyNotes' | i18n" + [label]="'copyNotes' | i18n" > diff --git a/libs/vault/src/cipher-view/autofill-options/autofill-options-view.component.html b/libs/vault/src/cipher-view/autofill-options/autofill-options-view.component.html index 1e17886f50b..1e8f7173167 100644 --- a/libs/vault/src/cipher-view/autofill-options/autofill-options-view.component.html +++ b/libs/vault/src/cipher-view/autofill-options/autofill-options-view.component.html @@ -26,7 +26,7 @@ type="button" (click)="openWebsite(login.launchUri)" data-testid="launch-website" - [attr.aria-label]="('launch' | i18n) + ' ' + login.hostOrUri" + [label]="('launch' | i18n) + ' ' + login.hostOrUri" > diff --git a/libs/vault/src/cipher-view/card-details/card-details-view.component.html b/libs/vault/src/cipher-view/card-details/card-details-view.component.html index 9d2fa45ba9e..ef023b69079 100644 --- a/libs/vault/src/cipher-view/card-details/card-details-view.component.html +++ b/libs/vault/src/cipher-view/card-details/card-details-view.component.html @@ -43,7 +43,7 @@ [appCopyClick]="card.number" showToast [valueLabel]="'number' | i18n" - [appA11yTitle]="'copyNumber' | i18n" + [label]="'copyNumber' | i18n" data-testid="copy-number" > @@ -87,7 +87,7 @@ [appCopyClick]="card.code" showToast [valueLabel]="'securityCode' | i18n" - [appA11yTitle]="'copySecurityCode' | i18n" + [label]="'copySecurityCode' | i18n" data-testid="copy-code" (click)="logCardEvent(true, EventType.Cipher_ClientCopiedCardCode)" > diff --git a/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.html b/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.html index 7c60d35965f..bc59c9058e2 100644 --- a/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.html +++ b/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.html @@ -25,7 +25,7 @@ [appCopyClick]="field.value" showToast [valueLabel]="field.name" - [appA11yTitle]="'copyCustomField' | i18n: field.name" + [label]="'copyCustomField' | i18n: field.name" data-testid="copy-custom-field" > @@ -57,7 +57,7 @@ bitSuffix type="button" data-testid="toggle-hidden-field-value-count" - [appA11yTitle]=" + [label]=" (showHiddenValueCountFields.includes(i) ? 'hideCharacterCount' : 'showCharacterCount') | i18n " @@ -81,7 +81,7 @@ showToast [valueLabel]="field.name" *ngIf="canViewPassword" - [appA11yTitle]="'copyCustomField' | i18n: field.name" + [label]="'copyCustomField' | i18n: field.name" (click)="logCopyEvent()" > diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html index 76a2d466369..53fcab70506 100644 --- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html +++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html @@ -23,7 +23,7 @@ [appCopyClick]="cipher.login.username" [valueLabel]="'username' | i18n" showToast - [appA11yTitle]="'copyUsername' | i18n" + [label]="'copyUsername' | i18n" data-testid="copy-username" > @@ -63,7 +63,7 @@ bitSuffix type="button" data-testid="toggle-password-count" - [appA11yTitle]="(showPasswordCount ? 'hideCharacterCount' : 'showCharacterCount') | i18n" + [label]="(showPasswordCount ? 'hideCharacterCount' : 'showCharacterCount') | i18n" [attr.aria-expanded]="showPasswordCount" appStopClick (click)="togglePasswordCount()" @@ -86,7 +86,7 @@ [appCopyClick]="cipher.login.password" [valueLabel]="'password' | i18n" showToast - [appA11yTitle]="'copyPassword' | i18n" + [label]="'copyPassword' | i18n" data-testid="copy-password" (click)="logCopyEvent()" > @@ -158,7 +158,7 @@ [appCopyClick]="totpCodeCopyObj?.totpCode" [valueLabel]="'verificationCodeTotp' | i18n" showToast - [appA11yTitle]="'copyVerificationCode' | i18n" + [label]="'copyVerificationCode' | i18n" data-testid="copy-totp" [disabled]="!(isPremium$ | async)" class="disabled:tw-cursor-default" diff --git a/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.html b/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.html index e74c0b06818..555f59fd22f 100644 --- a/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.html +++ b/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.html @@ -27,7 +27,7 @@ type="button" [appCopyClick]="sshKey.privateKey" showToast - [appA11yTitle]="'copyValue' | i18n" + [label]="'copyValue' | i18n" > @@ -45,7 +45,7 @@ type="button" [appCopyClick]="sshKey.publicKey" showToast - [appA11yTitle]="'copyValue' | i18n" + [label]="'copyValue' | i18n" > @@ -63,7 +63,7 @@ type="button" [appCopyClick]="sshKey.keyFingerprint" showToast - [appA11yTitle]="'copyValue' | i18n" + [label]="'copyValue' | i18n" > diff --git a/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.html b/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.html index 1b0a1f48f05..f3154994bbf 100644 --- a/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.html +++ b/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.html @@ -17,7 +17,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copyName' | i18n" + [label]="'copyName' | i18n" [appCopyClick]="cipher.identity.fullName" showToast [valueLabel]="'name' | i18n" @@ -37,7 +37,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copyUsername' | i18n" + [label]="'copyUsername' | i18n" [appCopyClick]="cipher.identity.username" showToast [valueLabel]="'username' | i18n" @@ -56,7 +56,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copyCompany' | i18n" + [label]="'copyCompany' | i18n" [appCopyClick]="cipher.identity.company" showToast [valueLabel]="'company' | i18n" @@ -93,7 +93,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copySSN' | i18n" + [label]="'copySSN' | i18n" [appCopyClick]="cipher.identity.ssn" showToast [valueLabel]="'ssn' | i18n" @@ -123,7 +123,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copyPassportNumber' | i18n" + [label]="'copyPassportNumber' | i18n" [appCopyClick]="cipher.identity.passportNumber" showToast [valueLabel]="'passportNumber' | i18n" @@ -145,7 +145,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copyLicenseNumber' | i18n" + [label]="'copyLicenseNumber' | i18n" [appCopyClick]="cipher.identity.licenseNumber" showToast [valueLabel]="'licenseNumber' | i18n" @@ -168,7 +168,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copyEmail' | i18n" + [label]="'copyEmail' | i18n" [appCopyClick]="cipher.identity.email" showToast [valueLabel]="'email' | i18n" @@ -182,7 +182,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copyPhone' | i18n" + [label]="'copyPhone' | i18n" [appCopyClick]="cipher.identity.phone" showToast [valueLabel]="'phone' | i18n" @@ -204,7 +204,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copyAddress' | i18n" + [label]="'copyAddress' | i18n" [appCopyClick]="addressFields" showToast [valueLabel]="'address' | i18n" diff --git a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html index cefd6305973..1f385fffc93 100644 --- a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html +++ b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html @@ -33,7 +33,7 @@ buttonType="danger" class="tw-ml-auto" bitIconButton="bwi-trash" - [appA11yTitle]="'deleteFolder' | i18n" + [label]="'deleteFolder' | i18n" [bitAction]="deleteFolder" > diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.html b/libs/vault/src/components/download-attachment/download-attachment.component.html index e6a20ba044b..47ee3c4761c 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.html +++ b/libs/vault/src/components/download-attachment/download-attachment.component.html @@ -4,5 +4,5 @@ buttonType="main" size="small" type="button" - [appA11yTitle]="'downloadAttachmentName' | i18n: attachment.fileName" + [label]="'downloadAttachmentName' | i18n: attachment.fileName" > diff --git a/libs/vault/src/components/password-history-view/password-history-view.component.html b/libs/vault/src/components/password-history-view/password-history-view.component.html index 7510d80a549..05772fde461 100644 --- a/libs/vault/src/components/password-history-view/password-history-view.component.html +++ b/libs/vault/src/components/password-history-view/password-history-view.component.html @@ -13,7 +13,7 @@