1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 16:53:34 +00:00

[PM-15442]Upgrade modal additional instances (#13557)

* display inline information error message

* Add collection service

* Refactor the code

* Add a feature flag to the change

* Add the modal pop for free org

* Use custom error messages passed from the validator

* Add the js document

* Merge changes in main

* Add the changes after file movement

* remove these floating promises

* Adding unit test and seprating the validation

* fix the unit test request

* Remove  the conditional statment in test
This commit is contained in:
cyprain-okeke
2025-03-11 14:42:10 +01:00
committed by GitHub
parent 9683779dbf
commit ef06e9f03c
6 changed files with 261 additions and 4 deletions

View File

@@ -124,7 +124,7 @@
buttonType="primary"
[disabled]="loading || dialogReadonly"
>
{{ "save" | i18n }}
{{ buttonDisplayName | i18n }}
</button>
<button
type="button"

View File

@@ -24,6 +24,8 @@ import {
OrganizationUserUserMiniResponse,
CollectionResponse,
CollectionView,
CollectionService,
Collection,
} from "@bitwarden/admin-console/common";
import {
getOrganizationById,
@@ -32,13 +34,17 @@ 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SelectModule, BitValidators, DialogService, ToastService } from "@bitwarden/components";
import { openChangePlanDialog } from "../../../../../billing/organizations/change-plan-dialog.component";
import { SharedModule } from "../../../../../shared";
import { GroupApiService, GroupView } from "../../../core";
import { freeOrgCollectionLimitValidator } from "../../validators/free-org-collection-limit.validator";
import { PermissionMode } from "../access-selector/access-selector.component";
import {
AccessItemType,
@@ -55,6 +61,19 @@ export enum CollectionDialogTabType {
Access = 1,
}
/**
* Enum representing button labels for the "Add New Collection" dialog.
*
* @readonly
* @enum {string}
*/
enum ButtonType {
/** Displayed when the user has reached the maximum number of collections allowed for the organization. */
Upgrade = "upgrade",
/** Displayed when the user can still add more collections within the allowed limit. */
Save = "save",
}
export interface CollectionDialogParams {
collectionId?: string;
organizationId: string;
@@ -78,6 +97,7 @@ export enum CollectionDialogAction {
Saved = "saved",
Canceled = "canceled",
Deleted = "deleted",
Upgrade = "upgrade",
}
@Component({
@@ -107,6 +127,9 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
protected PermissionMode = PermissionMode;
protected showDeleteButton = false;
protected showAddAccessWarning = false;
protected collections: Collection[];
protected buttonDisplayName: ButtonType = ButtonType.Save;
private orgExceedingCollectionLimit!: Organization;
constructor(
@Inject(DIALOG_DATA) private params: CollectionDialogParams,
@@ -122,6 +145,8 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
private changeDetectorRef: ChangeDetectorRef,
private accountService: AccountService,
private toastService: ToastService,
private collectionService: CollectionService,
private configService: ConfigService,
) {
this.tabIndex = params.initialTab ?? CollectionDialogTabType.Info;
}
@@ -151,6 +176,23 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.formGroup.patchValue({ selectedOrg: this.params.organizationId });
await this.loadOrg(this.params.organizationId);
}
const isBreadcrumbEventLogsEnabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs),
);
if (isBreadcrumbEventLogsEnabled) {
this.collections = await this.collectionService.getAll();
this.organizationSelected.setAsyncValidators(
freeOrgCollectionLimitValidator(this.organizations$, this.collections, this.i18nService),
);
this.formGroup.updateValueAndValidity();
}
this.organizationSelected.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((_) => {
this.organizationSelected.markAsTouched();
this.formGroup.updateValueAndValidity();
});
}
async loadOrg(orgId: string) {
@@ -263,6 +305,10 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
});
}
get organizationSelected() {
return this.formGroup.controls.selectedOrg;
}
protected get collectionId() {
return this.params.collectionId;
}
@@ -287,6 +333,12 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.formGroup.markAllAsTouched();
if (this.buttonDisplayName == ButtonType.Upgrade) {
this.close(CollectionDialogAction.Upgrade);
this.changePlan(this.orgExceedingCollectionLimit);
return;
}
if (this.formGroup.invalid) {
const accessTabError = this.formGroup.controls.access.hasError("managePermissionRequired");
@@ -369,6 +421,16 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
private changePlan(org: Organization) {
openChangePlanDialog(this.dialogService, {
data: {
organizationId: org.id,
subscription: null,
productTierType: org.productTierType,
},
});
}
private handleAddAccessWarning(): boolean {
if (
!this.organization?.allowAdminAccessToAllCollectionItems &&

View File

@@ -0,0 +1,78 @@
import { AbstractControl, FormControl, ValidationErrors } from "@angular/forms";
import { lastValueFrom, Observable, of } from "rxjs";
import { Collection } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { freeOrgCollectionLimitValidator } from "./free-org-collection-limit.validator";
describe("freeOrgCollectionLimitValidator", () => {
let i18nService: I18nService;
beforeEach(() => {
i18nService = {
t: (key: string) => key,
} as any;
});
it("returns null if organization is not found", async () => {
const orgs: Organization[] = [];
const validator = freeOrgCollectionLimitValidator(of(orgs), [], i18nService);
const control = new FormControl("org-id");
const result: Observable<ValidationErrors> = validator(control) as Observable<ValidationErrors>;
const value = await lastValueFrom(result);
expect(value).toBeNull();
});
it("returns null if control is not an instance of FormControl", async () => {
const validator = freeOrgCollectionLimitValidator(of([]), [], i18nService);
const control = {} as AbstractControl;
const result: Observable<ValidationErrors | null> = validator(
control,
) as Observable<ValidationErrors>;
const value = await lastValueFrom(result);
expect(value).toBeNull();
});
it("returns null if control is not provided", async () => {
const validator = freeOrgCollectionLimitValidator(of([]), [], i18nService);
const result: Observable<ValidationErrors | null> = validator(
undefined as any,
) as Observable<ValidationErrors>;
const value = await lastValueFrom(result);
expect(value).toBeNull();
});
it("returns null if organization has not reached collection limit (Observable)", async () => {
const org = { id: "org-id", maxCollections: 2 } as Organization;
const collections = [{ organizationId: "org-id" } as Collection];
const validator = freeOrgCollectionLimitValidator(of([org]), collections, i18nService);
const control = new FormControl("org-id");
const result$ = validator(control) as Observable<ValidationErrors | null>;
const value = await lastValueFrom(result$);
expect(value).toBeNull();
});
it("returns error if organization has reached collection limit (Observable)", async () => {
const org = { id: "org-id", maxCollections: 1 } as Organization;
const collections = [{ organizationId: "org-id" } as Collection];
const validator = freeOrgCollectionLimitValidator(of([org]), collections, i18nService);
const control = new FormControl("org-id");
const result$ = validator(control) as Observable<ValidationErrors | null>;
const value = await lastValueFrom(result$);
expect(value).toEqual({
cannotCreateCollections: { message: "cannotCreateCollection" },
});
});
});

View File

@@ -0,0 +1,44 @@
import { AbstractControl, AsyncValidatorFn, FormControl, ValidationErrors } from "@angular/forms";
import { map, Observable, of } from "rxjs";
import { Collection } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
export function freeOrgCollectionLimitValidator(
orgs: Observable<Organization[]>,
collections: Collection[],
i18nService: I18nService,
): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!(control instanceof FormControl)) {
return of(null);
}
const orgId = control.value;
if (!orgId) {
return of(null);
}
return orgs.pipe(
map((organizations) => organizations.find((org) => org.id === orgId)),
map((org) => {
if (!org) {
return null;
}
const orgCollections = collections.filter((c) => c.organizationId === org.id);
const hasReachedLimit = org.maxCollections === orgCollections.length;
if (hasReachedLimit) {
return {
cannotCreateCollections: { message: i18nService.t("cannotCreateCollection") },
};
}
return null;
}),
);
};
}

View File

@@ -9,14 +9,28 @@ import {
OnInit,
Output,
} from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { Unassigned, CollectionView } from "@bitwarden/admin-console/common";
import {
Unassigned,
CollectionView,
CollectionAdminService,
} from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
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";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { BreadcrumbsModule, MenuModule } from "@bitwarden/components";
import {
BreadcrumbsModule,
DialogService,
MenuModule,
SimpleDialogOptions,
} from "@bitwarden/components";
import { CollectionDialogTabType } from "../../../admin-console/organizations/shared/components/collection-dialog";
import { HeaderModule } from "../../../layouts/header/header.module";
@@ -81,7 +95,13 @@ export class VaultHeaderComponent implements OnInit {
/** Emits an event when the delete collection button is clicked in the header */
@Output() onDeleteCollection = new EventEmitter<void>();
constructor(private i18nService: I18nService) {}
constructor(
private i18nService: I18nService,
private collectionAdminService: CollectionAdminService,
private dialogService: DialogService,
private router: Router,
private configService: ConfigService,
) {}
async ngOnInit() {}
@@ -199,6 +219,56 @@ export class VaultHeaderComponent implements OnInit {
}
async addCollection(): Promise<void> {
const organization = this.organizations?.find(
(org) => org.productTierType === ProductTierType.Free,
);
const isBreadcrumbEventLogsEnabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs),
);
if (
this.organizations.length == 1 &&
organization.productTierType === ProductTierType.Free &&
isBreadcrumbEventLogsEnabled
) {
const collections = await this.collectionAdminService.getAll(organization.id);
if (collections.length === organization.maxCollections) {
await this.showFreeOrgUpgradeDialog(organization);
return;
}
}
this.onAddCollection.emit();
}
private async showFreeOrgUpgradeDialog(organization: Organization): Promise<void> {
const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = {
title: this.i18nService.t("upgradeOrganization"),
content: this.i18nService.t(
organization.canEditSubscription
? "freeOrgMaxCollectionReachedManageBilling"
: "freeOrgMaxCollectionReachedNoManageBilling",
organization.maxCollections,
),
type: "primary",
};
if (organization.canEditSubscription) {
orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("upgrade");
} else {
orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("ok");
orgUpgradeSimpleDialogOpts.cancelButtonText = null; // hide secondary btn
}
const simpleDialog = this.dialogService.openSimpleDialogRef(orgUpgradeSimpleDialogOpts);
const result: boolean | undefined = await firstValueFrom(simpleDialog.closed);
if (!result) {
return;
}
if (organization.canEditSubscription) {
await this.router.navigate(["/organizations", organization.id, "billing", "subscription"], {
queryParams: { upgrade: true },
});
}
}
}

View File

@@ -10608,5 +10608,8 @@
},
"upgradeEventLogMessage":{
"message" : "These events are examples only and do not reflect real events within your Bitwarden organization."
},
"cannotCreateCollection": {
"message": "Free organizations may have up to 2 collections. Upgrade to a paid plan to add more collections."
}
}