1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-25 12:15:18 +01:00

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 <bit-simple-dialog> 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
This commit is contained in:
Jared Snider 2022-12-16 15:11:37 -05:00 committed by GitHub
parent 9bbe13fa71
commit 3d008da287
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 205 additions and 47 deletions

View File

@ -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) => {

View File

@ -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,

View File

@ -0,0 +1,33 @@
<bit-simple-dialog>
<i
bit-dialog-icon
class="bwi bwi-business tw-text-5xl tw-text-primary-500"
aria-hidden="true"
></i>
<span bitDialogTitle class="font-bold">{{ "upgradeOrganization" | i18n }}</span>
<span bitDialogContent>
{{ data.dialogBodyText }}
</span>
<div bitDialogFooter class="tw-flex tw-flex-row tw-gap-2">
<ng-container *ngIf="data.orgCanManageBilling">
<button
bitButton
buttonType="primary"
[routerLink]="['/organizations', data.orgId, 'billing', 'subscription']"
[queryParams]="{ upgrade: true }"
(click)="dialogRef.close()"
>
{{ "upgrade" | i18n }}
</button>
<button bitButton buttonType="secondary" (click)="dialogRef.close()">
{{ "cancel" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="!data.orgCanManageBilling">
<button bitButton buttonType="primary" (click)="dialogRef.close()">
{{ "ok" | i18n }}
</button>
</ng-container>
</div>
</bit-simple-dialog>

View File

@ -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
) {}
}

View File

@ -221,7 +221,7 @@
href="#"
appStopClick
(click)="groups(u)"
*ngIf="accessGroups"
*ngIf="organization.useGroups"
>
<i class="bwi bwi-fw bwi-sitemap" aria-hidden="true"></i>
{{ "groups" | i18n }}
@ -231,7 +231,7 @@
href="#"
appStopClick
(click)="events(u)"
*ngIf="accessEvents && u.status === userStatusType.Confirmed"
*ngIf="organization.useEvents && u.status === userStatusType.Confirmed"
>
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }}

View File

@ -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<void>();
@ -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<ListResponse<OrganizationUserUserDetailsResponse>> {
return this.apiService.getOrganizationUsers(this.organizationId);
return this.apiService.getOrganizationUsers(this.organization.id);
}
deleteUser(id: string): Promise<void> {
return this.apiService.deleteOrganizationUser(this.organizationId, id);
return this.apiService.deleteOrganizationUser(this.organization.id, id);
}
revokeUser(id: string): Promise<void> {
return this.apiService.revokeOrganizationUser(this.organizationId, id);
return this.apiService.revokeOrganizationUser(this.organization.id, id);
}
restoreUser(id: string): Promise<void> {
return this.apiService.restoreOrganizationUser(this.organizationId, id);
return this.apiService.restoreOrganizationUser(this.organization.id, id);
}
reinviteUser(id: string): Promise<void> {
return this.apiService.postOrganizationUserReinvite(this.organizationId, id);
return this.apiService.postOrganizationUserReinvite(this.organization.id, id);
}
async confirmUser(
user: OrganizationUserUserDetailsResponse,
publicKey: Uint8Array
): Promise<void> {
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

View File

@ -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 {}

View File

@ -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"
}
}
}
}

View File

@ -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 {}