1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 13:23:34 +00:00

[PM-24550] Remove Feature Flag Code for PM-12276 (#16173)

* tests: remove feature flag use in tests

* tests: remove breadcrumbingPolicyTests and add service tests

* refactor: remove event log use of flag from org-layout component

* refactor: remove new policy code from org-layout component

* refactor: remove event log use of flag from events component

* refactor: remove event log use from collection dialog component

* refactor: remove event log use from vault-header component

* refactor: remove event-log route logic for org-reporting

* refactor: remove logic from org-settings routing

* refactor: remove breadcrumbing function and from billing service

* refactor: remove ConfigService from DI for billing service

* refactor: remove new policy code from policy-edit component

* refactor: remove new policy code from policies component

* refactor: remove feature flag

* fix(Admin Console): revert to use of reactive observables pattern

* fix(Admin Console): remove type artifact from reversion
This commit is contained in:
Stephon Brown
2025-09-03 15:28:15 -04:00
committed by GitHub
parent 3a62e9c2f1
commit 363d6bea44
17 changed files with 279 additions and 342 deletions

View File

@@ -55,10 +55,7 @@
<bit-nav-item
[text]="'eventLogs' | i18n"
route="reporting/events"
*ngIf="
(organization.canAccessEventLogs && organization.useEvents) ||
(organization.isOwner && (isBreadcrumbEventLogsEnabled$ | async))
"
*ngIf="organization.canAccessEventLogs || organization.isOwner"
></bit-nav-item>
<bit-nav-item
[text]="'reports' | i18n"
@@ -102,7 +99,7 @@
<bit-nav-item
[text]="'policies' | i18n"
route="settings/policies"
*ngIf="canShowPoliciesTab$ | async"
*ngIf="organization.canManagePolicies"
></bit-nav-item>
<bit-nav-item
[text]="'twoStepLogin' | i18n"

View File

@@ -68,9 +68,7 @@ export class OrganizationLayoutComponent implements OnInit {
hideNewOrgButton$: Observable<boolean>;
organizationIsUnmanaged$: Observable<boolean>;
protected isBreadcrumbEventLogsEnabled$: Observable<boolean>;
protected showSponsoredFamiliesDropdown$: Observable<boolean>;
protected canShowPoliciesTab$: Observable<boolean>;
protected paymentDetailsPageData$: Observable<{
route: string;
@@ -94,9 +92,6 @@ export class OrganizationLayoutComponent implements OnInit {
) {}
async ngOnInit() {
this.isBreadcrumbEventLogsEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.PM12276_BreadcrumbEventLogs,
);
document.body.classList.remove("layout_frontend");
this.organization$ = this.route.params.pipe(
@@ -141,18 +136,6 @@ export class OrganizationLayoutComponent implements OnInit {
this.integrationPageEnabled$ = this.organization$.pipe(map((org) => org.canAccessIntegrations));
this.canShowPoliciesTab$ = this.organization$.pipe(
switchMap((organization) =>
this.organizationBillingService
.isBreadcrumbingPoliciesEnabled$(organization)
.pipe(
map(
(isBreadcrumbingEnabled) => isBreadcrumbingEnabled || organization.canManagePolicies,
),
),
),
);
this.paymentDetailsPageData$ = this.configService
.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout)
.pipe(

View File

@@ -1,4 +1,4 @@
@let usePlaceHolderEvents = !organization?.useEvents && (isBreadcrumbEventLogsEnabled$ | async);
@let usePlaceHolderEvents = !organization?.useEvents;
<app-header>
<span
bitBadge

View File

@@ -19,7 +19,6 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { EventSystemUser } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EventResponse } from "@bitwarden/common/models/response/event.response";
import { EventView } from "@bitwarden/common/models/view/event.view";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -62,10 +61,6 @@ export class EventsComponent extends BaseEventsComponent implements OnInit, OnDe
private orgUsersUserIdMap = new Map<string, any>();
readonly ProductTierType = ProductTierType;
protected isBreadcrumbEventLogsEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.PM12276_BreadcrumbEventLogs,
);
constructor(
private apiService: ApiService,
private route: ActivatedRoute,

View File

@@ -1,20 +1,7 @@
<app-header>
@let organization = organization$ | async;
@if (isBreadcrumbingEnabled$ | async) {
<button
bitBadge
class="!tw-align-middle"
(click)="changePlan(organization)"
slot="title-suffix"
type="button"
variant="primary"
>
{{ "upgrade" | i18n }}
</button>
}
</app-header>
<app-header></app-header>
<bit-container>
@let organization = organization$ | async;
@if (loading) {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"

View File

@@ -2,8 +2,8 @@
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { firstValueFrom, lastValueFrom, map, Observable, switchMap } from "rxjs";
import { first } from "rxjs/operators";
import { firstValueFrom, lastValueFrom, Observable } from "rxjs";
import { first, map } from "rxjs/operators";
import {
getOrganizationById,
@@ -14,18 +14,11 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import {
ChangePlanDialogResultType,
openChangePlanDialog,
} from "@bitwarden/web-vault/app/billing/organizations/change-plan-dialog.component";
import { All } from "@bitwarden/web-vault/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model";
import { PolicyListService } from "../../core/policy-list.service";
import { BasePolicy } from "../policies";
import { CollectionDialogTabType } from "../shared/components/collection-dialog";
import { PolicyEditComponent, PolicyEditDialogResult } from "./policy-edit.component";
@@ -38,19 +31,17 @@ export class PoliciesComponent implements OnInit {
loading = true;
organizationId: string;
policies: BasePolicy[];
protected organization$: Observable<Organization>;
organization$: Observable<Organization>;
private orgPolicies: PolicyResponse[];
protected policiesEnabledMap: Map<PolicyType, boolean> = new Map<PolicyType, boolean>();
protected isBreadcrumbingEnabled$: Observable<boolean>;
constructor(
private route: ActivatedRoute,
private accountService: AccountService,
private organizationService: OrganizationService,
private accountService: AccountService,
private policyApiService: PolicyApiServiceAbstraction,
private policyListService: PolicyListService,
private organizationBillingService: OrganizationBillingServiceAbstraction,
private dialogService: DialogService,
protected configService: ConfigService,
) {}
@@ -62,9 +53,11 @@ export class PoliciesComponent implements OnInit {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
this.organization$ = this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(this.organizationId));
this.policies = this.policyListService.getPolicies();
await this.load();
@@ -100,11 +93,7 @@ export class PoliciesComponent implements OnInit {
this.orgPolicies.forEach((op) => {
this.policiesEnabledMap.set(op.type, op.enabled);
});
this.isBreadcrumbingEnabled$ = this.organization$.pipe(
switchMap((organization) =>
this.organizationBillingService.isBreadcrumbingPoliciesEnabled$(organization),
),
);
this.loading = false;
}
@@ -117,34 +106,8 @@ export class PoliciesComponent implements OnInit {
});
const result = await lastValueFrom(dialogRef.closed);
switch (result) {
case PolicyEditDialogResult.Saved:
await this.load();
break;
case PolicyEditDialogResult.UpgradePlan:
await this.changePlan(await firstValueFrom(this.organization$));
break;
if (result === PolicyEditDialogResult.Saved) {
await this.load();
}
}
protected readonly CollectionDialogTabType = CollectionDialogTabType;
protected readonly All = All;
protected async changePlan(organization: Organization) {
const reference = openChangePlanDialog(this.dialogService, {
data: {
organizationId: organization.id,
subscription: null,
productTierType: organization.productTierType,
},
});
const result = await lastValueFrom(reference.closed);
if (result === ChangePlanDialogResultType.Closed) {
return;
}
await this.load();
}
}

View File

@@ -1,17 +1,5 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [loading]="loading" [title]="'editPolicy' | i18n" [subtitle]="policy.name | i18n">
<ng-container bitDialogTitle>
<button
bitBadge
class="!tw-align-middle"
(click)="upgradePlan()"
*ngIf="isBreadcrumbingEnabled$ | async"
type="button"
variant="primary"
>
{{ "planNameEnterprise" | i18n }}
</button>
</ng-container>
<ng-container bitDialogContent>
<div *ngIf="loading">
<i
@@ -30,7 +18,6 @@
</ng-container>
<ng-container bitDialogFooter>
<button
*ngIf="!(isBreadcrumbingEnabled$ | async); else breadcrumbing"
bitButton
buttonType="primary"
[disabled]="saveDisabled$ | async"
@@ -39,11 +26,6 @@
>
{{ "save" | i18n }}
</button>
<ng-template #breadcrumbing>
<button bitButton buttonType="primary" bitFormButton type="button" (click)="upgradePlan()">
{{ "upgrade" | i18n }}
</button>
</ng-template>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>

View File

@@ -9,20 +9,12 @@ import {
ViewContainerRef,
} from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { map, Observable, switchMap } from "rxjs";
import { Observable, map } from "rxjs";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
DIALOG_DATA,
@@ -45,7 +37,6 @@ export type PolicyEditDialogData = {
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum PolicyEditDialogResult {
Saved = "saved",
UpgradePlan = "upgrade-plan",
}
@Component({
selector: "app-policy-edit",
@@ -66,22 +57,15 @@ export class PolicyEditComponent implements AfterViewInit {
formGroup = this.formBuilder.group({
enabled: [this.enabled],
});
protected organization$: Observable<Organization>;
protected isBreadcrumbingEnabled$: Observable<boolean>;
constructor(
@Inject(DIALOG_DATA) protected data: PolicyEditDialogData,
private accountService: AccountService,
private policyApiService: PolicyApiServiceAbstraction,
private organizationService: OrganizationService,
private i18nService: I18nService,
private cdr: ChangeDetectorRef,
private formBuilder: FormBuilder,
private dialogRef: DialogRef<PolicyEditDialogResult>,
private toastService: ToastService,
private organizationBillingService: OrganizationBillingServiceAbstraction,
) {}
get policy(): BasePolicy {
return this.data.policy;
}
@@ -115,16 +99,6 @@ export class PolicyEditComponent implements AfterViewInit {
throw e;
}
}
this.organization$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.organizationService.organizations$(userId)),
getOrganizationById(this.data.organizationId),
);
this.isBreadcrumbingEnabled$ = this.organization$.pipe(
switchMap((organization) =>
this.organizationBillingService.isBreadcrumbingPoliciesEnabled$(organization),
),
);
}
submit = async () => {
@@ -154,8 +128,4 @@ export class PolicyEditComponent implements AfterViewInit {
static open = (dialogService: DialogService, config: DialogConfig<PolicyEditDialogData>) => {
return dialogService.open<PolicyEditDialogResult>(PolicyEditComponent, config);
};
protected upgradePlan(): void {
this.dialogRef.close(PolicyEditDialogResult.UpgradePlan);
}
}

View File

@@ -1,13 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { inject, NgModule } from "@angular/core";
import { CanMatchFn, RouterModule, Routes } from "@angular/router";
import { map } from "rxjs";
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { canAccessReportingTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
// eslint-disable-next-line no-restricted-imports
import { ExposedPasswordsReportComponent } from "../../../dirt/reports/pages/organizations/exposed-passwords-report.component";
@@ -26,11 +23,6 @@ import { EventsComponent } from "../manage/events.component";
import { ReportsHomeComponent } from "./reports-home.component";
const breadcrumbEventLogsPermission$: CanMatchFn = () =>
inject(ConfigService)
.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs)
.pipe(map((breadcrumbEventLogs) => breadcrumbEventLogs === true));
const routes: Routes = [
{
path: "",
@@ -92,24 +84,10 @@ const routes: Routes = [
},
],
},
// Event routing is temporarily duplicated
{
path: "events",
component: EventsComponent,
canMatch: [breadcrumbEventLogsPermission$], // if this matches, the flag is ON
canActivate: [
organizationPermissionsGuard(
(org) => (org.canAccessEventLogs && org.useEvents) || org.isOwner,
),
],
data: {
titleId: "eventLogs",
},
},
{
path: "events",
component: EventsComponent,
canActivate: [organizationPermissionsGuard((org) => org.canAccessEventLogs)],
canActivate: [organizationPermissionsGuard((org) => org.canAccessEventLogs || org.isOwner)],
data: {
titleId: "eventLogs",
},

View File

@@ -1,10 +1,8 @@
import { NgModule, inject } from "@angular/core";
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { map } from "rxjs";
import { canAccessSettingsTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { organizationPermissionsGuard } from "../../organizations/guards/org-permissions.guard";
import { organizationRedirectGuard } from "../../organizations/guards/org-redirect.guard";
@@ -43,14 +41,7 @@ const routes: Routes = [
{
path: "policies",
component: PoliciesComponent,
canActivate: [
organizationPermissionsGuard((o: Organization) => {
const organizationBillingService = inject(OrganizationBillingServiceAbstraction);
return organizationBillingService
.isBreadcrumbingPoliciesEnabled$(o)
.pipe(map((isBreadcrumbingEnabled) => o.canManagePolicies || isBreadcrumbingEnabled));
}),
],
canActivate: [organizationPermissionsGuard((org) => org.canManagePolicies)],
data: {
titleId: "policies",
},

View File

@@ -34,7 +34,6 @@ import {
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { getById } from "@bitwarden/common/platform/misc";
@@ -188,22 +187,16 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
await this.loadOrg(this.params.organizationId);
}
const isBreadcrumbEventLogsEnabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs),
this.organizationSelected.setAsyncValidators(
freeOrgCollectionLimitValidator(
this.organizations$,
this.collectionService
.encryptedCollections$(userId)
.pipe(map((collections) => collections ?? [])),
this.i18nService,
),
);
if (isBreadcrumbEventLogsEnabled) {
this.organizationSelected.setAsyncValidators(
freeOrgCollectionLimitValidator(
this.organizations$,
this.collectionService
.encryptedCollections$(userId)
.pipe(map((collections) => collections ?? [])),
this.i18nService,
),
);
this.formGroup.updateValueAndValidity();
}
this.formGroup.updateValueAndValidity();
this.organizationSelected.valueChanges
.pipe(

View File

@@ -13,7 +13,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -218,28 +217,22 @@ export class VaultHeaderComponent {
}
async addCollection(): Promise<void> {
const isBreadcrumbEventLogsEnabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs),
const organization = this.organizations?.find(
(org) => org.productTierType === ProductTierType.Free,
);
if (isBreadcrumbEventLogsEnabled) {
const organization = this.organizations?.find(
(org) => org.productTierType === ProductTierType.Free,
);
if (this.organizations?.length == 1 && !!organization) {
const collections = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.collectionAdminService.collectionAdminViews$(organization.id, userId),
),
if (this.organizations?.length == 1 && !!organization) {
const collections = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.collectionAdminService.collectionAdminViews$(organization.id, userId),
),
);
if (collections.length === organization.maxCollections) {
await this.showFreeOrgUpgradeDialog(organization);
return;
}
),
);
if (collections.length === organization.maxCollections) {
await this.showFreeOrgUpgradeDialog(organization);
return;
}
}

View File

@@ -1332,7 +1332,6 @@ const safeProviders: SafeProvider[] = [
I18nServiceAbstraction,
OrganizationApiServiceAbstraction,
SyncService,
ConfigService,
],
}),
safeProvider({

View File

@@ -1,7 +1,3 @@
import { Observable } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationResponse } from "../../admin-console/models/response/organization.response";
import { InitiationPath } from "../../models/request/reference-event.request";
import { PaymentMethodType, PlanType } from "../enums";
@@ -63,10 +59,4 @@ export abstract class OrganizationBillingServiceAbstraction {
organizationId: string,
subscription: SubscriptionInformation,
): Promise<void>;
/**
* Determines if breadcrumbing policies is enabled for the organizations meeting certain criteria.
* @param organization
*/
abstract isBreadcrumbingPoliciesEnabled$(organization: Organization): Observable<boolean>;
}

View File

@@ -1,22 +1,26 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction as OrganizationApiService } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import {
BillingApiServiceAbstraction,
SubscriptionInformation,
} from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SyncService } from "@bitwarden/common/platform/sync";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "@bitwarden/key-management";
describe("BillingAccountProfileStateService", () => {
import { OrganizationResponse } from "../../admin-console/models/response/organization.response";
import { EncString } from "../../key-management/crypto/models/enc-string";
import { OrgKey } from "../../types/key";
import { PaymentMethodResponse } from "../models/response/payment-method.response";
describe("OrganizationBillingService", () => {
let apiService: jest.Mocked<ApiService>;
let billingApiService: jest.Mocked<BillingApiServiceAbstraction>;
let keyService: jest.Mocked<KeyService>;
@@ -24,7 +28,6 @@ describe("BillingAccountProfileStateService", () => {
let i18nService: jest.Mocked<I18nService>;
let organizationApiService: jest.Mocked<OrganizationApiService>;
let syncService: jest.Mocked<SyncService>;
let configService: jest.Mocked<ConfigService>;
let sut: OrganizationBillingService;
@@ -36,7 +39,6 @@ describe("BillingAccountProfileStateService", () => {
i18nService = mock<I18nService>();
organizationApiService = mock<OrganizationApiService>();
syncService = mock<SyncService>();
configService = mock<ConfigService>();
sut = new OrganizationBillingService(
apiService,
@@ -46,7 +48,6 @@ describe("BillingAccountProfileStateService", () => {
i18nService,
organizationApiService,
syncService,
configService,
);
});
@@ -54,98 +55,246 @@ describe("BillingAccountProfileStateService", () => {
return jest.resetAllMocks();
});
describe("isBreadcrumbingPoliciesEnabled", () => {
it("returns false when feature flag is disabled", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
const org = {
isProviderUser: false,
canEditSubscription: true,
productTierType: ProductTierType.Teams,
} as Organization;
describe("getPaymentSource()", () => {
it("given a valid organization id, then it returns a payment source", async () => {
//Arrange
const orgId = "organization-test";
const paymentMethodResponse = {
paymentSource: { type: PaymentMethodType.Card },
} as PaymentMethodResponse;
billingApiService.getOrganizationPaymentMethod.mockResolvedValue(paymentMethodResponse);
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(actual).toBe(false);
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM12276_BreadcrumbEventLogs,
//Act
const returnedPaymentSource = await sut.getPaymentSource(orgId);
//Assert
expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1);
expect(returnedPaymentSource).toEqual(paymentMethodResponse.paymentSource);
});
it("given an invalid organizationId, it should return undefined", async () => {
//Arrange
const orgId = "invalid-id";
billingApiService.getOrganizationPaymentMethod.mockResolvedValue(null);
//Act
const returnedPaymentSource = await sut.getPaymentSource(orgId);
//Assert
expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1);
expect(returnedPaymentSource).toBeUndefined();
});
it("given an API error occurs, then it throws the error", async () => {
// Arrange
const orgId = "error-org";
billingApiService.getOrganizationPaymentMethod.mockRejectedValue(new Error("API Error"));
// Act & Assert
await expect(sut.getPaymentSource(orgId)).rejects.toThrow("API Error");
expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1);
});
});
describe("purchaseSubscription()", () => {
it("given valid subscription information, then it returns successful response", async () => {
//Arrange
const subscriptionInformation = {
organization: { name: "test-business", billingEmail: "test@test.com" },
plan: { type: PlanType.EnterpriseAnnually2023 },
payment: {
paymentMethod: ["card-token", PaymentMethodType.Card],
billing: { postalCode: "12345" },
},
} as SubscriptionInformation;
const organizationResponse = {
name: subscriptionInformation.organization.name,
billingEmail: subscriptionInformation.organization.billingEmail,
planType: subscriptionInformation.plan.type,
} as OrganizationResponse;
organizationApiService.create.mockResolvedValue(organizationResponse);
keyService.makeOrgKey.mockResolvedValue([new EncString("encrypyted-key"), {} as OrgKey]);
keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypyted-key")]);
encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypyted"));
//Act
const response = await sut.purchaseSubscription(subscriptionInformation);
//Assert
expect(organizationApiService.create).toHaveBeenCalledTimes(1);
expect(response).toEqual(organizationResponse);
});
it("given organization creation fails, then it throws an error", async () => {
// Arrange
const subscriptionInformation = {
organization: { name: "test-business", billingEmail: "test@test.com" },
plan: { type: PlanType.EnterpriseAnnually2023 },
payment: {
paymentMethod: ["card-token", PaymentMethodType.Card],
billing: { postalCode: "12345" },
},
} as SubscriptionInformation;
organizationApiService.create.mockRejectedValue(new Error("Failed to create organization"));
keyService.makeOrgKey.mockResolvedValue([new EncString("encrypted-key"), {} as OrgKey]);
keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]);
encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypyted"));
// Act & Assert
await expect(sut.purchaseSubscription(subscriptionInformation)).rejects.toThrow(
"Failed to create organization",
);
});
it("returns false when organization belongs to a provider", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const org = {
isProviderUser: true,
canEditSubscription: true,
productTierType: ProductTierType.Teams,
} as Organization;
it("given key generation fails, then it throws an error", async () => {
// Arrange
const subscriptionInformation = {
organization: { name: "test-business", billingEmail: "test@test.com" },
plan: { type: PlanType.EnterpriseAnnually2023 },
payment: {
paymentMethod: ["card-token", PaymentMethodType.Card],
billing: { postalCode: "12345" },
},
} as SubscriptionInformation;
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(actual).toBe(false);
});
keyService.makeOrgKey.mockRejectedValue(new Error("Key generation failed"));
it("returns false when cannot edit subscription", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const org = {
isProviderUser: false,
canEditSubscription: false,
productTierType: ProductTierType.Teams,
} as Organization;
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(actual).toBe(false);
});
it.each([
["Teams", ProductTierType.Teams],
["TeamsStarter", ProductTierType.TeamsStarter],
])("returns true when all conditions are met with %s tier", async (_, productTierType) => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const org = {
isProviderUser: false,
canEditSubscription: true,
productTierType: productTierType,
} as Organization;
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(actual).toBe(true);
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM12276_BreadcrumbEventLogs,
// Act & Assert
await expect(sut.purchaseSubscription(subscriptionInformation)).rejects.toThrow(
"Key generation failed",
);
});
it("returns false when product tier is not supported", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const org = {
isProviderUser: false,
canEditSubscription: true,
productTierType: ProductTierType.Enterprise,
} as Organization;
it("given an invalid plan type, then it throws an error", async () => {
// Arrange
const subscriptionInformation = {
organization: { name: "test-business", billingEmail: "test@test.com" },
plan: { type: -1 as unknown as PlanType },
payment: {
paymentMethod: ["card-token", PaymentMethodType.Card],
billing: { postalCode: "12345" },
},
} as SubscriptionInformation;
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(actual).toBe(false);
// Act & Assert
await expect(sut.purchaseSubscription(subscriptionInformation)).rejects.toThrow();
});
});
describe("purchaseSubscriptionNoPaymentMethod()", () => {
it("given valid subscription information, then it returns successful response", async () => {
//Arrange
const subscriptionInformation = {
organization: { name: "test-business", billingEmail: "test@test.com" },
plan: { type: PlanType.EnterpriseAnnually2023 },
} as SubscriptionInformation;
const organizationResponse = {
name: subscriptionInformation.organization.name,
plan: { type: subscriptionInformation.plan.type },
planType: subscriptionInformation.plan.type,
} as OrganizationResponse;
organizationApiService.createWithoutPayment.mockResolvedValue(organizationResponse);
keyService.makeOrgKey.mockResolvedValue([new EncString("encrypted-key"), {} as OrgKey]);
keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]);
encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypted"));
//Act
const response = await sut.purchaseSubscriptionNoPaymentMethod(subscriptionInformation);
//Assert
expect(organizationApiService.createWithoutPayment).toHaveBeenCalledTimes(1);
expect(response).toEqual(organizationResponse);
});
it("handles all conditions false correctly", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
const org = {
isProviderUser: true,
canEditSubscription: false,
productTierType: ProductTierType.Free,
} as Organization;
it("given organization creation fails without payment method, then it throws an error", async () => {
const subscriptionInformation = {
organization: { name: "test-business", billingEmail: "test@test.com" },
plan: { type: PlanType.EnterpriseAnnually2023 },
} as SubscriptionInformation;
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(actual).toBe(false);
organizationApiService.createWithoutPayment.mockRejectedValue(new Error("Creation failed"));
keyService.makeOrgKey.mockResolvedValue([new EncString("encrypted-key"), {} as OrgKey]);
keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]);
encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypted"));
await expect(
sut.purchaseSubscriptionNoPaymentMethod(subscriptionInformation),
).rejects.toThrow("Creation failed");
});
it("verifies feature flag is only called once", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
const org = {
isProviderUser: false,
canEditSubscription: true,
productTierType: ProductTierType.Teams,
} as Organization;
it("given key generation fails, then it throws an error", async () => {
const subscriptionInformation = {
organization: { name: "test-business", billingEmail: "test@test.com" },
plan: { type: PlanType.EnterpriseAnnually2023 },
} as SubscriptionInformation;
await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(configService.getFeatureFlag$).toHaveBeenCalledTimes(1);
keyService.makeOrgKey.mockRejectedValue(new Error("Key generation failed"));
keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]);
await expect(
sut.purchaseSubscriptionNoPaymentMethod(subscriptionInformation),
).rejects.toThrow("Key generation failed");
});
});
describe("startFree()", () => {
it("given valid free plan information, then it creates a free organization", async () => {
const subscriptionInformation = {
organization: { name: "test-business", billingEmail: "test@test.com" },
plan: { type: PlanType.Free },
} as SubscriptionInformation;
const organizationResponse = {
name: subscriptionInformation.organization.name,
billingEmail: subscriptionInformation.organization.billingEmail,
planType: subscriptionInformation.plan.type,
} as OrganizationResponse;
organizationApiService.create.mockResolvedValue(organizationResponse);
keyService.makeOrgKey.mockResolvedValue([new EncString("encrypyted-key"), {} as OrgKey]);
keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypyted-key")]);
encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypyted"));
//Act
const response = await sut.startFree(subscriptionInformation);
//Assert
expect(organizationApiService.create).toHaveBeenCalledTimes(1);
expect(response).toEqual(organizationResponse);
});
it("given key generation fails, then it throws an error", async () => {
const subscriptionInformation = {
organization: { name: "test-business", billingEmail: "test@test.com" },
plan: { type: PlanType.Free },
} as SubscriptionInformation;
keyService.makeOrgKey.mockRejectedValue(new Error("Key generation failed"));
keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]);
await expect(sut.startFree(subscriptionInformation)).rejects.toThrow("Key generation failed");
});
it("given organization creation fails, then it throws an error", async () => {
// Arrange
const subscriptionInformation = {
organization: { name: "test-business", billingEmail: "test@test.com" },
plan: { type: PlanType.Free },
} as SubscriptionInformation;
organizationApiService.create.mockRejectedValue(new Error("Failed to create organization"));
keyService.makeOrgKey.mockResolvedValue([new EncString("encrypted-key"), {} as OrgKey]);
keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]);
encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypyted"));
// Act & Assert
await expect(sut.startFree(subscriptionInformation)).rejects.toThrow(
"Failed to create organization",
);
});
});
});

