From 3d008da2879beff89ddd2f274557b5422a4349c5 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Fri, 16 Dec 2022 15:11:37 -0500 Subject: [PATCH] Web - SG-668 update flows for free 2 person orgs (#4093) * People-component - Minor refactoring - Make org a comp prop instead of creating multiple component props for props on the org object * Added IconDirective to Dialog.module so that bit-dialog-icon directive can work within components * SG-668 - #2 - If a free org has members (any status) at max seat limit, then prompt for upgrade with dialog which takes you to upgrade flow on billing/subscription management page * SG-668 - (1) Refactored upgrade dialog to accept translated body text for better re-usability (2) Completed task #3 - If user has max collections for free org and tries to add a 3rd, they are prompted via upgrade dialog. * SG-668 - Update equality checks to use strict equality * SG-668 - Upgrade dialog now shows contextual body text based on if the user can manage billing or not --- .../organization-subscription.component.ts | 4 + .../manage/collections.component.ts | 32 ++++- .../org-upgrade-dialog.component.html | 33 ++++++ .../org-upgrade-dialog.component.ts | 19 +++ .../manage/people.component.html | 4 +- .../organizations/manage/people.component.ts | 111 +++++++++++------- .../app/organizations/organization.module.ts | 2 + apps/web/src/locales/en/messages.json | 36 ++++++ libs/components/src/dialog/dialog.module.ts | 11 +- 9 files changed, 205 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/app/organizations/manage/org-upgrade-dialog/org-upgrade-dialog.component.html create mode 100644 apps/web/src/app/organizations/manage/org-upgrade-dialog/org-upgrade-dialog.component.ts diff --git a/apps/web/src/app/organizations/billing/organization-subscription.component.ts b/apps/web/src/app/organizations/billing/organization-subscription.component.ts index 803be05097..1cfbd7ef86 100644 --- a/apps/web/src/app/organizations/billing/organization-subscription.component.ts +++ b/apps/web/src/app/organizations/billing/organization-subscription.component.ts @@ -75,6 +75,10 @@ export class OrganizationSubscriptionComponent implements OnInit, OnDestroy { } async ngOnInit() { + if (this.route.snapshot.queryParamMap.get("upgrade")) { + this.changePlan(); + } + this.route.params .pipe( concatMap(async (params) => { diff --git a/apps/web/src/app/organizations/manage/collections.component.ts b/apps/web/src/app/organizations/manage/collections.component.ts index 111b0cd139..1c733281c6 100644 --- a/apps/web/src/app/organizations/manage/collections.component.ts +++ b/apps/web/src/app/organizations/manage/collections.component.ts @@ -10,6 +10,7 @@ import { LogService } from "@bitwarden/common/abstractions/log.service"; import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; +import { ProductType } from "@bitwarden/common/enums/productType"; import { CollectionData } from "@bitwarden/common/models/data/collection.data"; import { Collection } from "@bitwarden/common/models/domain/collection"; import { Organization } from "@bitwarden/common/models/domain/organization"; @@ -19,9 +20,11 @@ import { } from "@bitwarden/common/models/response/collection.response"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { CollectionView } from "@bitwarden/common/models/view/collection.view"; +import { DialogService } from "@bitwarden/components"; import { CollectionAddEditComponent } from "./collection-add-edit.component"; import { EntityUsersComponent } from "./entity-users.component"; +import { OrgUpgradeDialogComponent } from "./org-upgrade-dialog/org-upgrade-dialog.component"; @Component({ selector: "app-org-manage-collections", @@ -56,7 +59,8 @@ export class CollectionsComponent implements OnInit { private platformUtilsService: PlatformUtilsService, private searchService: SearchService, private logService: LogService, - private organizationService: OrganizationService + private organizationService: OrganizationService, + private dialogService: DialogService ) {} async ngOnInit() { @@ -126,6 +130,32 @@ export class CollectionsComponent implements OnInit { return; } + if ( + !collection && + this.organization.planProductType === ProductType.Free && + this.collections.length === this.organization.maxCollections + ) { + // Show org upgrade modal + const dialogBodyText = this.organization.canManageBilling + ? this.i18nService.t( + "freeOrgMaxCollectionReachedManageBilling", + this.organization.maxCollections.toString() + ) + : this.i18nService.t( + "freeOrgMaxCollectionReachedNoManageBilling", + this.organization.maxCollections.toString() + ); + + this.dialogService.open(OrgUpgradeDialogComponent, { + data: { + orgId: this.organization.id, + dialogBodyText: dialogBodyText, + orgCanManageBilling: this.organization.canManageBilling, + }, + }); + return; + } + const [modal] = await this.modalService.openViewRef( CollectionAddEditComponent, this.addEditModalRef, diff --git a/apps/web/src/app/organizations/manage/org-upgrade-dialog/org-upgrade-dialog.component.html b/apps/web/src/app/organizations/manage/org-upgrade-dialog/org-upgrade-dialog.component.html new file mode 100644 index 0000000000..16e99d76ff --- /dev/null +++ b/apps/web/src/app/organizations/manage/org-upgrade-dialog/org-upgrade-dialog.component.html @@ -0,0 +1,33 @@ + + + {{ "upgradeOrganization" | i18n }} + + {{ data.dialogBodyText }} + +
+ + + + + + + + +
+
diff --git a/apps/web/src/app/organizations/manage/org-upgrade-dialog/org-upgrade-dialog.component.ts b/apps/web/src/app/organizations/manage/org-upgrade-dialog/org-upgrade-dialog.component.ts new file mode 100644 index 0000000000..9ebdef9be1 --- /dev/null +++ b/apps/web/src/app/organizations/manage/org-upgrade-dialog/org-upgrade-dialog.component.ts @@ -0,0 +1,19 @@ +import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject } from "@angular/core"; + +export interface OrgUpgradeDialogData { + orgId: string; + orgCanManageBilling: boolean; + dialogBodyText: string; +} + +@Component({ + selector: "app-org-upgrade-dialog", + templateUrl: "org-upgrade-dialog.component.html", +}) +export class OrgUpgradeDialogComponent { + constructor( + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) public data: OrgUpgradeDialogData + ) {} +} diff --git a/apps/web/src/app/organizations/manage/people.component.html b/apps/web/src/app/organizations/manage/people.component.html index a032aa4158..81374668a4 100644 --- a/apps/web/src/app/organizations/manage/people.component.html +++ b/apps/web/src/app/organizations/manage/people.component.html @@ -221,7 +221,7 @@ href="#" appStopClick (click)="groups(u)" - *ngIf="accessGroups" + *ngIf="organization.useGroups" > {{ "groups" | i18n }} @@ -231,7 +231,7 @@ href="#" appStopClick (click)="events(u)" - *ngIf="accessEvents && u.status === userStatusType.Confirmed" + *ngIf="organization.useEvents && u.status === userStatusType.Confirmed" > {{ "eventLogs" | i18n }} diff --git a/apps/web/src/app/organizations/manage/people.component.ts b/apps/web/src/app/organizations/manage/people.component.ts index 8fb4e789a5..0055b59717 100644 --- a/apps/web/src/app/organizations/manage/people.component.ts +++ b/apps/web/src/app/organizations/manage/people.component.ts @@ -20,12 +20,15 @@ import { ValidationService } from "@bitwarden/common/abstractions/validation.ser import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType"; import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType"; import { PolicyType } from "@bitwarden/common/enums/policyType"; +import { ProductType } from "@bitwarden/common/enums/productType"; +import { Organization } from "@bitwarden/common/models/domain/organization"; import { OrganizationKeysRequest } from "@bitwarden/common/models/request/organization-keys.request"; import { OrganizationUserBulkRequest } from "@bitwarden/common/models/request/organization-user-bulk.request"; import { OrganizationUserConfirmRequest } from "@bitwarden/common/models/request/organization-user-confirm.request"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { OrganizationUserBulkResponse } from "@bitwarden/common/models/response/organization-user-bulk.response"; import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/models/response/organization-user.response"; +import { DialogService } from "@bitwarden/components"; import { BasePeopleComponent } from "../../common/base.people.component"; @@ -34,6 +37,7 @@ import { BulkRemoveComponent } from "./bulk/bulk-remove.component"; import { BulkRestoreRevokeComponent } from "./bulk/bulk-restore-revoke.component"; import { BulkStatusComponent } from "./bulk/bulk-status.component"; import { EntityEventsComponent } from "./entity-events.component"; +import { OrgUpgradeDialogComponent } from "./org-upgrade-dialog/org-upgrade-dialog.component"; import { ResetPasswordComponent } from "./reset-password.component"; import { UserAddEditComponent } from "./user-add-edit.component"; import { UserGroupsComponent } from "./user-groups.component"; @@ -65,15 +69,9 @@ export class PeopleComponent userType = OrganizationUserType; userStatusType = OrganizationUserStatusType; - organizationId: string; + organization: Organization; status: OrganizationUserStatusType = null; - accessEvents = false; - accessGroups = false; - canResetPassword = false; // User permission (admin/custom) - orgUseResetPassword = false; // Org plan ability - orgHasKeys = false; // Org public/private keys orgResetPasswordPolicyEnabled = false; - callingUserType: OrganizationUserType = null; private destroy$ = new Subject(); @@ -93,7 +91,8 @@ export class PeopleComponent private syncService: SyncService, stateService: StateService, private organizationService: OrganizationService, - private organizationApiService: OrganizationApiServiceAbstraction + private organizationApiService: OrganizationApiServiceAbstraction, + private dialogService: DialogService ) { super( apiService, @@ -114,26 +113,23 @@ export class PeopleComponent combineLatest([this.route.params, this.route.queryParams, this.policyService.policies$]) .pipe( concatMap(async ([params, qParams, policies]) => { - this.organizationId = params.organizationId; - const organization = await this.organizationService.get(this.organizationId); - this.accessEvents = organization.useEvents; - this.accessGroups = organization.useGroups; - this.canResetPassword = organization.canManageUsersPassword; - this.orgUseResetPassword = organization.useResetPassword; - this.callingUserType = organization.type; - this.orgHasKeys = organization.hasPublicAndPrivateKeys; + this.organization = await this.organizationService.get(params.organizationId); // Backfill pub/priv key if necessary - if (this.canResetPassword && !this.orgHasKeys) { - const orgShareKey = await this.cryptoService.getOrgKey(this.organizationId); + if ( + this.organization.canManageUsersPassword && + !this.organization.hasPublicAndPrivateKeys + ) { + const orgShareKey = await this.cryptoService.getOrgKey(this.organization.id); const orgKeys = await this.cryptoService.makeKeyPair(orgShareKey); const request = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); const response = await this.organizationApiService.updateKeys( - this.organizationId, + this.organization.id, request ); if (response != null) { - this.orgHasKeys = response.publicKey != null && response.privateKey != null; + this.organization.hasPublicAndPrivateKeys = + response.publicKey != null && response.privateKey != null; await this.syncService.fullSync(true); // Replace oganizations with new data } else { throw new Error(this.i18nService.t("resetPasswordOrgKeysError")); @@ -142,7 +138,7 @@ export class PeopleComponent const resetPasswordPolicy = policies .filter((policy) => policy.type === PolicyType.ResetPassword) - .find((p) => p.organizationId === this.organizationId); + .find((p) => p.organizationId === this.organization.id); this.orgResetPasswordPolicyEnabled = resetPasswordPolicy?.enabled; await this.load(); @@ -171,41 +167,41 @@ export class PeopleComponent } getUsers(): Promise> { - return this.apiService.getOrganizationUsers(this.organizationId); + return this.apiService.getOrganizationUsers(this.organization.id); } deleteUser(id: string): Promise { - return this.apiService.deleteOrganizationUser(this.organizationId, id); + return this.apiService.deleteOrganizationUser(this.organization.id, id); } revokeUser(id: string): Promise { - return this.apiService.revokeOrganizationUser(this.organizationId, id); + return this.apiService.revokeOrganizationUser(this.organization.id, id); } restoreUser(id: string): Promise { - return this.apiService.restoreOrganizationUser(this.organizationId, id); + return this.apiService.restoreOrganizationUser(this.organization.id, id); } reinviteUser(id: string): Promise { - return this.apiService.postOrganizationUserReinvite(this.organizationId, id); + return this.apiService.postOrganizationUserReinvite(this.organization.id, id); } async confirmUser( user: OrganizationUserUserDetailsResponse, publicKey: Uint8Array ): Promise { - const orgKey = await this.cryptoService.getOrgKey(this.organizationId); + const orgKey = await this.cryptoService.getOrgKey(this.organization.id); const key = await this.cryptoService.rsaEncrypt(orgKey.key, publicKey.buffer); const request = new OrganizationUserConfirmRequest(); request.key = key.encryptedString; - await this.apiService.postOrganizationUserConfirm(this.organizationId, user.id, request); + await this.apiService.postOrganizationUserConfirm(this.organization.id, user.id, request); } allowResetPassword(orgUser: OrganizationUserUserDetailsResponse): boolean { // Hierarchy check let callingUserHasPermission = false; - switch (this.callingUserType) { + switch (this.organization.type) { case OrganizationUserType.Owner: callingUserHasPermission = true; break; @@ -221,10 +217,10 @@ export class PeopleComponent // Final return ( - this.canResetPassword && + this.organization.canManageUsersPassword && callingUserHasPermission && - this.orgUseResetPassword && - this.orgHasKeys && + this.organization.useResetPassword && + this.organization.hasPublicAndPrivateKeys && orgUser.resetPasswordEnrolled && this.orgResetPasswordPolicyEnabled && orgUser.status === OrganizationUserStatusType.Confirmed @@ -233,20 +229,51 @@ export class PeopleComponent showEnrolledStatus(orgUser: OrganizationUserUserDetailsResponse): boolean { return ( - this.orgUseResetPassword && + this.organization.useResetPassword && orgUser.resetPasswordEnrolled && this.orgResetPasswordPolicyEnabled ); } async edit(user: OrganizationUserUserDetailsResponse) { + // Invite User: Add Flow + // Click on user email: Edit Flow + + // User attempting to invite new users in a free org with max users + if ( + !user && + this.organization.planProductType === ProductType.Free && + this.users.length === this.organization.seats + ) { + // Show org upgrade modal + + const dialogBodyText = this.organization.canManageBilling + ? this.i18nService.t( + "freeOrgInvLimitReachedManageBilling", + this.organization.seats.toString() + ) + : this.i18nService.t( + "freeOrgInvLimitReachedNoManageBilling", + this.organization.seats.toString() + ); + + this.dialogService.open(OrgUpgradeDialogComponent, { + data: { + orgId: this.organization.id, + orgCanManageBilling: this.organization.canManageBilling, + dialogBodyText: dialogBodyText, + }, + }); + return; + } + const [modal] = await this.modalService.openViewRef( UserAddEditComponent, this.addEditModalRef, (comp) => { comp.name = this.userNamePipe.transform(user); - comp.organizationId = this.organizationId; - comp.organizationUserId = user != null ? user.id : null; + comp.organizationId = this.organization.id; + comp.organizationUserId = user?.id || null; comp.usesKeyConnector = user?.usesKeyConnector; // eslint-disable-next-line rxjs-angular/prefer-takeuntil comp.onSavedUser.subscribe(() => { @@ -278,7 +305,7 @@ export class PeopleComponent this.groupsModalRef, (comp) => { comp.name = this.userNamePipe.transform(user); - comp.organizationId = this.organizationId; + comp.organizationId = this.organization.id; comp.organizationUserId = user != null ? user.id : null; // eslint-disable-next-line rxjs-angular/prefer-takeuntil comp.onSavedUser.subscribe(() => { @@ -297,7 +324,7 @@ export class PeopleComponent BulkRemoveComponent, this.bulkRemoveModalRef, (comp) => { - comp.organizationId = this.organizationId; + comp.organizationId = this.organization.id; comp.users = this.getCheckedUsers(); } ); @@ -322,7 +349,7 @@ export class PeopleComponent const ref = this.modalService.open(BulkRestoreRevokeComponent, { allowMultipleModals: true, data: { - organizationId: this.organizationId, + organizationId: this.organization.id, users: this.getCheckedUsers(), isRevoking: isRevoking, }, @@ -352,7 +379,7 @@ export class PeopleComponent try { const request = new OrganizationUserBulkRequest(filteredUsers.map((user) => user.id)); const response = this.apiService.postManyOrganizationUserReinvite( - this.organizationId, + this.organization.id, request ); this.showBulkStatus( @@ -376,7 +403,7 @@ export class PeopleComponent BulkConfirmComponent, this.bulkConfirmModalRef, (comp) => { - comp.organizationId = this.organizationId; + comp.organizationId = this.organization.id; comp.users = this.getCheckedUsers(); } ); @@ -388,7 +415,7 @@ export class PeopleComponent async events(user: OrganizationUserUserDetailsResponse) { await this.modalService.openViewRef(EntityEventsComponent, this.eventsModalRef, (comp) => { comp.name = this.userNamePipe.transform(user); - comp.organizationId = this.organizationId; + comp.organizationId = this.organization.id; comp.entityId = user.id; comp.showUser = false; comp.entity = "user"; @@ -402,7 +429,7 @@ export class PeopleComponent (comp) => { comp.name = this.userNamePipe.transform(user); comp.email = user != null ? user.email : null; - comp.organizationId = this.organizationId; + comp.organizationId = this.organization.id; comp.id = user != null ? user.id : null; // eslint-disable-next-line rxjs-angular/prefer-takeuntil diff --git a/apps/web/src/app/organizations/organization.module.ts b/apps/web/src/app/organizations/organization.module.ts index d894f53e09..db2e13320a 100644 --- a/apps/web/src/app/organizations/organization.module.ts +++ b/apps/web/src/app/organizations/organization.module.ts @@ -1,10 +1,12 @@ import { NgModule } from "@angular/core"; import { AccessSelectorModule } from "./components/access-selector"; +import { OrgUpgradeDialogComponent } from "./manage/org-upgrade-dialog/org-upgrade-dialog.component"; import { OrganizationsRoutingModule } from "./organization-routing.module"; import { SharedOrganizationModule } from "./shared"; @NgModule({ imports: [SharedOrganizationModule, AccessSelectorModule, OrganizationsRoutingModule], + declarations: [OrgUpgradeDialogComponent], }) export class OrganizationModule {} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index aa27478ea7..726d8da64e 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -5771,5 +5771,41 @@ }, "memberAccessAll": { "message": "This member can access and modify all items." + }, + "freeOrgInvLimitReachedManageBilling": { + "message": "Free organizations may have up to $SEATCOUNT$ members. Upgrade to a paid plan to invite more members.", + "placeholders": { + "seatcount": { + "content": "$1", + "example": "2" + } + } + }, + "freeOrgInvLimitReachedNoManageBilling": { + "message": "Free organizations may have up to $SEATCOUNT$ members. Contact your organization owner to upgrade.", + "placeholders": { + "seatcount": { + "content": "$1", + "example": "2" + } + } + }, + "freeOrgMaxCollectionReachedManageBilling": { + "message": "Free organizations may have up to $COLLECTIONCOUNT$ collections. Upgrade to a paid plan to add more collections.", + "placeholders": { + "COLLECTIONCOUNT": { + "content": "$1", + "example": "2" + } + } + }, + "freeOrgMaxCollectionReachedNoManageBilling": { + "message": "Free organizations may have up to $COLLECTIONCOUNT$ collections. Contact your organization owner to upgrade.", + "placeholders": { + "COLLECTIONCOUNT": { + "content": "$1", + "example": "2" + } + } } } diff --git a/libs/components/src/dialog/dialog.module.ts b/libs/components/src/dialog/dialog.module.ts index 421ebded71..3932c0c85e 100644 --- a/libs/components/src/dialog/dialog.module.ts +++ b/libs/components/src/dialog/dialog.module.ts @@ -8,7 +8,7 @@ import { DialogService } from "./dialog.service"; import { DialogComponent } from "./dialog/dialog.component"; import { DialogCloseDirective } from "./directives/dialog-close.directive"; import { DialogTitleContainerDirective } from "./directives/dialog-title-container.directive"; -import { SimpleDialogComponent } from "./simple-dialog/simple-dialog.component"; +import { IconDirective, SimpleDialogComponent } from "./simple-dialog/simple-dialog.component"; @NgModule({ imports: [SharedModule, IconButtonModule, CdkDialogModule], @@ -17,8 +17,15 @@ import { SimpleDialogComponent } from "./simple-dialog/simple-dialog.component"; DialogTitleContainerDirective, DialogComponent, SimpleDialogComponent, + IconDirective, + ], + exports: [ + CdkDialogModule, + DialogComponent, + SimpleDialogComponent, + DialogCloseDirective, + IconDirective, ], - exports: [CdkDialogModule, DialogComponent, SimpleDialogComponent, DialogCloseDirective], providers: [DialogService], }) export class DialogModule {}