From b93602b09e90e4127b8c8ba337b862004de1f3fd Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Mon, 8 Sep 2025 09:42:02 -0400 Subject: [PATCH] [PM-24412] Make billing api service call in members component non blocking (#16103) * refactor organization to signal, unblock loading due to api call * continue refactor WIP * clean up * refactor billingMetadata signal to observble * deffer billing call * refactor billingMetadata * cleanup, add comment * qa bug: add missing param --- .../common/base-members.component.ts | 62 +- .../common/people-table-data-source.ts | 4 +- .../members/members.component.html | 853 +++++++++--------- .../members/members.component.ts | 458 +++++----- .../change-plan-dialog.component.ts | 2 +- .../providers/manage/members.component.ts | 4 + .../src/admin-console/models/domain/policy.ts | 4 +- .../services/policy/default-policy.service.ts | 2 +- 8 files changed, 714 insertions(+), 675 deletions(-) diff --git a/apps/web/src/app/admin-console/common/base-members.component.ts b/apps/web/src/app/admin-console/common/base-members.component.ts index 624615edd6a..6fc0a70a8c8 100644 --- a/apps/web/src/app/admin-console/common/base-members.component.ts +++ b/apps/web/src/app/admin-console/common/base-members.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Directive } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl } from "@angular/forms"; @@ -14,6 +12,7 @@ import { ProviderUserStatusType, ProviderUserType, } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -66,20 +65,20 @@ export abstract class BaseMembersComponent { protected abstract dataSource: PeopleTableDataSource; - firstLoaded: boolean; + firstLoaded: boolean = false; /** - * The currently selected status filter, or null to show all active users. + * The currently selected status filter, or undefined to show all active users. */ - status: StatusType | null; + status?: StatusType; /** * The currently executing promise - used to avoid multiple user actions executing at once. */ - actionPromise: Promise; + actionPromise?: Promise; protected searchControl = new FormControl("", { nonNullable: true }); - protected statusToggle = new BehaviorSubject(null); + protected statusToggle = new BehaviorSubject(undefined); constructor( protected apiService: ApiService, @@ -100,15 +99,20 @@ export abstract class BaseMembersComponent { ); } - abstract edit(user: UserView): void; - abstract getUsers(): Promise | UserView[]>; - abstract removeUser(id: string): Promise; - abstract reinviteUser(id: string): Promise; - abstract confirmUser(user: UserView, publicKey: Uint8Array): Promise; + abstract edit(user: UserView, organization?: Organization): void; + abstract getUsers(organization?: Organization): Promise | UserView[]>; + abstract removeUser(id: string, organization?: Organization): Promise; + abstract reinviteUser(id: string, organization?: Organization): Promise; + abstract confirmUser( + user: UserView, + publicKey: Uint8Array, + organization?: Organization, + ): Promise; + abstract invite(organization?: Organization): void; - async load() { + async load(organization?: Organization) { // Load new users from the server - const response = await this.getUsers(); + const response = await this.getUsers(organization); // GetUsers can return a ListResponse or an Array if (response instanceof ListResponse) { @@ -120,10 +124,6 @@ export abstract class BaseMembersComponent { this.firstLoaded = true; } - invite() { - this.edit(null); - } - protected async removeUserConfirmationDialog(user: UserView) { return this.dialogService.openSimpleDialog({ title: this.userNamePipe.transform(user), @@ -132,64 +132,61 @@ export abstract class BaseMembersComponent { }); } - async remove(user: UserView) { + async remove(user: UserView, organization?: Organization) { const confirmed = await this.removeUserConfirmationDialog(user); if (!confirmed) { return false; } - this.actionPromise = this.removeUser(user.id); + this.actionPromise = this.removeUser(user.id, organization); try { await this.actionPromise; this.toastService.showToast({ variant: "success", - title: null, message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)), }); this.dataSource.removeUser(user); } catch (e) { this.validationService.showError(e); } - this.actionPromise = null; + this.actionPromise = undefined; } - async reinvite(user: UserView) { + async reinvite(user: UserView, organization?: Organization) { if (this.actionPromise != null) { return; } - this.actionPromise = this.reinviteUser(user.id); + this.actionPromise = this.reinviteUser(user.id, organization); try { await this.actionPromise; this.toastService.showToast({ variant: "success", - title: null, message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)), }); } catch (e) { this.validationService.showError(e); } - this.actionPromise = null; + this.actionPromise = undefined; } - async confirm(user: UserView) { + async confirm(user: UserView, organization?: Organization) { const confirmUser = async (publicKey: Uint8Array) => { try { - this.actionPromise = this.confirmUser(user, publicKey); + this.actionPromise = this.confirmUser(user, publicKey, organization); await this.actionPromise; user.status = this.userStatusType.Confirmed; this.dataSource.replaceUser(user); this.toastService.showToast({ variant: "success", - title: null, message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)), }); } catch (e) { this.validationService.showError(e); throw e; } finally { - this.actionPromise = null; + this.actionPromise = undefined; } }; @@ -204,11 +201,14 @@ export abstract class BaseMembersComponent { const autoConfirm = await firstValueFrom( this.organizationManagementPreferencesService.autoConfirmFingerPrints.state$, ); + if (user == null) { + throw new Error("Cannot confirm null user."); + } if (autoConfirm == null || !autoConfirm) { const dialogRef = UserConfirmComponent.open(this.dialogService, { data: { name: this.userNamePipe.transform(user), - userId: user != null ? user.userId : null, + userId: user.id, publicKey: publicKey, confirmUser: () => confirmUser(publicKey), }, diff --git a/apps/web/src/app/admin-console/common/people-table-data-source.ts b/apps/web/src/app/admin-console/common/people-table-data-source.ts index c1ece833d83..4696f8a6738 100644 --- a/apps/web/src/app/admin-console/common/people-table-data-source.ts +++ b/apps/web/src/app/admin-console/common/people-table-data-source.ts @@ -13,7 +13,7 @@ const MaxCheckedCount = 500; /** * Returns true if the user matches the status, or where the status is `null`, if the user is active (not revoked). */ -function statusFilter(user: UserViewTypes, status: StatusType) { +function statusFilter(user: UserViewTypes, status?: StatusType) { if (status == null) { return user.status != OrganizationUserStatusType.Revoked; } @@ -35,7 +35,7 @@ function textFilter(user: UserViewTypes, text: string) { ); } -export function peopleFilter(searchText: string, status: StatusType) { +export function peopleFilter(searchText: string, status?: StatusType) { return (user: UserViewTypes) => statusFilter(user, status) && textFilter(user, searchText); } diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.html b/apps/web/src/app/admin-console/organizations/members/members.component.html index cc475009700..282291eb60e 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.html +++ b/apps/web/src/app/admin-console/organizations/members/members.component.html @@ -1,466 +1,479 @@ - - - - - - - + + + -
- - - {{ "all" | i18n }} - {{ - allCount - }} - - - - {{ "invited" | i18n }} - {{ - invitedCount - }} - - - - {{ "needsConfirmation" | i18n }} - {{ - acceptedUserCount - }} - - - - {{ "revoked" | i18n }} - {{ - revokedCount - }} - - -
- - - {{ "loading" | i18n }} - - -

{{ "noMembersInList" | i18n }}

- - - {{ "usersNeedConfirmed" | i18n }} - - - - - - - - - - - {{ "name" | i18n }} - {{ (organization.useGroups ? "groups" : "collections") | i18n }} - {{ "role" | i18n }} - {{ "policies" | i18n }} - - + + + + + + + + + {{ "name" | i18n }} + {{ (organization.useGroups ? "groups" : "collections") | i18n }} + {{ "role" | i18n }} + {{ "policies" | i18n }} + + - - - - - - - - - - - - - - - - - - - - - - -
- -
-
- - - {{ "invited" | i18n }} - - - {{ "needsConfirmation" | i18n }} - - - {{ "revoked" | i18n }} - -
-
- {{ u.email }} -
-
-
- -
- - -
- -
-
- {{ u.name ?? u.email }} - - {{ "invited" | i18n }} - - - {{ "needsConfirmation" | i18n }} - - - {{ "revoked" | i18n }} - -
-
- {{ u.email }} -
-
-
- -
- - - - - - - - - - - - - - - {{ u.type | userType }} - - - - - {{ u.type | userType }} - - - - - - - {{ "userUsingTwoStep" | i18n }} - - - - {{ "enrolledAccountRecovery" | i18n }} - - - - - - - + + + + + - - - - - - - - - - - - + + + +
+ + + + + + + +
+ +
+
+ + + {{ "invited" | i18n }} + + + {{ "needsConfirmation" | i18n }} + + + {{ "revoked" | i18n }} + +
+
+ {{ u.email }} +
+
+
+ +
+ + +
+ +
+
+ {{ u.name ?? u.email }} + + {{ "invited" | i18n }} + + + {{ "needsConfirmation" | i18n }} + + + {{ "revoked" | i18n }} + +
+
+ {{ u.email }} +
+
+
+ +
+ + + + + + + + + + + + + + + {{ u.type | userType }} + + + + + {{ u.type | userType }} + + + + + + + {{ "userUsingTwoStep" | i18n }} - - - -
-
-
+ + + {{ "enrolledAccountRecovery" | i18n }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
- +} diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index dedf13720bf..5a1ae6cd98b 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -1,11 +1,11 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Component, computed, Signal } from "@angular/core"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; import { + BehaviorSubject, combineLatest, concatMap, + filter, firstValueFrom, from, lastValueFrom, @@ -14,6 +14,7 @@ import { Observable, shareReplay, switchMap, + take, } from "rxjs"; import { @@ -47,13 +48,14 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums"; +import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; @@ -102,23 +104,24 @@ export class MembersComponent extends BaseMembersComponent memberTab = MemberDialogTab; protected dataSource = new MembersTableDataSource(); - organization: Organization; - status: OrganizationUserStatusType = null; + organization: Signal; + status: OrganizationUserStatusType | undefined; orgResetPasswordPolicyEnabled = false; - orgIsOnSecretsManagerStandalone = false; - protected canUseSecretsManager$: Observable; - protected showUserManagementControls$: Observable; + protected canUseSecretsManager: Signal = computed( + () => this.organization()?.useSecretsManager ?? false, + ); + protected showUserManagementControls: Signal = computed( + () => this.organization()?.canManageUsers ?? false, + ); + private refreshBillingMetadata$: BehaviorSubject = new BehaviorSubject(null); + protected billingMetadata$: Observable; // Fixed sizes used for cdkVirtualScroll protected rowHeight = 66; protected rowHeightClass = `tw-h-[66px]`; - private organizationUsersCount = 0; - - get occupiedSeatCount(): number { - return this.organizationUsersCount; - } + private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); constructor( apiService: ApiService, @@ -162,61 +165,58 @@ export class MembersComponent extends BaseMembersComponent const organization$ = this.route.params.pipe( concatMap((params) => - this.accountService.activeAccount$.pipe( - switchMap((account) => + this.userId$.pipe( + switchMap((userId) => this.organizationService - .organizations$(account?.id) + .organizations$(userId) .pipe(getOrganizationById(params.organizationId)), ), ), ), + filter((organization): organization is Organization => organization != null), shareReplay({ refCount: true, bufferSize: 1 }), ); - this.canUseSecretsManager$ = organization$.pipe(map((org) => org.useSecretsManager)); + this.organization = toSignal(organization$); - const policies$ = combineLatest([ - this.accountService.activeAccount$.pipe(getUserId), - organization$, - ]).pipe( - switchMap(([userId, organization]) => { - if (organization.isProviderUser) { - return from(this.policyApiService.getPolicies(organization.id)).pipe( - map((response) => Policy.fromListResponse(response)), - ); - } - - return this.policyService.policies$(userId); - }), + const policies$ = combineLatest([this.userId$, organization$]).pipe( + switchMap(([userId, organization]) => + organization.isProviderUser + ? from(this.policyApiService.getPolicies(organization.id)).pipe( + map((response) => Policy.fromListResponse(response)), + ) + : this.policyService.policies$(userId), + ), ); combineLatest([this.route.queryParams, policies$, organization$]) .pipe( concatMap(async ([qParams, policies, organization]) => { - this.organization = organization; - // Backfill pub/priv key if necessary - if ( - this.organization.canManageUsersPassword && - !this.organization.hasPublicAndPrivateKeys - ) { + if (organization.canManageUsersPassword && !organization.hasPublicAndPrivateKeys) { const orgShareKey = await firstValueFrom( - this.accountService.activeAccount$.pipe( - getUserId, + this.userId$.pipe( switchMap((userId) => this.keyService.orgKeys$(userId)), - map((orgKeys) => orgKeys[this.organization.id] ?? null), + map((orgKeys) => { + if (orgKeys == null || orgKeys[organization.id] == null) { + throw new Error("Organization keys not found for provided User."); + } + return orgKeys[organization.id]; + }), ), ); - const orgKeys = await this.keyService.makeKeyPair(orgShareKey); - const request = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); - const response = await this.organizationApiService.updateKeys( - this.organization.id, - request, + const [orgPublicKey, encryptedOrgPrivateKey] = + await this.keyService.makeKeyPair(orgShareKey); + if (encryptedOrgPrivateKey.encryptedString == null) { + throw new Error("Encrypted private key is null."); + } + const request = new OrganizationKeysRequest( + orgPublicKey, + encryptedOrgPrivateKey.encryptedString, ); + const response = await this.organizationApiService.updateKeys(organization.id, request); if (response != null) { - this.organization.hasPublicAndPrivateKeys = - response.publicKey != null && response.privateKey != null; await this.syncService.fullSync(true); // Replace organizations with new data } else { throw new Error(this.i18nService.t("resetPasswordOrgKeysError")); @@ -225,24 +225,17 @@ export class MembersComponent extends BaseMembersComponent const resetPasswordPolicy = policies .filter((policy) => policy.type === PolicyType.ResetPassword) - .find((p) => p.organizationId === this.organization.id); - this.orgResetPasswordPolicyEnabled = resetPasswordPolicy?.enabled; + .find((p) => p.organizationId === organization.id); + this.orgResetPasswordPolicyEnabled = resetPasswordPolicy?.enabled ?? false; - const billingMetadata = await this.billingApiService.getOrganizationBillingMetadata( - this.organization.id, - ); - - this.orgIsOnSecretsManagerStandalone = billingMetadata.isOnSecretsManagerStandalone; - this.organizationUsersCount = billingMetadata.organizationOccupiedSeats; - - await this.load(); + await this.load(organization); this.searchControl.setValue(qParams.search); if (qParams.viewEvents != null) { const user = this.dataSource.data.filter((u) => u.id === qParams.viewEvents); if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) { - this.openEventsDialog(user[0]); + this.openEventsDialog(user[0], organization); } } }), @@ -250,10 +243,6 @@ export class MembersComponent extends BaseMembersComponent ) .subscribe(); - this.showUserManagementControls$ = organization$.pipe( - map((organization) => organization.canManageUsers), - ); - organization$ .pipe( switchMap((organization) => @@ -265,23 +254,40 @@ export class MembersComponent extends BaseMembersComponent takeUntilDestroyed(), ) .subscribe(); + + this.billingMetadata$ = combineLatest([this.refreshBillingMetadata$, organization$]).pipe( + switchMap(([_, organization]) => + this.billingApiService.getOrganizationBillingMetadata(organization.id), + ), + takeUntilDestroyed(), + shareReplay({ bufferSize: 1, refCount: false }), + ); + + // Stripe is slow, so kick this off in the background but without blocking page load. + // Anyone who needs it will still await the first emission. + this.billingMetadata$.pipe(take(1), takeUntilDestroyed()).subscribe(); } - async getUsers(): Promise { - let groupsPromise: Promise>; - let collectionsPromise: Promise>; + override async load(organization: Organization) { + this.refreshBillingMetadata$.next(null); + await super.load(organization); + } + + async getUsers(organization: Organization): Promise { + let groupsPromise: Promise> | undefined; + let collectionsPromise: Promise> | undefined; // We don't need both groups and collections for the table, so only load one - const userPromise = this.organizationUserApiService.getAllUsers(this.organization.id, { - includeGroups: this.organization.useGroups, - includeCollections: !this.organization.useGroups, + const userPromise = this.organizationUserApiService.getAllUsers(organization.id, { + includeGroups: organization.useGroups, + includeCollections: !organization.useGroups, }); // Depending on which column is displayed, we need to load the group/collection names - if (this.organization.useGroups) { - groupsPromise = this.getGroupNameMap(); + if (organization.useGroups) { + groupsPromise = this.getGroupNameMap(organization); } else { - collectionsPromise = this.getCollectionNameMap(); + collectionsPromise = this.getCollectionNameMap(organization); } const [usersResponse, groupNamesMap, collectionNamesMap] = await Promise.all([ @@ -290,22 +296,26 @@ export class MembersComponent extends BaseMembersComponent collectionsPromise, ]); - return usersResponse.data?.map((r) => { - const userView = OrganizationUserView.fromResponse(r); + return ( + usersResponse.data?.map((r) => { + const userView = OrganizationUserView.fromResponse(r); - userView.groupNames = userView.groups - .map((g) => groupNamesMap.get(g)) - .sort(this.i18nService.collator?.compare); - userView.collectionNames = userView.collections - .map((c) => collectionNamesMap.get(c.id)) - .sort(this.i18nService.collator?.compare); + userView.groupNames = userView.groups + .map((g) => groupNamesMap?.get(g)) + .filter((name): name is string => name != null) + .sort(this.i18nService.collator?.compare); + userView.collectionNames = userView.collections + .map((c) => collectionNamesMap?.get(c.id)) + .filter((name): name is string => name != null) + .sort(this.i18nService.collator?.compare); - return userView; - }); + return userView; + }) ?? [] + ); } - async getGroupNameMap(): Promise> { - const groups = await this.groupService.getAll(this.organization.id); + async getGroupNameMap(organization: Organization): Promise> { + const groups = await this.groupService.getAll(organization.id); const groupNameMap = new Map(); groups.forEach((g) => groupNameMap.set(g.id, g.name)); return groupNameMap; @@ -314,8 +324,8 @@ export class MembersComponent extends BaseMembersComponent /** * Retrieve a map of all collection IDs <-> names for the organization. */ - async getCollectionNameMap() { - const response = from(this.apiService.getCollections(this.organization.id)).pipe( + async getCollectionNameMap(organization: Organization) { + const response = from(this.apiService.getCollections(organization.id)).pipe( map((res) => res.data.map((r) => Collection.fromCollectionData(new CollectionData(r as CollectionDetailsResponse)), @@ -324,9 +334,9 @@ export class MembersComponent extends BaseMembersComponent ); const decryptedCollections$ = combineLatest([ - this.accountService.activeAccount$.pipe( - getUserId, + this.userId$.pipe( switchMap((userId) => this.keyService.orgKeys$(userId)), + filter((orgKeys) => orgKeys != null), ), response, ]).pipe( @@ -343,91 +353,94 @@ export class MembersComponent extends BaseMembersComponent return await firstValueFrom(decryptedCollections$); } - removeUser(id: string): Promise { - return this.organizationUserApiService.removeOrganizationUser(this.organization.id, id); + removeUser(id: string, organization: Organization): Promise { + return this.organizationUserApiService.removeOrganizationUser(organization.id, id); } - revokeUser(id: string): Promise { - return this.organizationUserApiService.revokeOrganizationUser(this.organization.id, id); + revokeUser(id: string, organization: Organization): Promise { + return this.organizationUserApiService.revokeOrganizationUser(organization.id, id); } - restoreUser(id: string): Promise { - return this.organizationUserApiService.restoreOrganizationUser(this.organization.id, id); + restoreUser(id: string, organization: Organization): Promise { + return this.organizationUserApiService.restoreOrganizationUser(organization.id, id); } - reinviteUser(id: string): Promise { - return this.organizationUserApiService.postOrganizationUserReinvite(this.organization.id, id); + reinviteUser(id: string, organization: Organization): Promise { + return this.organizationUserApiService.postOrganizationUserReinvite(organization.id, id); } - async confirmUser(user: OrganizationUserView, publicKey: Uint8Array): Promise { + async confirmUser( + user: OrganizationUserView, + publicKey: Uint8Array, + organization: Organization, + ): Promise { if ( await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)) ) { - await firstValueFrom( - this.organizationUserService.confirmUser(this.organization, user, publicKey), - ); + await firstValueFrom(this.organizationUserService.confirmUser(organization, user, publicKey)); } else { - const orgKey = await firstValueFrom( - this.accountService.activeAccount$.pipe( - getUserId, + const request = await firstValueFrom( + this.userId$.pipe( switchMap((userId) => this.keyService.orgKeys$(userId)), - map((orgKeys) => orgKeys[this.organization.id] ?? null), + filter((orgKeys) => orgKeys != null), + map((orgKeys) => orgKeys[organization.id]), + switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)), + map((encKey) => { + const req = new OrganizationUserConfirmRequest(); + req.key = encKey.encryptedString; + return req; + }), ), ); - const key = await this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey); - const request = new OrganizationUserConfirmRequest(); - request.key = key.encryptedString; + await this.organizationUserApiService.postOrganizationUserConfirm( - this.organization.id, + organization.id, user.id, request, ); } } - async revoke(user: OrganizationUserView) { + async revoke(user: OrganizationUserView, organization: Organization) { const confirmed = await this.revokeUserConfirmationDialog(user); if (!confirmed) { return false; } - this.actionPromise = this.revokeUser(user.id); + this.actionPromise = this.revokeUser(user.id, organization); try { await this.actionPromise; this.toastService.showToast({ variant: "success", - title: null, message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)), }); - await this.load(); + await this.load(organization); } catch (e) { this.validationService.showError(e); } - this.actionPromise = null; + this.actionPromise = undefined; } - async restore(user: OrganizationUserView) { - this.actionPromise = this.restoreUser(user.id); + async restore(user: OrganizationUserView, organization: Organization) { + this.actionPromise = this.restoreUser(user.id, organization); try { await this.actionPromise; this.toastService.showToast({ variant: "success", - title: null, message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)), }); - await this.load(); + await this.load(organization); } catch (e) { this.validationService.showError(e); } - this.actionPromise = null; + this.actionPromise = undefined; } - allowResetPassword(orgUser: OrganizationUserView): boolean { - // Hierarchy check + allowResetPassword(orgUser: OrganizationUserView, organization: Organization): boolean { let callingUserHasPermission = false; - switch (this.organization.type) { + switch (organization.type) { case OrganizationUserType.Owner: callingUserHasPermission = true; break; @@ -441,33 +454,35 @@ export class MembersComponent extends BaseMembersComponent break; } - // Final return ( - this.organization.canManageUsersPassword && + organization.canManageUsersPassword && callingUserHasPermission && - this.organization.useResetPassword && - this.organization.hasPublicAndPrivateKeys && + organization.useResetPassword && + organization.hasPublicAndPrivateKeys && orgUser.resetPasswordEnrolled && this.orgResetPasswordPolicyEnabled && orgUser.status === OrganizationUserStatusType.Confirmed ); } - showEnrolledStatus(orgUser: OrganizationUserUserDetailsResponse): boolean { + showEnrolledStatus( + orgUser: OrganizationUserUserDetailsResponse, + organization: Organization, + ): boolean { return ( - this.organization.useResetPassword && + organization.useResetPassword && orgUser.resetPasswordEnrolled && this.orgResetPasswordPolicyEnabled ); } - private getManageBillingText(): string { - return this.organization.canEditSubscription ? "ManageBilling" : "NoManageBilling"; + private getManageBillingText(organization: Organization): string { + return organization.canEditSubscription ? "ManageBilling" : "NoManageBilling"; } - private getProductKey(productType: ProductTierType): string { + private getProductKey(organization: Organization): string { let product = ""; - switch (productType) { + switch (organization.productTierType) { case ProductTierType.Free: product = "freeOrg"; break; @@ -478,24 +493,21 @@ export class MembersComponent extends BaseMembersComponent product = "familiesPlan"; break; default: - throw new Error(`Unsupported product type: ${productType}`); + throw new Error(`Unsupported product type: ${organization.productTierType}`); } - return `${product}InvLimitReached${this.getManageBillingText()}`; + return `${product}InvLimitReached${this.getManageBillingText(organization)}`; } - private getDialogContent(): string { - return this.i18nService.t( - this.getProductKey(this.organization.productTierType), - this.organization.seats, - ); + private getDialogContent(organization: Organization): string { + return this.i18nService.t(this.getProductKey(organization), organization.seats); } - private getAcceptButtonText(): string { - if (!this.organization.canEditSubscription) { + private getAcceptButtonText(organization: Organization): string { + if (!organization.canEditSubscription) { return this.i18nService.t("ok"); } - const productType = this.organization.productTierType; + const productType = organization.productTierType; if (isNotSelfUpgradable(productType)) { throw new Error(`Unsupported product type: ${productType}`); @@ -504,82 +516,88 @@ export class MembersComponent extends BaseMembersComponent return this.i18nService.t("upgrade"); } - private async handleDialogClose(result: boolean | undefined): Promise { - if (!result || !this.organization.canEditSubscription) { + private async handleDialogClose( + result: boolean | undefined, + organization: Organization, + ): Promise { + if (!result || !organization.canEditSubscription) { return; } - const productType = this.organization.productTierType; + const productType = organization.productTierType; if (isNotSelfUpgradable(productType)) { - throw new Error(`Unsupported product type: ${this.organization.productTierType}`); + throw new Error(`Unsupported product type: ${organization.productTierType}`); } - await this.router.navigate( - ["/organizations", this.organization.id, "billing", "subscription"], - { queryParams: { upgrade: true } }, - ); + await this.router.navigate(["/organizations", organization.id, "billing", "subscription"], { + queryParams: { upgrade: true }, + }); } - private async showSeatLimitReachedDialog(): Promise { + private async showSeatLimitReachedDialog(organization: Organization): Promise { const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = { title: this.i18nService.t("upgradeOrganization"), - content: this.getDialogContent(), + content: this.getDialogContent(organization), type: "primary", - acceptButtonText: this.getAcceptButtonText(), + acceptButtonText: this.getAcceptButtonText(organization), }; - if (!this.organization.canEditSubscription) { + if (!organization.canEditSubscription) { orgUpgradeSimpleDialogOpts.cancelButtonText = null; } const simpleDialog = this.dialogService.openSimpleDialogRef(orgUpgradeSimpleDialogOpts); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - firstValueFrom(simpleDialog.closed).then(this.handleDialogClose.bind(this)); + await lastValueFrom( + simpleDialog.closed.pipe(map((closed) => this.handleDialogClose(closed, organization))), + ); } - private async handleInviteDialog() { + private async handleInviteDialog(organization: Organization) { + const billingMetadata = await firstValueFrom(this.billingMetadata$); const dialog = openUserAddEditDialog(this.dialogService, { data: { kind: "Add", - organizationId: this.organization.id, + organizationId: organization.id, allOrganizationUserEmails: this.dataSource.data?.map((user) => user.email) ?? [], - occupiedSeatCount: this.occupiedSeatCount, - isOnSecretsManagerStandalone: this.orgIsOnSecretsManagerStandalone, + occupiedSeatCount: billingMetadata?.organizationOccupiedSeats ?? 0, + isOnSecretsManagerStandalone: billingMetadata?.isOnSecretsManagerStandalone ?? false, }, }); const result = await lastValueFrom(dialog.closed); if (result === MemberDialogResult.Saved) { - await this.load(); + await this.load(organization); } } - private async handleSeatLimitForFixedTiers() { - if (!this.organization.canEditSubscription) { - await this.showSeatLimitReachedDialog(); + private async handleSeatLimitForFixedTiers(organization: Organization) { + if (!organization.canEditSubscription) { + await this.showSeatLimitReachedDialog(organization); return; } const reference = openChangePlanDialog(this.dialogService, { data: { - organizationId: this.organization.id, - subscription: null, - productTierType: this.organization.productTierType, + organizationId: organization.id, + productTierType: organization.productTierType, }, }); const result = await lastValueFrom(reference.closed); if (result === ChangePlanDialogResultType.Submitted) { - await this.load(); + await this.load(organization); } } - async invite() { - if (this.organization.hasReseller && this.organization.seats === this.occupiedSeatCount) { + async invite(organization: Organization) { + const billingMetadata = await firstValueFrom(this.billingMetadata$); + if ( + organization.hasReseller && + organization.seats === billingMetadata?.organizationOccupiedSeats + ) { this.toastService.showToast({ variant: "error", title: this.i18nService.t("seatLimitReached"), @@ -590,26 +608,31 @@ export class MembersComponent extends BaseMembersComponent } if ( - this.occupiedSeatCount === this.organization.seats && - isFixedSeatPlan(this.organization.productTierType) + billingMetadata?.organizationOccupiedSeats === organization.seats && + isFixedSeatPlan(organization.productTierType) ) { - await this.handleSeatLimitForFixedTiers(); + await this.handleSeatLimitForFixedTiers(organization); return; } - await this.handleInviteDialog(); + await this.handleInviteDialog(organization); } - async edit(user: OrganizationUserView, initialTab: MemberDialogTab = MemberDialogTab.Role) { + async edit( + user: OrganizationUserView, + organization: Organization, + initialTab: MemberDialogTab = MemberDialogTab.Role, + ) { + const billingMetadata = await firstValueFrom(this.billingMetadata$); const dialog = openUserAddEditDialog(this.dialogService, { data: { kind: "Edit", name: this.userNamePipe.transform(user), - organizationId: this.organization.id, + organizationId: organization.id, organizationUserId: user.id, usesKeyConnector: user.usesKeyConnector, - isOnSecretsManagerStandalone: this.orgIsOnSecretsManagerStandalone, + isOnSecretsManagerStandalone: billingMetadata?.isOnSecretsManagerStandalone ?? false, initialTab: initialTab, managedByOrganization: user.managedByOrganization, }, @@ -623,35 +646,35 @@ export class MembersComponent extends BaseMembersComponent case MemberDialogResult.Saved: case MemberDialogResult.Revoked: case MemberDialogResult.Restored: - await this.load(); + await this.load(organization); break; } } - async bulkRemove() { + async bulkRemove(organization: Organization) { if (this.actionPromise != null) { return; } const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, { data: { - organizationId: this.organization.id, + organizationId: organization.id, users: this.dataSource.getCheckedUsers(), }, }); await lastValueFrom(dialogRef.closed); - await this.load(); + await this.load(organization); } - async bulkDelete() { + async bulkDelete(organization: Organization) { const warningAcknowledged = await firstValueFrom( - this.deleteManagedMemberWarningService.warningAcknowledged(this.organization.id), + this.deleteManagedMemberWarningService.warningAcknowledged(organization.id), ); if ( !warningAcknowledged && - this.organization.canManageUsers && - this.organization.productTierType === ProductTierType.Enterprise + organization.canManageUsers && + organization.productTierType === ProductTierType.Enterprise ) { const acknowledged = await this.deleteManagedMemberWarningService.showWarning(); if (!acknowledged) { @@ -665,38 +688,38 @@ export class MembersComponent extends BaseMembersComponent const dialogRef = BulkDeleteDialogComponent.open(this.dialogService, { data: { - organizationId: this.organization.id, + organizationId: organization.id, users: this.dataSource.getCheckedUsers(), }, }); await lastValueFrom(dialogRef.closed); - await this.load(); + await this.load(organization); } - async bulkRevoke() { - await this.bulkRevokeOrRestore(true); + async bulkRevoke(organization: Organization) { + await this.bulkRevokeOrRestore(true, organization); } - async bulkRestore() { - await this.bulkRevokeOrRestore(false); + async bulkRestore(organization: Organization) { + await this.bulkRevokeOrRestore(false, organization); } - async bulkRevokeOrRestore(isRevoking: boolean) { + async bulkRevokeOrRestore(isRevoking: boolean, organization: Organization) { if (this.actionPromise != null) { return; } const ref = BulkRestoreRevokeComponent.open(this.dialogService, { - organizationId: this.organization.id, + organizationId: organization.id, users: this.dataSource.getCheckedUsers(), isRevoking: isRevoking, }); await firstValueFrom(ref.closed); - await this.load(); + await this.load(organization); } - async bulkReinvite() { + async bulkReinvite(organization: Organization) { if (this.actionPromise != null) { return; } @@ -715,7 +738,7 @@ export class MembersComponent extends BaseMembersComponent try { const response = this.organizationUserApiService.postManyOrganizationUserReinvite( - this.organization.id, + organization.id, filteredUsers.map((user) => user.id), ); // Bulk Status component open @@ -731,26 +754,26 @@ export class MembersComponent extends BaseMembersComponent } catch (e) { this.validationService.showError(e); } - this.actionPromise = null; + this.actionPromise = undefined; } - async bulkConfirm() { + async bulkConfirm(organization: Organization) { if (this.actionPromise != null) { return; } const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, { data: { - organization: this.organization, + organization: organization, users: this.dataSource.getCheckedUsers(), }, }); await lastValueFrom(dialogRef.closed); - await this.load(); + await this.load(organization); } - async bulkEnableSM() { + async bulkEnableSM(organization: Organization) { const users = this.dataSource.getCheckedUsers().filter((ou) => !ou.accessSecretsManager); if (users.length === 0) { @@ -763,20 +786,20 @@ export class MembersComponent extends BaseMembersComponent } const dialogRef = BulkEnableSecretsManagerDialogComponent.open(this.dialogService, { - orgId: this.organization.id, + orgId: organization.id, users, }); await lastValueFrom(dialogRef.closed); this.dataSource.uncheckAllUsers(); - await this.load(); + await this.load(organization); } - openEventsDialog(user: OrganizationUserView) { + openEventsDialog(user: OrganizationUserView, organization: Organization) { openEntityEventsDialog(this.dialogService, { data: { name: this.userNamePipe.transform(user), - organizationId: this.organization.id, + organizationId: organization.id, entityId: user.id, showUser: false, entity: "user", @@ -784,7 +807,7 @@ export class MembersComponent extends BaseMembersComponent }); } - async resetPassword(user: OrganizationUserView) { + async resetPassword(user: OrganizationUserView, organization: Organization) { if (!user || !user.email || !user.id) { this.toastService.showToast({ variant: "error", @@ -800,14 +823,14 @@ export class MembersComponent extends BaseMembersComponent data: { name: this.userNamePipe.transform(user), email: user.email, - organizationId: this.organization.id as OrganizationId, + organizationId: organization.id as OrganizationId, organizationUserId: user.id, }, }); const result = await lastValueFrom(dialogRef.closed); if (result === AccountRecoveryDialogResultType.Ok) { - await this.load(); + await this.load(organization); } return; @@ -857,15 +880,15 @@ export class MembersComponent extends BaseMembersComponent return true; } - async deleteUser(user: OrganizationUserView) { + async deleteUser(user: OrganizationUserView, organization: Organization) { const warningAcknowledged = await firstValueFrom( - this.deleteManagedMemberWarningService.warningAcknowledged(this.organization.id), + this.deleteManagedMemberWarningService.warningAcknowledged(organization.id), ); if ( !warningAcknowledged && - this.organization.canManageUsers && - this.organization.productTierType === ProductTierType.Enterprise + organization.canManageUsers && + organization.productTierType === ProductTierType.Enterprise ) { const acknowledged = await this.deleteManagedMemberWarningService.showWarning(); if (!acknowledged) { @@ -891,24 +914,23 @@ export class MembersComponent extends BaseMembersComponent return false; } - await this.deleteManagedMemberWarningService.acknowledgeWarning(this.organization.id); + await this.deleteManagedMemberWarningService.acknowledgeWarning(organization.id); this.actionPromise = this.organizationUserApiService.deleteOrganizationUser( - this.organization.id, + organization.id, user.id, ); try { await this.actionPromise; this.toastService.showToast({ variant: "success", - title: null, message: this.i18nService.t("organizationUserDeleted", this.userNamePipe.transform(user)), }); this.dataSource.removeUser(user); } catch (e) { this.validationService.showError(e); } - this.actionPromise = null; + this.actionPromise = undefined; } private async noMasterPasswordConfirmationDialog(user: OrganizationUserView) { @@ -952,12 +974,12 @@ export class MembersComponent extends BaseMembersComponent .every((member) => member.managedByOrganization && validStatuses.includes(member.status)); } - async navigateToPaymentMethod() { + async navigateToPaymentMethod(organization: Organization) { const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, ); const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; - await this.router.navigate(["organizations", `${this.organization?.id}`, "billing", route], { + await this.router.navigate(["organizations", `${organization.id}`, "billing", route], { state: { launchPaymentModalAutomatically: true }, }); } diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index 0dd0c0d26f2..6fc2dc57ba2 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -72,8 +72,8 @@ import { PaymentComponent } from "../shared/payment/payment.component"; type ChangePlanDialogParams = { organizationId: string; - subscription: OrganizationSubscriptionResponse; productTierType: ProductTierType; + subscription?: OrganizationSubscriptionResponse; }; // FIXME: update to use a const object instead of a typescript enum diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts index 69d02214717..b4433f84fe5 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts @@ -170,6 +170,10 @@ export class MembersComponent extends BaseMembersComponent { } } + async invite() { + await this.edit(null); + } + async bulkRemove(): Promise { if (this.actionPromise != null) { return; diff --git a/libs/common/src/admin-console/models/domain/policy.ts b/libs/common/src/admin-console/models/domain/policy.ts index 8408f4832df..eb67c4412e9 100644 --- a/libs/common/src/admin-console/models/domain/policy.ts +++ b/libs/common/src/admin-console/models/domain/policy.ts @@ -36,7 +36,7 @@ export class Policy extends Domain { return new Policy(new PolicyData(response)); } - static fromListResponse(response: ListResponse): Policy[] | undefined { - return response.data?.map((d) => Policy.fromResponse(d)) ?? undefined; + static fromListResponse(response: ListResponse): Policy[] { + return response.data.map((d) => Policy.fromResponse(d)); } } diff --git a/libs/common/src/admin-console/services/policy/default-policy.service.ts b/libs/common/src/admin-console/services/policy/default-policy.service.ts index 2d9518ee508..5781dd938f3 100644 --- a/libs/common/src/admin-console/services/policy/default-policy.service.ts +++ b/libs/common/src/admin-console/services/policy/default-policy.service.ts @@ -13,7 +13,7 @@ import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-p import { POLICIES } from "./policy-state"; -export function policyRecordToArray(policiesMap: { [id: string]: PolicyData }) { +export function policyRecordToArray(policiesMap: { [id: string]: PolicyData }): Policy[] { return Object.values(policiesMap || {}).map((f) => new Policy(f)); }