1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-29 06:33:40 +00:00
Files
browser/apps/web/src/app/admin-console/organizations/members/people.component.ts
Will Martin 3b1860b9ee [SM-771] bulk add SM dialog (#5669)
* add dialog; add service method; add menu button

* update service layer

* update service method; add i18n; add success and error logic

* remove comment

* remove SM Beta copy in member dialog

* refactor error logic to utilize bitAction

* update i18n key

* use i18n in menu option

* use i18n in footer

* rename component file

* rename enableAccess method; remove button; use userName pipe

* only show if SM flag is enabled

* [SM-830] fix: close checkboxes on dialog close
2023-06-29 12:42:27 -04:00

683 lines
23 KiB
TypeScript

import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import {
combineLatest,
concatMap,
firstValueFrom,
from,
lastValueFrom,
map,
Observable,
shareReplay,
Subject,
switchMap,
takeUntil,
} from "rxjs";
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import {
DialogServiceAbstraction,
SimpleDialogCloseType,
SimpleDialogOptions,
SimpleDialogType,
} from "@bitwarden/angular/services/dialog";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import { OrganizationUserConfirmRequest } from "@bitwarden/common/abstractions/organization-user/requests";
import {
OrganizationUserBulkResponse,
OrganizationUserUserDetailsResponse,
} from "@bitwarden/common/abstractions/organization-user/responses";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction as PolicyApiService } 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 { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
import { ProductType } from "@bitwarden/common/enums";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data";
import { Collection } from "@bitwarden/common/vault/models/domain/collection";
import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/response/collection.response";
import { flagEnabled } from "../../../../utils/flags";
import { openEntityEventsDialog } from "../../../admin-console/organizations/manage/entity-events.component";
import { BasePeopleComponent } from "../../../common/base.people.component";
import { GroupService } from "../core";
import { OrganizationUserView } from "../core/views/organization-user.view";
import { BulkConfirmComponent } from "./components/bulk/bulk-confirm.component";
import { BulkEnableSecretsManagerDialogComponent } from "./components/bulk/bulk-enable-sm-dialog.component";
import { BulkRemoveComponent } from "./components/bulk/bulk-remove.component";
import { BulkRestoreRevokeComponent } from "./components/bulk/bulk-restore-revoke.component";
import { BulkStatusComponent } from "./components/bulk/bulk-status.component";
import {
MemberDialogResult,
MemberDialogTab,
openUserAddEditDialog,
} from "./components/member-dialog";
import { ResetPasswordComponent } from "./components/reset-password.component";
@Component({
selector: "app-org-people",
templateUrl: "people.component.html",
})
export class PeopleComponent
extends BasePeopleComponent<OrganizationUserView>
implements OnInit, OnDestroy
{
@ViewChild("groupsTemplate", { read: ViewContainerRef, static: true })
groupsModalRef: ViewContainerRef;
@ViewChild("confirmTemplate", { read: ViewContainerRef, static: true })
confirmModalRef: ViewContainerRef;
@ViewChild("resetPasswordTemplate", { read: ViewContainerRef, static: true })
resetPasswordModalRef: ViewContainerRef;
@ViewChild("bulkStatusTemplate", { read: ViewContainerRef, static: true })
bulkStatusModalRef: ViewContainerRef;
@ViewChild("bulkConfirmTemplate", { read: ViewContainerRef, static: true })
bulkConfirmModalRef: ViewContainerRef;
@ViewChild("bulkRemoveTemplate", { read: ViewContainerRef, static: true })
bulkRemoveModalRef: ViewContainerRef;
userType = OrganizationUserType;
userStatusType = OrganizationUserStatusType;
memberTab = MemberDialogTab;
organization: Organization;
status: OrganizationUserStatusType = null;
orgResetPasswordPolicyEnabled = false;
protected canUseSecretsManager$: Observable<boolean>;
private destroy$ = new Subject<void>();
constructor(
apiService: ApiService,
private route: ActivatedRoute,
i18nService: I18nService,
modalService: ModalService,
platformUtilsService: PlatformUtilsService,
cryptoService: CryptoService,
searchService: SearchService,
validationService: ValidationService,
private policyService: PolicyService,
private policyApiService: PolicyApiService,
logService: LogService,
searchPipe: SearchPipe,
userNamePipe: UserNamePipe,
private syncService: SyncService,
stateService: StateService,
private organizationService: OrganizationService,
private organizationApiService: OrganizationApiServiceAbstraction,
private organizationUserService: OrganizationUserService,
dialogService: DialogServiceAbstraction,
private router: Router,
private groupService: GroupService,
private collectionService: CollectionService
) {
super(
apiService,
searchService,
i18nService,
platformUtilsService,
cryptoService,
validationService,
modalService,
logService,
searchPipe,
userNamePipe,
stateService,
dialogService
);
}
async ngOnInit() {
const organization$ = this.route.params.pipe(
map((params) => this.organizationService.get(params.organizationId)),
shareReplay({ refCount: true, bufferSize: 1 })
);
this.canUseSecretsManager$ = organization$.pipe(
map((org) => org.useSecretsManager && flagEnabled("secretsManager"))
);
const policies$ = organization$.pipe(
switchMap((organization) => {
if (organization.isProviderUser) {
return from(this.policyApiService.getPolicies(organization.id)).pipe(
map((response) => this.policyService.mapPoliciesFromToken(response))
);
}
return this.policyService.policies$;
})
);
combineLatest([this.route.queryParams, policies$, organization$])
.pipe(
concatMap(async ([qParams, policies, organization]) => {
this.organization = organization;
// Backfill pub/priv key if necessary
if (
this.organization.canManageUsersPassword &&
!this.organization.hasPublicAndPrivateKeys
) {
const orgShareKey = await this.cryptoService.getOrgKey(this.organization.id);
const orgKeys = await this.cryptoService.makeKeyPair(orgShareKey);
const request = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
const response = await this.organizationApiService.updateKeys(
this.organization.id,
request
);
if (response != null) {
this.organization.hasPublicAndPrivateKeys =
response.publicKey != null && response.privateKey != null;
await this.syncService.fullSync(true); // Replace organizations with new data
} else {
throw new Error(this.i18nService.t("resetPasswordOrgKeysError"));
}
}
const resetPasswordPolicy = policies
.filter((policy) => policy.type === PolicyType.ResetPassword)
.find((p) => p.organizationId === this.organization.id);
this.orgResetPasswordPolicyEnabled = resetPasswordPolicy?.enabled;
await this.load();
this.searchText = qParams.search;
if (qParams.viewEvents != null) {
const user = this.users.filter((u) => u.id === qParams.viewEvents);
if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) {
this.events(user[0]);
}
}
}),
takeUntil(this.destroy$)
)
.subscribe();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
async load() {
await super.load();
}
async getUsers(): Promise<OrganizationUserView[]> {
let groupsPromise: Promise<Map<string, string>>;
let collectionsPromise: Promise<Map<string, string>>;
// We don't need both groups and collections for the table, so only load one
const userPromise = this.organizationUserService.getAllUsers(this.organization.id, {
includeGroups: this.organization.useGroups,
includeCollections: !this.organization.useGroups,
});
// Depending on which column is displayed, we need to load the group/collection names
if (this.organization.useGroups) {
groupsPromise = this.getGroupNameMap();
} else {
collectionsPromise = this.getCollectionNameMap();
}
const [usersResponse, groupNamesMap, collectionNamesMap] = await Promise.all([
userPromise,
groupsPromise,
collectionsPromise,
]);
return usersResponse.data?.map<OrganizationUserView>((r) => {
const userView = OrganizationUserView.fromResponse(r);
userView.groupNames = userView.groups
.map((g) => groupNamesMap.get(g))
.sort(this.i18nService.collator?.compare);
userView.collectionNames = userView.collections
.map((c) => collectionNamesMap.get(c.id))
.sort(this.i18nService.collator?.compare);
return userView;
});
}
async getGroupNameMap(): Promise<Map<string, string>> {
const groups = await this.groupService.getAll(this.organization.id);
const groupNameMap = new Map<string, string>();
groups.forEach((g) => groupNameMap.set(g.id, g.name));
return groupNameMap;
}
/**
* Retrieve a map of all collection IDs <-> names for the organization.
*/
async getCollectionNameMap() {
const collectionMap = new Map<string, string>();
const response = await this.apiService.getCollections(this.organization.id);
const collections = response.data.map(
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse))
);
const decryptedCollections = await this.collectionService.decryptMany(collections);
decryptedCollections.forEach((c) => collectionMap.set(c.id, c.name));
return collectionMap;
}
deleteUser(id: string): Promise<void> {
return this.organizationUserService.deleteOrganizationUser(this.organization.id, id);
}
revokeUser(id: string): Promise<void> {
return this.organizationUserService.revokeOrganizationUser(this.organization.id, id);
}
restoreUser(id: string): Promise<void> {
return this.organizationUserService.restoreOrganizationUser(this.organization.id, id);
}
reinviteUser(id: string): Promise<void> {
return this.organizationUserService.postOrganizationUserReinvite(this.organization.id, id);
}
async confirmUser(user: OrganizationUserView, publicKey: Uint8Array): Promise<void> {
const orgKey = await this.cryptoService.getOrgKey(this.organization.id);
const key = await this.cryptoService.rsaEncrypt(orgKey.key, publicKey.buffer);
const request = new OrganizationUserConfirmRequest();
request.key = key.encryptedString;
await this.organizationUserService.postOrganizationUserConfirm(
this.organization.id,
user.id,
request
);
}
allowResetPassword(orgUser: OrganizationUserView): boolean {
// Hierarchy check
let callingUserHasPermission = false;
switch (this.organization.type) {
case OrganizationUserType.Owner:
callingUserHasPermission = true;
break;
case OrganizationUserType.Admin:
callingUserHasPermission = orgUser.type !== OrganizationUserType.Owner;
break;
case OrganizationUserType.Custom:
callingUserHasPermission =
orgUser.type !== OrganizationUserType.Owner &&
orgUser.type !== OrganizationUserType.Admin;
break;
}
// Final
return (
this.organization.canManageUsersPassword &&
callingUserHasPermission &&
this.organization.useResetPassword &&
this.organization.hasPublicAndPrivateKeys &&
orgUser.resetPasswordEnrolled &&
this.orgResetPasswordPolicyEnabled &&
orgUser.status === OrganizationUserStatusType.Confirmed
);
}
showEnrolledStatus(orgUser: OrganizationUserUserDetailsResponse): boolean {
return (
this.organization.useResetPassword &&
orgUser.resetPasswordEnrolled &&
this.orgResetPasswordPolicyEnabled
);
}
private async showFreeOrgUpgradeDialog(): Promise<void> {
const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = {
title: this.i18nService.t("upgradeOrganization"),
content: this.i18nService.t(
this.organization.canEditSubscription
? "freeOrgInvLimitReachedManageBilling"
: "freeOrgInvLimitReachedNoManageBilling",
this.organization.seats
),
type: SimpleDialogType.PRIMARY,
};
if (this.organization.canEditSubscription) {
orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("upgrade");
} else {
orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("ok");
orgUpgradeSimpleDialogOpts.cancelButtonText = null; // hide secondary btn
}
const simpleDialog = this.dialogService.openSimpleDialogRef(orgUpgradeSimpleDialogOpts);
firstValueFrom(simpleDialog.closed).then((result: SimpleDialogCloseType | undefined) => {
if (!result) {
return;
}
if (result == SimpleDialogCloseType.ACCEPT && this.organization.canEditSubscription) {
this.router.navigate(["/organizations", this.organization.id, "billing", "subscription"], {
queryParams: { upgrade: true },
});
}
});
}
async edit(user: OrganizationUserView, initialTab: MemberDialogTab = MemberDialogTab.Role) {
// Invite User: Add Flow
// Click on user email: Edit Flow
// User attempting to invite new users in a free org with max users
if (
!user &&
this.organization.planProductType === ProductType.Free &&
this.allUsers.length === this.organization.seats
) {
// Show org upgrade modal
await this.showFreeOrgUpgradeDialog();
return;
}
const dialog = openUserAddEditDialog(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
organizationId: this.organization.id,
organizationUserId: user != null ? user.id : null,
allOrganizationUserEmails: this.allUsers?.map((user) => user.email) ?? [],
usesKeyConnector: user?.usesKeyConnector,
initialTab: initialTab,
},
});
const result = await lastValueFrom(dialog.closed);
switch (result) {
case MemberDialogResult.Deleted:
this.removeUser(user);
break;
case MemberDialogResult.Saved:
case MemberDialogResult.Revoked:
case MemberDialogResult.Restored:
this.load();
break;
}
}
async bulkRemove() {
if (this.actionPromise != null) {
return;
}
const [modal] = await this.modalService.openViewRef(
BulkRemoveComponent,
this.bulkRemoveModalRef,
(comp) => {
comp.organizationId = this.organization.id;
comp.users = this.getCheckedUsers();
}
);
await modal.onClosedPromise();
await this.load();
}
async bulkRevoke() {
await this.bulkRevokeOrRestore(true);
}
async bulkRestore() {
await this.bulkRevokeOrRestore(false);
}
async bulkRevokeOrRestore(isRevoking: boolean) {
if (this.actionPromise != null) {
return;
}
const ref = this.modalService.open(BulkRestoreRevokeComponent, {
allowMultipleModals: true,
data: {
organizationId: this.organization.id,
users: this.getCheckedUsers(),
isRevoking: isRevoking,
},
});
await ref.onClosedPromise();
await this.load();
}
async bulkReinvite() {
if (this.actionPromise != null) {
return;
}
const users = this.getCheckedUsers();
const filteredUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited);
if (filteredUsers.length <= 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("noSelectedUsersApplicable")
);
return;
}
try {
const response = this.organizationUserService.postManyOrganizationUserReinvite(
this.organization.id,
filteredUsers.map((user) => user.id)
);
this.showBulkStatus(
users,
filteredUsers,
response,
this.i18nService.t("bulkReinviteMessage")
);
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
}
async bulkConfirm() {
if (this.actionPromise != null) {
return;
}
const [modal] = await this.modalService.openViewRef(
BulkConfirmComponent,
this.bulkConfirmModalRef,
(comp) => {
comp.organizationId = this.organization.id;
comp.users = this.getCheckedUsers();
}
);
await modal.onClosedPromise();
await this.load();
}
async bulkEnableSM() {
const users = this.getCheckedUsers();
if (users.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("noSelectedUsersApplicable")
);
return;
}
const dialogRef = BulkEnableSecretsManagerDialogComponent.open(this.dialogService, {
orgId: this.organization.id,
users,
});
await lastValueFrom(dialogRef.closed);
this.selectAll(false);
}
async events(user: OrganizationUserView) {
await openEntityEventsDialog(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
organizationId: this.organization.id,
entityId: user.id,
showUser: false,
entity: "user",
},
});
}
async resetPassword(user: OrganizationUserView) {
const [modal] = await this.modalService.openViewRef(
ResetPasswordComponent,
this.resetPasswordModalRef,
(comp) => {
comp.name = this.userNamePipe.transform(user);
comp.email = user != null ? user.email : null;
comp.organizationId = this.organization.id;
comp.id = user != null ? user.id : null;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
comp.onPasswordReset.subscribe(() => {
modal.close();
this.load();
});
}
);
}
protected async removeUserConfirmationDialog(user: OrganizationUserView) {
const content = user.usesKeyConnector
? "removeUserConfirmationKeyConnector"
: "removeOrgUserConfirmation";
const confirmed = await this.dialogService.openSimpleDialog({
title: {
key: "removeUserIdAccess",
placeholders: [this.userNamePipe.transform(user)],
},
content: { key: content },
type: SimpleDialogType.WARNING,
});
if (!confirmed) {
return false;
}
if (user.status > OrganizationUserStatusType.Invited && user.hasMasterPassword === false) {
return await this.noMasterPasswordConfirmationDialog(user);
}
return true;
}
protected async revokeUserConfirmationDialog(user: OrganizationUserView) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "revokeAccess", placeholders: [this.userNamePipe.transform(user)] },
content: this.revokeWarningMessage(),
acceptButtonText: { key: "revokeAccess" },
type: SimpleDialogType.WARNING,
});
if (!confirmed) {
return false;
}
if (user.status > OrganizationUserStatusType.Invited && user.hasMasterPassword === false) {
return await this.noMasterPasswordConfirmationDialog(user);
}
return true;
}
private async showBulkStatus(
users: OrganizationUserView[],
filteredUsers: OrganizationUserView[],
request: Promise<ListResponse<OrganizationUserBulkResponse>>,
successfullMessage: string
) {
const [modal, childComponent] = await this.modalService.openViewRef(
BulkStatusComponent,
this.bulkStatusModalRef,
(comp) => {
comp.loading = true;
}
);
// Workaround to handle closing the modal shortly after it has been opened
let close = false;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
modal.onShown.subscribe(() => {
if (close) {
modal.close();
}
});
try {
const response = await request;
if (modal) {
const keyedErrors: any = response.data
.filter((r) => r.error !== "")
.reduce((a, x) => ({ ...a, [x.id]: x.error }), {});
const keyedFilteredUsers: any = filteredUsers.reduce((a, x) => ({ ...a, [x.id]: x }), {});
childComponent.users = users.map((user) => {
let message = keyedErrors[user.id] ?? successfullMessage;
// eslint-disable-next-line
if (!keyedFilteredUsers.hasOwnProperty(user.id)) {
message = this.i18nService.t("bulkFilteredMessage");
}
return {
user: user,
error: keyedErrors.hasOwnProperty(user.id), // eslint-disable-line
message: message,
};
});
childComponent.loading = false;
}
} catch {
close = true;
modal.close();
}
}
private async noMasterPasswordConfirmationDialog(user: OrganizationUserView) {
return this.dialogService.openSimpleDialog({
title: {
key: "removeOrgUserNoMasterPasswordTitle",
},
content: {
key: "removeOrgUserNoMasterPasswordDesc",
placeholders: [this.userNamePipe.transform(user)],
},
type: SimpleDialogType.WARNING,
});
}
}