1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00

[PM-14366] Deprecated active user state from billing state service (#12273)

* Updated billing state provider to not rely on ActiveUserStateProvider

* Updated usages

* Resolved browser build

* Resolved web build

* Resolved CLI build

* resolved desktop build

* Update apps/cli/src/tools/send/commands/create.command.ts

Co-authored-by:  Audrey  <ajensen@bitwarden.com>

* Move subscription visibility logic from component to service

* Resolved unit test failures. Using existing userIds where present

* Simplified activeUserId access

* Resolved typescript strict errors

* Resolved broken unit test

* Resolved ts strict error

---------

Co-authored-by:  Audrey  <ajensen@bitwarden.com>
This commit is contained in:
Conner Turnbull
2025-01-07 10:25:26 -05:00
committed by GitHub
parent 003f5fdae9
commit 91d6963074
56 changed files with 595 additions and 227 deletions

View File

@@ -11,27 +11,32 @@ export type BillingAccountProfile = {
export abstract class BillingAccountProfileStateService {
/**
* Emits `true` when the active user's account has been granted premium from any of the
* Emits `true` when the user's account has been granted premium from any of the
* organizations it is a member of. Otherwise, emits `false`
*/
hasPremiumFromAnyOrganization$: Observable<boolean>;
abstract hasPremiumFromAnyOrganization$(userId: UserId): Observable<boolean>;
/**
* Emits `true` when the active user's account has an active premium subscription at the
* Emits `true` when the user's account has an active premium subscription at the
* individual user level
*/
hasPremiumPersonally$: Observable<boolean>;
abstract hasPremiumPersonally$(userId: UserId): Observable<boolean>;
/**
* Emits `true` when either `hasPremiumPersonally` or `hasPremiumFromAnyOrganization` is `true`
*/
hasPremiumFromAnySource$: Observable<boolean>;
abstract hasPremiumFromAnySource$(userId: UserId): Observable<boolean>;
/**
* Sets the active user's premium status fields upon every full sync, either from their personal
* Emits `true` when the subscription menu item should be shown in navigation.
* This is hidden for organizations that provide premium, except if the user has premium personally
* or has a billing history.
*/
abstract canViewSubscription$(userId: UserId): Observable<boolean>;
/**
* Sets the 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,

View File

@@ -1,5 +1,9 @@
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BillingHistoryResponse } from "@bitwarden/common/billing/models/response/billing-history.response";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
FakeAccountService,
mockAccountServiceWith,
@@ -19,14 +23,26 @@ describe("BillingAccountProfileStateService", () => {
let sut: DefaultBillingAccountProfileStateService;
let userBillingAccountProfileState: FakeSingleUserState<BillingAccountProfile>;
let accountService: FakeAccountService;
let platformUtilsService: jest.Mocked<PlatformUtilsService>;
let apiService: jest.Mocked<ApiService>;
const userId = "fakeUserId" as UserId;
beforeEach(() => {
accountService = mockAccountServiceWith(userId);
stateProvider = new FakeStateProvider(accountService);
platformUtilsService = {
isSelfHost: jest.fn(),
} as any;
apiService = {
getUserBillingHistory: jest.fn(),
} as any;
sut = new DefaultBillingAccountProfileStateService(stateProvider);
sut = new DefaultBillingAccountProfileStateService(
stateProvider,
platformUtilsService,
apiService,
);
userBillingAccountProfileState = stateProvider.singleUser.getFake(
userId,
@@ -45,7 +61,7 @@ describe("BillingAccountProfileStateService", () => {
hasPremiumFromAnyOrganization: true,
});
expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(true);
expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$(userId))).toBe(true);
});
it("return false when they do not have premium from an organization", async () => {
@@ -54,13 +70,7 @@ describe("BillingAccountProfileStateService", () => {
hasPremiumFromAnyOrganization: false,
});
expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(false);
});
it("returns false when there is no active user", async () => {
await accountService.switchAccount(null);
expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(false);
expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$(userId))).toBe(false);
});
});
@@ -71,7 +81,7 @@ describe("BillingAccountProfileStateService", () => {
hasPremiumFromAnyOrganization: false,
});
expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(true);
expect(await firstValueFrom(sut.hasPremiumPersonally$(userId))).toBe(true);
});
it("returns false when the user does not have premium personally", async () => {
@@ -80,13 +90,7 @@ describe("BillingAccountProfileStateService", () => {
hasPremiumFromAnyOrganization: false,
});
expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(false);
});
it("returns false when there is no active user", async () => {
await accountService.switchAccount(null);
expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(false);
expect(await firstValueFrom(sut.hasPremiumPersonally$(userId))).toBe(false);
});
});
@@ -97,7 +101,7 @@ describe("BillingAccountProfileStateService", () => {
hasPremiumFromAnyOrganization: false,
});
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
expect(await firstValueFrom(sut.hasPremiumFromAnySource$(userId))).toBe(true);
});
it("returns true when the user has premium from an organization", async () => {
@@ -106,7 +110,7 @@ describe("BillingAccountProfileStateService", () => {
hasPremiumFromAnyOrganization: true,
});
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
expect(await firstValueFrom(sut.hasPremiumFromAnySource$(userId))).toBe(true);
});
it("returns true when they have premium personally AND from an organization", async () => {
@@ -115,23 +119,87 @@ describe("BillingAccountProfileStateService", () => {
hasPremiumFromAnyOrganization: true,
});
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
});
it("returns false when there is no active user", async () => {
await accountService.switchAccount(null);
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(false);
expect(await firstValueFrom(sut.hasPremiumFromAnySource$(userId))).toBe(true);
});
});
describe("setHasPremium", () => {
it("should update the active users state when called", async () => {
it("should update the user's state when called", async () => {
await sut.setHasPremium(true, false, userId);
expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(false);
expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(true);
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$(userId))).toBe(false);
expect(await firstValueFrom(sut.hasPremiumPersonally$(userId))).toBe(true);
expect(await firstValueFrom(sut.hasPremiumFromAnySource$(userId))).toBe(true);
});
});
describe("canViewSubscription$", () => {
beforeEach(() => {
platformUtilsService.isSelfHost.mockReturnValue(false);
apiService.getUserBillingHistory.mockResolvedValue(
new BillingHistoryResponse({ invoices: [], transactions: [] }),
);
});
it("returns true when user has premium personally", async () => {
userBillingAccountProfileState.nextState({
hasPremiumPersonally: true,
hasPremiumFromAnyOrganization: true,
});
expect(await firstValueFrom(sut.canViewSubscription$(userId))).toBe(true);
});
it("returns true when user has no premium from any source", async () => {
userBillingAccountProfileState.nextState({
hasPremiumPersonally: false,
hasPremiumFromAnyOrganization: false,
});
expect(await firstValueFrom(sut.canViewSubscription$(userId))).toBe(true);
});
it("returns true when user has billing history in cloud environment", async () => {
userBillingAccountProfileState.nextState({
hasPremiumPersonally: false,
hasPremiumFromAnyOrganization: true,
});
platformUtilsService.isSelfHost.mockReturnValue(false);
apiService.getUserBillingHistory.mockResolvedValue(
new BillingHistoryResponse({
invoices: [{ id: "1" }],
transactions: [{ id: "2" }],
}),
);
expect(await firstValueFrom(sut.canViewSubscription$(userId))).toBe(true);
});
it("returns false when user has no premium personally, has org premium, and no billing history", async () => {
userBillingAccountProfileState.nextState({
hasPremiumPersonally: false,
hasPremiumFromAnyOrganization: true,
});
platformUtilsService.isSelfHost.mockReturnValue(false);
apiService.getUserBillingHistory.mockResolvedValue(
new BillingHistoryResponse({
invoices: [],
transactions: [],
}),
);
expect(await firstValueFrom(sut.canViewSubscription$(userId))).toBe(false);
});
it("returns false when user has no premium personally, has org premium, in self-hosted environment", async () => {
userBillingAccountProfileState.nextState({
hasPremiumPersonally: false,
hasPremiumFromAnyOrganization: true,
});
platformUtilsService.isSelfHost.mockReturnValue(true);
expect(await firstValueFrom(sut.canViewSubscription$(userId))).toBe(false);
expect(apiService.getUserBillingHistory).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,11 +1,9 @@
import { map, Observable, of, switchMap } from "rxjs";
import { map, Observable, combineLatest, concatMap } from "rxjs";
import {
ActiveUserState,
BILLING_DISK,
StateProvider,
UserKeyDefinition,
} from "../../../platform/state";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { BILLING_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import {
BillingAccountProfile,
@@ -22,42 +20,34 @@ export const BILLING_ACCOUNT_PROFILE_KEY_DEFINITION = new UserKeyDefinition<Bill
);
export class DefaultBillingAccountProfileStateService implements BillingAccountProfileStateService {
private billingAccountProfileState: ActiveUserState<BillingAccountProfile>;
constructor(
private readonly stateProvider: StateProvider,
private readonly platformUtilsService: PlatformUtilsService,
private readonly apiService: ApiService,
) {}
hasPremiumFromAnyOrganization$: Observable<boolean>;
hasPremiumPersonally$: Observable<boolean>;
hasPremiumFromAnySource$: Observable<boolean>;
hasPremiumFromAnyOrganization$(userId: UserId): Observable<boolean> {
return this.stateProvider
.getUser(userId, BILLING_ACCOUNT_PROFILE_KEY_DEFINITION)
.state$.pipe(map((profile) => !!profile?.hasPremiumFromAnyOrganization));
}
constructor(private readonly stateProvider: StateProvider) {
this.billingAccountProfileState = stateProvider.getActive(
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
);
hasPremiumPersonally$(userId: UserId): Observable<boolean> {
return this.stateProvider
.getUser(userId, BILLING_ACCOUNT_PROFILE_KEY_DEFINITION)
.state$.pipe(map((profile) => !!profile?.hasPremiumPersonally));
}
// Setup an observable that will always track the currently active user
// but will fallback to emitting null when there is no active user.
const billingAccountProfileOrNull = stateProvider.activeUserId$.pipe(
switchMap((userId) =>
userId != null
? stateProvider.getUser(userId, BILLING_ACCOUNT_PROFILE_KEY_DEFINITION).state$
: of(null),
),
);
this.hasPremiumFromAnyOrganization$ = billingAccountProfileOrNull.pipe(
map((billingAccountProfile) => !!billingAccountProfile?.hasPremiumFromAnyOrganization),
);
this.hasPremiumPersonally$ = billingAccountProfileOrNull.pipe(
map((billingAccountProfile) => !!billingAccountProfile?.hasPremiumPersonally),
);
this.hasPremiumFromAnySource$ = billingAccountProfileOrNull.pipe(
map(
(billingAccountProfile) =>
billingAccountProfile?.hasPremiumFromAnyOrganization === true ||
billingAccountProfile?.hasPremiumPersonally === true,
),
);
hasPremiumFromAnySource$(userId: UserId): Observable<boolean> {
return this.stateProvider
.getUser(userId, BILLING_ACCOUNT_PROFILE_KEY_DEFINITION)
.state$.pipe(
map(
(profile) =>
profile?.hasPremiumFromAnyOrganization === true ||
profile?.hasPremiumPersonally === true,
),
);
}
async setHasPremium(
@@ -72,4 +62,23 @@ export class DefaultBillingAccountProfileStateService implements BillingAccountP
};
});
}
canViewSubscription$(userId: UserId): Observable<boolean> {
return combineLatest([
this.hasPremiumPersonally$(userId),
this.hasPremiumFromAnyOrganization$(userId),
]).pipe(
concatMap(async ([hasPremiumPersonally, hasPremiumFromOrg]) => {
const isCloud = !this.platformUtilsService.isSelfHost();
let billing = null;
if (isCloud) {
billing = await this.apiService.getUserBillingHistory();
}
const cloudAndBillingHistory = isCloud && !billing?.hasNoHistory;
return hasPremiumPersonally || !hasPremiumFromOrg || cloudAndBillingHistory;
}),
);
}
}