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"
|
buttonType="primary"
|
||||||
[disabled]="loading || dialogReadonly"
|
[disabled]="loading || dialogReadonly"
|
||||||
>
|
>
|
||||||
{{ "save" | i18n }}
|
{{ buttonDisplayName | i18n }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
OrganizationUserUserMiniResponse,
|
OrganizationUserUserMiniResponse,
|
||||||
CollectionResponse,
|
CollectionResponse,
|
||||||
CollectionView,
|
CollectionView,
|
||||||
|
CollectionService,
|
||||||
|
Collection,
|
||||||
} from "@bitwarden/admin-console/common";
|
} from "@bitwarden/admin-console/common";
|
||||||
import {
|
import {
|
||||||
getOrganizationById,
|
getOrganizationById,
|
||||||
@@ -32,13 +34,17 @@ import {
|
|||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { getUserId } from "@bitwarden/common/auth/services/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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
import { SelectModule, BitValidators, DialogService, ToastService } from "@bitwarden/components";
|
import { SelectModule, BitValidators, DialogService, ToastService } from "@bitwarden/components";
|
||||||
|
|
||||||
|
import { openChangePlanDialog } from "../../../../../billing/organizations/change-plan-dialog.component";
|
||||||
import { SharedModule } from "../../../../../shared";
|
import { SharedModule } from "../../../../../shared";
|
||||||
import { GroupApiService, GroupView } from "../../../core";
|
import { GroupApiService, GroupView } from "../../../core";
|
||||||
|
import { freeOrgCollectionLimitValidator } from "../../validators/free-org-collection-limit.validator";
|
||||||
import { PermissionMode } from "../access-selector/access-selector.component";
|
import { PermissionMode } from "../access-selector/access-selector.component";
|
||||||
import {
|
import {
|
||||||
AccessItemType,
|
AccessItemType,
|
||||||
@@ -55,6 +61,19 @@ export enum CollectionDialogTabType {
|
|||||||
Access = 1,
|
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 {
|
export interface CollectionDialogParams {
|
||||||
collectionId?: string;
|
collectionId?: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -78,6 +97,7 @@ export enum CollectionDialogAction {
|
|||||||
Saved = "saved",
|
Saved = "saved",
|
||||||
Canceled = "canceled",
|
Canceled = "canceled",
|
||||||
Deleted = "deleted",
|
Deleted = "deleted",
|
||||||
|
Upgrade = "upgrade",
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -107,6 +127,9 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
|||||||
protected PermissionMode = PermissionMode;
|
protected PermissionMode = PermissionMode;
|
||||||
protected showDeleteButton = false;
|
protected showDeleteButton = false;
|
||||||
protected showAddAccessWarning = false;
|
protected showAddAccessWarning = false;
|
||||||
|
protected collections: Collection[];
|
||||||
|
protected buttonDisplayName: ButtonType = ButtonType.Save;
|
||||||
|
private orgExceedingCollectionLimit!: Organization;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DIALOG_DATA) private params: CollectionDialogParams,
|
@Inject(DIALOG_DATA) private params: CollectionDialogParams,
|
||||||
@@ -122,6 +145,8 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
|||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
private toastService: ToastService,
|
private toastService: ToastService,
|
||||||
|
private collectionService: CollectionService,
|
||||||
|
private configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.tabIndex = params.initialTab ?? CollectionDialogTabType.Info;
|
this.tabIndex = params.initialTab ?? CollectionDialogTabType.Info;
|
||||||
}
|
}
|
||||||
@@ -151,6 +176,23 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
|||||||
this.formGroup.patchValue({ selectedOrg: this.params.organizationId });
|
this.formGroup.patchValue({ selectedOrg: this.params.organizationId });
|
||||||
await this.loadOrg(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) {
|
async loadOrg(orgId: string) {
|
||||||
@@ -263,6 +305,10 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get organizationSelected() {
|
||||||
|
return this.formGroup.controls.selectedOrg;
|
||||||
|
}
|
||||||
|
|
||||||
protected get collectionId() {
|
protected get collectionId() {
|
||||||
return this.params.collectionId;
|
return this.params.collectionId;
|
||||||
}
|
}
|
||||||
@@ -287,6 +333,12 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.formGroup.markAllAsTouched();
|
this.formGroup.markAllAsTouched();
|
||||||
|
|
||||||
|
if (this.buttonDisplayName == ButtonType.Upgrade) {
|
||||||
|
this.close(CollectionDialogAction.Upgrade);
|
||||||
|
this.changePlan(this.orgExceedingCollectionLimit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.formGroup.invalid) {
|
if (this.formGroup.invalid) {
|
||||||
const accessTabError = this.formGroup.controls.access.hasError("managePermissionRequired");
|
const accessTabError = this.formGroup.controls.access.hasError("managePermissionRequired");
|
||||||
|
|
||||||
@@ -369,6 +421,16 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
|||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private changePlan(org: Organization) {
|
||||||
|
openChangePlanDialog(this.dialogService, {
|
||||||
|
data: {
|
||||||
|
organizationId: org.id,
|
||||||
|
subscription: null,
|
||||||
|
productTierType: org.productTierType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private handleAddAccessWarning(): boolean {
|
private handleAddAccessWarning(): boolean {
|
||||||
if (
|
if (
|
||||||
!this.organization?.allowAdminAccessToAllCollectionItems &&
|
!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,
|
OnInit,
|
||||||
Output,
|
Output,
|
||||||
} from "@angular/core";
|
} 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 { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
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 { CollectionDialogTabType } from "../../../admin-console/organizations/shared/components/collection-dialog";
|
||||||
import { HeaderModule } from "../../../layouts/header/header.module";
|
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 */
|
/** Emits an event when the delete collection button is clicked in the header */
|
||||||
@Output() onDeleteCollection = new EventEmitter<void>();
|
@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() {}
|
async ngOnInit() {}
|
||||||
|
|
||||||
@@ -199,6 +219,56 @@ export class VaultHeaderComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async addCollection(): Promise<void> {
|
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();
|
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":{
|
"upgradeEventLogMessage":{
|
||||||
"message" : "These events are examples only and do not reflect real events within your Bitwarden organization."
|
"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