diff --git a/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts b/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts index 078bfb8a637b..b0ae87a75f7d 100644 --- a/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts @@ -9,6 +9,7 @@ import { ApiServiceInitOptions, } from "../../../platform/background/service-factories/api-service.factory"; import { appIdServiceFactory } from "../../../platform/background/service-factories/app-id-service.factory"; +import { billingAccountProfileStateServiceFactory } from "../../../platform/background/service-factories/billing-account-profile-state-service.factory"; import { CryptoServiceInitOptions, cryptoServiceFactory, @@ -119,6 +120,7 @@ export function loginStrategyServiceFactory( await deviceTrustCryptoServiceFactory(cache, opts), await authRequestServiceFactory(cache, opts), await globalStateProviderFactory(cache, opts), + await billingAccountProfileStateServiceFactory(cache, opts), ), ); } diff --git a/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts b/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts index bbbca2f16a4c..d62e4857224b 100644 --- a/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts +++ b/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts @@ -6,6 +6,7 @@ import { EventCollectionServiceInitOptions, eventCollectionServiceFactory, } from "../../../background/service-factories/event-collection-service.factory"; +import { billingAccountProfileStateServiceFactory } from "../../../platform/background/service-factories/billing-account-profile-state-service.factory"; import { CachedServices, factory, @@ -69,6 +70,7 @@ export function autofillServiceFactory( await logServiceFactory(cache, opts), await domainSettingsServiceFactory(cache, opts), await userVerificationServiceFactory(cache, opts), + await billingAccountProfileStateServiceFactory(cache, opts), ), ); } 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 b827788d75c1..67637da2fdd4 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 @@ -3,6 +3,7 @@ import { of } from "rxjs"; import { NOOP_COMMAND_SUFFIX } from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +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 { CipherType } from "@bitwarden/common/vault/enums"; @@ -18,6 +19,7 @@ describe("context-menu", () => { let autofillSettingsService: MockProxy; let i18nService: MockProxy; let logService: MockProxy; + let billingAccountProfileStateService: MockProxy; let removeAllSpy: jest.SpyInstance void]>; let createSpy: jest.SpyInstance< @@ -32,6 +34,7 @@ describe("context-menu", () => { autofillSettingsService = mock(); i18nService = mock(); logService = mock(); + billingAccountProfileStateService = mock(); removeAllSpy = jest .spyOn(chrome.contextMenus, "removeAll") @@ -50,6 +53,7 @@ describe("context-menu", () => { autofillSettingsService, i18nService, logService, + billingAccountProfileStateService, ); autofillSettingsService.enableContextMenu$ = of(true); }); @@ -66,7 +70,7 @@ describe("context-menu", () => { }); it("has menu enabled, but does not have premium", async () => { - stateService.getCanAccessPremium.mockResolvedValue(false); + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(false); const createdMenu = await sut.init(); expect(createdMenu).toBeTruthy(); @@ -74,7 +78,7 @@ describe("context-menu", () => { }); it("has menu enabled and has premium", async () => { - stateService.getCanAccessPremium.mockResolvedValue(true); + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true); const createdMenu = await sut.init(); expect(createdMenu).toBeTruthy(); @@ -128,7 +132,7 @@ describe("context-menu", () => { }); it("create entry for each cipher piece", async () => { - stateService.getCanAccessPremium.mockResolvedValue(true); + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true); await sut.loadOptions("TEST_TITLE", "1", createCipher()); @@ -137,7 +141,7 @@ describe("context-menu", () => { }); it("creates a login/unlock item for each context menu action option when user is not authenticated", async () => { - stateService.getCanAccessPremium.mockResolvedValue(true); + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true); await sut.loadOptions("TEST_TITLE", "NOOP"); 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 b7e26be4a9c4..9422756e07b6 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.ts @@ -17,6 +17,7 @@ import { SEPARATOR_ID, } from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +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 { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; @@ -27,6 +28,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { autofillSettingsServiceFactory } from "../../autofill/background/service_factories/autofill-settings-service.factory"; import { Account } from "../../models/account"; +import { billingAccountProfileStateServiceFactory } from "../../platform/background/service-factories/billing-account-profile-state-service.factory"; import { CachedServices } from "../../platform/background/service-factories/factory-options"; import { i18nServiceFactory, @@ -163,6 +165,7 @@ export class MainContextMenuHandler { private autofillSettingsService: AutofillSettingsServiceAbstraction, private i18nService: I18nService, private logService: LogService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} static async mv3Create(cachedServices: CachedServices) { @@ -196,6 +199,7 @@ export class MainContextMenuHandler { await autofillSettingsServiceFactory(cachedServices, serviceOptions), await i18nServiceFactory(cachedServices, serviceOptions), await logServiceFactory(cachedServices, serviceOptions), + await billingAccountProfileStateServiceFactory(cachedServices, serviceOptions), ); } @@ -217,7 +221,10 @@ export class MainContextMenuHandler { try { for (const options of this.initContextMenuItems) { - if (options.checkPremiumAccess && !(await this.stateService.getCanAccessPremium())) { + if ( + options.checkPremiumAccess && + !(await firstValueFrom(this.billingAccountProfileStateService.hasPremiumFromAnySource$)) + ) { continue; } @@ -312,7 +319,9 @@ export class MainContextMenuHandler { await createChildItem(COPY_USERNAME_ID); } - const canAccessPremium = await this.stateService.getCanAccessPremium(); + const canAccessPremium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$, + ); if (canAccessPremium && (!cipher || !Utils.isNullOrEmpty(cipher.login?.totp))) { await createChildItem(COPY_VERIFICATION_CODE_ID); } diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index eb70f0f7dc44..f6c1fa906774 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -8,6 +8,7 @@ import { DefaultDomainSettingsService, DomainSettingsService, } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -72,6 +73,7 @@ describe("AutofillService", () => { const eventCollectionService = mock(); const logService = mock(); const userVerificationService = mock(); + const billingAccountProfileStateService = mock(); beforeEach(() => { autofillService = new AutofillService( @@ -83,6 +85,7 @@ describe("AutofillService", () => { logService, domainSettingsService, userVerificationService, + billingAccountProfileStateService, ); domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); @@ -476,6 +479,7 @@ describe("AutofillService", () => { it("throws an error if an autofill did not occur for any of the passed pages", async () => { autofillOptions.tab.url = "https://a-different-url.com"; + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true); try { await autofillService.doAutoFill(autofillOptions); @@ -487,7 +491,6 @@ describe("AutofillService", () => { }); it("will autofill login data for a page", async () => { - jest.spyOn(stateService, "getCanAccessPremium"); jest.spyOn(autofillService as any, "generateFillScript"); jest.spyOn(autofillService as any, "generateLoginFillScript"); jest.spyOn(logService, "info"); @@ -497,8 +500,6 @@ describe("AutofillService", () => { const autofillResult = await autofillService.doAutoFill(autofillOptions); const currentAutofillPageDetails = autofillOptions.pageDetails[0]; - expect(stateService.getCanAccessPremium).toHaveBeenCalled(); - expect(autofillService["getDefaultUriMatchStrategy"]).toHaveBeenCalled(); expect(autofillService["generateFillScript"]).toHaveBeenCalledWith( currentAutofillPageDetails.details, { @@ -660,7 +661,7 @@ describe("AutofillService", () => { it("returns a TOTP value", async () => { const totpCode = "123456"; autofillOptions.cipher.login.totp = "totp"; - jest.spyOn(stateService, "getCanAccessPremium").mockResolvedValue(true); + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true); jest.spyOn(autofillService, "getShouldAutoCopyTotp").mockResolvedValue(true); jest.spyOn(totpService, "getCode").mockResolvedValue(totpCode); @@ -673,7 +674,7 @@ describe("AutofillService", () => { it("does not return a TOTP value if the user does not have premium features", async () => { autofillOptions.cipher.login.totp = "totp"; - jest.spyOn(stateService, "getCanAccessPremium").mockResolvedValue(false); + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(false); jest.spyOn(autofillService, "getShouldAutoCopyTotp").mockResolvedValue(true); const autofillResult = await autofillService.doAutoFill(autofillOptions); @@ -707,7 +708,7 @@ describe("AutofillService", () => { it("returns a null value if the user cannot access premium and the organization does not use TOTP", async () => { autofillOptions.cipher.login.totp = "totp"; autofillOptions.cipher.organizationUseTotp = false; - jest.spyOn(stateService, "getCanAccessPremium").mockResolvedValueOnce(false); + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(false); const autofillResult = await autofillService.doAutoFill(autofillOptions); @@ -717,13 +718,12 @@ describe("AutofillService", () => { it("returns a null value if the user has disabled `auto TOTP copy`", async () => { autofillOptions.cipher.login.totp = "totp"; autofillOptions.cipher.organizationUseTotp = true; - jest.spyOn(stateService, "getCanAccessPremium").mockResolvedValue(true); + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true); jest.spyOn(autofillService, "getShouldAutoCopyTotp").mockResolvedValue(false); jest.spyOn(totpService, "getCode"); const autofillResult = await autofillService.doAutoFill(autofillOptions); - expect(stateService.getCanAccessPremium).toHaveBeenCalled(); expect(autofillService.getShouldAutoCopyTotp).toHaveBeenCalled(); expect(totpService.getCode).not.toHaveBeenCalled(); expect(autofillResult).toBeNull(); diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 3a809af0c387..e353a34ea077 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -5,6 +5,7 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; import { UriMatchStrategySetting, @@ -55,6 +56,7 @@ export default class AutofillService implements AutofillServiceInterface { private logService: LogService, private domainSettingsService: DomainSettingsService, private userVerificationService: UserVerificationService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} /** @@ -240,7 +242,9 @@ export default class AutofillService implements AutofillServiceInterface { let totp: string | null = null; - const canAccessPremium = await this.stateService.getCanAccessPremium(); + const canAccessPremium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$, + ); const defaultUriMatch = await this.getDefaultUriMatchStrategy(); if (!canAccessPremium) { diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 3075515ad750..4979ee6838ec 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -65,6 +65,8 @@ import { UserNotificationSettingsService, UserNotificationSettingsServiceAbstraction, } from "@bitwarden/common/autofill/services/user-notification-settings.service"; +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 { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; @@ -311,6 +313,7 @@ export default class MainBackground { biometricStateService: BiometricStateService; stateEventRunnerService: StateEventRunnerService; ssoLoginService: SsoLoginServiceAbstraction; + billingAccountProfileStateService: BillingAccountProfileStateService; onUpdatedRan: boolean; onReplacedRan: boolean; @@ -569,6 +572,10 @@ export default class MainBackground { this.stateService, ); + this.billingAccountProfileStateService = new DefaultBillingAccountProfileStateService( + this.activeUserStateProvider, + ); + this.loginStrategyService = new LoginStrategyService( this.cryptoService, this.apiService, @@ -588,6 +595,7 @@ export default class MainBackground { this.deviceTrustCryptoService, this.authRequestService, this.globalStateProvider, + this.billingAccountProfileStateService, ); this.ssoLoginService = new SsoLoginService(this.stateProvider); @@ -715,6 +723,7 @@ export default class MainBackground { this.sendApiService, this.avatarService, logoutCallback, + this.billingAccountProfileStateService, ); this.eventUploadService = new EventUploadService( this.apiService, @@ -738,6 +747,7 @@ export default class MainBackground { this.logService, this.domainSettingsService, this.userVerificationService, + this.billingAccountProfileStateService, ); this.auditService = new AuditService(this.cryptoFunctionService, this.apiService); @@ -958,6 +968,7 @@ export default class MainBackground { this.autofillSettingsService, this.i18nService, this.logService, + this.billingAccountProfileStateService, ); this.cipherContextMenuHandler = new CipherContextMenuHandler( diff --git a/apps/browser/src/platform/background/service-factories/billing-account-profile-state-service.factory.ts b/apps/browser/src/platform/background/service-factories/billing-account-profile-state-service.factory.ts new file mode 100644 index 000000000000..80482eacb673 --- /dev/null +++ b/apps/browser/src/platform/background/service-factories/billing-account-profile-state-service.factory.ts @@ -0,0 +1,28 @@ +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 { activeUserStateProviderFactory } from "./active-user-state-provider.factory"; +import { FactoryOptions, CachedServices, factory } from "./factory-options"; +import { StateProviderInitOptions } from "./state-provider.factory"; + +type BillingAccountProfileStateServiceFactoryOptions = FactoryOptions; + +export type BillingAccountProfileStateServiceInitOptions = + BillingAccountProfileStateServiceFactoryOptions & StateProviderInitOptions; + +export function billingAccountProfileStateServiceFactory( + cache: { + billingAccountProfileStateService?: BillingAccountProfileStateService; + } & CachedServices, + opts: BillingAccountProfileStateServiceInitOptions, +): Promise { + return factory( + cache, + "billingAccountProfileStateService", + opts, + async () => + new DefaultBillingAccountProfileStateService( + await activeUserStateProviderFactory(cache, opts), + ), + ); +} diff --git a/apps/browser/src/popup/settings/premium.component.html b/apps/browser/src/popup/settings/premium.component.html index 2727ee405b9e..a8f9855e62db 100644 --- a/apps/browser/src/popup/settings/premium.component.html +++ b/apps/browser/src/popup/settings/premium.component.html @@ -12,7 +12,7 @@

- +

{{ "premiumNotCurrentMember" | i18n }}

{{ "premiumSignUpAndGet" | i18n }}

    @@ -61,7 +61,7 @@

    > - +

    {{ "premiumCurrentMember" | i18n }}

    {{ "premiumCurrentMemberThanks" | i18n }}

    -
    +
    diff --git a/apps/web/src/app/billing/individual/premium.component.ts b/apps/web/src/app/billing/individual/premium.component.ts index 8f40c3f1c2c9..e6e63264d5bd 100644 --- a/apps/web/src/app/billing/individual/premium.component.ts +++ b/apps/web/src/app/billing/individual/premium.component.ts @@ -1,14 +1,15 @@ import { Component, OnInit, ViewChild } from "@angular/core"; import { Router } from "@angular/router"; +import { firstValueFrom, Observable } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { PaymentComponent, TaxInfoComponent } from "../shared"; @@ -20,7 +21,7 @@ export class PremiumComponent implements OnInit { @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; @ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent; - canAccessPremium = false; + canAccessPremium$: Observable; selfHosted = false; premiumPrice = 10; familyPlanMaxUserCount = 6; @@ -39,17 +40,16 @@ export class PremiumComponent implements OnInit { private messagingService: MessagingService, private syncService: SyncService, private logService: LogService, - private stateService: StateService, private environmentService: EnvironmentService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) { this.selfHosted = platformUtilsService.isSelfHost(); this.cloudWebVaultUrl = this.environmentService.getCloudWebVaultUrl(); + this.canAccessPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$; } async ngOnInit() { - this.canAccessPremium = await this.stateService.getCanAccessPremium(); - const premiumPersonally = await this.stateService.getHasPremiumPersonally(); - if (premiumPersonally) { + if (await firstValueFrom(this.billingAccountProfileStateService.hasPremiumPersonally$)) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises this.router.navigate(["/settings/subscription/user-subscription"]); diff --git a/apps/web/src/app/billing/individual/subscription.component.html b/apps/web/src/app/billing/individual/subscription.component.html index 59326cb70ae5..934a24570f4f 100644 --- a/apps/web/src/app/billing/individual/subscription.component.html +++ b/apps/web/src/app/billing/individual/subscription.component.html @@ -1,6 +1,8 @@ - {{ "subscription" | i18n }} + {{ + "subscription" | i18n + }} {{ "paymentMethod" | i18n }} {{ "billingHistory" | i18n }} diff --git a/apps/web/src/app/billing/individual/subscription.component.ts b/apps/web/src/app/billing/individual/subscription.component.ts index 143d531e1d66..c316bae4f136 100644 --- a/apps/web/src/app/billing/individual/subscription.component.ts +++ b/apps/web/src/app/billing/individual/subscription.component.ts @@ -1,26 +1,24 @@ -import { Component } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; +import { Observable } from "rxjs"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @Component({ templateUrl: "subscription.component.html", }) -export class SubscriptionComponent { - hasPremium: boolean; +export class SubscriptionComponent implements OnInit { + hasPremium$: Observable; selfHosted: boolean; constructor( - private stateService: StateService, private platformUtilsService: PlatformUtilsService, - ) {} - - async ngOnInit() { - this.hasPremium = await this.stateService.getHasPremiumPersonally(); - this.selfHosted = this.platformUtilsService.isSelfHost(); + billingAccountProfileStateService: BillingAccountProfileStateService, + ) { + this.hasPremium$ = billingAccountProfileStateService.hasPremiumPersonally$; } - get subscriptionRoute(): string { - return this.hasPremium ? "user-subscription" : "premium"; + ngOnInit() { + this.selfHosted = this.platformUtilsService.isSelfHost(); } } diff --git a/apps/web/src/app/billing/individual/user-subscription.component.ts b/apps/web/src/app/billing/individual/user-subscription.component.ts index fd4bb62a5ee6..3ec0cd54d128 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.ts +++ b/apps/web/src/app/billing/individual/user-subscription.component.ts @@ -1,8 +1,9 @@ import { Component, OnInit } from "@angular/core"; import { Router } from "@angular/router"; -import { lastValueFrom, Observable } from "rxjs"; +import { firstValueFrom, lastValueFrom, Observable } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { SubscriptionResponse } from "@bitwarden/common/billing/models/response/subscription.response"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigServiceAbstraction as ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; @@ -11,7 +12,6 @@ import { FileDownloadService } from "@bitwarden/common/platform/abstractions/fil 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 { DialogService } from "@bitwarden/components"; import { @@ -37,7 +37,6 @@ export class UserSubscriptionComponent implements OnInit { presentUserWithOffboardingSurvey$: Observable; constructor( - private stateService: StateService, private apiService: ApiService, private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, @@ -47,6 +46,7 @@ export class UserSubscriptionComponent implements OnInit { private dialogService: DialogService, private environmentService: EnvironmentService, private configService: ConfigService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) { this.selfHosted = platformUtilsService.isSelfHost(); this.cloudWebVaultUrl = this.environmentService.getCloudWebVaultUrl(); @@ -65,8 +65,7 @@ export class UserSubscriptionComponent implements OnInit { return; } - // eslint-disable-next-line @typescript-eslint/no-misused-promises - if (this.stateService.getHasPremiumPersonally()) { + if (await firstValueFrom(this.billingAccountProfileStateService.hasPremiumPersonally$)) { this.loading = true; this.sub = await this.apiService.getUserSubscription(); } else { diff --git a/apps/web/src/app/core/guards/has-premium.guard.ts b/apps/web/src/app/core/guards/has-premium.guard.ts index bb4d07f1d165..ab544dafb613 100644 --- a/apps/web/src/app/core/guards/has-premium.guard.ts +++ b/apps/web/src/app/core/guards/has-premium.guard.ts @@ -4,32 +4,39 @@ import { RouterStateSnapshot, Router, CanActivateFn, + UrlTree, } from "@angular/router"; +import { Observable } from "rxjs"; +import { tap } from "rxjs/operators"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; /** * CanActivate guard that checks if the user has premium and otherwise triggers the "premiumRequired" * message and blocks navigation. */ export function hasPremiumGuard(): CanActivateFn { - return async (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => { + return ( + _route: ActivatedRouteSnapshot, + _state: RouterStateSnapshot, + ): Observable => { const router = inject(Router); - const stateService = inject(StateService); const messagingService = inject(MessagingService); + const billingAccountProfileStateService = inject(BillingAccountProfileStateService); - const userHasPremium = await stateService.getCanAccessPremium(); - - if (!userHasPremium) { - messagingService.send("premiumRequired"); - } - - // Prevent trapping the user on the login page, since that's an awful UX flow - if (!userHasPremium && router.url === "/login") { - return router.createUrlTree(["/"]); - } - - return userHasPremium; + return billingAccountProfileStateService.hasPremiumFromAnySource$.pipe( + tap((userHasPremium: boolean) => { + if (!userHasPremium) { + messagingService.send("premiumRequired"); + } + }), + // Prevent trapping the user on the login page, since that's an awful UX flow + tap((userHasPremium: boolean) => { + if (!userHasPremium && router.url === "/login") { + return router.createUrlTree(["/"]); + } + }), + ); }; } diff --git a/apps/web/src/app/layouts/user-layout.component.ts b/apps/web/src/app/layouts/user-layout.component.ts index 1f116ee76e44..2e1813697efd 100644 --- a/apps/web/src/app/layouts/user-layout.component.ts +++ b/apps/web/src/app/layouts/user-layout.component.ts @@ -1,15 +1,16 @@ import { CommonModule } from "@angular/common"; import { Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { RouterModule } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { ConfigServiceAbstraction as ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { IconModule, LayoutComponent, NavigationModule } from "@bitwarden/components"; @@ -48,10 +49,10 @@ export class UserLayoutComponent implements OnInit, OnDestroy { private ngZone: NgZone, private platformUtilsService: PlatformUtilsService, private organizationService: OrganizationService, - private stateService: StateService, private apiService: ApiService, private syncService: SyncService, private configService: ConfigService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} async ngOnInit() { @@ -79,16 +80,21 @@ export class UserLayoutComponent implements OnInit, OnDestroy { } async load() { - const premium = await this.stateService.getHasPremiumPersonally(); + const hasPremiumPersonally = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumPersonally$, + ); + const hasPremiumFromOrg = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$, + ); const selfHosted = this.platformUtilsService.isSelfHost(); this.hasFamilySponsorshipAvailable = await this.organizationService.canManageSponsorships(); - const hasPremiumFromOrg = await this.stateService.getHasPremiumFromOrganization(); let billing = null; if (!selfHosted) { // TODO: We should remove the need to call this! billing = await this.apiService.getUserBillingHistory(); } - this.hideSubscription = !premium && hasPremiumFromOrg && (selfHosted || billing?.hasNoHistory); + this.hideSubscription = + !hasPremiumPersonally && hasPremiumFromOrg && (selfHosted || billing?.hasNoHistory); } } diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 918973b86c5c..b5b198d0ac46 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -1,12 +1,12 @@ import { Component, NgZone, OnDestroy, OnInit } from "@angular/core"; +import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "../core"; - const BroadcasterSubscriptionId = "SettingsComponent"; @Component({ @@ -24,8 +24,8 @@ export class SettingsComponent implements OnInit, OnDestroy { private ngZone: NgZone, private platformUtilsService: PlatformUtilsService, private organizationService: OrganizationService, - private stateService: StateService, private apiService: ApiService, + private billingAccountProfileStateServiceAbstraction: BillingAccountProfileStateService, ) {} async ngOnInit() { @@ -51,9 +51,13 @@ export class SettingsComponent implements OnInit, OnDestroy { } async load() { - this.premium = await this.stateService.getHasPremiumPersonally(); + this.premium = await firstValueFrom( + this.billingAccountProfileStateServiceAbstraction.hasPremiumPersonally$, + ); this.hasFamilySponsorshipAvailable = await this.organizationService.canManageSponsorships(); - const hasPremiumFromOrg = await this.stateService.getHasPremiumFromOrganization(); + const hasPremiumFromOrg = await firstValueFrom( + this.billingAccountProfileStateServiceAbstraction.hasPremiumFromAnyOrganization$, + ); let billing = null; if (!this.selfHosted) { billing = await this.apiService.getUserBillingHistory(); diff --git a/apps/web/src/app/tools/reports/pages/reports-home.component.ts b/apps/web/src/app/tools/reports/pages/reports-home.component.ts index 3d85db8cb268..541193fafabf 100644 --- a/apps/web/src/app/tools/reports/pages/reports-home.component.ts +++ b/apps/web/src/app/tools/reports/pages/reports-home.component.ts @@ -1,6 +1,7 @@ import { Component, OnInit } from "@angular/core"; +import { firstValueFrom } from "rxjs"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { reports, ReportType } from "../reports"; import { ReportEntry, ReportVariant } from "../shared"; @@ -12,11 +13,12 @@ import { ReportEntry, ReportVariant } from "../shared"; export class ReportsHomeComponent implements OnInit { reports: ReportEntry[]; - constructor(private stateService: StateService) {} + constructor(private billingAccountProfileStateService: BillingAccountProfileStateService) {} async ngOnInit(): Promise { - const userHasPremium = await this.stateService.getCanAccessPremium(); - + const userHasPremium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$, + ); const reportRequiresPremium = userHasPremium ? ReportVariant.Enabled : ReportVariant.RequiresPremium; diff --git a/apps/web/src/app/tools/send/add-edit.component.ts b/apps/web/src/app/tools/send/add-edit.component.ts index 5eb1d3619814..ee4be4148891 100644 --- a/apps/web/src/app/tools/send/add-edit.component.ts +++ b/apps/web/src/app/tools/send/add-edit.component.ts @@ -5,6 +5,7 @@ import { FormBuilder } from "@angular/forms"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -36,6 +37,7 @@ export class AddEditComponent extends BaseAddEditComponent { sendApiService: SendApiService, dialogService: DialogService, formBuilder: FormBuilder, + billingAccountProfileStateService: BillingAccountProfileStateService, protected dialogRef: DialogRef, @Inject(DIALOG_DATA) params: { sendId: string }, ) { @@ -52,6 +54,7 @@ export class AddEditComponent extends BaseAddEditComponent { sendApiService, dialogService, formBuilder, + billingAccountProfileStateService, ); this.sendId = params.sendId; diff --git a/apps/web/src/app/tools/tools.component.ts b/apps/web/src/app/tools/tools.component.ts index 7c6020f6d120..52ef698fd3c7 100644 --- a/apps/web/src/app/tools/tools.component.ts +++ b/apps/web/src/app/tools/tools.component.ts @@ -1,22 +1,33 @@ -import { Component, OnInit } from "@angular/core"; +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { Subject, takeUntil } from "rxjs"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @Component({ selector: "app-tools", templateUrl: "tools.component.html", }) -export class ToolsComponent implements OnInit { +export class ToolsComponent implements OnInit, OnDestroy { + private componentIsDestroyed$ = new Subject(); canAccessPremium = false; constructor( - private stateService: StateService, private messagingService: MessagingService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} async ngOnInit() { - this.canAccessPremium = await this.stateService.getCanAccessPremium(); + this.billingAccountProfileStateService.hasPremiumFromAnySource$ + .pipe(takeUntil(this.componentIsDestroyed$)) + .subscribe((canAccessPremium: boolean) => { + this.canAccessPremium = canAccessPremium; + }); + } + + ngOnDestroy() { + this.componentIsDestroyed$.next(true); + this.componentIsDestroyed$.complete(); } premiumRequired() { diff --git a/apps/web/src/app/vault/components/premium-badge.stories.ts b/apps/web/src/app/vault/components/premium-badge.stories.ts index 4585f235ba58..ffe11d738684 100644 --- a/apps/web/src/app/vault/components/premium-badge.stories.ts +++ b/apps/web/src/app/vault/components/premium-badge.stories.ts @@ -3,8 +3,6 @@ import { Meta, moduleMetadata, Story } from "@storybook/angular"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; import { BadgeModule, I18nMockService } from "@bitwarden/components"; import { PremiumBadgeComponent } from "./premium-badge.component"; @@ -15,12 +13,6 @@ class MockMessagingService implements MessagingService { } } -class MockedStateService implements Partial { - async getCanAccessPremium(options?: StorageOptions) { - return false; - } -} - export default { title: "Web/Premium Badge", component: PremiumBadgeComponent, @@ -42,12 +34,6 @@ export default { return new MockMessagingService(); }, }, - { - provide: StateService, - useFactory: () => { - return new MockedStateService(); - }, - }, ], }), ], diff --git a/apps/web/src/app/vault/individual-vault/add-edit.component.ts b/apps/web/src/app/vault/individual-vault/add-edit.component.ts index 00464882aef6..8332b7e95f12 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/individual-vault/add-edit.component.ts @@ -1,11 +1,13 @@ import { DatePipe } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; +import { firstValueFrom } from "rxjs"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType, ProductType } from "@bitwarden/common/enums"; import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -64,6 +66,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On dialogService: DialogService, datePipe: DatePipe, configService: ConfigServiceAbstraction, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( cipherService, @@ -98,7 +101,9 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On this.hasPasswordHistory = this.cipher.hasPasswordHistory; this.cleanUp(); - this.canAccessPremium = await this.stateService.getCanAccessPremium(); + this.canAccessPremium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$, + ); if (this.showTotp()) { await this.totpUpdateCode(); const interval = this.totpService.getTimeInterval(this.cipher.login.totp); diff --git a/apps/web/src/app/vault/individual-vault/attachments.component.ts b/apps/web/src/app/vault/individual-vault/attachments.component.ts index 0ce66d07fa06..ae4e8fafabe5 100644 --- a/apps/web/src/app/vault/individual-vault/attachments.component.ts +++ b/apps/web/src/app/vault/individual-vault/attachments.component.ts @@ -2,6 +2,7 @@ import { Component } from "@angular/core"; import { AttachmentsComponent as BaseAttachmentsComponent } from "@bitwarden/angular/vault/components/attachments.component"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -30,6 +31,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent { logService: LogService, fileDownloadService: FileDownloadService, dialogService: DialogService, + billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( cipherService, @@ -42,6 +44,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent { stateService, fileDownloadService, dialogService, + billingAccountProfileStateService, ); } diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index b7fcb89c770d..d7d9ab8074a9 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -37,6 +37,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; @@ -182,6 +183,7 @@ export class VaultComponent implements OnInit, OnDestroy { private configService: ConfigServiceAbstraction, private apiService: ApiService, private userVerificationService: UserVerificationService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} async ngOnInit() { @@ -201,7 +203,9 @@ export class VaultComponent implements OnInit, OnDestroy { : false; await this.syncService.fullSync(false); - const canAccessPremium = await this.stateService.getCanAccessPremium(); + const canAccessPremium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$, + ); this.showPremiumCallout = !this.showVerifyEmail && !canAccessPremium && !this.platformUtilsService.isSelfHost(); @@ -242,9 +246,6 @@ export class VaultComponent implements OnInit, OnDestroy { }); const filter$ = this.routedVaultFilterService.filter$; - const canAccessPremium$ = Utils.asyncToObservable(() => - this.stateService.getCanAccessPremium(), - ).pipe(shareReplay({ refCount: true, bufferSize: 1 })); const allCollections$ = Utils.asyncToObservable(() => this.collectionService.getAllDecrypted()); const nestedCollections$ = allCollections$.pipe( map((collections) => getNestedCollectionTree(collections)), @@ -368,7 +369,7 @@ export class VaultComponent implements OnInit, OnDestroy { switchMap(() => combineLatest([ filter$, - canAccessPremium$, + this.billingAccountProfileStateService.hasPremiumFromAnySource$, allCollections$, this.organizationService.organizations$, ciphers$, @@ -513,8 +514,7 @@ export class VaultComponent implements OnInit, OnDestroy { return; } - const canAccessPremium = await this.stateService.getCanAccessPremium(); - if (cipher.organizationId == null && !canAccessPremium) { + if (cipher.organizationId == null && !this.canAccessPremium) { this.messagingService.send("premiumRequired"); return; } else if (cipher.organizationId != null) { diff --git a/apps/web/src/app/vault/org-vault/add-edit.component.ts b/apps/web/src/app/vault/org-vault/add-edit.component.ts index 567dcf05df50..cb879dfcc757 100644 --- a/apps/web/src/app/vault/org-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/org-vault/add-edit.component.ts @@ -6,6 +6,7 @@ import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -54,6 +55,7 @@ export class AddEditComponent extends BaseAddEditComponent { dialogService: DialogService, datePipe: DatePipe, configService: ConfigServiceAbstraction, + billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( cipherService, @@ -75,6 +77,7 @@ export class AddEditComponent extends BaseAddEditComponent { dialogService, datePipe, configService, + billingAccountProfileStateService, ); } diff --git a/apps/web/src/app/vault/org-vault/attachments.component.ts b/apps/web/src/app/vault/org-vault/attachments.component.ts index ca6e0faccd56..f7ef372a2e38 100644 --- a/apps/web/src/app/vault/org-vault/attachments.component.ts +++ b/apps/web/src/app/vault/org-vault/attachments.component.ts @@ -2,6 +2,7 @@ import { Component } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -34,6 +35,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent { logService: LogService, fileDownloadService: FileDownloadService, dialogService: DialogService, + billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( cipherService, @@ -45,6 +47,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent { logService, fileDownloadService, dialogService, + billingAccountProfileStateService, ); } diff --git a/libs/angular/src/directives/not-premium.directive.ts b/libs/angular/src/directives/not-premium.directive.ts index 46fbaa17619a..3aee9b192d26 100644 --- a/libs/angular/src/directives/not-premium.directive.ts +++ b/libs/angular/src/directives/not-premium.directive.ts @@ -1,6 +1,7 @@ import { Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core"; +import { firstValueFrom } from "rxjs"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; /** * Hides the element if the user has premium. @@ -12,11 +13,13 @@ export class NotPremiumDirective implements OnInit { constructor( private templateRef: TemplateRef, private viewContainer: ViewContainerRef, - private stateService: StateService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} async ngOnInit(): Promise { - const premium = await this.stateService.getCanAccessPremium(); + const premium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$, + ); if (premium) { this.viewContainer.clear(); diff --git a/libs/angular/src/directives/premium.directive.ts b/libs/angular/src/directives/premium.directive.ts index 9e2991e97c95..d475669a1ab2 100644 --- a/libs/angular/src/directives/premium.directive.ts +++ b/libs/angular/src/directives/premium.directive.ts @@ -1,6 +1,7 @@ -import { Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core"; +import { Directive, OnDestroy, OnInit, TemplateRef, ViewContainerRef } from "@angular/core"; +import { Subject, takeUntil } from "rxjs"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; /** * Only shows the element if the user has premium. @@ -8,20 +9,29 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv @Directive({ selector: "[appPremium]", }) -export class PremiumDirective implements OnInit { +export class PremiumDirective implements OnInit, OnDestroy { + private directiveIsDestroyed$ = new Subject(); + constructor( private templateRef: TemplateRef, private viewContainer: ViewContainerRef, - private stateService: StateService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} async ngOnInit(): Promise { - const premium = await this.stateService.getCanAccessPremium(); + this.billingAccountProfileStateService.hasPremiumFromAnySource$ + .pipe(takeUntil(this.directiveIsDestroyed$)) + .subscribe((premium: boolean) => { + if (premium) { + this.viewContainer.clear(); + } else { + this.viewContainer.createEmbeddedView(this.templateRef); + } + }); + } - if (premium) { - this.viewContainer.createEmbeddedView(this.templateRef); - } else { - this.viewContainer.clear(); - } + ngOnDestroy() { + this.directiveIsDestroyed$.next(true); + this.directiveIsDestroyed$.complete(); } } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index c9239f1fc4a5..b155138d82e0 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -96,9 +96,11 @@ import { DomainSettingsService, DefaultDomainSettingsService, } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction"; import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-billing.service"; import { PaymentMethodWarningsServiceAbstraction } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction"; +import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; import { PaymentMethodWarningsService } from "@bitwarden/common/billing/services/payment-method-warnings.service"; @@ -368,6 +370,7 @@ const typesafeProviders: Array = [ DeviceTrustCryptoServiceAbstraction, AuthRequestServiceAbstraction, GlobalStateProvider, + BillingAccountProfileStateService, ], }), safeProvider({ @@ -482,12 +485,7 @@ const typesafeProviders: Array = [ safeProvider({ provide: TokenServiceAbstraction, useClass: TokenService, - deps: [ - SingleUserStateProvider, - GlobalStateProvider, - SUPPORTS_SECURE_STORAGE, - AbstractStorageService, - ], + deps: [SingleUserStateProvider, GlobalStateProvider, SUPPORTS_SECURE_STORAGE, SECURE_STORAGE], }), safeProvider({ provide: KeyGenerationServiceAbstraction, @@ -576,6 +574,7 @@ const typesafeProviders: Array = [ SendApiServiceAbstraction, AvatarServiceAbstraction, LOGOUT_CALLBACK, + BillingAccountProfileStateService, ], }), safeProvider({ provide: BroadcasterServiceAbstraction, useClass: BroadcasterService, deps: [] }), @@ -1045,6 +1044,11 @@ const typesafeProviders: Array = [ useClass: PaymentMethodWarningsService, deps: [BillingApiServiceAbstraction, StateProvider], }), + safeProvider({ + provide: BillingAccountProfileStateService, + useClass: DefaultBillingAccountProfileStateService, + deps: [ActiveUserStateProvider], + }), ]; function encryptServiceFactory( diff --git a/libs/angular/src/tools/send/add-edit.component.ts b/libs/angular/src/tools/send/add-edit.component.ts index 9742de1a7b80..dafac1e92ba8 100644 --- a/libs/angular/src/tools/send/add-edit.component.ts +++ b/libs/angular/src/tools/send/add-edit.component.ts @@ -5,6 +5,7 @@ import { BehaviorSubject, Subject, concatMap, firstValueFrom, map, takeUntil } f import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -116,6 +117,7 @@ export class AddEditComponent implements OnInit, OnDestroy { protected sendApiService: SendApiService, protected dialogService: DialogService, protected formBuilder: FormBuilder, + protected billingAccountProfileStateService: BillingAccountProfileStateService, ) { this.typeOptions = [ { name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true }, @@ -188,6 +190,12 @@ export class AddEditComponent implements OnInit, OnDestroy { } }); + this.billingAccountProfileStateService.hasPremiumFromAnySource$ + .pipe(takeUntil(this.destroy$)) + .subscribe((hasPremiumFromAnySource) => { + this.canAccessPremium = hasPremiumFromAnySource; + }); + await this.load(); } @@ -205,7 +213,6 @@ export class AddEditComponent implements OnInit, OnDestroy { } async load() { - this.canAccessPremium = await this.stateService.getCanAccessPremium(); this.emailVerified = await this.stateService.getEmailVerified(); this.type = !this.canAccessPremium || !this.emailVerified ? SendType.Text : SendType.File; diff --git a/libs/angular/src/vault/components/attachments.component.ts b/libs/angular/src/vault/components/attachments.component.ts index 2c81dccdc776..fc86f2f5277c 100644 --- a/libs/angular/src/vault/components/attachments.component.ts +++ b/libs/angular/src/vault/components/attachments.component.ts @@ -1,6 +1,8 @@ import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; @@ -42,6 +44,7 @@ export class AttachmentsComponent implements OnInit { protected stateService: StateService, protected fileDownloadService: FileDownloadService, protected dialogService: DialogService, + protected billingAccountProfileStateService: BillingAccountProfileStateService, ) {} async ngOnInit() { @@ -185,7 +188,9 @@ export class AttachmentsComponent implements OnInit { await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain), ); - const canAccessPremium = await this.stateService.getCanAccessPremium(); + const canAccessPremium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$, + ); this.canAccessAttachments = canAccessPremium || this.cipher.organizationId != null; if (!this.canAccessAttachments) { diff --git a/libs/angular/src/vault/components/premium.component.ts b/libs/angular/src/vault/components/premium.component.ts index 526e453b2c1e..974a2b6cdd1b 100644 --- a/libs/angular/src/vault/components/premium.component.ts +++ b/libs/angular/src/vault/components/premium.component.ts @@ -1,6 +1,8 @@ -import { Directive, OnInit } from "@angular/core"; +import { Directive } from "@angular/core"; +import { Observable, Subject } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -9,11 +11,12 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv import { DialogService } from "@bitwarden/components"; @Directive() -export class PremiumComponent implements OnInit { - isPremium = false; +export class PremiumComponent { + isPremium$: Observable; price = 10; refreshPromise: Promise; cloudWebVaultUrl: string; + private directiveIsDestroyed$ = new Subject(); constructor( protected i18nService: I18nService, @@ -22,13 +25,11 @@ export class PremiumComponent implements OnInit { private logService: LogService, protected stateService: StateService, protected dialogService: DialogService, - private environmentService: EnvironmentService, + environmentService: EnvironmentService, + billingAccountProfileStateService: BillingAccountProfileStateService, ) { - this.cloudWebVaultUrl = this.environmentService.getCloudWebVaultUrl(); - } - - async ngOnInit() { - this.isPremium = await this.stateService.getCanAccessPremium(); + this.cloudWebVaultUrl = environmentService.getCloudWebVaultUrl(); + this.isPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$; } async refresh() { @@ -36,7 +37,6 @@ export class PremiumComponent implements OnInit { this.refreshPromise = this.apiService.refreshIdentityToken(); await this.refreshPromise; this.platformUtilsService.showToast("success", null, this.i18nService.t("refreshComplete")); - this.isPremium = await this.stateService.getCanAccessPremium(); } catch (e) { this.logService.error(e); } diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index 365041010a72..42349737f0d1 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -9,12 +9,13 @@ import { OnInit, Output, } from "@angular/core"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, Subject, takeUntil } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; @@ -68,6 +69,7 @@ export class ViewComponent implements OnDestroy, OnInit { private totpInterval: any; private previousCipherId: string; private passwordReprompted = false; + private directiveIsDestroyed$ = new Subject(); get fido2CredentialCreationDateValue(): string { const dateCreated = this.i18nService.t("dateCreated"); @@ -99,6 +101,7 @@ export class ViewComponent implements OnDestroy, OnInit { protected fileDownloadService: FileDownloadService, protected dialogService: DialogService, protected datePipe: DatePipe, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} ngOnInit() { @@ -116,11 +119,19 @@ export class ViewComponent implements OnDestroy, OnInit { } }); }); + + this.billingAccountProfileStateService.hasPremiumFromAnySource$ + .pipe(takeUntil(this.directiveIsDestroyed$)) + .subscribe((canAccessPremium: boolean) => { + this.canAccessPremium = canAccessPremium; + }); } ngOnDestroy() { this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); this.cleanUp(); + this.directiveIsDestroyed$.next(true); + this.directiveIsDestroyed$.complete(); } async load() { @@ -130,7 +141,6 @@ export class ViewComponent implements OnDestroy, OnInit { this.cipher = await cipher.decrypt( await this.cipherService.getKeyForCipherKeyDecryption(cipher), ); - this.canAccessPremium = await this.stateService.getCanAccessPremium(); this.showPremiumRequiredTotp = this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp; diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts index 6a045a8f623f..18ac9f0bf786 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts @@ -5,6 +5,7 @@ import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abst import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -37,6 +38,7 @@ describe("AuthRequestLoginStrategy", () => { let stateService: MockProxy; let twoFactorService: MockProxy; let deviceTrustCryptoService: MockProxy; + let billingAccountProfileStateService: MockProxy; let authRequestLoginStrategy: AuthRequestLoginStrategy; let credentials: AuthRequestLoginCredentials; @@ -64,6 +66,7 @@ describe("AuthRequestLoginStrategy", () => { stateService = mock(); twoFactorService = mock(); deviceTrustCryptoService = mock(); + billingAccountProfileStateService = mock(); tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); @@ -81,6 +84,7 @@ describe("AuthRequestLoginStrategy", () => { stateService, twoFactorService, deviceTrustCryptoService, + billingAccountProfileStateService, ); tokenResponse = identityTokenResponseFactory(); diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts index 01a2c970776c..09312226d8ba 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts @@ -9,6 +9,7 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { PasswordTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/password-token.request"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -54,6 +55,7 @@ export class AuthRequestLoginStrategy extends LoginStrategy { stateService: StateService, twoFactorService: TwoFactorService, private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, + billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( cryptoService, @@ -65,6 +67,7 @@ export class AuthRequestLoginStrategy extends LoginStrategy { logService, stateService, twoFactorService, + billingAccountProfileStateService, ); this.cache = new BehaviorSubject(data); 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 a9938bd39c4f..6f3d480f2012 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.spec.ts @@ -14,6 +14,7 @@ import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/id import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -109,6 +110,7 @@ describe("LoginStrategy", () => { let twoFactorService: MockProxy; let policyService: MockProxy; let passwordStrengthService: MockProxy; + let billingAccountProfileStateService: MockProxy; let passwordLoginStrategy: PasswordLoginStrategy; let credentials: PasswordLoginCredentials; @@ -127,6 +129,7 @@ describe("LoginStrategy", () => { policyService = mock(); passwordStrengthService = mock(); + billingAccountProfileStateService = mock(); appIdService.getAppId.mockResolvedValue(deviceId); tokenService.decodeAccessToken.calledWith(accessToken).mockResolvedValue(decodedToken); @@ -146,6 +149,7 @@ describe("LoginStrategy", () => { passwordStrengthService, policyService, loginStrategyService, + billingAccountProfileStateService, ); credentials = new PasswordLoginCredentials(email, masterPassword); }); @@ -192,7 +196,6 @@ describe("LoginStrategy", () => { userId: userId, name: name, email: email, - hasPremiumPersonally: false, kdfIterations: kdfIterations, kdfType: kdf, }, @@ -409,6 +412,7 @@ describe("LoginStrategy", () => { passwordStrengthService, policyService, loginStrategyService, + billingAccountProfileStateService, ); apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory()); diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index c6d441af2367..f5f28dd04409 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -15,6 +15,7 @@ import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request import { IdentityCaptchaResponse } from "@bitwarden/common/auth/models/response/identity-captcha.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; @@ -68,6 +69,7 @@ export abstract class LoginStrategy { protected logService: LogService, protected stateService: StateService, protected twoFactorService: TwoFactorService, + protected billingAccountProfileStateService: BillingAccountProfileStateService, ) {} abstract exportCache(): CacheData; @@ -191,7 +193,6 @@ export abstract class LoginStrategy { userId, name: accountInformation.name, email: accountInformation.email, - hasPremiumPersonally: accountInformation.premium, kdfIterations: tokenResponse.kdfIterations, kdfMemory: tokenResponse.kdfMemory, kdfParallelism: tokenResponse.kdfParallelism, @@ -206,6 +207,8 @@ export abstract class LoginStrategy { adminAuthRequest: adminAuthRequest?.toJSON(), }), ); + + await this.billingAccountProfileStateService.setHasPremium(accountInformation.premium, false); } protected async processTokenResponse(response: IdentityTokenResponse): Promise { diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts index 1ab908ac9e0f..007c33afc6b5 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts @@ -9,6 +9,7 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -61,6 +62,7 @@ describe("PasswordLoginStrategy", () => { let twoFactorService: MockProxy; let policyService: MockProxy; let passwordStrengthService: MockProxy; + let billingAccountProfileStateService: MockProxy; let passwordLoginStrategy: PasswordLoginStrategy; let credentials: PasswordLoginCredentials; @@ -79,6 +81,7 @@ describe("PasswordLoginStrategy", () => { twoFactorService = mock(); policyService = mock(); passwordStrengthService = mock(); + billingAccountProfileStateService = mock(); appIdService.getAppId.mockResolvedValue(deviceId); tokenService.decodeAccessToken.mockResolvedValue({}); @@ -108,6 +111,7 @@ describe("PasswordLoginStrategy", () => { passwordStrengthService, policyService, loginStrategyService, + billingAccountProfileStateService, ); credentials = new PasswordLoginCredentials(email, masterPassword); tokenResponse = identityTokenResponseFactory(masterPasswordPolicy); diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.ts b/libs/auth/src/common/login-strategies/password-login.strategy.ts index 2c99c243e07c..2104595b4503 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.ts @@ -13,6 +13,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide import { IdentityCaptchaResponse } from "@bitwarden/common/auth/models/response/identity-captcha.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -86,6 +87,7 @@ export class PasswordLoginStrategy extends LoginStrategy { private passwordStrengthService: PasswordStrengthServiceAbstraction, private policyService: PolicyService, private loginStrategyService: LoginStrategyServiceAbstraction, + billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( cryptoService, @@ -97,6 +99,7 @@ export class PasswordLoginStrategy extends LoginStrategy { logService, stateService, twoFactorService, + billingAccountProfileStateService, ); this.cache = new BehaviorSubject(data); diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts index 9946a6141f72..c987bcc95a66 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts @@ -9,6 +9,7 @@ import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/a import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -42,6 +43,7 @@ describe("SsoLoginStrategy", () => { let deviceTrustCryptoService: MockProxy; let authRequestService: MockProxy; let i18nService: MockProxy; + let billingAccountProfileStateService: MockProxy; let ssoLoginStrategy: SsoLoginStrategy; let credentials: SsoLoginCredentials; @@ -68,6 +70,7 @@ describe("SsoLoginStrategy", () => { deviceTrustCryptoService = mock(); authRequestService = mock(); i18nService = mock(); + billingAccountProfileStateService = mock(); tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); @@ -88,6 +91,7 @@ describe("SsoLoginStrategy", () => { deviceTrustCryptoService, authRequestService, i18nService, + billingAccountProfileStateService, ); credentials = new SsoLoginCredentials(ssoCode, ssoCodeVerifier, ssoRedirectUrl, ssoOrgId); }); diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.ts index 6b88a92f7013..b8d1df6f5772 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.ts @@ -10,6 +10,7 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for import { SsoTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/sso-token.request"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { HttpStatusCode } from "@bitwarden/common/enums"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -87,6 +88,7 @@ export class SsoLoginStrategy extends LoginStrategy { private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, private authRequestService: AuthRequestServiceAbstraction, private i18nService: I18nService, + billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( cryptoService, @@ -98,6 +100,7 @@ export class SsoLoginStrategy extends LoginStrategy { logService, stateService, twoFactorService, + billingAccountProfileStateService, ); this.cache = new BehaviorSubject(data); 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 da856a282eb0..48f6fd32aba0 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 @@ -4,6 +4,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -36,6 +37,7 @@ describe("UserApiLoginStrategy", () => { let twoFactorService: MockProxy; let keyConnectorService: MockProxy; let environmentService: MockProxy; + let billingAccountProfileStateService: MockProxy; let apiLogInStrategy: UserApiLoginStrategy; let credentials: UserApiLoginCredentials; @@ -57,6 +59,7 @@ describe("UserApiLoginStrategy", () => { twoFactorService = mock(); keyConnectorService = mock(); environmentService = mock(); + billingAccountProfileStateService = mock(); appIdService.getAppId.mockResolvedValue(deviceId); tokenService.getTwoFactorToken.mockResolvedValue(null); @@ -75,6 +78,7 @@ describe("UserApiLoginStrategy", () => { twoFactorService, environmentService, keyConnectorService, + billingAccountProfileStateService, ); credentials = new UserApiLoginCredentials(apiClientId, apiClientSecret); diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts index 68916b6e8e18..9bb6d8fb1258 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts @@ -7,6 +7,7 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service" import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { UserApiTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/user-api-token.request"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -48,6 +49,7 @@ export class UserApiLoginStrategy extends LoginStrategy { twoFactorService: TwoFactorService, private environmentService: EnvironmentService, private keyConnectorService: KeyConnectorService, + billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( cryptoService, @@ -59,6 +61,7 @@ export class UserApiLoginStrategy extends LoginStrategy { logService, stateService, twoFactorService, + billingAccountProfileStateService, ); this.cache = new BehaviorSubject(data); } diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts index b7a56e623083..9ab64170c1df 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts @@ -7,6 +7,7 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response"; import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -34,6 +35,7 @@ describe("WebAuthnLoginStrategy", () => { let logService!: MockProxy; let stateService!: MockProxy; let twoFactorService!: MockProxy; + let billingAccountProfileStateService: MockProxy; let webAuthnLoginStrategy!: WebAuthnLoginStrategy; @@ -68,6 +70,7 @@ describe("WebAuthnLoginStrategy", () => { logService = mock(); stateService = mock(); twoFactorService = mock(); + billingAccountProfileStateService = mock(); tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); @@ -84,6 +87,7 @@ describe("WebAuthnLoginStrategy", () => { logService, stateService, twoFactorService, + billingAccountProfileStateService, ); // Create credentials diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts index c42e6d657452..b60342f0b41e 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts @@ -7,6 +7,7 @@ import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/webauthn-login-token.request"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -48,6 +49,7 @@ export class WebAuthnLoginStrategy extends LoginStrategy { logService: LogService, stateService: StateService, twoFactorService: TwoFactorService, + billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( cryptoService, @@ -59,6 +61,7 @@ export class WebAuthnLoginStrategy extends LoginStrategy { logService, stateService, twoFactorService, + billingAccountProfileStateService, ); this.cache = new BehaviorSubject(data); diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts index 2304dc4d3393..3d4c1b7b7d5c 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts @@ -11,6 +11,7 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; @@ -50,6 +51,7 @@ describe("LoginStrategyService", () => { let policyService: MockProxy; let deviceTrustCryptoService: MockProxy; let authRequestService: MockProxy; + let billingAccountProfileStateService: MockProxy; let stateProvider: FakeGlobalStateProvider; let loginStrategyCacheExpirationState: FakeGlobalState; @@ -72,6 +74,7 @@ describe("LoginStrategyService", () => { policyService = mock(); deviceTrustCryptoService = mock(); authRequestService = mock(); + billingAccountProfileStateService = mock(); stateProvider = new FakeGlobalStateProvider(); sut = new LoginStrategyService( @@ -93,6 +96,7 @@ describe("LoginStrategyService", () => { deviceTrustCryptoService, authRequestService, stateProvider, + billingAccountProfileStateService, ); loginStrategyCacheExpirationState = stateProvider.getFake(CACHE_EXPIRATION_KEY); diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts index 7ef8432aa5f4..5c0e41404468 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts @@ -20,6 +20,7 @@ import { KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { PasswordlessAuthRequest } from "@bitwarden/common/auth/models/request/passwordless-auth.request"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { PreloginRequest } from "@bitwarden/common/models/request/prelogin.request"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response"; @@ -101,6 +102,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, protected authRequestService: AuthRequestServiceAbstraction, protected stateProvider: GlobalStateProvider, + protected billingAccountProfileStateService: BillingAccountProfileStateService, ) { this.currentAuthnTypeState = this.stateProvider.get(CURRENT_LOGIN_STRATEGY_KEY); this.loginStrategyCacheState = this.stateProvider.get(CACHE_KEY); @@ -355,6 +357,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { this.passwordStrengthService, this.policyService, this, + this.billingAccountProfileStateService, ); case AuthenticationType.Sso: return new SsoLoginStrategy( @@ -372,6 +375,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { this.deviceTrustCryptoService, this.authRequestService, this.i18nService, + this.billingAccountProfileStateService, ); case AuthenticationType.UserApiKey: return new UserApiLoginStrategy( @@ -387,6 +391,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { this.twoFactorService, this.environmentService, this.keyConnectorService, + this.billingAccountProfileStateService, ); case AuthenticationType.AuthRequest: return new AuthRequestLoginStrategy( @@ -401,6 +406,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { this.stateService, this.twoFactorService, this.deviceTrustCryptoService, + this.billingAccountProfileStateService, ); case AuthenticationType.WebAuthn: return new WebAuthnLoginStrategy( @@ -414,6 +420,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { this.logService, this.stateService, this.twoFactorService, + this.billingAccountProfileStateService, ); } }), diff --git a/libs/common/src/billing/abstractions/account/billing-account-profile-state.service.ts b/libs/common/src/billing/abstractions/account/billing-account-profile-state.service.ts new file mode 100644 index 000000000000..e07dec3cf900 --- /dev/null +++ b/libs/common/src/billing/abstractions/account/billing-account-profile-state.service.ts @@ -0,0 +1,36 @@ +import { Observable } from "rxjs"; + +export type BillingAccountProfile = { + hasPremiumPersonally: boolean; + hasPremiumFromAnyOrganization: boolean; +}; + +export abstract class BillingAccountProfileStateService { + /** + * Emits `true` when the active user's account has been granted premium from any of the + * organizations it is a member of. Otherwise, emits `false` + */ + hasPremiumFromAnyOrganization$: Observable; + + /** + * Emits `true` when the active user's account has an active premium subscription at the + * individual user level + */ + hasPremiumPersonally$: Observable; + + /** + * Emits `true` when either `hasPremiumPersonally` or `hasPremiumFromAnyOrganization` is `true` + */ + hasPremiumFromAnySource$: Observable; + + /** + * Sets the active user's premium status fields upon every full sync, either from their personal + * subscription to premium, or an organization they're a part of that grants them premium. + * @param hasPremiumPersonally + * @param hasPremiumFromAnyOrganization + */ + abstract setHasPremium( + hasPremiumPersonally: boolean, + hasPremiumFromAnyOrganization: boolean, + ): Promise; +} diff --git a/libs/common/src/billing/services/account/billing-account-profile-state.service.spec.ts b/libs/common/src/billing/services/account/billing-account-profile-state.service.spec.ts new file mode 100644 index 000000000000..4a2a94e9c602 --- /dev/null +++ b/libs/common/src/billing/services/account/billing-account-profile-state.service.spec.ts @@ -0,0 +1,165 @@ +import { firstValueFrom } from "rxjs"; + +import { + FakeAccountService, + FakeActiveUserStateProvider, + mockAccountServiceWith, + FakeActiveUserState, + trackEmissions, +} from "../../../../spec"; +import { UserId } from "../../../types/guid"; +import { BillingAccountProfile } from "../../abstractions/account/billing-account-profile-state.service"; + +import { + BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, + DefaultBillingAccountProfileStateService, +} from "./billing-account-profile-state.service"; + +describe("BillingAccountProfileStateService", () => { + let activeUserStateProvider: FakeActiveUserStateProvider; + let sut: DefaultBillingAccountProfileStateService; + let billingAccountProfileState: FakeActiveUserState; + let accountService: FakeAccountService; + + const userId = "fakeUserId" as UserId; + + beforeEach(() => { + accountService = mockAccountServiceWith(userId); + activeUserStateProvider = new FakeActiveUserStateProvider(accountService); + + sut = new DefaultBillingAccountProfileStateService(activeUserStateProvider); + + billingAccountProfileState = activeUserStateProvider.getFake( + BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, + ); + }); + + afterEach(() => { + return jest.resetAllMocks(); + }); + + describe("accountHasPremiumFromAnyOrganization$", () => { + it("should emit changes in hasPremiumFromAnyOrganization", async () => { + billingAccountProfileState.nextState({ + hasPremiumPersonally: false, + hasPremiumFromAnyOrganization: true, + }); + + expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(true); + }); + + it("should emit once when calling setHasPremium once", async () => { + const emissions = trackEmissions(sut.hasPremiumFromAnyOrganization$); + const startingEmissionCount = emissions.length; + + await sut.setHasPremium(true, true); + + const endingEmissionCount = emissions.length; + expect(endingEmissionCount - startingEmissionCount).toBe(1); + }); + }); + + describe("hasPremiumPersonally$", () => { + it("should emit changes in hasPremiumPersonally", async () => { + billingAccountProfileState.nextState({ + hasPremiumPersonally: true, + hasPremiumFromAnyOrganization: false, + }); + + expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(true); + }); + + it("should emit once when calling setHasPremium once", async () => { + const emissions = trackEmissions(sut.hasPremiumPersonally$); + const startingEmissionCount = emissions.length; + + await sut.setHasPremium(true, true); + + const endingEmissionCount = emissions.length; + expect(endingEmissionCount - startingEmissionCount).toBe(1); + }); + }); + + describe("canAccessPremium$", () => { + it("should emit changes in hasPremiumPersonally", async () => { + billingAccountProfileState.nextState({ + hasPremiumPersonally: true, + hasPremiumFromAnyOrganization: false, + }); + + expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true); + }); + + it("should emit changes in hasPremiumFromAnyOrganization", async () => { + billingAccountProfileState.nextState({ + hasPremiumPersonally: false, + hasPremiumFromAnyOrganization: true, + }); + + expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true); + }); + + it("should emit changes in both hasPremiumPersonally and hasPremiumFromAnyOrganization", async () => { + billingAccountProfileState.nextState({ + hasPremiumPersonally: true, + hasPremiumFromAnyOrganization: true, + }); + + expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true); + }); + + it("should emit once when calling setHasPremium once", async () => { + const emissions = trackEmissions(sut.hasPremiumFromAnySource$); + const startingEmissionCount = emissions.length; + + await sut.setHasPremium(true, true); + + const endingEmissionCount = emissions.length; + expect(endingEmissionCount - startingEmissionCount).toBe(1); + }); + }); + + describe("setHasPremium", () => { + it("should have `hasPremiumPersonally$` emit `true` when passing `true` as an argument for hasPremiumPersonally", async () => { + await sut.setHasPremium(true, false); + + expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(true); + }); + + it("should have `hasPremiumFromAnyOrganization$` emit `true` when passing `true` as an argument for hasPremiumFromAnyOrganization", async () => { + await sut.setHasPremium(false, true); + + expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(true); + }); + + it("should have `hasPremiumPersonally$` emit `false` when passing `false` as an argument for hasPremiumPersonally", async () => { + await sut.setHasPremium(false, false); + + expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(false); + }); + + it("should have `hasPremiumFromAnyOrganization$` emit `false` when passing `false` as an argument for hasPremiumFromAnyOrganization", async () => { + await sut.setHasPremium(false, false); + + expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(false); + }); + + it("should have `canAccessPremium$` emit `true` when passing `true` as an argument for hasPremiumPersonally", async () => { + await sut.setHasPremium(true, false); + + expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true); + }); + + it("should have `canAccessPremium$` emit `true` when passing `true` as an argument for hasPremiumFromAnyOrganization", async () => { + await sut.setHasPremium(false, true); + + expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true); + }); + + it("should have `canAccessPremium$` emit `false` when passing `false` for all arguments", async () => { + await sut.setHasPremium(false, false); + + expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(false); + }); + }); +}); diff --git a/libs/common/src/billing/services/account/billing-account-profile-state.service.ts b/libs/common/src/billing/services/account/billing-account-profile-state.service.ts new file mode 100644 index 000000000000..c6b6f104a8ee --- /dev/null +++ b/libs/common/src/billing/services/account/billing-account-profile-state.service.ts @@ -0,0 +1,62 @@ +import { map, Observable } from "rxjs"; + +import { + ActiveUserState, + ActiveUserStateProvider, + BILLING_DISK, + KeyDefinition, +} from "../../../platform/state"; +import { + BillingAccountProfile, + BillingAccountProfileStateService, +} from "../../abstractions/account/billing-account-profile-state.service"; + +export const BILLING_ACCOUNT_PROFILE_KEY_DEFINITION = new KeyDefinition( + BILLING_DISK, + "accountProfile", + { + deserializer: (billingAccountProfile) => billingAccountProfile, + }, +); + +export class DefaultBillingAccountProfileStateService implements BillingAccountProfileStateService { + private billingAccountProfileState: ActiveUserState; + + hasPremiumFromAnyOrganization$: Observable; + hasPremiumPersonally$: Observable; + hasPremiumFromAnySource$: Observable; + + constructor(activeUserStateProvider: ActiveUserStateProvider) { + this.billingAccountProfileState = activeUserStateProvider.get( + BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, + ); + + this.hasPremiumFromAnyOrganization$ = this.billingAccountProfileState.state$.pipe( + map((billingAccountProfile) => !!billingAccountProfile?.hasPremiumFromAnyOrganization), + ); + + this.hasPremiumPersonally$ = this.billingAccountProfileState.state$.pipe( + map((billingAccountProfile) => !!billingAccountProfile?.hasPremiumPersonally), + ); + + this.hasPremiumFromAnySource$ = this.billingAccountProfileState.state$.pipe( + map( + (billingAccountProfile) => + billingAccountProfile?.hasPremiumFromAnyOrganization || + billingAccountProfile?.hasPremiumPersonally, + ), + ); + } + + async setHasPremium( + hasPremiumPersonally: boolean, + hasPremiumFromAnyOrganization: boolean, + ): Promise { + await this.billingAccountProfileState.update((billingAccountProfile) => { + return { + hasPremiumPersonally: hasPremiumPersonally, + hasPremiumFromAnyOrganization: hasPremiumFromAnyOrganization, + }; + }); + } +} diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 1c0cf12d9501..68b6260d48bc 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -60,11 +60,6 @@ export abstract class StateService { setAutoConfirmFingerprints: (value: boolean, options?: StorageOptions) => Promise; getBiometricFingerprintValidated: (options?: StorageOptions) => Promise; setBiometricFingerprintValidated: (value: boolean, options?: StorageOptions) => Promise; - getCanAccessPremium: (options?: StorageOptions) => Promise; - getHasPremiumPersonally: (options?: StorageOptions) => Promise; - setHasPremiumPersonally: (value: boolean, options?: StorageOptions) => Promise; - setHasPremiumFromOrganization: (value: boolean, options?: StorageOptions) => Promise; - getHasPremiumFromOrganization: (options?: StorageOptions) => Promise; getConvertAccountToKeyConnector: (options?: StorageOptions) => Promise; setConvertAccountToKeyConnector: (value: boolean, options?: StorageOptions) => Promise; /** diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 2df2b53b0119..64232ec61502 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -170,8 +170,6 @@ export class AccountProfile { emailVerified?: boolean; everBeenUnlocked?: boolean; forceSetPasswordReason?: ForceSetPasswordReason; - hasPremiumPersonally?: boolean; - hasPremiumFromOrganization?: boolean; lastSync?: string; userId?: string; usesKeyConnector?: boolean; diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 60256564dfab..73a56fda3110 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -337,58 +337,6 @@ export class StateService< ); } - async getCanAccessPremium(options?: StorageOptions): Promise { - if (!(await this.getIsAuthenticated(options))) { - return false; - } - - return ( - (await this.getHasPremiumPersonally(options)) || - (await this.getHasPremiumFromOrganization(options)) - ); - } - - async getHasPremiumPersonally(options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - return account?.profile?.hasPremiumPersonally; - } - - async setHasPremiumPersonally(value: boolean, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.profile.hasPremiumPersonally = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - - async getHasPremiumFromOrganization(options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - - if (account.profile?.hasPremiumFromOrganization) { - return true; - } - - return false; - } - - async setHasPremiumFromOrganization(value: boolean, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.profile.hasPremiumFromOrganization = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getConvertAccountToKeyConnector(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 34b6bb097f04..6a41b82dcc89 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -23,6 +23,9 @@ export const ORGANIZATIONS_DISK = new StateDefinition("organizations", "disk"); export const POLICIES_DISK = new StateDefinition("policies", "disk"); export const PROVIDERS_DISK = new StateDefinition("providers", "disk"); +// Billing +export const BILLING_DISK = new StateDefinition("billing", "disk"); + // Auth export const ACCOUNT_MEMORY = new StateDefinition("account", "memory"); @@ -43,15 +46,11 @@ export const USER_NOTIFICATION_SETTINGS_DISK = new StateDefinition( "disk", ); -// Billing - export const DOMAIN_SETTINGS_DISK = new StateDefinition("domainSettings", "disk"); - export const AUTOFILL_SETTINGS_DISK = new StateDefinition("autofillSettings", "disk"); export const AUTOFILL_SETTINGS_DISK_LOCAL = new StateDefinition("autofillSettingsLocal", "disk", { web: "disk-local", }); -export const BILLING_DISK = new StateDefinition("billing", "disk"); // Components diff --git a/libs/common/src/platform/state/user-key-definition.ts b/libs/common/src/platform/state/user-key-definition.ts index 99e3039e1ebf..3405b388375b 100644 --- a/libs/common/src/platform/state/user-key-definition.ts +++ b/libs/common/src/platform/state/user-key-definition.ts @@ -138,7 +138,9 @@ export class UserKeyDefinition { buildKey(userId: UserId) { if (!Utils.isGuid(userId)) { - throw new Error("You cannot build a user key without a valid UserId"); + throw new Error( + `You cannot build a user key without a valid UserId, building for key ${this.fullName}`, + ); } return `user_${userId}_${this.stateDefinition.name}_${this.key}` as StorageKey; } diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 8f59d38549ce..fe1080b52733 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -33,8 +33,9 @@ import { MoveThemeToStateProviderMigrator } from "./migrations/35-move-theme-to- import { VaultSettingsKeyMigrator } from "./migrations/36-move-show-card-and-identity-to-state-provider"; import { AvatarColorMigrator } from "./migrations/37-move-avatar-color-to-state-providers"; import { TokenServiceStateProviderMigrator } from "./migrations/38-migrate-token-svc-to-state-provider"; -import { OrganizationMigrator } from "./migrations/39-move-organization-state-to-state-provider"; +import { MoveBillingAccountProfileMigrator } from "./migrations/39-move-billing-account-profile-to-state-providers"; import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked"; +import { OrganizationMigrator } from "./migrations/40-move-organization-state-to-state-provider"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; @@ -43,7 +44,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 39; +export const CURRENT_VERSION = 40; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -84,6 +85,7 @@ export function createMigrationBuilder() { .with(VaultSettingsKeyMigrator, 35, 36) .with(AvatarColorMigrator, 36, 37) .with(TokenServiceStateProviderMigrator, 37, 38) + .with(MoveBillingAccountProfileMigrator, 38, 39) .with(OrganizationMigrator, 38, CURRENT_VERSION); } diff --git a/libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.spec.ts b/libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.spec.ts new file mode 100644 index 000000000000..8616dda81de5 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.spec.ts @@ -0,0 +1,126 @@ +import { MockProxy, any } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { + BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, + MoveBillingAccountProfileMigrator, +} from "./39-move-billing-account-profile-to-state-providers"; + +const exampleJSON = () => ({ + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2", "user-3"], + "user-1": { + profile: { + hasPremiumPersonally: true, + hasPremiumFromOrganization: false, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + otherStuff: "otherStuff4", + }, +}); + +const rollbackJSON = () => ({ + "user_user-1_billing_accountProfile": { + hasPremiumPersonally: true, + hasPremiumFromOrganization: false, + }, + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2", "user-3"], + "user-1": { + profile: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + otherStuff: "otherStuff4", + }, +}); + +describe("MoveBillingAccountProfileToStateProviders migrator", () => { + let helper: MockProxy; + let sut: MoveBillingAccountProfileMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 39); + sut = new MoveBillingAccountProfileMigrator(38, 39); + }); + + it("removes from all accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("user-1", { + profile: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it("sets hasPremiumPersonally value for account that have it", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith( + "user-1", + BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, + { hasPremiumFromOrganization: false, hasPremiumPersonally: true }, + ); + }); + + it("should not call extra setToUser", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledTimes(1); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 39); + sut = new MoveBillingAccountProfileMigrator(38, 39); + }); + + it("nulls out new values", async () => { + await sut.rollback(helper); + + expect(helper.setToUser).toHaveBeenCalledWith( + "user-1", + BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, + null, + ); + }); + + it("adds explicit value back to accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("user-1", { + profile: { + hasPremiumPersonally: true, + hasPremiumFromOrganization: false, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it.each(["user-2", "user-3"])( + "does not restore values when accounts are not present", + async (userId) => { + await sut.rollback(helper); + + expect(helper.set).not.toHaveBeenCalledWith(userId, any()); + }, + ); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.ts b/libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.ts new file mode 100644 index 000000000000..b6c0e531ef57 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.ts @@ -0,0 +1,67 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type ExpectedAccountType = { + profile?: { + hasPremiumPersonally?: boolean; + hasPremiumFromOrganization?: boolean; + }; +}; + +type ExpectedBillingAccountProfileType = { + hasPremiumPersonally: boolean; + hasPremiumFromOrganization: boolean; +}; + +export const BILLING_ACCOUNT_PROFILE_KEY_DEFINITION: KeyDefinitionLike = { + key: "accountProfile", + stateDefinition: { + name: "billing", + }, +}; + +export class MoveBillingAccountProfileMigrator extends Migrator<38, 39> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + const migrateAccount = async (userId: string, account: ExpectedAccountType): Promise => { + const hasPremiumPersonally = account?.profile?.hasPremiumPersonally; + const hasPremiumFromOrganization = account?.profile?.hasPremiumFromOrganization; + + if (hasPremiumPersonally != null || hasPremiumFromOrganization != null) { + await helper.setToUser(userId, BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, { + hasPremiumPersonally: hasPremiumPersonally, + hasPremiumFromOrganization: hasPremiumFromOrganization, + }); + + delete account?.profile?.hasPremiumPersonally; + delete account?.profile?.hasPremiumFromOrganization; + await helper.set(userId, account); + } + }; + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + const rollbackAccount = async (userId: string, account: ExpectedAccountType): Promise => { + const value = await helper.getFromUser( + userId, + BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, + ); + + if (account && value) { + account.profile = Object.assign(account.profile ?? {}, { + hasPremiumPersonally: value?.hasPremiumPersonally, + hasPremiumFromOrganization: value?.hasPremiumFromOrganization, + }); + await helper.set(userId, account); + } + + await helper.setToUser(userId, BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, null); + }; + + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +} diff --git a/libs/common/src/state-migrations/migrations/39-move-organization-state-to-state-provider.spec.ts b/libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.spec.ts similarity index 94% rename from libs/common/src/state-migrations/migrations/39-move-organization-state-to-state-provider.spec.ts rename to libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.spec.ts index f63846c670b0..94078e8153af 100644 --- a/libs/common/src/state-migrations/migrations/39-move-organization-state-to-state-provider.spec.ts +++ b/libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.spec.ts @@ -3,7 +3,7 @@ import { any, MockProxy } from "jest-mock-extended"; import { MigrationHelper } from "../migration-helper"; import { mockMigrationHelper } from "../migration-helper.spec"; -import { OrganizationMigrator } from "./39-move-organization-state-to-state-provider"; +import { OrganizationMigrator } from "./40-move-organization-state-to-state-provider"; const testDate = new Date(); function exampleOrganization1() { @@ -121,8 +121,8 @@ describe("OrganizationMigrator", () => { describe("migrate", () => { beforeEach(() => { - helper = mockMigrationHelper(exampleJSON(), 39); - sut = new OrganizationMigrator(38, 39); + helper = mockMigrationHelper(exampleJSON(), 40); + sut = new OrganizationMigrator(39, 40); }); it("should remove organizations from all accounts", async () => { @@ -149,8 +149,8 @@ describe("OrganizationMigrator", () => { describe("rollback", () => { beforeEach(() => { - helper = mockMigrationHelper(rollbackJSON(), 39); - sut = new OrganizationMigrator(38, 39); + helper = mockMigrationHelper(rollbackJSON(), 40); + sut = new OrganizationMigrator(39, 40); }); it.each(["user-1", "user-2"])("should null out new values", async (userId) => { diff --git a/libs/common/src/state-migrations/migrations/39-move-organization-state-to-state-provider.ts b/libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.ts similarity index 98% rename from libs/common/src/state-migrations/migrations/39-move-organization-state-to-state-provider.ts rename to libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.ts index 1cc81b315611..1dfb019942a9 100644 --- a/libs/common/src/state-migrations/migrations/39-move-organization-state-to-state-provider.ts +++ b/libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.ts @@ -115,7 +115,7 @@ const USER_ORGANIZATIONS: KeyDefinitionLike = { }, }; -export class OrganizationMigrator extends Migrator<38, 39> { +export class OrganizationMigrator extends Migrator<39, 40> { async migrate(helper: MigrationHelper): Promise { const accounts = await helper.getAccounts(); async function migrateAccount(userId: string, account: ExpectedAccountType): Promise { diff --git a/libs/common/src/vault/services/sync/sync.service.ts b/libs/common/src/vault/services/sync/sync.service.ts index 1b46bf432946..1b3e63d0012b 100644 --- a/libs/common/src/vault/services/sync/sync.service.ts +++ b/libs/common/src/vault/services/sync/sync.service.ts @@ -11,6 +11,7 @@ import { AvatarService } from "../../../auth/abstractions/avatar.service"; import { KeyConnectorService } from "../../../auth/abstractions/key-connector.service"; import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; +import { BillingAccountProfileStateService } from "../../../billing/abstractions/account/billing-account-profile-state.service"; import { DomainsResponse } from "../../../models/response/domains.response"; import { SyncCipherNotification, @@ -62,6 +63,7 @@ export class SyncService implements SyncServiceAbstraction { private sendApiService: SendApiService, private avatarService: AvatarService, private logoutCallback: (expired: boolean) => Promise, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} async getLastSync(): Promise { @@ -314,8 +316,11 @@ export class SyncService implements SyncServiceAbstraction { await this.avatarService.setAvatarColor(response.avatarColor); await this.stateService.setSecurityStamp(response.securityStamp); await this.stateService.setEmailVerified(response.emailVerified); - await this.stateService.setHasPremiumPersonally(response.premiumPersonally); - await this.stateService.setHasPremiumFromOrganization(response.premiumFromOrganization); + + await this.billingAccountProfileStateService.setHasPremium( + response.premiumPersonally, + response.premiumFromOrganization, + ); await this.keyConnectorService.setUsesKeyConnector(response.usesKeyConnector); await this.setForceSetPasswordReasonIfNeeded(response);