View File

@@ -1,10 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable, of, switchMap } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "@bitwarden/key-management";
@@ -27,7 +22,7 @@ import {
PlanInformation,
SubscriptionInformation,
} from "../abstractions";
import { PlanType, ProductTierType } from "../enums";
import { PlanType } from "../enums";
import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request";
import { PaymentSourceResponse } from "../models/response/payment-source.response";
@@ -47,12 +42,11 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
private i18nService: I18nService,
private organizationApiService: OrganizationApiService,
private syncService: SyncService,
private configService: ConfigService,
) {}
async getPaymentSource(organizationId: string): Promise<PaymentSourceResponse> {
const paymentMethod = await this.billingApiService.getOrganizationPaymentMethod(organizationId);
return paymentMethod.paymentSource;
return paymentMethod?.paymentSource;
}
async purchaseSubscription(subscription: SubscriptionInformation): Promise<OrganizationResponse> {
@@ -229,29 +223,4 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
this.setPaymentInformation(request, subscription.payment);
await this.billingApiService.restartSubscription(organizationId, request);
}
isBreadcrumbingPoliciesEnabled$(organization: Organization): Observable<boolean> {
if (organization === null || organization === undefined) {
return of(false);
}
return this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs).pipe(
switchMap((featureFlagEnabled) => {
if (!featureFlagEnabled) {
return of(false);
}
if (organization.isProviderUser || !organization.canEditSubscription) {
return of(false);
}
const supportedProducts = [ProductTierType.Teams, ProductTierType.TeamsStarter];
const isSupportedProduct = supportedProducts.some(
(product) => product === organization.productTierType,
);
return of(isSupportedProduct);
}),
);
}
}

View File

@@ -23,7 +23,6 @@ export enum FeatureFlag {
/* Billing */
TrialPaymentOptional = "PM-8163-trial-payment",
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
UseOrganizationWarningsService = "use-organization-warnings-service",
PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout",
@@ -95,7 +94,6 @@ export const DefaultFeatureFlagValue = {
/* Billing */
[FeatureFlag.TrialPaymentOptional]: FALSE,
[FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE,
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
[FeatureFlag.UseOrganizationWarningsService]: FALSE,
[FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE,