1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-23 11:43:46 +00:00

EC-263 - Deactivate/activate in user management (#2893)

* SM-48 - Disable/enable in user management

* SM-48 - Disabled badge added to edit user

* SM-48 - Fix linter issues

* SM-48 - Color adjustments to badging

* SM-48 - Fix prettier formatting

* EC-263 - Rename disable to deactivate

* EC-263 - lint errors and cleanup

* EC-263 - Fix build and importer errors

* EC-263 - import grouping order fix

* EC-263 - PR review feedback and cleanup

* EC-263 - Fix build error in loose components

* EC-263 - Fix build error on formPromise in user edit

* EC-263 - Fix a11y bindings and modal handling
This commit is contained in:
Chad Scharf
2022-06-20 10:21:50 -04:00
committed by GitHub
parent 98152fee54
commit b28c07790d
14 changed files with 601 additions and 7 deletions

View File

@@ -34,7 +34,7 @@ export abstract class BasePeopleComponent<
confirmModalRef: ViewContainerRef;
get allCount() {
return this.allUsers != null ? this.allUsers.length : 0;
return this.activeUsers != null ? this.activeUsers.length : 0;
}
get invitedCount() {
@@ -55,11 +55,17 @@ export abstract class BasePeopleComponent<
: 0;
}
get deactivatedCount() {
return this.statusMap.has(this.userStatusType.Deactivated)
? this.statusMap.get(this.userStatusType.Deactivated).length
: 0;
}
get showConfirmUsers(): boolean {
return (
this.allUsers != null &&
this.activeUsers != null &&
this.statusMap != null &&
this.allUsers.length > 1 &&
this.activeUsers.length > 1 &&
this.confirmedCount > 0 &&
this.confirmedCount < 3 &&
this.acceptedCount > 0
@@ -82,6 +88,7 @@ export abstract class BasePeopleComponent<
actionPromise: Promise<any>;
protected allUsers: UserType[] = [];
protected activeUsers: UserType[] = [];
protected didScroll = false;
protected pageSize = 100;
@@ -105,12 +112,15 @@ export abstract class BasePeopleComponent<
abstract edit(user: UserType): void;
abstract getUsers(): Promise<ListResponse<UserType>>;
abstract deleteUser(id: string): Promise<any>;
abstract deactivateUser(id: string): Promise<any>;
abstract activateUser(id: string): Promise<any>;
abstract reinviteUser(id: string): Promise<any>;
abstract confirmUser(user: UserType, publicKey: Uint8Array): Promise<any>;
async load() {
const response = await this.getUsers();
this.statusMap.clear();
this.activeUsers = [];
for (const status of Utils.iterateEnum(this.userStatusType)) {
this.statusMap.set(status, []);
}
@@ -123,6 +133,9 @@ export abstract class BasePeopleComponent<
} else {
this.statusMap.get(u.status).push(u);
}
if (u.status !== this.userStatusType.Deactivated) {
this.activeUsers.push(u);
}
});
this.filter(this.status);
this.loading = false;
@@ -133,7 +146,7 @@ export abstract class BasePeopleComponent<
if (this.status != null) {
this.users = this.statusMap.get(this.status);
} else {
this.users = this.allUsers;
this.users = this.activeUsers;
}
// Reset checkbox selecton
this.selectAll(false);
@@ -219,6 +232,62 @@ export abstract class BasePeopleComponent<
this.actionPromise = null;
}
async deactivate(user: UserType) {
const confirmed = await this.platformUtilsService.showDialog(
this.deactivateWarningMessage(),
this.i18nService.t("deactivateUserId", this.userNamePipe.transform(user)),
this.i18nService.t("deactivate"),
this.i18nService.t("cancel"),
"warning"
);
if (!confirmed) {
return false;
}
this.actionPromise = this.deactivateUser(user.id);
try {
await this.actionPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("deactivatedUserId", this.userNamePipe.transform(user))
);
await this.load();
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
}
async activate(user: UserType) {
const confirmed = await this.platformUtilsService.showDialog(
this.activateWarningMessage(),
this.i18nService.t("activateUserId", this.userNamePipe.transform(user)),
this.i18nService.t("activate"),
this.i18nService.t("cancel"),
"warning"
);
if (!confirmed) {
return false;
}
this.actionPromise = this.activateUser(user.id);
try {
await this.actionPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("activatedUserId", this.userNamePipe.transform(user))
);
await this.load();
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
}
async reinvite(user: UserType) {
if (this.actionPromise != null) {
return;
@@ -325,6 +394,14 @@ export abstract class BasePeopleComponent<
return this.i18nService.t("removeUserConfirmation");
}
protected deactivateWarningMessage(): string {
return this.i18nService.t("deactivateUserConfirmation");
}
protected activateWarningMessage(): string {
return this.i18nService.t("activateUserConfirmation");
}
protected getCheckedUsers() {
return this.users.filter((u) => (u as any).checked);
}

View File

@@ -30,6 +30,7 @@ import { NavbarComponent } from "../layouts/navbar.component";
import { UserLayoutComponent } from "../layouts/user-layout.component";
import { OrganizationLayoutComponent } from "../organizations/layouts/organization-layout.component";
import { BulkConfirmComponent as OrgBulkConfirmComponent } from "../organizations/manage/bulk/bulk-confirm.component";
import { BulkDeactivateComponent as OrgBulkDeactivateomponent } from "../organizations/manage/bulk/bulk-deactivate.component";
import { BulkRemoveComponent as OrgBulkRemoveComponent } from "../organizations/manage/bulk/bulk-remove.component";
import { BulkStatusComponent as OrgBulkStatusComponent } from "../organizations/manage/bulk/bulk-status.component";
import { CollectionAddEditComponent as OrgCollectionAddEditComponent } from "../organizations/manage/collection-add-edit.component";
@@ -236,6 +237,7 @@ import { OrganizationBadgeModule } from "./vault/modules/organization-badge/orga
OrganizationSubscriptionComponent,
OrgAttachmentsComponent,
OrgBulkConfirmComponent,
OrgBulkDeactivateomponent,
OrgBulkRemoveComponent,
OrgBulkStatusComponent,
OrgCiphersComponent,
@@ -395,6 +397,7 @@ import { OrganizationBadgeModule } from "./vault/modules/organization-badge/orga
OrganizationSubscriptionComponent,
OrgAttachmentsComponent,
OrgBulkConfirmComponent,
OrgBulkDeactivateomponent,
OrgBulkRemoveComponent,
OrgBulkStatusComponent,
OrgCiphersComponent,

View File

@@ -0,0 +1,102 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="bulkTitle">
<div class="modal-dialog modal-dialog-scrollable modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title" id="bulkTitle">
{{ bulkTitle }}
</h2>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<app-callout type="danger" *ngIf="users.length <= 0">
{{ "noSelectedUsersApplicable" | i18n }}
</app-callout>
<app-callout type="error" *ngIf="error">
{{ error }}
</app-callout>
<ng-container *ngIf="!done">
<app-callout type="warning" *ngIf="users.length > 0 && !error">
{{ usersWarning }}
</app-callout>
<table class="table table-hover table-list">
<thead>
<tr>
<th colspan="2">{{ "user" | i18n }}</th>
</tr>
</thead>
<tr *ngFor="let user of users">
<td width="30">
<app-avatar
[data]="user | userName"
[email]="user.email"
size="25"
[circle]="true"
[fontSize]="14"
>
</app-avatar>
</td>
<td>
{{ user.email }}
<small class="text-muted d-block" *ngIf="user.name">{{ user.name }}</small>
</td>
</tr>
</table>
</ng-container>
<ng-container *ngIf="done">
<table class="table table-hover table-list">
<thead>
<tr>
<th colspan="2">{{ "user" | i18n }}</th>
<th>{{ "status" | i18n }}</th>
</tr>
</thead>
<tr *ngFor="let user of users">
<td width="30">
<app-avatar
[data]="user | userName"
[email]="user.email"
size="25"
[circle]="true"
[fontSize]="14"
>
</app-avatar>
</td>
<td>
{{ user.email }}
<small class="text-muted d-block" *ngIf="user.name">{{ user.name }}</small>
</td>
<td *ngIf="statuses.has(user.id)">
{{ statuses.get(user.id) }}
</td>
<td *ngIf="!statuses.has(user.id)">
{{ "bulkFilteredMessage" | i18n }}
</td>
</tr>
</table>
</ng-container>
</div>
<div class="modal-footer">
<button
type="submit"
class="btn btn-primary btn-submit"
*ngIf="!done && users.length > 0"
[disabled]="loading"
(click)="submit()"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ bulkTitle }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "close" | i18n }}
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,74 @@
import { Component } from "@angular/core";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalConfig } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationUserBulkRequest } from "@bitwarden/common/models/request/organizationUserBulkRequest";
import { BulkUserDetails } from "./bulk-status.component";
@Component({
selector: "app-bulk-deactivate",
templateUrl: "bulk-deactivate.component.html",
})
export class BulkDeactivateComponent {
isDeactivating: boolean;
organizationId: string;
users: BulkUserDetails[];
statuses: Map<string, string> = new Map();
loading = false;
done = false;
error: string;
constructor(
protected apiService: ApiService,
protected i18nService: I18nService,
private modalRef: ModalRef,
config: ModalConfig
) {
this.isDeactivating = config.data.isDeactivating;
this.organizationId = config.data.organizationId;
this.users = config.data.users;
}
get bulkTitle() {
const titleKey = this.isDeactivating ? "deactivateUsers" : "activateUsers";
return this.i18nService.t(titleKey);
}
get usersWarning() {
const warningKey = this.isDeactivating ? "deactivateUsersWarning" : "activateUsersWarning";
return this.i18nService.t(warningKey);
}
async submit() {
this.loading = true;
try {
const response = await this.performBulkUserAction();
const bulkMessage = this.isDeactivating ? "bulkDeactivatedMessage" : "bulkActivatedMessage";
response.data.forEach((entry) => {
const error = entry.error !== "" ? entry.error : this.i18nService.t(bulkMessage);
this.statuses.set(entry.id, error);
});
this.done = true;
} catch (e) {
this.error = e.message;
}
this.loading = false;
this.modalRef.close();
}
protected async performBulkUserAction() {
const request = new OrganizationUserBulkRequest(this.users.map((user) => user.id));
if (this.isDeactivating) {
return await this.apiService.deactivateManyOrganizationUsers(this.organizationId, request);
} else {
return await this.apiService.activateManyOrganizationUsers(this.organizationId, request);
}
}
}

