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 9ac370d8c0d..d39a4f29653 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 @@ -2,18 +2,24 @@ // @ts-strict-ignore import { computed, Signal } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; -import { map } from "rxjs"; +import { Observable, Subject, map } from "rxjs"; import { OrganizationUserStatusType, ProviderUserStatusType, } from "@bitwarden/common/admin-console/enums"; +import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { TableDataSource } from "@bitwarden/components"; -import { StatusType, UserViewTypes } from "./base-members.component"; +import { OrganizationUserView } from "../organizations/core/views/organization-user.view"; + +export type StatusType = OrganizationUserStatusType | ProviderUserStatusType; + +export type UserViewTypes = ProviderUser | OrganizationUserView; +export type ProviderUser = ProviderUserUserDetailsResponse; /** * Default maximum for most bulk operations (confirm, remove, delete, etc.) @@ -100,6 +106,8 @@ export abstract class PeopleTableDataSource extends Tab this.data?.filter((u) => u.status === this.statusType.Confirmed).length ?? 0; this.revokedUserCount = this.data?.filter((u) => u.status === this.statusType.Revoked).length ?? 0; + + this.checkedUsersUpdated$.next(); } override get data() { @@ -112,6 +120,15 @@ export abstract class PeopleTableDataSource extends Tab * @param select check the user (true), uncheck the user (false), or toggle the current state (null) */ checkUser(user: T, select?: boolean) { + this.setUserChecked(user, select); + this.checkedUsersUpdated$.next(); + } + + /** + * Internal method to set checked state without triggering emissions. + * Use this in bulk operations to avoid excessive emissions. + */ + private setUserChecked(user: T, select?: boolean) { (user as any).checked = select == null ? !(user as any).checked : select; } @@ -119,6 +136,12 @@ export abstract class PeopleTableDataSource extends Tab return this.data.filter((u) => (u as any).checked); } + private checkedUsersUpdated$ = new Subject(); + + usersUpdated(): Observable { + return this.checkedUsersUpdated$.asObservable().pipe(map(() => this.getCheckedUsers())); + } + /** * Gets checked users in the order they appear in the filtered/sorted table view. * Use this when enforcing limits to ensure visual consistency (top N visible rows stay checked). @@ -147,8 +170,10 @@ export abstract class PeopleTableDataSource extends Tab : Math.min(filteredUsers.length, MaxCheckedCount); for (let i = 0; i < selectCount; i++) { - this.checkUser(filteredUsers[i], select); + this.setUserChecked(filteredUsers[i], select); } + + this.checkedUsersUpdated$.next(); } uncheckAllUsers() { @@ -190,7 +215,10 @@ export abstract class PeopleTableDataSource extends Tab } // Uncheck users beyond the limit - users.slice(limit).forEach((user) => this.checkUser(user, false)); + users.slice(limit).forEach((user) => this.setUserChecked(user, false)); + + // Emit once after all unchecking is done + this.checkedUsersUpdated$.next(); return users.slice(0, limit); } @@ -213,3 +241,26 @@ export abstract class PeopleTableDataSource extends Tab } } } + +export class ProvidersTableDataSource extends PeopleTableDataSource { + protected statusType = ProviderUserStatusType; +} + +export class MembersTableDataSource extends PeopleTableDataSource { + protected statusType = OrganizationUserStatusType; +} + +/** + * Helper function to determine if the confirm users banner should be shown + * @params dataSource Either a ProvidersTableDataSource or a MembersTableDataSource + */ +export function showConfirmBanner( + dataSource: ProvidersTableDataSource | MembersTableDataSource, +): boolean { + return ( + dataSource.activeUserCount > 1 && + dataSource.confirmedUserCount > 0 && + dataSource.confirmedUserCount < 3 && + dataSource.acceptedUserCount > 0 + ); +} diff --git a/apps/web/src/app/admin-console/organizations/manage/user-confirm.component.ts b/apps/web/src/app/admin-console/organizations/manage/user-confirm.component.ts index 86d22fdf5e9..03130d0b946 100644 --- a/apps/web/src/app/admin-console/organizations/manage/user-confirm.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/user-confirm.component.ts @@ -2,8 +2,11 @@ // @ts-strict-ignore import { Component, Inject, OnInit } from "@angular/core"; import { FormControl, FormGroup } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; @@ -14,7 +17,8 @@ export type UserConfirmDialogData = { name: string; userId: string; publicKey: Uint8Array; - confirmUser: (publicKey: Uint8Array) => Promise; + // @TODO remove this when doing feature flag cleanup for members component refactor. + confirmUser?: (publicKey: Uint8Array) => Promise; }; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -42,6 +46,7 @@ export class UserConfirmComponent implements OnInit { private keyService: KeyService, private logService: LogService, private organizationManagementPreferencesService: OrganizationManagementPreferencesService, + private configService: ConfigService, ) { this.name = data.name; this.userId = data.userId; @@ -64,16 +69,21 @@ export class UserConfirmComponent implements OnInit { submit = async () => { if (this.loading) { - return; + return false; } if (this.formGroup.value.dontAskAgain) { await this.organizationManagementPreferencesService.autoConfirmFingerPrints.set(true); } - await this.data.confirmUser(this.publicKey); + const membersComponentRefactorEnabled = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.MembersComponentRefactor), + ); + if (!membersComponentRefactorEnabled) { + await this.data.confirmUser(this.publicKey); + } - this.dialogRef.close(); + this.dialogRef.close(true); }; static open(dialogService: DialogService, config: DialogConfig) { diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts index c95f94b1e11..57c3bb3820d 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts @@ -36,6 +36,7 @@ type BulkConfirmDialogParams = { // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "bulk-confirm-dialog.component.html", + selector: "member-bulk-comfirm-dialog", standalone: false, }) export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.ts index 0fd60b859f0..87e44355688 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.ts @@ -20,6 +20,7 @@ type BulkDeleteDialogParams = { // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "bulk-delete-dialog.component.html", + selector: "member-bulk-delete-dialog", standalone: false, }) export class BulkDeleteDialogComponent { diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-enable-sm-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-enable-sm-dialog.component.ts index a97d595e443..5f457b4eaee 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-enable-sm-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-enable-sm-dialog.component.ts @@ -24,6 +24,7 @@ export type BulkEnableSecretsManagerDialogData = { // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: `bulk-enable-sm-dialog.component.html`, + selector: "member-bulk-enable-sm-dialog", standalone: false, }) export class BulkEnableSecretsManagerDialogComponent implements OnInit { diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.ts index 7c95e43c8cf..f5628860d69 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.ts @@ -23,6 +23,7 @@ type BulkRemoveDialogParams = { // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "bulk-remove-dialog.component.html", + selector: "member-bulk-remove-dialog", standalone: false, }) export class BulkRemoveDialogComponent extends BaseBulkRemoveComponent { diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts index 5e542de907a..dc7b079fefe 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts @@ -18,7 +18,7 @@ type BulkRestoreDialogParams = { // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - selector: "app-bulk-restore-revoke", + selector: "member-bulk-restore-revoke", templateUrl: "bulk-restore-revoke.component.html", standalone: false, }) diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-status.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-status.component.ts index 4f2456e1dc6..5c9bf919ed4 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-status.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-status.component.ts @@ -41,7 +41,7 @@ type BulkStatusDialogData = { // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - selector: "app-bulk-status", + selector: "member-bulk-status", templateUrl: "bulk-status.component.html", standalone: false, }) diff --git a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.html b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.html new file mode 100644 index 00000000000..921004e315d --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.html @@ -0,0 +1,495 @@ +@let organization = this.organization(); +@if (organization) { + + + + + + + + +
+ + + {{ "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 }} + +
+ + +
+ + + + + + + + + + + + + + + +
+ + + + + + + +
+ +
+
+ + + {{ "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 }} + + @let resetPasswordPolicyEnabled = resetPasswordPolicyEnabled$ | async; + + + {{ "enrolledAccountRecovery" | i18n }} + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+} diff --git a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts new file mode 100644 index 00000000000..99fd81aa48d --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts @@ -0,0 +1,616 @@ +import { Component, computed, Signal } from "@angular/core"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; +import { ActivatedRoute } from "@angular/router"; +import { + combineLatest, + concatMap, + filter, + firstValueFrom, + from, + map, + merge, + Observable, + shareReplay, + switchMap, + take, +} from "rxjs"; + +import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common"; +import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { + OrganizationUserStatusType, + OrganizationUserType, + PolicyType, +} from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; +import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.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 { getById } from "@bitwarden/common/platform/misc"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; +import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; + +import { BaseMembersComponent } from "../../common/base-members.component"; +import { + CloudBulkReinviteLimit, + MaxCheckedCount, + PeopleTableDataSource, +} from "../../common/people-table-data-source"; +import { OrganizationUserView } from "../core/views/organization-user.view"; + +import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component"; +import { MemberDialogResult, MemberDialogTab } from "./components/member-dialog"; +import { + MemberDialogManagerService, + MemberExportService, + OrganizationMembersService, +} from "./services"; +import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service"; +import { + MemberActionsService, + MemberActionResult, +} from "./services/member-actions/member-actions.service"; + +class MembersTableDataSource extends PeopleTableDataSource { + protected statusType = OrganizationUserStatusType; +} + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + templateUrl: "deprecated_members.component.html", + standalone: false, +}) +export class MembersComponent extends BaseMembersComponent { + userType = OrganizationUserType; + userStatusType = OrganizationUserStatusType; + memberTab = MemberDialogTab; + protected dataSource: MembersTableDataSource; + + readonly organization: Signal; + status: OrganizationUserStatusType | undefined; + + private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); + + resetPasswordPolicyEnabled$: Observable; + + protected readonly canUseSecretsManager: Signal = computed( + () => this.organization()?.useSecretsManager ?? false, + ); + protected readonly showUserManagementControls: Signal = computed( + () => this.organization()?.canManageUsers ?? false, + ); + protected billingMetadata$: Observable; + + // Fixed sizes used for cdkVirtualScroll + protected rowHeight = 66; + protected rowHeightClass = `tw-h-[66px]`; + + constructor( + apiService: ApiService, + i18nService: I18nService, + organizationManagementPreferencesService: OrganizationManagementPreferencesService, + keyService: KeyService, + validationService: ValidationService, + logService: LogService, + userNamePipe: UserNamePipe, + dialogService: DialogService, + toastService: ToastService, + private route: ActivatedRoute, + protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService, + private organizationWarningsService: OrganizationWarningsService, + private memberActionsService: MemberActionsService, + private memberDialogManager: MemberDialogManagerService, + protected billingConstraint: BillingConstraintService, + protected memberService: OrganizationMembersService, + private organizationService: OrganizationService, + private accountService: AccountService, + private policyService: PolicyService, + private policyApiService: PolicyApiServiceAbstraction, + private organizationMetadataService: OrganizationMetadataServiceAbstraction, + private memberExportService: MemberExportService, + private configService: ConfigService, + private environmentService: EnvironmentService, + ) { + super( + apiService, + i18nService, + keyService, + validationService, + logService, + userNamePipe, + dialogService, + organizationManagementPreferencesService, + toastService, + ); + + this.dataSource = new MembersTableDataSource(this.configService, this.environmentService); + + const organization$ = this.route.params.pipe( + concatMap((params) => + this.userId$.pipe( + switchMap((userId) => + this.organizationService.organizations$(userId).pipe(getById(params.organizationId)), + ), + filter((organization): organization is Organization => organization != null), + shareReplay({ refCount: true, bufferSize: 1 }), + ), + ), + ); + + this.organization = toSignal(organization$); + + 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), + ), + ); + + this.resetPasswordPolicyEnabled$ = combineLatest([organization$, policies$]).pipe( + map( + ([organization, policies]) => + policies + .filter((policy) => policy.type === PolicyType.ResetPassword) + .find((p) => p.organizationId === organization.id)?.enabled ?? false, + ), + ); + + combineLatest([this.route.queryParams, organization$]) + .pipe( + concatMap(async ([qParams, organization]) => { + 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], organization!); + } + } + }), + takeUntilDestroyed(), + ) + .subscribe(); + + organization$ + .pipe( + switchMap((organization) => + merge( + this.organizationWarningsService.showInactiveSubscriptionDialog$(organization), + this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization), + ), + ), + takeUntilDestroyed(), + ) + .subscribe(); + + this.billingMetadata$ = organization$.pipe( + switchMap((organization) => + this.organizationMetadataService.getOrganizationMetadata$(organization.id), + ), + 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(); + } + + override async load(organization: Organization) { + await super.load(organization); + } + + async getUsers(organization: Organization): Promise { + return await this.memberService.loadUsers(organization); + } + + async removeUser(id: string, organization: Organization): Promise { + return await this.memberActionsService.removeUser(organization, id); + } + + async revokeUser(id: string, organization: Organization): Promise { + return await this.memberActionsService.revokeUser(organization, id); + } + + async restoreUser(id: string, organization: Organization): Promise { + return await this.memberActionsService.restoreUser(organization, id); + } + + async reinviteUser(id: string, organization: Organization): Promise { + return await this.memberActionsService.reinviteUser(organization, id); + } + + async confirmUser( + user: OrganizationUserView, + publicKey: Uint8Array, + organization: Organization, + ): Promise { + return await this.memberActionsService.confirmUser(user, publicKey, organization); + } + + async revoke(user: OrganizationUserView, organization: Organization) { + const confirmed = await this.revokeUserConfirmationDialog(user); + + if (!confirmed) { + return false; + } + + this.actionPromise = this.revokeUser(user.id, organization); + try { + const result = await this.actionPromise; + if (result.success) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)), + }); + await this.load(organization); + } else { + throw new Error(result.error); + } + } catch (e) { + this.validationService.showError(e); + } + this.actionPromise = undefined; + } + + async restore(user: OrganizationUserView, organization: Organization) { + this.actionPromise = this.restoreUser(user.id, organization); + try { + const result = await this.actionPromise; + if (result.success) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)), + }); + await this.load(organization); + } else { + throw new Error(result.error); + } + } catch (e) { + this.validationService.showError(e); + } + this.actionPromise = undefined; + } + + allowResetPassword( + orgUser: OrganizationUserView, + organization: Organization, + orgResetPasswordPolicyEnabled: boolean, + ): boolean { + return this.memberActionsService.allowResetPassword( + orgUser, + organization, + orgResetPasswordPolicyEnabled, + ); + } + + showEnrolledStatus( + orgUser: OrganizationUserUserDetailsResponse, + organization: Organization, + orgResetPasswordPolicyEnabled: boolean, + ): boolean { + return ( + organization.useResetPassword && + orgUser.resetPasswordEnrolled && + orgResetPasswordPolicyEnabled + ); + } + + private async handleInviteDialog(organization: Organization) { + const billingMetadata = await firstValueFrom(this.billingMetadata$); + const allUserEmails = this.dataSource.data?.map((user) => user.email) ?? []; + + const result = await this.memberDialogManager.openInviteDialog( + organization, + billingMetadata, + allUserEmails, + ); + + if (result === MemberDialogResult.Saved) { + await this.load(organization); + } + } + + async invite(organization: Organization) { + const billingMetadata = await firstValueFrom(this.billingMetadata$); + const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata); + if (!(await this.billingConstraint.seatLimitReached(seatLimitResult, organization))) { + await this.handleInviteDialog(organization); + this.organizationMetadataService.refreshMetadataCache(); + } + } + + async edit( + user: OrganizationUserView, + organization: Organization, + initialTab: MemberDialogTab = MemberDialogTab.Role, + ) { + const billingMetadata = await firstValueFrom(this.billingMetadata$); + + const result = await this.memberDialogManager.openEditDialog( + user, + organization, + billingMetadata, + initialTab, + ); + + switch (result) { + case MemberDialogResult.Deleted: + this.dataSource.removeUser(user); + break; + case MemberDialogResult.Saved: + case MemberDialogResult.Revoked: + case MemberDialogResult.Restored: + await this.load(organization); + break; + } + } + + async bulkRemove(organization: Organization) { + if (this.actionPromise != null) { + return; + } + + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + await this.memberDialogManager.openBulkRemoveDialog(organization, users); + this.organizationMetadataService.refreshMetadataCache(); + await this.load(organization); + } + + async bulkDelete(organization: Organization) { + if (this.actionPromise != null) { + return; + } + + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + await this.memberDialogManager.openBulkDeleteDialog(organization, users); + await this.load(organization); + } + + async bulkRevoke(organization: Organization) { + await this.bulkRevokeOrRestore(true, organization); + } + + async bulkRestore(organization: Organization) { + await this.bulkRevokeOrRestore(false, organization); + } + + async bulkRevokeOrRestore(isRevoking: boolean, organization: Organization) { + if (this.actionPromise != null) { + return; + } + + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + await this.memberDialogManager.openBulkRestoreRevokeDialog(organization, users, isRevoking); + await this.load(organization); + } + + async bulkReinvite(organization: Organization) { + if (this.actionPromise != null) { + return; + } + + let users: OrganizationUserView[]; + if (this.dataSource.isIncreasedBulkLimitEnabled()) { + users = this.dataSource.getCheckedUsersInVisibleOrder(); + } else { + users = this.dataSource.getCheckedUsers(); + } + + const allInvitedUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited); + + // Capture the original count BEFORE enforcing the limit + const originalInvitedCount = allInvitedUsers.length; + + // When feature flag is enabled, limit invited users and uncheck the excess + let filteredUsers: OrganizationUserView[]; + if (this.dataSource.isIncreasedBulkLimitEnabled()) { + filteredUsers = this.dataSource.limitAndUncheckExcess( + allInvitedUsers, + CloudBulkReinviteLimit, + ); + } else { + filteredUsers = allInvitedUsers; + } + + if (filteredUsers.length <= 0) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("noSelectedUsersApplicable"), + }); + return; + } + + try { + const result = await this.memberActionsService.bulkReinvite( + organization, + filteredUsers.map((user) => user.id as UserId), + ); + + if (!result.successful) { + throw new Error(); + } + + // When feature flag is enabled, show toast instead of dialog + if (this.dataSource.isIncreasedBulkLimitEnabled()) { + const selectedCount = originalInvitedCount; + const invitedCount = filteredUsers.length; + + if (selectedCount > CloudBulkReinviteLimit) { + const excludedCount = selectedCount - CloudBulkReinviteLimit; + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t( + "bulkReinviteLimitedSuccessToast", + CloudBulkReinviteLimit.toLocaleString(), + selectedCount.toLocaleString(), + excludedCount.toLocaleString(), + ), + }); + } else { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()), + }); + } + } else { + // Feature flag disabled - show legacy dialog + await this.memberDialogManager.openBulkStatusDialog( + users, + filteredUsers, + Promise.resolve(result.successful), + this.i18nService.t("bulkReinviteMessage"), + ); + } + } catch (e) { + this.validationService.showError(e); + } + this.actionPromise = undefined; + } + + async bulkConfirm(organization: Organization) { + if (this.actionPromise != null) { + return; + } + + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + await this.memberDialogManager.openBulkConfirmDialog(organization, users); + await this.load(organization); + } + + async bulkEnableSM(organization: Organization) { + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users); + + this.dataSource.uncheckAllUsers(); + await this.load(organization); + } + + openEventsDialog(user: OrganizationUserView, organization: Organization) { + this.memberDialogManager.openEventsDialog(user, organization); + } + + async resetPassword(user: OrganizationUserView, organization: Organization) { + if (!user || !user.email || !user.id) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("orgUserDetailsNotFound"), + }); + this.logService.error("Org user details not found when attempting account recovery"); + + return; + } + + const result = await this.memberDialogManager.openAccountRecoveryDialog(user, organization); + if (result === AccountRecoveryDialogResultType.Ok) { + await this.load(organization); + } + + return; + } + + protected async removeUserConfirmationDialog(user: OrganizationUserView) { + return await this.memberDialogManager.openRemoveUserConfirmationDialog(user); + } + + protected async revokeUserConfirmationDialog(user: OrganizationUserView) { + return await this.memberDialogManager.openRevokeUserConfirmationDialog(user); + } + + async deleteUser(user: OrganizationUserView, organization: Organization) { + const confirmed = await this.memberDialogManager.openDeleteUserConfirmationDialog( + user, + organization, + ); + + if (!confirmed) { + return false; + } + + this.actionPromise = this.memberActionsService.deleteUser(organization, user.id); + try { + const result = await this.actionPromise; + if (!result.success) { + throw new Error(result.error); + } + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("organizationUserDeleted", this.userNamePipe.transform(user)), + }); + this.dataSource.removeUser(user); + } catch (e) { + this.validationService.showError(e); + } + this.actionPromise = undefined; + } + + get showBulkRestoreUsers(): boolean { + return this.dataSource + .getCheckedUsers() + .every((member) => member.status == this.userStatusType.Revoked); + } + + get showBulkRevokeUsers(): boolean { + return this.dataSource + .getCheckedUsers() + .every((member) => member.status != this.userStatusType.Revoked); + } + + get showBulkRemoveUsers(): boolean { + return this.dataSource.getCheckedUsers().every((member) => !member.managedByOrganization); + } + + get showBulkDeleteUsers(): boolean { + const validStatuses = [ + this.userStatusType.Accepted, + this.userStatusType.Confirmed, + this.userStatusType.Revoked, + ]; + + return this.dataSource + .getCheckedUsers() + .every((member) => member.managedByOrganization && validStatuses.includes(member.status)); + } + + exportMembers = () => { + const result = this.memberExportService.getMemberExport(this.dataSource.data); + if (result.success) { + this.toastService.showToast({ + variant: "success", + title: undefined, + message: this.i18nService.t("dataExportSuccess"), + }); + } + + if (result.error != null) { + this.validationService.showError(result.error.message); + } + }; +} diff --git a/apps/web/src/app/admin-console/organizations/members/members-routing.module.ts b/apps/web/src/app/admin-console/organizations/members/members-routing.module.ts index 153a2f3a956..2f22b9871b7 100644 --- a/apps/web/src/app/admin-console/organizations/members/members-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members-routing.module.ts @@ -1,23 +1,30 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; import { canAccessMembersTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FreeBitwardenFamiliesComponent } from "../../../billing/members/free-bitwarden-families.component"; import { organizationPermissionsGuard } from "../guards/org-permissions.guard"; import { canAccessSponsoredFamilies } from "./../../../billing/guards/can-access-sponsored-families.guard"; -import { MembersComponent } from "./members.component"; +import { MembersComponent } from "./deprecated_members.component"; +import { vNextMembersComponent } from "./members.component"; const routes: Routes = [ - { - path: "", - component: MembersComponent, - canActivate: [organizationPermissionsGuard(canAccessMembersTab)], - data: { - titleId: "members", + ...featureFlaggedRoute({ + defaultComponent: MembersComponent, + flaggedComponent: vNextMembersComponent, + featureFlag: FeatureFlag.MembersComponentRefactor, + routeOptions: { + path: "", + canActivate: [organizationPermissionsGuard(canAccessMembersTab)], + data: { + titleId: "members", + }, }, - }, + }), { path: "sponsored-families", component: FreeBitwardenFamiliesComponent, 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 921004e315d..074e9c38f6e 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,5 +1,10 @@ @let organization = this.organization(); -@if (organization) { +@let dataSource = this.dataSource(); +@let bulkActions = bulkMenuOptions$ | async; +@let showConfirmBanner = showConfirmBanner$ | async; +@let isProcessing = this.isProcessing(); + +@if (organization && dataSource) { - + @if (showUserManagementControls()) { + + }
- - - {{ "all" | i18n }} - {{ - allCount - }} - + @if (showUserManagementControls()) { + + + {{ "all" | i18n }} + @if (dataSource.activeUserCount; as allCount) { + {{ allCount }} + } + - - {{ "invited" | i18n }} - {{ - invitedCount - }} - + + {{ "invited" | i18n }} + @if (dataSource.invitedUserCount; as invitedCount) { + {{ invitedCount }} + } + - - {{ "needsConfirmation" | i18n }} - {{ - acceptedUserCount - }} - + + {{ "needsConfirmation" | i18n }} + @if (dataSource.acceptedUserCount; as acceptedUserCount) { + {{ acceptedUserCount }} + } + - - {{ "revoked" | i18n }} - {{ - revokedCount - }} - - + + {{ "revoked" | i18n }} + @if (dataSource.revokedUserCount; as revokedCount) { + {{ revokedCount }} + } + + + }
- + @if (!firstLoaded() || !organization || !dataSource) { {{ "loading" | i18n }} - - -

{{ "noMembersInList" | i18n }}

- - - {{ "usersNeedConfirmed" | i18n }} - + } @else { + @if (!dataSource.filteredData?.length) { +

{{ "noMembersInList" | i18n }}

+ } + @if (dataSource.filteredData?.length) { + @if (showConfirmBanner) { + + {{ "usersNeedConfirmed" | i18n }} + + } + - - - - + @if (showUserManagementControls()) { + + + + + } {{ "name" | i18n }} {{ (organization.useGroups ? "groups" : "collections") | i18n }} {{ "role" | i18n }} {{ "policies" | i18n }} - -
- - -
+ + @if (showUserManagementControls()) { + +
+ + +
+ + } - - - - - - - - - + } + @if (bulkActions.showBulkReinviteUsers) { + + } + @if (bulkActions.showBulkConfirmUsers) { + + } + @if (bulkActions.showBulkRestoreUsers) { + + } + @if (bulkActions.showBulkRevokeUsers) { + + } + @if (bulkActions.showBulkRemoveUsers) { + + } + @if (bulkActions.showBulkDeleteUsers) { + + } @@ -200,10 +221,10 @@ alignContent="middle" [ngClass]="rowHeightClass" > - - - - + @if (showUserManagementControls()) { + + +
{{ u.name ?? u.email }} - - {{ "invited" | i18n }} - - - {{ "needsConfirmation" | i18n }} - - - {{ "revoked" | i18n }} - -
-
- {{ u.email }} + @if (u.status === userStatusType.Invited) { + + {{ "invited" | i18n }} + + } + @if (u.status === userStatusType.Accepted) { + + {{ "needsConfirmation" | i18n }} + + } + @if (u.status === userStatusType.Revoked) { + + {{ "revoked" | i18n }} + + }
+ @if (u.name) { +
+ {{ u.email }} +
+ } -
- + } @else {
{{ u.name ?? u.email }} - - {{ "invited" | i18n }} - - - {{ "needsConfirmation" | i18n }} - - - {{ "revoked" | i18n }} - -
-
- {{ u.email }} + @if (u.status === userStatusType.Invited) { + + {{ "invited" | i18n }} + + } + @if (u.status === userStatusType.Accepted) { + + {{ "needsConfirmation" | i18n }} + + } + @if (u.status === userStatusType.Revoked) { + + {{ "revoked" | i18n }} + + }
+ @if (u.name) { +
+ {{ u.email }} +
+ }
-
+ } - + @if (showUserManagementControls()) { - - + } @else { - + } - + @if (showUserManagementControls()) { {{ u.type | userType }} - - + } @else { {{ u.type | userType }} - + } - + @if (u.twoFactorEnabled) { {{ "userUsingTwoStep" | i18n }} - + } @let resetPasswordPolicyEnabled = resetPasswordPolicyEnabled$ | async; - + @if (showEnrolledStatus(u, organization, resetPasswordPolicyEnabled)) { {{ "enrolledAccountRecovery" | i18n }} - + }
@@ -374,122 +376,131 @@
- + @if (showUserManagementControls()) { + @if (u.status === userStatusType.Invited) { + + } + @if (u.status === userStatusType.Accepted) { + + } + @if ( + u.status === userStatusType.Accepted || u.status === userStatusType.Invited + ) { + + } - - - + @if (organization.useGroups) { + + } - - - + @if (organization.useEvents && u.status === userStatusType.Confirmed) { + + } + } - + @if (allowResetPassword(u, organization, resetPasswordPolicyEnabled)) { + + } - - - - - - + @if (showUserManagementControls()) { + @if (u.status === userStatusType.Revoked) { + + } + @if (u.status !== userStatusType.Revoked) { + + } + @if (!u.managedByOrganization) { + + } @else { + + } + }
-
-
+ } + } } diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts b/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts new file mode 100644 index 00000000000..246c3d8a1c0 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts @@ -0,0 +1,696 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ActivatedRoute } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { + OrganizationUserStatusType, + OrganizationUserType, +} from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; +import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.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 { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { newGuid } from "@bitwarden/guid"; +import { KeyService } from "@bitwarden/key-management"; +import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; + +import { OrganizationUserView } from "../core/views/organization-user.view"; + +import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component"; +import { MemberDialogResult } from "./components/member-dialog"; +import { vNextMembersComponent } from "./members.component"; +import { + MemberDialogManagerService, + MemberExportService, + OrganizationMembersService, +} from "./services"; +import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service"; +import { + MemberActionsService, + MemberActionResult, +} from "./services/member-actions/member-actions.service"; + +describe("vNextMembersComponent", () => { + let component: vNextMembersComponent; + let fixture: ComponentFixture; + + let mockApiService: MockProxy; + let mockI18nService: MockProxy; + let mockOrganizationManagementPreferencesService: MockProxy; + let mockKeyService: MockProxy; + let mockValidationService: MockProxy; + let mockLogService: MockProxy; + let mockUserNamePipe: MockProxy; + let mockDialogService: MockProxy; + let mockToastService: MockProxy; + let mockActivatedRoute: ActivatedRoute; + let mockDeleteManagedMemberWarningService: MockProxy; + let mockOrganizationWarningsService: MockProxy; + let mockMemberActionsService: MockProxy; + let mockMemberDialogManager: MockProxy; + let mockBillingConstraint: MockProxy; + let mockMemberService: MockProxy; + let mockOrganizationService: MockProxy; + let mockAccountService: FakeAccountService; + let mockPolicyService: MockProxy; + let mockPolicyApiService: MockProxy; + let mockOrganizationMetadataService: MockProxy; + let mockConfigService: MockProxy; + let mockEnvironmentService: MockProxy; + let mockMemberExportService: MockProxy; + let mockFileDownloadService: MockProxy; + + let routeParamsSubject: BehaviorSubject; + let queryParamsSubject: BehaviorSubject; + + const mockUserId = newGuid() as UserId; + const mockOrgId = newGuid() as OrganizationId; + const mockOrg = { + id: mockOrgId, + name: "Test Organization", + enabled: true, + canManageUsers: true, + useSecretsManager: true, + useResetPassword: true, + isProviderUser: false, + } as Organization; + + const mockUser = { + id: newGuid(), + userId: newGuid(), + type: OrganizationUserType.User, + status: OrganizationUserStatusType.Confirmed, + email: "test@example.com", + name: "Test User", + resetPasswordEnrolled: false, + accessSecretsManager: false, + managedByOrganization: false, + twoFactorEnabled: false, + usesKeyConnector: false, + hasMasterPassword: true, + } as OrganizationUserView; + + const mockBillingMetadata = { + isSubscriptionUnpaid: false, + } as Partial; + + beforeEach(async () => { + routeParamsSubject = new BehaviorSubject({ organizationId: mockOrgId }); + queryParamsSubject = new BehaviorSubject({}); + + mockActivatedRoute = { + params: routeParamsSubject.asObservable(), + queryParams: queryParamsSubject.asObservable(), + } as any; + + mockApiService = mock(); + mockI18nService = mock(); + mockI18nService.t.mockImplementation((key: string) => key); + + mockOrganizationManagementPreferencesService = mock(); + mockOrganizationManagementPreferencesService.autoConfirmFingerPrints = { + state$: of(false), + } as any; + + mockKeyService = mock(); + mockValidationService = mock(); + mockLogService = mock(); + mockUserNamePipe = mock(); + mockUserNamePipe.transform.mockReturnValue("Test User"); + + mockDialogService = mock(); + mockToastService = mock(); + mockDeleteManagedMemberWarningService = mock(); + mockOrganizationWarningsService = mock(); + mockMemberActionsService = mock(); + mockMemberDialogManager = mock(); + mockBillingConstraint = mock(); + + mockMemberService = mock(); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + mockOrganizationService = mock(); + mockOrganizationService.organizations$.mockReturnValue(of([mockOrg])); + + mockAccountService = mockAccountServiceWith(mockUserId); + + mockPolicyService = mock(); + + mockPolicyApiService = mock(); + mockOrganizationMetadataService = mock(); + mockOrganizationMetadataService.getOrganizationMetadata$.mockReturnValue( + of(mockBillingMetadata), + ); + + mockConfigService = mock(); + mockConfigService.getFeatureFlag$.mockReturnValue(of(false)); + + mockEnvironmentService = mock(); + mockEnvironmentService.environment$ = of({ + isCloud: () => false, + } as any); + + mockMemberExportService = mock(); + mockFileDownloadService = mock(); + + await TestBed.configureTestingModule({ + declarations: [vNextMembersComponent], + providers: [ + { provide: ApiService, useValue: mockApiService }, + { provide: I18nService, useValue: mockI18nService }, + { + provide: OrganizationManagementPreferencesService, + useValue: mockOrganizationManagementPreferencesService, + }, + { provide: KeyService, useValue: mockKeyService }, + { provide: ValidationService, useValue: mockValidationService }, + { provide: LogService, useValue: mockLogService }, + { provide: UserNamePipe, useValue: mockUserNamePipe }, + { provide: DialogService, useValue: mockDialogService }, + { provide: ToastService, useValue: mockToastService }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { + provide: DeleteManagedMemberWarningService, + useValue: mockDeleteManagedMemberWarningService, + }, + { provide: OrganizationWarningsService, useValue: mockOrganizationWarningsService }, + { provide: MemberActionsService, useValue: mockMemberActionsService }, + { provide: MemberDialogManagerService, useValue: mockMemberDialogManager }, + { provide: BillingConstraintService, useValue: mockBillingConstraint }, + { provide: OrganizationMembersService, useValue: mockMemberService }, + { provide: OrganizationService, useValue: mockOrganizationService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: PolicyService, useValue: mockPolicyService }, + { provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService }, + { + provide: OrganizationMetadataServiceAbstraction, + useValue: mockOrganizationMetadataService, + }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: EnvironmentService, useValue: mockEnvironmentService }, + { provide: MemberExportService, useValue: mockMemberExportService }, + { provide: FileDownloadService, useValue: mockFileDownloadService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(vNextMembersComponent, { + remove: { imports: [] }, + add: { template: "
" }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(vNextMembersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + if (fixture) { + fixture.destroy(); + } + jest.restoreAllMocks(); + }); + + describe("load", () => { + it("should load users and set data source", async () => { + const users = [mockUser]; + mockMemberService.loadUsers.mockResolvedValue(users); + + await component.load(mockOrg); + + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + expect(component["dataSource"]().data).toEqual(users); + expect(component["firstLoaded"]()).toBe(true); + }); + + it("should handle empty response", async () => { + mockMemberService.loadUsers.mockResolvedValue([]); + + await component.load(mockOrg); + + expect(component["dataSource"]().data).toEqual([]); + }); + }); + + describe("remove", () => { + it("should remove user when confirmed", async () => { + mockMemberDialogManager.openRemoveUserConfirmationDialog.mockResolvedValue(true); + mockMemberActionsService.removeUser.mockResolvedValue({ success: true }); + + const removeSpy = jest.spyOn(component["dataSource"](), "removeUser"); + + await component.remove(mockUser, mockOrg); + + expect(mockMemberDialogManager.openRemoveUserConfirmationDialog).toHaveBeenCalledWith( + mockUser, + ); + expect(mockMemberActionsService.removeUser).toHaveBeenCalledWith(mockOrg, mockUser.id); + expect(removeSpy).toHaveBeenCalledWith(mockUser); + expect(mockToastService.showToast).toHaveBeenCalled(); + }); + + it("should not remove user when not confirmed", async () => { + mockMemberDialogManager.openRemoveUserConfirmationDialog.mockResolvedValue(false); + + const result = await component.remove(mockUser, mockOrg); + + expect(result).toBe(false); + expect(mockMemberActionsService.removeUser).not.toHaveBeenCalled(); + }); + + it("should handle errors via handleMemberActionResult", async () => { + mockMemberDialogManager.openRemoveUserConfirmationDialog.mockResolvedValue(true); + mockMemberActionsService.removeUser.mockResolvedValue({ + success: false, + error: "Remove failed", + }); + + await component.remove(mockUser, mockOrg); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "Remove failed", + }); + expect(mockLogService.error).toHaveBeenCalledWith("Remove failed"); + }); + }); + + describe("reinvite", () => { + it("should reinvite user successfully", async () => { + mockMemberActionsService.reinviteUser.mockResolvedValue({ success: true }); + + await component.reinvite(mockUser, mockOrg); + + expect(mockMemberActionsService.reinviteUser).toHaveBeenCalledWith(mockOrg, mockUser.id); + expect(mockToastService.showToast).toHaveBeenCalled(); + }); + + it("should handle errors via handleMemberActionResult", async () => { + mockMemberActionsService.reinviteUser.mockResolvedValue({ + success: false, + error: "Reinvite failed", + }); + + await component.reinvite(mockUser, mockOrg); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "Reinvite failed", + }); + expect(mockLogService.error).toHaveBeenCalledWith("Reinvite failed"); + }); + }); + + describe("confirm", () => { + it("should confirm user with auto-confirm enabled", async () => { + mockOrganizationManagementPreferencesService.autoConfirmFingerPrints.state$ = of(true); + mockMemberActionsService.confirmUser.mockResolvedValue({ success: true }); + + // Mock getPublicKeyForConfirm to return a public key + const mockPublicKey = new Uint8Array([1, 2, 3, 4]); + mockMemberActionsService.getPublicKeyForConfirm.mockResolvedValue(mockPublicKey); + + const replaceSpy = jest.spyOn(component["dataSource"](), "replaceUser"); + + await component.confirm(mockUser, mockOrg); + + expect(mockMemberActionsService.getPublicKeyForConfirm).toHaveBeenCalledWith(mockUser); + expect(mockMemberActionsService.confirmUser).toHaveBeenCalledWith( + mockUser, + mockPublicKey, + mockOrg, + ); + expect(replaceSpy).toHaveBeenCalled(); + expect(mockToastService.showToast).toHaveBeenCalled(); + }); + + it("should handle null user", async () => { + mockOrganizationManagementPreferencesService.autoConfirmFingerPrints.state$ = of(true); + + // Mock getPublicKeyForConfirm to return null + mockMemberActionsService.getPublicKeyForConfirm.mockResolvedValue(null); + + await component.confirm(mockUser, mockOrg); + + expect(mockMemberActionsService.getPublicKeyForConfirm).toHaveBeenCalled(); + expect(mockMemberActionsService.confirmUser).not.toHaveBeenCalled(); + expect(mockLogService.warning).toHaveBeenCalledWith("Public key not found"); + }); + + it("should handle API errors gracefully", async () => { + // Mock getPublicKeyForConfirm to return null + mockMemberActionsService.getPublicKeyForConfirm.mockResolvedValue(null); + + await component.confirm(mockUser, mockOrg); + + expect(mockMemberActionsService.getPublicKeyForConfirm).toHaveBeenCalled(); + expect(mockLogService.warning).toHaveBeenCalledWith("Public key not found"); + }); + }); + + describe("revoke", () => { + it("should revoke user when confirmed", async () => { + mockMemberDialogManager.openRevokeUserConfirmationDialog.mockResolvedValue(true); + mockMemberActionsService.revokeUser.mockResolvedValue({ success: true }); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.revoke(mockUser, mockOrg); + + expect(mockMemberDialogManager.openRevokeUserConfirmationDialog).toHaveBeenCalledWith( + mockUser, + ); + expect(mockMemberActionsService.revokeUser).toHaveBeenCalledWith(mockOrg, mockUser.id); + expect(mockToastService.showToast).toHaveBeenCalled(); + }); + + it("should not revoke user when not confirmed", async () => { + mockMemberDialogManager.openRevokeUserConfirmationDialog.mockResolvedValue(false); + + const result = await component.revoke(mockUser, mockOrg); + + expect(result).toBe(false); + expect(mockMemberActionsService.revokeUser).not.toHaveBeenCalled(); + }); + }); + + describe("restore", () => { + it("should restore user successfully", async () => { + mockMemberActionsService.restoreUser.mockResolvedValue({ success: true }); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.restore(mockUser, mockOrg); + + expect(mockMemberActionsService.restoreUser).toHaveBeenCalledWith(mockOrg, mockUser.id); + expect(mockToastService.showToast).toHaveBeenCalled(); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }); + + it("should handle errors via handleMemberActionResult", async () => { + mockMemberActionsService.restoreUser.mockResolvedValue({ + success: false, + error: "Restore failed", + }); + + await component.restore(mockUser, mockOrg); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "Restore failed", + }); + expect(mockLogService.error).toHaveBeenCalledWith("Restore failed"); + }); + }); + + describe("invite", () => { + it("should open invite dialog when seat limit not reached", async () => { + mockBillingConstraint.seatLimitReached.mockResolvedValue(false); + mockMemberDialogManager.openInviteDialog.mockResolvedValue(MemberDialogResult.Saved); + + await component.invite(mockOrg); + + expect(mockBillingConstraint.checkSeatLimit).toHaveBeenCalledWith( + mockOrg, + mockBillingMetadata, + ); + expect(mockMemberDialogManager.openInviteDialog).toHaveBeenCalledWith( + mockOrg, + mockBillingMetadata, + expect.any(Array), + ); + }); + + it("should reload organization and refresh metadata cache after successful invite", async () => { + mockBillingConstraint.seatLimitReached.mockResolvedValue(false); + mockMemberDialogManager.openInviteDialog.mockResolvedValue(MemberDialogResult.Saved); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.invite(mockOrg); + + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + expect(mockOrganizationMetadataService.refreshMetadataCache).toHaveBeenCalled(); + }); + + it("should not open dialog when seat limit reached", async () => { + mockBillingConstraint.seatLimitReached.mockResolvedValue(true); + + await component.invite(mockOrg); + + expect(mockMemberDialogManager.openInviteDialog).not.toHaveBeenCalled(); + }); + }); + + describe("bulkRemove", () => { + it("should open bulk remove dialog and reload", async () => { + const users = [mockUser]; + jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.bulkRemove(mockOrg); + + expect(mockMemberDialogManager.openBulkRemoveDialog).toHaveBeenCalledWith(mockOrg, users); + expect(mockOrganizationMetadataService.refreshMetadataCache).toHaveBeenCalled(); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }); + }); + + describe("bulkDelete", () => { + it("should open bulk delete dialog and reload", async () => { + const users = [mockUser]; + jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.bulkDelete(mockOrg); + + expect(mockMemberDialogManager.openBulkDeleteDialog).toHaveBeenCalledWith(mockOrg, users); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }); + }); + + describe("bulkRevokeOrRestore", () => { + it.each([ + { isRevoking: true, action: "revoke" }, + { isRevoking: false, action: "restore" }, + ])( + "should open bulk $action dialog and reload when isRevoking is $isRevoking", + async ({ isRevoking }) => { + const users = [mockUser]; + jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.bulkRevokeOrRestore(isRevoking, mockOrg); + + expect(mockMemberDialogManager.openBulkRestoreRevokeDialog).toHaveBeenCalledWith( + mockOrg, + users, + isRevoking, + ); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }, + ); + }); + + describe("bulkReinvite", () => { + it("should reinvite invited users", async () => { + const invitedUser = { + ...mockUser, + status: OrganizationUserStatusType.Invited, + }; + jest.spyOn(component["dataSource"](), "isIncreasedBulkLimitEnabled").mockReturnValue(false); + jest.spyOn(component["dataSource"](), "getCheckedUsers").mockReturnValue([invitedUser]); + mockMemberActionsService.bulkReinvite.mockResolvedValue({ successful: true }); + + await component.bulkReinvite(mockOrg); + + expect(mockMemberActionsService.bulkReinvite).toHaveBeenCalledWith(mockOrg, [invitedUser.id]); + expect(mockMemberDialogManager.openBulkStatusDialog).toHaveBeenCalled(); + }); + + it("should show error when no invited users selected", async () => { + const confirmedUser = { + ...mockUser, + status: OrganizationUserStatusType.Confirmed, + }; + jest.spyOn(component["dataSource"](), "isIncreasedBulkLimitEnabled").mockReturnValue(false); + jest.spyOn(component["dataSource"](), "getCheckedUsers").mockReturnValue([confirmedUser]); + + await component.bulkReinvite(mockOrg); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "errorOccurred", + message: "noSelectedUsersApplicable", + }); + expect(mockMemberActionsService.bulkReinvite).not.toHaveBeenCalled(); + }); + + it("should handle errors", async () => { + const invitedUser = { + ...mockUser, + status: OrganizationUserStatusType.Invited, + }; + jest.spyOn(component["dataSource"](), "isIncreasedBulkLimitEnabled").mockReturnValue(false); + jest.spyOn(component["dataSource"](), "getCheckedUsers").mockReturnValue([invitedUser]); + const error = new Error("Bulk reinvite failed"); + mockMemberActionsService.bulkReinvite.mockResolvedValue({ successful: false, failed: error }); + + await component.bulkReinvite(mockOrg); + + expect(mockValidationService.showError).toHaveBeenCalledWith(error); + }); + }); + + describe("bulkConfirm", () => { + it("should open bulk confirm dialog and reload", async () => { + const users = [mockUser]; + jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.bulkConfirm(mockOrg); + + expect(mockMemberDialogManager.openBulkConfirmDialog).toHaveBeenCalledWith(mockOrg, users); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }); + }); + + describe("bulkEnableSM", () => { + it("should open bulk enable SM dialog and reload", async () => { + const users = [mockUser]; + jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users); + jest.spyOn(component["dataSource"](), "uncheckAllUsers"); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.bulkEnableSM(mockOrg); + + expect(mockMemberDialogManager.openBulkEnableSecretsManagerDialog).toHaveBeenCalledWith( + mockOrg, + users, + ); + expect(component["dataSource"]().uncheckAllUsers).toHaveBeenCalled(); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }); + }); + + describe("resetPassword", () => { + it("should open account recovery dialog", async () => { + mockMemberDialogManager.openAccountRecoveryDialog.mockResolvedValue( + AccountRecoveryDialogResultType.Ok, + ); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.resetPassword(mockUser, mockOrg); + + expect(mockMemberDialogManager.openAccountRecoveryDialog).toHaveBeenCalledWith( + mockUser, + mockOrg, + ); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }); + }); + + describe("deleteUser", () => { + it("should delete user when confirmed", async () => { + mockMemberDialogManager.openDeleteUserConfirmationDialog.mockResolvedValue(true); + mockMemberActionsService.deleteUser.mockResolvedValue({ success: true }); + const removeSpy = jest.spyOn(component["dataSource"](), "removeUser"); + + await component.deleteUser(mockUser, mockOrg); + + expect(mockMemberDialogManager.openDeleteUserConfirmationDialog).toHaveBeenCalledWith( + mockUser, + mockOrg, + ); + expect(mockMemberActionsService.deleteUser).toHaveBeenCalledWith(mockOrg, mockUser.id); + expect(removeSpy).toHaveBeenCalledWith(mockUser); + expect(mockToastService.showToast).toHaveBeenCalled(); + }); + + it("should not delete user when not confirmed", async () => { + mockMemberDialogManager.openDeleteUserConfirmationDialog.mockResolvedValue(false); + + const result = await component.deleteUser(mockUser, mockOrg); + + expect(result).toBe(false); + expect(mockMemberActionsService.deleteUser).not.toHaveBeenCalled(); + }); + + it("should handle errors via handleMemberActionResult", async () => { + mockMemberDialogManager.openDeleteUserConfirmationDialog.mockResolvedValue(true); + mockMemberActionsService.deleteUser.mockResolvedValue({ + success: false, + error: "Delete failed", + }); + + await component.deleteUser(mockUser, mockOrg); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "Delete failed", + }); + expect(mockLogService.error).toHaveBeenCalledWith("Delete failed"); + }); + }); + + describe("handleMemberActionResult", () => { + it("should show success toast when result is successful", async () => { + const result: MemberActionResult = { success: true }; + + await component.handleMemberActionResult(result, "testSuccessKey", mockUser); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "success", + message: "testSuccessKey", + }); + }); + + it("should execute side effect when provided and successful", async () => { + const result: MemberActionResult = { success: true }; + const sideEffect = jest.fn(); + + await component.handleMemberActionResult(result, "testSuccessKey", mockUser, sideEffect); + + expect(sideEffect).toHaveBeenCalled(); + }); + + it("should show error toast when result is not successful", async () => { + const result: MemberActionResult = { success: false, error: "Error message" }; + const sideEffect = jest.fn(); + + await component.handleMemberActionResult(result, "testSuccessKey", mockUser, sideEffect); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "Error message", + }); + expect(mockLogService.error).toHaveBeenCalledWith("Error message"); + expect(sideEffect).not.toHaveBeenCalled(); + }); + + it("should propagate error when side effect throws", async () => { + const result: MemberActionResult = { success: true }; + const error = new Error("Side effect failed"); + const sideEffect = jest.fn().mockRejectedValue(error); + + await expect( + component.handleMemberActionResult(result, "testSuccessKey", mockUser, sideEffect), + ).rejects.toThrow("Side effect failed"); + }); + }); +}); 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 e57cf54c180..9d367657d55 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,9 +1,12 @@ -import { Component, computed, Signal } from "@angular/core"; +import { Component, computed, inject, signal, Signal, WritableSignal } from "@angular/core"; import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; +import { FormControl } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; import { + BehaviorSubject, combineLatest, concatMap, + debounceTime, filter, firstValueFrom, from, @@ -15,11 +18,8 @@ import { take, } from "rxjs"; -import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { @@ -35,22 +35,21 @@ import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billin import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.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 { getById } from "@bitwarden/common/platform/misc"; import { DialogService, ToastService } from "@bitwarden/components"; -import { KeyService } from "@bitwarden/key-management"; import { UserId } from "@bitwarden/user-core"; import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service"; import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; -import { BaseMembersComponent } from "../../common/base-members.component"; import { CloudBulkReinviteLimit, MaxCheckedCount, - PeopleTableDataSource, + MembersTableDataSource, + peopleFilter, + showConfirmBanner, } from "../../common/people-table-data-source"; import { OrganizationUserView } from "../core/views/organization-user.view"; @@ -67,8 +66,13 @@ import { MemberActionResult, } from "./services/member-actions/member-actions.service"; -class MembersTableDataSource extends PeopleTableDataSource { - protected statusType = OrganizationUserStatusType; +interface BulkMemberFlags { + showBulkRestoreUsers: boolean; + showBulkRevokeUsers: boolean; + showBulkRemoveUsers: boolean; + showBulkDeleteUsers: boolean; + showBulkConfirmUsers: boolean; + showBulkReinviteUsers: boolean; } // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -77,71 +81,76 @@ class MembersTableDataSource extends PeopleTableDataSource templateUrl: "members.component.html", standalone: false, }) -export class MembersComponent extends BaseMembersComponent { - userType = OrganizationUserType; - userStatusType = OrganizationUserStatusType; - memberTab = MemberDialogTab; - protected dataSource: MembersTableDataSource; - - readonly organization: Signal; - status: OrganizationUserStatusType | undefined; +export class vNextMembersComponent { + protected i18nService = inject(I18nService); + protected validationService = inject(ValidationService); + protected logService = inject(LogService); + protected userNamePipe = inject(UserNamePipe); + protected dialogService = inject(DialogService); + protected toastService = inject(ToastService); + private route = inject(ActivatedRoute); + protected deleteManagedMemberWarningService = inject(DeleteManagedMemberWarningService); + private organizationWarningsService = inject(OrganizationWarningsService); + private memberActionsService = inject(MemberActionsService); + private memberDialogManager = inject(MemberDialogManagerService); + protected billingConstraint = inject(BillingConstraintService); + protected memberService = inject(OrganizationMembersService); + private organizationService = inject(OrganizationService); + private accountService = inject(AccountService); + private policyService = inject(PolicyService); + private policyApiService = inject(PolicyApiServiceAbstraction); + private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction); + private configService = inject(ConfigService); + private environmentService = inject(EnvironmentService); + private memberExportService = inject(MemberExportService); private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); - resetPasswordPolicyEnabled$: Observable; + protected userType = OrganizationUserType; + protected userStatusType = OrganizationUserStatusType; + protected memberTab = MemberDialogTab; + + protected searchControl = new FormControl("", { nonNullable: true }); + protected statusToggle = new BehaviorSubject(undefined); + + protected readonly dataSource: Signal = signal( + new MembersTableDataSource(this.configService, this.environmentService), + ); + protected readonly organization: Signal; + protected readonly firstLoaded: WritableSignal = signal(false); + + protected bulkMenuOptions$ = this.dataSource() + .usersUpdated() + .pipe(map((members) => this.bulkMenuOptions(members))); + + protected showConfirmBanner$ = this.dataSource() + .usersUpdated() + .pipe(map(() => showConfirmBanner(this.dataSource()))); + + protected isProcessing = this.memberActionsService.isProcessing; protected readonly canUseSecretsManager: Signal = computed( () => this.organization()?.useSecretsManager ?? false, ); + protected readonly showUserManagementControls: Signal = computed( () => this.organization()?.canManageUsers ?? false, ); + protected billingMetadata$: Observable; + protected resetPasswordPolicyEnabled$: Observable; + // Fixed sizes used for cdkVirtualScroll protected rowHeight = 66; protected rowHeightClass = `tw-h-[66px]`; - constructor( - apiService: ApiService, - i18nService: I18nService, - organizationManagementPreferencesService: OrganizationManagementPreferencesService, - keyService: KeyService, - validationService: ValidationService, - logService: LogService, - userNamePipe: UserNamePipe, - dialogService: DialogService, - toastService: ToastService, - private route: ActivatedRoute, - protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService, - private organizationWarningsService: OrganizationWarningsService, - private memberActionsService: MemberActionsService, - private memberDialogManager: MemberDialogManagerService, - protected billingConstraint: BillingConstraintService, - protected memberService: OrganizationMembersService, - private organizationService: OrganizationService, - private accountService: AccountService, - private policyService: PolicyService, - private policyApiService: PolicyApiServiceAbstraction, - private organizationMetadataService: OrganizationMetadataServiceAbstraction, - private memberExportService: MemberExportService, - private fileDownloadService: FileDownloadService, - private configService: ConfigService, - private environmentService: EnvironmentService, - ) { - super( - apiService, - i18nService, - keyService, - validationService, - logService, - userNamePipe, - dialogService, - organizationManagementPreferencesService, - toastService, - ); - - this.dataSource = new MembersTableDataSource(this.configService, this.environmentService); + constructor() { + combineLatest([this.searchControl.valueChanges.pipe(debounceTime(200)), this.statusToggle]) + .pipe(takeUntilDestroyed()) + .subscribe( + ([searchText, status]) => (this.dataSource().filter = peopleFilter(searchText, status)), + ); const organization$ = this.route.params.pipe( concatMap((params) => @@ -184,7 +193,7 @@ export class MembersComponent extends BaseMembersComponent this.searchControl.setValue(qParams.search); if (qParams.viewEvents != null) { - const user = this.dataSource.data.filter((u) => u.id === qParams.viewEvents); + const user = this.dataSource().data.filter((u) => u.id === qParams.viewEvents); if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) { this.openEventsDialog(user[0], organization!); } @@ -218,80 +227,62 @@ export class MembersComponent extends BaseMembersComponent this.billingMetadata$.pipe(take(1), takeUntilDestroyed()).subscribe(); } - override async load(organization: Organization) { - await super.load(organization); + async load(organization: Organization) { + const response = await this.memberService.loadUsers(organization); + this.dataSource().data = response; + this.firstLoaded.set(true); } - async getUsers(organization: Organization): Promise { - return await this.memberService.loadUsers(organization); - } - - async removeUser(id: string, organization: Organization): Promise { - return await this.memberActionsService.removeUser(organization, id); - } - - async revokeUser(id: string, organization: Organization): Promise { - return await this.memberActionsService.revokeUser(organization, id); - } - - async restoreUser(id: string, organization: Organization): Promise { - return await this.memberActionsService.restoreUser(organization, id); - } - - async reinviteUser(id: string, organization: Organization): Promise { - return await this.memberActionsService.reinviteUser(organization, id); - } - - async confirmUser( - user: OrganizationUserView, - publicKey: Uint8Array, - organization: Organization, - ): Promise { - return await this.memberActionsService.confirmUser(user, publicKey, organization); - } - - async revoke(user: OrganizationUserView, organization: Organization) { - const confirmed = await this.revokeUserConfirmationDialog(user); + async remove(user: OrganizationUserView, organization: Organization) { + const confirmed = await this.memberDialogManager.openRemoveUserConfirmationDialog(user); if (!confirmed) { return false; } - this.actionPromise = this.revokeUser(user.id, organization); - try { - const result = await this.actionPromise; - if (result.success) { - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)), - }); - await this.load(organization); - } else { - throw new Error(result.error); - } - } catch (e) { - this.validationService.showError(e); + const result = await this.memberActionsService.removeUser(organization, user.id); + const sideEffect = () => this.dataSource().removeUser(user); + await this.handleMemberActionResult(result, "removedUserId", user, sideEffect); + } + + async reinvite(user: OrganizationUserView, organization: Organization) { + const result = await this.memberActionsService.reinviteUser(organization, user.id); + await this.handleMemberActionResult(result, "hasBeenReinvited", user); + } + + async confirm(user: OrganizationUserView, organization: Organization) { + const confirmUserSideEffect = () => { + user.status = this.userStatusType.Confirmed; + this.dataSource().replaceUser(user); + }; + + const publicKeyResult = await this.memberActionsService.getPublicKeyForConfirm(user); + + if (publicKeyResult == null) { + this.logService.warning("Public key not found"); + return; } - this.actionPromise = undefined; + + const result = await this.memberActionsService.confirmUser(user, publicKeyResult, organization); + await this.handleMemberActionResult(result, "hasBeenConfirmed", user, confirmUserSideEffect); + } + + async revoke(user: OrganizationUserView, organization: Organization) { + const confirmed = await this.memberDialogManager.openRevokeUserConfirmationDialog(user); + + if (!confirmed) { + return false; + } + + const result = await this.memberActionsService.revokeUser(organization, user.id); + const sideEffect = async () => await this.load(organization); + await this.handleMemberActionResult(result, "revokedUserId", user, sideEffect); } async restore(user: OrganizationUserView, organization: Organization) { - this.actionPromise = this.restoreUser(user.id, organization); - try { - const result = await this.actionPromise; - if (result.success) { - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)), - }); - await this.load(organization); - } else { - throw new Error(result.error); - } - } catch (e) { - this.validationService.showError(e); - } - this.actionPromise = undefined; + const result = await this.memberActionsService.restoreUser(organization, user.id); + const sideEffect = async () => await this.load(organization); + await this.handleMemberActionResult(result, "restoredUserId", user, sideEffect); } allowResetPassword( @@ -307,7 +298,7 @@ export class MembersComponent extends BaseMembersComponent } showEnrolledStatus( - orgUser: OrganizationUserUserDetailsResponse, + orgUser: OrganizationUserView, organization: Organization, orgResetPasswordPolicyEnabled: boolean, ): boolean { @@ -318,9 +309,15 @@ export class MembersComponent extends BaseMembersComponent ); } - private async handleInviteDialog(organization: Organization) { + async invite(organization: Organization) { const billingMetadata = await firstValueFrom(this.billingMetadata$); - const allUserEmails = this.dataSource.data?.map((user) => user.email) ?? []; + const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata); + + if (await this.billingConstraint.seatLimitReached(seatLimitResult, organization)) { + return; + } + + const allUserEmails = this.dataSource().data?.map((user) => user.email) ?? []; const result = await this.memberDialogManager.openInviteDialog( organization, @@ -330,14 +327,6 @@ export class MembersComponent extends BaseMembersComponent if (result === MemberDialogResult.Saved) { await this.load(organization); - } - } - - async invite(organization: Organization) { - const billingMetadata = await firstValueFrom(this.billingMetadata$); - const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata); - if (!(await this.billingConstraint.seatLimitReached(seatLimitResult, organization))) { - await this.handleInviteDialog(organization); this.organizationMetadataService.refreshMetadataCache(); } } @@ -358,7 +347,7 @@ export class MembersComponent extends BaseMembersComponent switch (result) { case MemberDialogResult.Deleted: - this.dataSource.removeUser(user); + this.dataSource().removeUser(user); break; case MemberDialogResult.Saved: case MemberDialogResult.Revoked: @@ -369,57 +358,30 @@ export class MembersComponent extends BaseMembersComponent } async bulkRemove(organization: Organization) { - if (this.actionPromise != null) { - return; - } - - const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); - + const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount); await this.memberDialogManager.openBulkRemoveDialog(organization, users); this.organizationMetadataService.refreshMetadataCache(); await this.load(organization); } async bulkDelete(organization: Organization) { - if (this.actionPromise != null) { - return; - } - - const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); - + const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount); await this.memberDialogManager.openBulkDeleteDialog(organization, users); await this.load(organization); } - async bulkRevoke(organization: Organization) { - await this.bulkRevokeOrRestore(true, organization); - } - - async bulkRestore(organization: Organization) { - await this.bulkRevokeOrRestore(false, organization); - } - async bulkRevokeOrRestore(isRevoking: boolean, organization: Organization) { - if (this.actionPromise != null) { - return; - } - - const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); - + const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount); await this.memberDialogManager.openBulkRestoreRevokeDialog(organization, users, isRevoking); await this.load(organization); } async bulkReinvite(organization: Organization) { - if (this.actionPromise != null) { - return; - } - let users: OrganizationUserView[]; - if (this.dataSource.isIncreasedBulkLimitEnabled()) { - users = this.dataSource.getCheckedUsersInVisibleOrder(); + if (this.dataSource().isIncreasedBulkLimitEnabled()) { + users = this.dataSource().getCheckedUsersInVisibleOrder(); } else { - users = this.dataSource.getCheckedUsers(); + users = this.dataSource().getCheckedUsers(); } const allInvitedUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited); @@ -429,8 +391,8 @@ export class MembersComponent extends BaseMembersComponent // When feature flag is enabled, limit invited users and uncheck the excess let filteredUsers: OrganizationUserView[]; - if (this.dataSource.isIncreasedBulkLimitEnabled()) { - filteredUsers = this.dataSource.limitAndUncheckExcess( + if (this.dataSource().isIncreasedBulkLimitEnabled()) { + filteredUsers = this.dataSource().limitAndUncheckExcess( allInvitedUsers, CloudBulkReinviteLimit, ); @@ -447,70 +409,59 @@ export class MembersComponent extends BaseMembersComponent return; } - try { - const result = await this.memberActionsService.bulkReinvite( - organization, - filteredUsers.map((user) => user.id as UserId), - ); + const result = await this.memberActionsService.bulkReinvite( + organization, + filteredUsers.map((user) => user.id as UserId), + ); - if (!result.successful) { - throw new Error(); - } - - // When feature flag is enabled, show toast instead of dialog - if (this.dataSource.isIncreasedBulkLimitEnabled()) { - const selectedCount = originalInvitedCount; - const invitedCount = filteredUsers.length; - - if (selectedCount > CloudBulkReinviteLimit) { - const excludedCount = selectedCount - CloudBulkReinviteLimit; - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t( - "bulkReinviteLimitedSuccessToast", - CloudBulkReinviteLimit.toLocaleString(), - selectedCount.toLocaleString(), - excludedCount.toLocaleString(), - ), - }); - } else { - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()), - }); - } - } else { - // Feature flag disabled - show legacy dialog - await this.memberDialogManager.openBulkStatusDialog( - users, - filteredUsers, - Promise.resolve(result.successful), - this.i18nService.t("bulkReinviteMessage"), - ); - } - } catch (e) { - this.validationService.showError(e); + if (!result.successful) { + this.validationService.showError(result.failed); + } + + // When feature flag is enabled, show toast instead of dialog + if (this.dataSource().isIncreasedBulkLimitEnabled()) { + const selectedCount = originalInvitedCount; + const invitedCount = filteredUsers.length; + + if (selectedCount > CloudBulkReinviteLimit) { + const excludedCount = selectedCount - CloudBulkReinviteLimit; + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t( + "bulkReinviteLimitedSuccessToast", + CloudBulkReinviteLimit.toLocaleString(), + selectedCount.toLocaleString(), + excludedCount.toLocaleString(), + ), + }); + } else { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()), + }); + } + } else { + // Feature flag disabled - show legacy dialog + await this.memberDialogManager.openBulkStatusDialog( + users, + filteredUsers, + Promise.resolve(result.successful), + this.i18nService.t("bulkReinviteMessage"), + ); } - this.actionPromise = undefined; } async bulkConfirm(organization: Organization) { - if (this.actionPromise != null) { - return; - } - - const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); - + const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount); await this.memberDialogManager.openBulkConfirmDialog(organization, users); await this.load(organization); } async bulkEnableSM(organization: Organization) { - const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); - + const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount); await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users); - this.dataSource.uncheckAllUsers(); + this.dataSource().uncheckAllUsers(); await this.load(organization); } @@ -538,14 +489,6 @@ export class MembersComponent extends BaseMembersComponent return; } - protected async removeUserConfirmationDialog(user: OrganizationUserView) { - return await this.memberDialogManager.openRemoveUserConfirmationDialog(user); - } - - protected async revokeUserConfirmationDialog(user: OrganizationUserView) { - return await this.memberDialogManager.openRevokeUserConfirmationDialog(user); - } - async deleteUser(user: OrganizationUserView, organization: Organization) { const confirmed = await this.memberDialogManager.openDeleteUserConfirmationDialog( user, @@ -556,80 +499,72 @@ export class MembersComponent extends BaseMembersComponent return false; } - this.actionPromise = this.memberActionsService.deleteUser(organization, user.id); - try { - const result = await this.actionPromise; - if (!result.success) { - throw new Error(result.error); - } + const result = await this.memberActionsService.deleteUser(organization, user.id); + await this.handleMemberActionResult(result, "organizationUserDeleted", user, () => { + this.dataSource().removeUser(user); + }); + } + + async handleMemberActionResult( + result: MemberActionResult, + successKey: string, + user: OrganizationUserView, + sideEffect?: () => void | Promise, + ) { + if (result.error != null) { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t(result.error), + }); + this.logService.error(result.error); + return; + } + + if (result.success) { this.toastService.showToast({ variant: "success", - message: this.i18nService.t("organizationUserDeleted", this.userNamePipe.transform(user)), + message: this.i18nService.t(successKey, this.userNamePipe.transform(user)), }); - this.dataSource.removeUser(user); - } catch (e) { - this.validationService.showError(e); + + if (sideEffect) { + await sideEffect(); + } } - this.actionPromise = undefined; } - get showBulkRestoreUsers(): boolean { - return this.dataSource - .getCheckedUsers() - .every((member) => member.status == this.userStatusType.Revoked); - } - - get showBulkRevokeUsers(): boolean { - return this.dataSource - .getCheckedUsers() - .every((member) => member.status != this.userStatusType.Revoked); - } - - get showBulkRemoveUsers(): boolean { - return this.dataSource.getCheckedUsers().every((member) => !member.managedByOrganization); - } - - get showBulkDeleteUsers(): boolean { + private bulkMenuOptions(members: OrganizationUserView[]): BulkMemberFlags { const validStatuses = [ - this.userStatusType.Accepted, - this.userStatusType.Confirmed, - this.userStatusType.Revoked, + OrganizationUserStatusType.Accepted, + OrganizationUserStatusType.Confirmed, + OrganizationUserStatusType.Revoked, ]; - return this.dataSource - .getCheckedUsers() - .every((member) => member.managedByOrganization && validStatuses.includes(member.status)); + const result = { + showBulkConfirmUsers: members.every((m) => m.status == OrganizationUserStatusType.Accepted), + showBulkReinviteUsers: members.every((m) => m.status == OrganizationUserStatusType.Invited), + showBulkRestoreUsers: members.every((m) => m.status == OrganizationUserStatusType.Revoked), + showBulkRevokeUsers: members.every((m) => m.status != OrganizationUserStatusType.Revoked), + showBulkRemoveUsers: members.every((m) => !m.managedByOrganization), + showBulkDeleteUsers: members.every( + (m) => m.managedByOrganization && validStatuses.includes(m.status), + ), + }; + + return result; } - exportMembers = async (): Promise => { - try { - const members = this.dataSource.data; - if (!members || members.length === 0) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("noMembersToExport"), - }); - return; - } - - const csvData = this.memberExportService.getMemberExport(members); - const fileName = this.memberExportService.getFileName("org-members"); - - this.fileDownloadService.download({ - fileName: fileName, - blobData: csvData, - blobOptions: { type: "text/plain" }, - }); - + exportMembers = () => { + const result = this.memberExportService.getMemberExport(this.dataSource().data); + if (result.success) { this.toastService.showToast({ variant: "success", title: undefined, message: this.i18nService.t("dataExportSuccess"), }); - } catch (e) { - this.validationService.showError(e); - this.logService.error(`Failed to export members: ${e}`); + } + + if (result.error != null) { + this.validationService.showError(result.error.message); } }; } diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index 65625cfd247..9fd477b1e29 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -17,8 +17,9 @@ import { BulkRemoveDialogComponent } from "./components/bulk/bulk-remove-dialog. import { BulkRestoreRevokeComponent } from "./components/bulk/bulk-restore-revoke.component"; import { BulkStatusComponent } from "./components/bulk/bulk-status.component"; import { UserDialogModule } from "./components/member-dialog"; +import { MembersComponent } from "./deprecated_members.component"; import { MembersRoutingModule } from "./members-routing.module"; -import { MembersComponent } from "./members.component"; +import { vNextMembersComponent } from "./members.component"; import { UserStatusPipe } from "./pipes"; import { OrganizationMembersService, @@ -46,6 +47,7 @@ import { BulkRestoreRevokeComponent, BulkStatusComponent, MembersComponent, + vNextMembersComponent, BulkDeleteDialogComponent, UserStatusPipe, ], diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts index 1dd75a79180..1df285d7ba2 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts @@ -1,3 +1,4 @@ +import { TestBed } from "@angular/core/testing"; import { MockProxy, mock } from "jest-mock-extended"; import { of } from "rxjs"; @@ -6,6 +7,9 @@ import { OrganizationUserBulkResponse, OrganizationUserService, } from "@bitwarden/admin-console/common"; +import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; import { OrganizationUserType, OrganizationUserStatusType, @@ -14,8 +18,11 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { DialogService } from "@bitwarden/components"; import { newGuid } from "@bitwarden/guid"; +import { KeyService } from "@bitwarden/key-management"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; @@ -56,12 +63,29 @@ describe("MemberActionsService", () => { resetPasswordEnrolled: true, } as OrganizationUserView; - service = new MemberActionsService( - organizationUserApiService, - organizationUserService, - configService, - organizationMetadataService, - ); + TestBed.configureTestingModule({ + providers: [ + MemberActionsService, + { provide: OrganizationUserApiService, useValue: organizationUserApiService }, + { provide: OrganizationUserService, useValue: organizationUserService }, + { provide: ConfigService, useValue: configService }, + { + provide: OrganizationMetadataServiceAbstraction, + useValue: organizationMetadataService, + }, + { provide: ApiService, useValue: mock() }, + { provide: DialogService, useValue: mock() }, + { provide: KeyService, useValue: mock() }, + { provide: LogService, useValue: mock() }, + { + provide: OrganizationManagementPreferencesService, + useValue: mock(), + }, + { provide: UserNamePipe, useValue: mock() }, + ], + }); + + service = TestBed.inject(MemberActionsService); }); describe("inviteUser", () => { @@ -660,4 +684,26 @@ describe("MemberActionsService", () => { expect(result).toBe(false); }); }); + + describe("isProcessing signal", () => { + it("should be false initially", () => { + expect(service.isProcessing()).toBe(false); + }); + + it("should be false after operation completes successfully", async () => { + organizationUserApiService.removeOrganizationUser.mockResolvedValue(undefined); + + await service.removeUser(mockOrganization, userIdToManage); + + expect(service.isProcessing()).toBe(false); + }); + + it("should be false after operation fails", async () => { + organizationUserApiService.removeOrganizationUser.mockRejectedValue(new Error("Failed")); + + await service.removeUser(mockOrganization, userIdToManage); + + expect(service.isProcessing()).toBe(false); + }); + }); }); diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts index a44bfa4b19c..5833238209c 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts @@ -1,23 +1,33 @@ -import { Injectable } from "@angular/core"; -import { firstValueFrom } from "rxjs"; +import { inject, Injectable, signal } from "@angular/core"; +import { lastValueFrom, firstValueFrom } from "rxjs"; import { OrganizationUserApiService, OrganizationUserBulkResponse, OrganizationUserService, } from "@bitwarden/admin-console/common"; +import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; import { OrganizationUserType, OrganizationUserStatusType, } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { assertNonNullish } from "@bitwarden/common/auth/utils"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { DialogService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; import { UserId } from "@bitwarden/user-core"; +import { ProviderUser } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; +import { UserConfirmComponent } from "../../../manage/user-confirm.component"; export const REQUESTS_PER_BATCH = 500; @@ -33,12 +43,26 @@ export interface BulkActionResult { @Injectable() export class MemberActionsService { - constructor( - private organizationUserApiService: OrganizationUserApiService, - private organizationUserService: OrganizationUserService, - private configService: ConfigService, - private organizationMetadataService: OrganizationMetadataServiceAbstraction, - ) {} + private organizationUserApiService = inject(OrganizationUserApiService); + private organizationUserService = inject(OrganizationUserService); + private configService = inject(ConfigService); + private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction); + private apiService = inject(ApiService); + private dialogService = inject(DialogService); + private keyService = inject(KeyService); + private logService = inject(LogService); + private orgManagementPrefs = inject(OrganizationManagementPreferencesService); + private userNamePipe = inject(UserNamePipe); + + readonly isProcessing = signal(false); + + private startProcessing(): void { + this.isProcessing.set(true); + } + + private endProcessing(): void { + this.isProcessing.set(false); + } async inviteUser( organization: Organization, @@ -48,6 +72,7 @@ export class MemberActionsService { collections?: any[], groups?: string[], ): Promise { + this.startProcessing(); try { await this.organizationUserApiService.postOrganizationUserInvite(organization.id, { emails: [email], @@ -60,55 +85,72 @@ export class MemberActionsService { return { success: true }; } catch (error) { return { success: false, error: (error as Error).message ?? String(error) }; + } finally { + this.endProcessing(); } } async removeUser(organization: Organization, userId: string): Promise { + this.startProcessing(); try { await this.organizationUserApiService.removeOrganizationUser(organization.id, userId); this.organizationMetadataService.refreshMetadataCache(); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message ?? String(error) }; + } finally { + this.endProcessing(); } } async revokeUser(organization: Organization, userId: string): Promise { + this.startProcessing(); try { await this.organizationUserApiService.revokeOrganizationUser(organization.id, userId); this.organizationMetadataService.refreshMetadataCache(); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message ?? String(error) }; + } finally { + this.endProcessing(); } } async restoreUser(organization: Organization, userId: string): Promise { + this.startProcessing(); try { await this.organizationUserApiService.restoreOrganizationUser(organization.id, userId); this.organizationMetadataService.refreshMetadataCache(); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message ?? String(error) }; + } finally { + this.endProcessing(); } } async deleteUser(organization: Organization, userId: string): Promise { + this.startProcessing(); try { await this.organizationUserApiService.deleteOrganizationUser(organization.id, userId); this.organizationMetadataService.refreshMetadataCache(); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message ?? String(error) }; + } finally { + this.endProcessing(); } } async reinviteUser(organization: Organization, userId: string): Promise { + this.startProcessing(); try { await this.organizationUserApiService.postOrganizationUserReinvite(organization.id, userId); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message ?? String(error) }; + } finally { + this.endProcessing(); } } @@ -117,6 +159,7 @@ export class MemberActionsService { publicKey: Uint8Array, organization: Organization, ): Promise { + this.startProcessing(); try { await firstValueFrom( this.organizationUserService.confirmUser(organization, user.id, publicKey), @@ -124,27 +167,32 @@ export class MemberActionsService { return { success: true }; } catch (error) { return { success: false, error: (error as Error).message ?? String(error) }; + } finally { + this.endProcessing(); } } async bulkReinvite(organization: Organization, userIds: UserId[]): Promise { - const increaseBulkReinviteLimitForCloud = await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud), - ); - if (increaseBulkReinviteLimitForCloud) { - return await this.vNextBulkReinvite(organization, userIds); - } else { - try { + this.startProcessing(); + try { + const increaseBulkReinviteLimitForCloud = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud), + ); + if (increaseBulkReinviteLimitForCloud) { + return await this.vNextBulkReinvite(organization, userIds); + } else { const result = await this.organizationUserApiService.postManyOrganizationUserReinvite( organization.id, userIds, ); return { successful: result, failed: [] }; - } catch (error) { - return { - failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })), - }; } + } catch (error) { + return { + failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })), + }; + } finally { + this.endProcessing(); } } @@ -236,4 +284,50 @@ export class MemberActionsService { failed: allFailed, }; } + + /** + * Shared dialog workflow that returns the public key when the user accepts the selected confirmation + * action. + * + * @param user - The user to confirm (must implement ConfirmableUser interface) + * @param userNamePipe - Pipe to transform user names for display + * @param orgManagementPrefs - Service providing organization management preferences + * @returns Promise containing the pulic key that resolves when the confirm action is accepted + * or undefined when cancelled + */ + async getPublicKeyForConfirm( + user: OrganizationUserView | ProviderUser, + ): Promise { + try { + assertNonNullish(user, "Cannot confirm null user."); + + const autoConfirmFingerPrint = await firstValueFrom( + this.orgManagementPrefs.autoConfirmFingerPrints.state$, + ); + + const publicKeyResponse = await this.apiService.getUserPublicKey(user.userId); + const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey); + + if (autoConfirmFingerPrint == null || !autoConfirmFingerPrint) { + const fingerprint = await this.keyService.getFingerprint(user.userId, publicKey); + this.logService.info(`User's fingerprint: ${fingerprint.join("-")}`); + + const confirmed = UserConfirmComponent.open(this.dialogService, { + data: { + name: this.userNamePipe.transform(user), + userId: user.userId, + publicKey: publicKey, + }, + }); + + if (!(await lastValueFrom(confirmed.closed))) { + return; + } + } + + return publicKey; + } catch (e) { + this.logService.error(`Handled exception: ${e}`); + } + } } diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.spec.ts index 1e229b95d24..08503f80f17 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.spec.ts @@ -6,7 +6,9 @@ import { OrganizationUserStatusType, OrganizationUserType, } from "@bitwarden/common/admin-console/enums"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/logging"; import { OrganizationUserView } from "../../../core"; import { UserStatusPipe } from "../../pipes"; @@ -16,9 +18,13 @@ import { MemberExportService } from "./member-export.service"; describe("MemberExportService", () => { let service: MemberExportService; let i18nService: MockProxy; + let fileDownloadService: MockProxy; + let logService: MockProxy; beforeEach(() => { i18nService = mock(); + fileDownloadService = mock(); + logService = mock(); // Setup common i18n translations i18nService.t.mockImplementation((key: string) => { @@ -44,9 +50,12 @@ describe("MemberExportService", () => { custom: "Custom", // Boolean states enabled: "Enabled", + optionEnabled: "Enabled", disabled: "Disabled", enrolled: "Enrolled", notEnrolled: "Not Enrolled", + // Error messages + noMembersToExport: "No members to export", }; return translations[key] || key; }); @@ -54,6 +63,8 @@ describe("MemberExportService", () => { TestBed.configureTestingModule({ providers: [ MemberExportService, + { provide: FileDownloadService, useValue: fileDownloadService }, + { provide: LogService, useValue: logService }, { provide: I18nService, useValue: i18nService }, UserTypePipe, UserStatusPipe, @@ -88,8 +99,18 @@ describe("MemberExportService", () => { } as OrganizationUserView, ]; - const csvData = service.getMemberExport(members); + const result = service.getMemberExport(members); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(fileDownloadService.download).toHaveBeenCalledTimes(1); + + const downloadCall = fileDownloadService.download.mock.calls[0][0]; + expect(downloadCall.fileName).toContain("org-members"); + expect(downloadCall.fileName).toContain(".csv"); + expect(downloadCall.blobOptions).toEqual({ type: "text/plain" }); + + const csvData = downloadCall.blobData as string; expect(csvData).toContain("Email,Name,Status,Role,Two-step Login,Account Recovery"); expect(csvData).toContain("user1@example.com"); expect(csvData).toContain("User One"); @@ -114,8 +135,12 @@ describe("MemberExportService", () => { } as OrganizationUserView, ]; - const csvData = service.getMemberExport(members); + const result = service.getMemberExport(members); + expect(result.success).toBe(true); + expect(fileDownloadService.download).toHaveBeenCalled(); + + const csvData = fileDownloadService.download.mock.calls[0][0].blobData as string; expect(csvData).toContain("user@example.com"); // Empty name is represented as an empty field in CSV expect(csvData).toContain("user@example.com,,Confirmed"); @@ -135,17 +160,23 @@ describe("MemberExportService", () => { } as OrganizationUserView, ]; - const csvData = service.getMemberExport(members); + const result = service.getMemberExport(members); + expect(result.success).toBe(true); + expect(fileDownloadService.download).toHaveBeenCalled(); + + const csvData = fileDownloadService.download.mock.calls[0][0].blobData as string; expect(csvData).toContain("user@example.com"); expect(csvData).toBeDefined(); }); it("should handle empty members array", () => { - const csvData = service.getMemberExport([]); + const result = service.getMemberExport([]); - // When array is empty, papaparse returns an empty string - expect(csvData).toBe(""); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.message).toBe("No members to export"); + expect(fileDownloadService.download).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.ts index c00881617a4..069c7d9d148 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.ts @@ -2,7 +2,9 @@ import { inject, Injectable } from "@angular/core"; import * as papa from "papaparse"; import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { ExportHelper } from "@bitwarden/vault-export-core"; import { OrganizationUserView } from "../../../core"; @@ -10,40 +12,71 @@ import { UserStatusPipe } from "../../pipes"; import { MemberExport } from "./member.export"; +export interface MemberExportResult { + success: boolean; + error?: { message: string }; +} + @Injectable() export class MemberExportService { private i18nService = inject(I18nService); private userTypePipe = inject(UserTypePipe); private userStatusPipe = inject(UserStatusPipe); + private fileDownloadService = inject(FileDownloadService); + private logService = inject(LogService); - getMemberExport(members: OrganizationUserView[]): string { - const exportData = members.map((m) => - MemberExport.fromOrganizationUserView( - this.i18nService, - this.userTypePipe, - this.userStatusPipe, - m, - ), - ); + getMemberExport(data: OrganizationUserView[]): MemberExportResult { + try { + const members = data; + if (!members || members.length === 0) { + return { success: false, error: { message: this.i18nService.t("noMembersToExport") } }; + } - const headers: string[] = [ - this.i18nService.t("email"), - this.i18nService.t("name"), - this.i18nService.t("status"), - this.i18nService.t("role"), - this.i18nService.t("twoStepLogin"), - this.i18nService.t("accountRecovery"), - this.i18nService.t("secretsManager"), - this.i18nService.t("groups"), - ]; + const exportData = members.map((m) => + MemberExport.fromOrganizationUserView( + this.i18nService, + this.userTypePipe, + this.userStatusPipe, + m, + ), + ); - return papa.unparse(exportData, { - columns: headers, - header: true, - }); + const headers: string[] = [ + this.i18nService.t("email"), + this.i18nService.t("name"), + this.i18nService.t("status"), + this.i18nService.t("role"), + this.i18nService.t("twoStepLogin"), + this.i18nService.t("accountRecovery"), + this.i18nService.t("secretsManager"), + this.i18nService.t("groups"), + ]; + + const csvData = papa.unparse(exportData, { + columns: headers, + header: true, + }); + + const fileName = this.getFileName("org-members"); + + this.fileDownloadService.download({ + fileName: fileName, + blobData: csvData, + blobOptions: { type: "text/plain" }, + }); + + return { success: true }; + } catch (error) { + this.logService.error(`Failed to export members: ${error}`); + + const errorMessage = + error instanceof Error ? error.message : this.i18nService.t("unexpectedError"); + + return { success: false, error: { message: errorMessage } }; + } } - getFileName(prefix: string | null = null, extension = "csv"): string { + private getFileName(prefix: string | null = null, extension = "csv"): string { return ExportHelper.getFileName(prefix ?? "", extension); } } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.html new file mode 100644 index 00000000000..e0b29dffeb8 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.html @@ -0,0 +1,225 @@ + + + + + + +
+ + + {{ "all" | i18n }} + + {{ allCount }} + + + + {{ "invited" | i18n }} + + {{ invitedCount }} + + + + {{ "needsConfirmation" | i18n }} + + {{ acceptedCount }} + + + +
+ + + + {{ "loading" | i18n }} + + + +

{{ "noMembersInList" | i18n }}

+ + + {{ "providerUsersNeedConfirmed" | i18n }} + + + + + + + + + + {{ "name" | i18n }} + {{ "role" | i18n }} + + + + + + + + + + + + + + + + +
+ +
+
+ + + {{ "invited" | i18n }} + + + {{ "needsConfirmation" | i18n }} + + + {{ "revoked" | i18n }} + +
+
+ {{ user.email }} +
+
+
+ + + {{ "providerAdmin" | i18n }} + {{ "serviceUser" | i18n }} + + + + + + + + + + + +
+
+
+
+
diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts new file mode 100644 index 00000000000..004b0a8f7c9 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts @@ -0,0 +1,338 @@ +// 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 { ActivatedRoute, Router } from "@angular/router"; +import { combineLatest, firstValueFrom, lastValueFrom, switchMap } from "rxjs"; +import { first, map } from "rxjs/operators"; + +import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; +import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { ProviderUserStatusType, ProviderUserType } from "@bitwarden/common/admin-console/enums"; +import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request"; +import { ProviderUserConfirmRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-confirm.request"; +import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { assertNonNullish } from "@bitwarden/common/auth/utils"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.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 { ProviderId } from "@bitwarden/common/types/guid"; +import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; +import { BaseMembersComponent } from "@bitwarden/web-vault/app/admin-console/common/base-members.component"; +import { + CloudBulkReinviteLimit, + MaxCheckedCount, + peopleFilter, + PeopleTableDataSource, +} from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source"; +import { openEntityEventsDialog } from "@bitwarden/web-vault/app/admin-console/organizations/manage/entity-events.component"; +import { BulkStatusComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-status.component"; +import { MemberActionResult } from "@bitwarden/web-vault/app/admin-console/organizations/members/services/member-actions/member-actions.service"; + +import { + AddEditMemberDialogComponent, + AddEditMemberDialogParams, + AddEditMemberDialogResultType, +} from "./dialogs/add-edit-member-dialog.component"; +import { BulkConfirmDialogComponent } from "./dialogs/bulk-confirm-dialog.component"; +import { BulkRemoveDialogComponent } from "./dialogs/bulk-remove-dialog.component"; + +type ProviderUser = ProviderUserUserDetailsResponse; + +class MembersTableDataSource extends PeopleTableDataSource { + protected statusType = ProviderUserStatusType; +} + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + templateUrl: "deprecated_members.component.html", + standalone: false, +}) +export class MembersComponent extends BaseMembersComponent { + accessEvents = false; + dataSource: MembersTableDataSource; + loading = true; + providerId: string; + rowHeight = 70; + rowHeightClass = `tw-h-[70px]`; + status: ProviderUserStatusType = null; + + userStatusType = ProviderUserStatusType; + userType = ProviderUserType; + + constructor( + apiService: ApiService, + keyService: KeyService, + dialogService: DialogService, + i18nService: I18nService, + logService: LogService, + organizationManagementPreferencesService: OrganizationManagementPreferencesService, + toastService: ToastService, + userNamePipe: UserNamePipe, + validationService: ValidationService, + private encryptService: EncryptService, + private activatedRoute: ActivatedRoute, + private providerService: ProviderService, + private router: Router, + private accountService: AccountService, + private configService: ConfigService, + private environmentService: EnvironmentService, + ) { + super( + apiService, + i18nService, + keyService, + validationService, + logService, + userNamePipe, + dialogService, + organizationManagementPreferencesService, + toastService, + ); + + this.dataSource = new MembersTableDataSource(this.configService, this.environmentService); + + combineLatest([ + this.activatedRoute.parent.params, + this.activatedRoute.queryParams.pipe(first()), + ]) + .pipe( + switchMap(async ([urlParams, queryParams]) => { + this.searchControl.setValue(queryParams.search); + this.dataSource.filter = peopleFilter(queryParams.search, null); + + this.providerId = urlParams.providerId; + const provider = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.providerService.get$(this.providerId, userId)), + ), + ); + + if (!provider || !provider.canManageUsers) { + return await this.router.navigate(["../"], { relativeTo: this.activatedRoute }); + } + this.accessEvents = provider.useEvents; + await this.load(); + + if (queryParams.viewEvents != null) { + const user = this.dataSource.data.find((user) => user.id === queryParams.viewEvents); + if (user && user.status === ProviderUserStatusType.Confirmed) { + this.openEventsDialog(user); + } + } + }), + takeUntilDestroyed(), + ) + .subscribe(); + } + + async bulkConfirm(): Promise { + if (this.actionPromise != null) { + return; + } + + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, { + data: { + providerId: this.providerId, + users: users, + }, + }); + + await lastValueFrom(dialogRef.closed); + await this.load(); + } + + async bulkReinvite(): Promise { + if (this.actionPromise != null) { + return; + } + + let users: ProviderUser[]; + if (this.dataSource.isIncreasedBulkLimitEnabled()) { + users = this.dataSource.getCheckedUsersInVisibleOrder(); + } else { + users = this.dataSource.getCheckedUsers(); + } + + const allInvitedUsers = users.filter((user) => user.status === ProviderUserStatusType.Invited); + + // Capture the original count BEFORE enforcing the limit + const originalInvitedCount = allInvitedUsers.length; + + // When feature flag is enabled, limit invited users and uncheck the excess + let checkedInvitedUsers: ProviderUser[]; + if (this.dataSource.isIncreasedBulkLimitEnabled()) { + checkedInvitedUsers = this.dataSource.limitAndUncheckExcess( + allInvitedUsers, + CloudBulkReinviteLimit, + ); + } else { + checkedInvitedUsers = allInvitedUsers; + } + + if (checkedInvitedUsers.length <= 0) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("noSelectedUsersApplicable"), + }); + return; + } + + try { + // When feature flag is enabled, show toast instead of dialog + if (this.dataSource.isIncreasedBulkLimitEnabled()) { + await this.apiService.postManyProviderUserReinvite( + this.providerId, + new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)), + ); + + const selectedCount = originalInvitedCount; + const invitedCount = checkedInvitedUsers.length; + + if (selectedCount > CloudBulkReinviteLimit) { + const excludedCount = selectedCount - CloudBulkReinviteLimit; + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t( + "bulkReinviteLimitedSuccessToast", + CloudBulkReinviteLimit.toLocaleString(), + selectedCount.toLocaleString(), + excludedCount.toLocaleString(), + ), + }); + } else { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()), + }); + } + } else { + // Feature flag disabled - show legacy dialog + const request = this.apiService.postManyProviderUserReinvite( + this.providerId, + new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)), + ); + + const dialogRef = BulkStatusComponent.open(this.dialogService, { + data: { + users: users, + filteredUsers: checkedInvitedUsers, + request, + successfulMessage: this.i18nService.t("bulkReinviteMessage"), + }, + }); + await lastValueFrom(dialogRef.closed); + } + } catch (error) { + this.validationService.showError(error); + } + } + + async invite() { + await this.edit(null); + } + + async bulkRemove(): Promise { + if (this.actionPromise != null) { + return; + } + + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, { + data: { + providerId: this.providerId, + users: users, + }, + }); + + await lastValueFrom(dialogRef.closed); + await this.load(); + } + + async confirmUser(user: ProviderUser, publicKey: Uint8Array): Promise { + try { + const providerKey = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.keyService.providerKeys$(userId)), + map((providerKeys) => providerKeys?.[this.providerId as ProviderId] ?? null), + ), + ); + assertNonNullish(providerKey, "Provider key not found"); + + const key = await this.encryptService.encapsulateKeyUnsigned(providerKey, publicKey); + const request = new ProviderUserConfirmRequest(key.encryptedString); + await this.apiService.postProviderUserConfirm(this.providerId, user.id, request); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + removeUser = async (id: string): Promise => { + try { + await this.apiService.deleteProviderUser(this.providerId, id); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }; + + edit = async (user: ProviderUser | null): Promise => { + const data: AddEditMemberDialogParams = { + providerId: this.providerId, + user, + }; + + const dialogRef = AddEditMemberDialogComponent.open(this.dialogService, { + data, + }); + + const result = await lastValueFrom(dialogRef.closed); + + switch (result) { + case AddEditMemberDialogResultType.Saved: + case AddEditMemberDialogResultType.Deleted: + await this.load(); + break; + } + }; + + openEventsDialog = (user: ProviderUser): DialogRef => + openEntityEventsDialog(this.dialogService, { + data: { + name: this.userNamePipe.transform(user), + providerId: this.providerId, + entityId: user.id, + showUser: false, + entity: "user", + }, + }); + + getUsers = (): Promise> => + this.apiService.getProviderUsers(this.providerId); + + reinviteUser = async (id: string): Promise => { + try { + await this.apiService.postProviderUserReinvite(this.providerId, id); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }; +} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.ts index 635aaf16b3f..1579e0409d1 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.ts @@ -3,6 +3,7 @@ import { Component, Inject } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; import { ProviderUserInviteRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-invite.request"; @@ -15,14 +16,11 @@ import { DialogService, ToastService, } from "@bitwarden/components"; +import { ProviderUser } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source"; export type AddEditMemberDialogParams = { providerId: string; - user?: { - id: string; - name: string; - type: ProviderUserType; - }; + user?: ProviderUser; }; // FIXME: update to use a const object instead of a typescript enum @@ -59,6 +57,7 @@ export class AddEditMemberDialogComponent { private dialogService: DialogService, private i18nService: I18nService, private toastService: ToastService, + private userNamePipe: UserNamePipe, ) { this.editing = this.loading = this.dialogParams.user != null; if (this.editing) { @@ -78,8 +77,10 @@ export class AddEditMemberDialogComponent { return; } + const userName = this.userNamePipe.transform(this.dialogParams.user); + const confirmed = await this.dialogService.openSimpleDialog({ - title: this.dialogParams.user.name, + title: userName, content: { key: "removeUserConfirmation" }, type: "warning", }); @@ -96,7 +97,7 @@ export class AddEditMemberDialogComponent { this.toastService.showToast({ variant: "success", title: null, - message: this.i18nService.t("removedUserId", this.dialogParams.user.name), + message: this.i18nService.t("removedUserId", userName), }); this.dialogRef.close(AddEditMemberDialogResultType.Deleted); @@ -118,13 +119,12 @@ export class AddEditMemberDialogComponent { await this.apiService.postProviderUserInvite(this.dialogParams.providerId, request); } + const userName = this.editing ? this.userNamePipe.transform(this.dialogParams.user) : undefined; + this.toastService.showToast({ variant: "success", title: null, - message: this.i18nService.t( - this.editing ? "editedUserId" : "invitedUsers", - this.dialogParams.user?.name, - ), + message: this.i18nService.t(this.editing ? "editedUserId" : "invitedUsers", userName), }); this.dialogRef.close(AddEditMemberDialogResultType.Saved); diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts index 7ade77ed01b..84bd6988f0b 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts @@ -36,6 +36,7 @@ type BulkConfirmDialogParams = { @Component({ templateUrl: "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.html", + selector: "provider-bulk-comfirm-dialog", standalone: false, }) export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts index 29b50f71c1b..c044b9379c5 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts @@ -21,6 +21,7 @@ type BulkRemoveDialogParams = { @Component({ templateUrl: "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.html", + selector: "provider-bulk-remove-dialog", standalone: false, }) export class BulkRemoveDialogComponent extends BaseBulkRemoveComponent { diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html index e0b29dffeb8..143693ed1d7 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html @@ -1,7 +1,13 @@ +@let providerId = providerId$ | async; +@let bulkMenuOptions = bulkMenuOptions$ | async; +@let showConfirmBanner = showConfirmBanner$ | async; +@let dataSource = this.dataSource(); +@let isProcessing = this.isProcessing(); + - @@ -13,28 +19,28 @@ (selectedChange)="statusToggle.next($event)" [attr.aria-label]="'memberStatusFilter' | i18n" > - + {{ "all" | i18n }} - - {{ allCount }} - + @if (dataSource.activeUserCount; as allCount) { + {{ allCount }} + } {{ "invited" | i18n }} - - {{ invitedCount }} - + @if (dataSource.invitedUserCount; as invitedCount) { + {{ invitedCount }} + } {{ "needsConfirmation" | i18n }} - - {{ acceptedCount }} - + @if (dataSource.acceptedUserCount; as acceptedCount) { + {{ acceptedCount }} + } - +@if (!firstLoaded()) { {{ "loading" | i18n }} - - - -

{{ "noMembersInList" | i18n }}

- - - {{ "providerUsersNeedConfirmed" | i18n }} - +} @else { + @if (!dataSource.filteredData?.length) { +

{{ "noMembersInList" | i18n }}

+ } + @if (dataSource.filteredData?.length) { + @if (showConfirmBanner) { + + {{ "providerUsersNeedConfirmed" | i18n }} + + } @@ -82,27 +85,33 @@ label="{{ 'options' | i18n }}" > + @if (bulkMenuOptions.showBulkReinviteUsers) { + + } + @if (bulkMenuOptions.showBulkConfirmUsers) { + + } - - - - {{ "invited" | i18n }} - - - {{ "needsConfirmation" | i18n }} - - - {{ "revoked" | i18n }} - - -
- {{ user.email }} + @if (user.status === userStatusType.Invited) { + + {{ "invited" | i18n }} + + } + @if (user.status === userStatusType.Accepted) { + + {{ "needsConfirmation" | i18n }} + + } + @if (user.status === userStatusType.Revoked) { + + {{ "revoked" | i18n }} + + }
+ @if (user.name) { +
+ {{ user.email }} +
+ } - {{ "providerAdmin" | i18n }} - {{ "serviceUser" | i18n }} + @if (user.type === userType.ProviderAdmin) { + {{ "providerAdmin" | i18n }} + } + @if (user.type === userType.ServiceUser) { + {{ "serviceUser" | i18n }} + } + @if (user.status === userStatusType.Invited) { + + } + @if (user.status === userStatusType.Accepted) { + + } + @if (accessEvents && user.status === userStatusType.Confirmed) { + + } - - -