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:
@@ -124,7 +124,7 @@
|
||||
buttonType="primary"
|
||||
[disabled]="loading || dialogReadonly"
|
||||
>
|
||||
{{ "save" | i18n }}
|
||||
{{ buttonDisplayName | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user