View File

@@ -1,6 +1,6 @@
<div class="page-header d-flex">
<div class="page-header">
<h1>{{ "people" | i18n }}</h1>
<div class="ml-auto d-flex">
<div class="mt-2 d-flex">
<div class="btn-group btn-group-sm" role="group">
<button
type="button"
@@ -31,6 +31,17 @@
acceptedCount
}}</span>
</button>
<button
type="button"
class="btn btn-outline-secondary"
[ngClass]="{ active: status == userStatusType.Deactivated }"
(click)="filter(userStatusType.Deactivated)"
>
{{ "deactivated" | i18n }}
<span class="badge badge-pill badge-info" *ngIf="deactivatedCount">{{
deactivatedCount
}}</span>
</button>
</div>
<div class="ml-3">
<label class="sr-only" for="search">{{ "search" | i18n }}</label>
@@ -68,6 +79,14 @@
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirmSelected" | i18n }}
</button>
<button class="dropdown-item" appStopClick (click)="bulkActivate()">
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
{{ "activate" | i18n }}
</button>
<button class="dropdown-item" appStopClick (click)="bulkDeactivate()">
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "deactivate" | i18n }}
</button>
<button class="dropdown-item text-danger" appStopClick (click)="bulkRemove()">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "remove" | i18n }}
@@ -143,6 +162,9 @@
<span class="badge badge-warning" *ngIf="u.status === userStatusType.Accepted">{{
"accepted" | i18n
}}</span>
<span class="badge badge-secondary" *ngIf="u.status === userStatusType.Deactivated">{{
"deactivated" | i18n
}}</span>
<small class="text-muted d-block" *ngIf="u.name">{{ u.name }}</small>
</td>
<td>
@@ -233,6 +255,26 @@
<i class="bwi bwi-fw bwi-key" aria-hidden="true"></i>
{{ "resetPassword" | i18n }}
</a>
<a
class="dropdown-item"
href="#"
appStopClick
(click)="activate(u)"
*ngIf="u.status === userStatusType.Deactivated"
>
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
{{ "activate" | i18n }}
</a>
<a
class="dropdown-item"
href="#"
appStopClick
(click)="deactivate(u)"
*ngIf="u.status !== userStatusType.Deactivated"
>
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "deactivate" | i18n }}
</a>
<a class="dropdown-item text-danger" href="#" appStopClick (click)="remove(u)">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "remove" | i18n }}

View File

@@ -29,6 +29,7 @@ import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/models/re
import { BasePeopleComponent } from "../../common/base.people.component";
import { BulkConfirmComponent } from "./bulk/bulk-confirm.component";
import { BulkDeactivateComponent } from "./bulk/bulk-deactivate.component";
import { BulkRemoveComponent } from "./bulk/bulk-remove.component";
import { BulkStatusComponent } from "./bulk/bulk-status.component";
import { EntityEventsComponent } from "./entity-events.component";
@@ -166,6 +167,14 @@ export class PeopleComponent
return this.apiService.deleteOrganizationUser(this.organizationId, id);
}
deactivateUser(id: string): Promise<any> {
return this.apiService.deactivateOrganizationUser(this.organizationId, id);
}
activateUser(id: string): Promise<any> {
return this.apiService.activateOrganizationUser(this.organizationId, id);
}
reinviteUser(id: string): Promise<any> {
return this.apiService.postOrganizationUserReinvite(this.organizationId, id);
}
@@ -236,6 +245,14 @@ export class PeopleComponent
modal.close();
this.removeUser(user);
});
comp.onDeactivatedUser.subscribe(() => {
modal.close();
this.load();
});
comp.onActivatedUser.subscribe(() => {
modal.close();
this.load();
});
}
);
}
@@ -273,6 +290,32 @@ export class PeopleComponent
await this.load();
}
async bulkDeactivate() {
await this.bulkActivateOrDeactivate(true);
}
async bulkActivate() {
await this.bulkActivateOrDeactivate(false);
}
async bulkActivateOrDeactivate(isDeactivating: boolean) {
if (this.actionPromise != null) {
return;
}
const ref = this.modalService.open(BulkDeactivateComponent, {
allowMultipleModals: true,
data: {
organizationId: this.organizationId,
users: this.getCheckedUsers(),
isDeactivating: isDeactivating,
},
});
await ref.onClosedPromise();
await this.load();
}
async bulkReinvite() {
if (this.actionPromise != null) {
return;

View File

@@ -11,6 +11,7 @@
<h2 class="modal-title" id="userAddEditTitle">
{{ title }}
<small class="text-muted" *ngIf="name">{{ name }}</small>
<span class="badge badge-dark" *ngIf="isDeactivated">{{ "deactivated" | i18n }}</span>
</h2>
<button
type="button"
@@ -378,6 +379,46 @@
{{ "cancel" | i18n }}
</button>
<div class="ml-auto">
<button
type="button"
(click)="activate()"
class="btn btn-outline-secondary"
appA11yTitle="{{ 'activate' | i18n }}"
*ngIf="editMode && isDeactivated"
[disabled]="form.loading"
>
<i
class="bwi bwi-plus-circle bwi-lg bwi-fw"
[hidden]="form.loading"
aria-hidden="true"
></i>
<i
class="bwi bwi-spinner bwi-spin bwi-lg bwi-fw"
[hidden]="!form.loading"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
</button>
<button
type="button"
(click)="deactivate()"
class="btn btn-outline-secondary"
appA11yTitle="{{ 'deactivate' | i18n }}"
*ngIf="editMode && !isDeactivated"
[disabled]="form.loading"
>
<i
class="bwi bwi-minus-circle bwi-lg bwi-fw"
[hidden]="form.loading"
aria-hidden="true"
></i>
<i
class="bwi bwi-spinner bwi-spin bwi-lg bwi-fw"
[hidden]="!form.loading"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
</button>
<button
#deleteBtn
type="button"

View File

@@ -5,6 +5,7 @@ import { CollectionService } from "@bitwarden/common/abstractions/collection.ser
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType";
import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType";
import { PermissionsApi } from "@bitwarden/common/models/api/permissionsApi";
import { CollectionData } from "@bitwarden/common/models/data/collectionData";
@@ -26,9 +27,12 @@ export class UserAddEditComponent implements OnInit {
@Input() usesKeyConnector = false;
@Output() onSavedUser = new EventEmitter();
@Output() onDeletedUser = new EventEmitter();
@Output() onDeactivatedUser = new EventEmitter();
@Output() onActivatedUser = new EventEmitter();
loading = true;
editMode = false;
isDeactivated = false;
title: string;
emails: string;
type: OrganizationUserType = OrganizationUserType.User;
@@ -97,6 +101,7 @@ export class UserAddEditComponent implements OnInit {
);
this.access = user.accessAll ? "all" : "selected";
this.type = user.type;
this.isDeactivated = user.status === OrganizationUserStatusType.Deactivated;
if (user.type === OrganizationUserType.Custom) {
this.permissions = user.permissions;
}
@@ -239,4 +244,72 @@ export class UserAddEditComponent implements OnInit {
this.logService.error(e);
}
}
async deactivate() {
if (!this.editMode) {
return;
}
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t("deactivateUserConfirmation"),
this.i18nService.t("deactivateUserId", this.name),
this.i18nService.t("deactivate"),
this.i18nService.t("cancel"),
"warning"
);
if (!confirmed) {
return false;
}
try {
this.formPromise = this.apiService.deactivateOrganizationUser(
this.organizationId,
this.organizationUserId
);
await this.formPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("deactivatedUserId", this.name)
);
this.isDeactivated = true;
this.onDeactivatedUser.emit();
} catch (e) {
this.logService.error(e);
}
}
async activate() {
if (!this.editMode) {
return;
}
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t("activateUserConfirmation"),
this.i18nService.t("activateUserId", this.name),
this.i18nService.t("activate"),
this.i18nService.t("cancel"),
"warning"
);
if (!confirmed) {
return false;
}
try {
this.formPromise = this.apiService.activateOrganizationUser(
this.organizationId,
this.organizationUserId
);
await this.formPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("activatedUserId", this.name)
);
this.isDeactivated = false;
this.onActivatedUser.emit();
} catch (e) {
this.logService.error(e);
}
}
}