1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-22 19:23:52 +00:00

refactor: move organizations folder to admin-console, refs AC-1202 (#5118)

This commit is contained in:
Vincent Salucci
2023-03-30 14:23:01 -05:00
committed by GitHub
parent 1129b48b2a
commit a462e93a64
52 changed files with 47 additions and 43 deletions

View File

@@ -0,0 +1,4 @@
import { NgModule } from "@angular/core";
@NgModule({})
export class CoreOrganizationModule {}

View File

@@ -0,0 +1,3 @@
export * from "./core-organization.module";
export * from "./services";
export * from "./views";

View File

@@ -0,0 +1,126 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { CollectionRequest } from "@bitwarden/common/admin-console/models/request/collection.request";
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
import {
CollectionAccessDetailsResponse,
CollectionResponse,
} from "@bitwarden/common/admin-console/models/response/collection.response";
import { EncString } from "@bitwarden/common/models/domain/enc-string";
import { CoreOrganizationModule } from "../core-organization.module";
import { CollectionAdminView } from "../views/collection-admin.view";
@Injectable({ providedIn: CoreOrganizationModule })
export class CollectionAdminService {
constructor(private apiService: ApiService, private cryptoService: CryptoService) {}
async getAll(organizationId: string): Promise<CollectionAdminView[]> {
const collectionResponse = await this.apiService.getManyCollectionsWithAccessDetails(
organizationId
);
if (collectionResponse?.data == null || collectionResponse.data.length === 0) {
return [];
}
return await this.decryptMany(organizationId, collectionResponse.data);
}
async get(
organizationId: string,
collectionId: string
): Promise<CollectionAdminView | undefined> {
const collectionResponse = await this.apiService.getCollectionAccessDetails(
organizationId,
collectionId
);
if (collectionResponse == null) {
return undefined;
}
const [view] = await this.decryptMany(organizationId, [collectionResponse]);
return view;
}
async save(collection: CollectionAdminView): Promise<unknown> {
const request = await this.encrypt(collection);
let response: CollectionResponse;
if (collection.id == null) {
response = await this.apiService.postCollection(collection.organizationId, request);
collection.id = response.id;
} else {
response = await this.apiService.putCollection(
collection.organizationId,
collection.id,
request
);
}
// TODO: Implement upsert when in PS-1083: Collection Service refactors
// await this.collectionService.upsert(data);
return;
}
async delete(organizationId: string, collectionId: string): Promise<void> {
await this.apiService.deleteCollection(organizationId, collectionId);
}
private async decryptMany(
organizationId: string,
collections: CollectionResponse[] | CollectionAccessDetailsResponse[]
): Promise<CollectionAdminView[]> {
const orgKey = await this.cryptoService.getOrgKey(organizationId);
const promises = collections.map(async (c) => {
const view = new CollectionAdminView();
view.id = c.id;
view.name = await this.cryptoService.decryptToUtf8(new EncString(c.name), orgKey);
view.externalId = c.externalId;
view.organizationId = c.organizationId;
if (isCollectionAccessDetailsResponse(c)) {
view.groups = c.groups;
view.users = c.users;
view.assigned = c.assigned;
}
return view;
});
return await Promise.all(promises);
}
private async encrypt(model: CollectionAdminView): Promise<CollectionRequest> {
if (model.organizationId == null) {
throw new Error("Collection has no organization id.");
}
const key = await this.cryptoService.getOrgKey(model.organizationId);
if (key == null) {
throw new Error("No key for this collection's organization.");
}
const collection = new CollectionRequest();
collection.externalId = model.externalId;
collection.name = (await this.cryptoService.encrypt(model.name, key)).encryptedString;
collection.groups = model.groups.map(
(group) => new SelectionReadOnlyRequest(group.id, group.readOnly, group.hidePasswords)
);
collection.users = model.users.map(
(user) => new SelectionReadOnlyRequest(user.id, user.readOnly, user.hidePasswords)
);
return collection;
}
}
function isCollectionAccessDetailsResponse(
response: CollectionResponse | CollectionAccessDetailsResponse
): response is CollectionAccessDetailsResponse {
const anyResponse = response as any;
return anyResponse?.groups instanceof Array && anyResponse?.users instanceof Array;
}

View File

@@ -0,0 +1,105 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { CoreOrganizationModule } from "../../core-organization.module";
import { GroupView } from "../../views/group.view";
import { GroupRequest } from "./requests/group.request";
import { OrganizationGroupBulkRequest } from "./requests/organization-group-bulk.request";
import { GroupDetailsResponse, GroupResponse } from "./responses/group.response";
@Injectable({ providedIn: CoreOrganizationModule })
export class GroupService {
constructor(private apiService: ApiService) {}
async delete(orgId: string, groupId: string): Promise<void> {
await this.apiService.send(
"DELETE",
"/organizations/" + orgId + "/groups/" + groupId,
null,
true,
false
);
}
async deleteMany(orgId: string, groupIds: string[]): Promise<void> {
await this.apiService.send(
"DELETE",
"/organizations/" + orgId + "/groups",
new OrganizationGroupBulkRequest(groupIds),
true,
true
);
}
async get(orgId: string, groupId: string): Promise<GroupView> {
const r = await this.apiService.send(
"GET",
"/organizations/" + orgId + "/groups/" + groupId + "/details",
null,
true,
true
);
return GroupView.fromResponse(new GroupDetailsResponse(r));
}
async getAll(orgId: string): Promise<GroupView[]> {
const r = await this.apiService.send(
"GET",
"/organizations/" + orgId + "/groups",
null,
true,
true
);
const listResponse = new ListResponse(r, GroupDetailsResponse);
return listResponse.data?.map((gr) => GroupView.fromResponse(gr)) ?? [];
}
async save(group: GroupView): Promise<GroupView> {
const request = new GroupRequest();
request.name = group.name;
request.accessAll = group.accessAll;
request.users = group.members;
request.collections = group.collections.map(
(c) => new SelectionReadOnlyRequest(c.id, c.readOnly, c.hidePasswords)
);
if (group.id == undefined) {
return await this.postGroup(group.organizationId, request);
} else {
return await this.putGroup(group.organizationId, group.id, request);
}
}
private async postGroup(organizationId: string, request: GroupRequest): Promise<GroupView> {
const r = await this.apiService.send(
"POST",
"/organizations/" + organizationId + "/groups",
request,
true,
true
);
return GroupView.fromResponse(new GroupResponse(r));
}
private async putGroup(
organizationId: string,
id: string,
request: GroupRequest
): Promise<GroupView> {
const r = await this.apiService.send(
"PUT",
"/organizations/" + organizationId + "/groups/" + id,
request,
true,
true
);
return GroupView.fromResponse(new GroupResponse(r));
}
}

View File

@@ -0,0 +1,8 @@
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
export class GroupRequest {
name: string;
accessAll: boolean;
collections: SelectionReadOnlyRequest[] = [];
users: string[] = [];
}

View File

@@ -0,0 +1,7 @@
export class OrganizationGroupBulkRequest {
ids: string[];
constructor(ids: string[]) {
this.ids = ids == null ? [] : ids;
}
}

View File

@@ -0,0 +1,31 @@
import { SelectionReadOnlyResponse } from "@bitwarden/common/admin-console/models/response/selection-read-only.response";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class GroupResponse extends BaseResponse {
id: string;
organizationId: string;
name: string;
accessAll: boolean;
externalId: string;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("Id");
this.organizationId = this.getResponseProperty("OrganizationId");
this.name = this.getResponseProperty("Name");
this.accessAll = this.getResponseProperty("AccessAll");
this.externalId = this.getResponseProperty("ExternalId");
}
}
export class GroupDetailsResponse extends GroupResponse {
collections: SelectionReadOnlyResponse[] = [];
constructor(response: any) {
super(response);
const collections = this.getResponseProperty("Collections");
if (collections != null) {
this.collections = collections.map((c: any) => new SelectionReadOnlyResponse(c));
}
}
}

View File

@@ -0,0 +1,3 @@
export * from "./group/group.service";
export * from "./collection-admin.service";
export * from "./user-admin.service";

View File

@@ -0,0 +1,92 @@
import { Injectable } from "@angular/core";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import {
OrganizationUserInviteRequest,
OrganizationUserUpdateRequest,
} from "@bitwarden/common/abstractions/organization-user/requests";
import { OrganizationUserDetailsResponse } from "@bitwarden/common/abstractions/organization-user/responses";
import { CoreOrganizationModule } from "../core-organization.module";
import { OrganizationUserAdminView } from "../views/organization-user-admin-view";
@Injectable({ providedIn: CoreOrganizationModule })
export class UserAdminService {
constructor(private organizationUserService: OrganizationUserService) {}
async get(
organizationId: string,
organizationUserId: string
): Promise<OrganizationUserAdminView | undefined> {
const userResponse = await this.organizationUserService.getOrganizationUser(
organizationId,
organizationUserId,
{
includeGroups: true,
}
);
if (userResponse == null) {
return undefined;
}
const [view] = await this.decryptMany(organizationId, [userResponse]);
return view;
}
async save(user: OrganizationUserAdminView): Promise<void> {
const request = new OrganizationUserUpdateRequest();
request.accessAll = user.accessAll;
request.permissions = user.permissions;
request.type = user.type;
request.collections = user.collections;
request.groups = user.groups;
request.accessSecretsManager = user.accessSecretsManager;
await this.organizationUserService.putOrganizationUser(user.organizationId, user.id, request);
}
async invite(emails: string[], user: OrganizationUserAdminView): Promise<void> {
const request = new OrganizationUserInviteRequest();
request.emails = emails;
request.accessAll = user.accessAll;
request.permissions = user.permissions;
request.type = user.type;
request.collections = user.collections;
request.groups = user.groups;
request.accessSecretsManager = user.accessSecretsManager;
await this.organizationUserService.postOrganizationUserInvite(user.organizationId, request);
}
private async decryptMany(
organizationId: string,
users: OrganizationUserDetailsResponse[]
): Promise<OrganizationUserAdminView[]> {
const promises = users.map(async (u) => {
const view = new OrganizationUserAdminView();
view.id = u.id;
view.organizationId = organizationId;
view.userId = u.userId;
view.type = u.type;
view.status = u.status;
view.externalId = u.externalId;
view.accessAll = u.accessAll;
view.permissions = u.permissions;
view.resetPasswordEnrolled = u.resetPasswordEnrolled;
view.collections = u.collections.map((c) => ({
id: c.id,
hidePasswords: c.hidePasswords,
readOnly: c.readOnly,
}));
view.groups = u.groups;
view.accessSecretsManager = u.accessSecretsManager;
return view;
});
return await Promise.all(promises);
}
}

View File

@@ -0,0 +1,25 @@
import { View } from "@bitwarden/common/models/view/view";
interface SelectionResponseLike {
id: string;
readOnly: boolean;
hidePasswords: boolean;
}
export class CollectionAccessSelectionView extends View {
readonly id: string;
readonly readOnly: boolean;
readonly hidePasswords: boolean;
constructor(response?: SelectionResponseLike) {
super();
if (!response) {
return;
}
this.id = response.id;
this.readOnly = response.readOnly;
this.hidePasswords = response.hidePasswords;
}
}

View File

@@ -0,0 +1,32 @@
import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view";
import { CollectionAccessDetailsResponse } from "@bitwarden/common/src/admin-console/models/response/collection.response";
import { CollectionAccessSelectionView } from "./collection-access-selection.view";
export class CollectionAdminView extends CollectionView {
groups: CollectionAccessSelectionView[] = [];
users: CollectionAccessSelectionView[] = [];
/**
* Flag indicating the user has been explicitly assigned to this Collection
*/
assigned: boolean;
constructor(response?: CollectionAccessDetailsResponse) {
super(response);
if (!response) {
return;
}
this.groups = response.groups
? response.groups.map((g) => new CollectionAccessSelectionView(g))
: [];
this.users = response.users
? response.users.map((g) => new CollectionAccessSelectionView(g))
: [];
this.assigned = response.assigned;
}
}

View File

@@ -0,0 +1,25 @@
import { View } from "@bitwarden/common/src/models/view/view";
import { GroupDetailsResponse, GroupResponse } from "../services/group/responses/group.response";
import { CollectionAccessSelectionView } from "./collection-access-selection.view";
export class GroupView implements View {
id: string;
organizationId: string;
name: string;
accessAll: boolean;
externalId: string;
collections: CollectionAccessSelectionView[] = [];
members: string[] = [];
static fromResponse(response: GroupResponse): GroupView {
const view: GroupView = Object.assign(new GroupView(), response) as GroupView;
if (response instanceof GroupDetailsResponse && response.collections != undefined) {
view.collections = response.collections.map((c) => new CollectionAccessSelectionView(c));
}
return view;
}
}

View File

@@ -0,0 +1,5 @@
export * from "./collection-access-selection.view";
export * from "./collection-admin.view";
export * from "./group.view";
export * from "./organization-user.view";
export * from "./organization-user-admin-view";

View File

@@ -0,0 +1,22 @@
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums/organization-user-status-type";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums/organization-user-type";
import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api";
import { CollectionAccessSelectionView } from "./collection-access-selection.view";
export class OrganizationUserAdminView {
id: string;
userId: string;
organizationId: string;
type: OrganizationUserType;
status: OrganizationUserStatusType;
externalId: string;
accessAll: boolean;
permissions: PermissionsApi;
resetPasswordEnrolled: boolean;
collections: CollectionAccessSelectionView[] = [];
groups: string[] = [];
accessSecretsManager: boolean;
}

View File

@@ -0,0 +1,41 @@
import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/abstractions/organization-user/responses";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums/organization-user-status-type";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums/organization-user-type";
import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api";
import { CollectionAccessSelectionView } from "./collection-access-selection.view";
export class OrganizationUserView {
id: string;
userId: string;
type: OrganizationUserType;
status: OrganizationUserStatusType;
accessAll: boolean;
permissions: PermissionsApi;
resetPasswordEnrolled: boolean;
name: string;
email: string;
avatarColor: string;
twoFactorEnabled: boolean;
usesKeyConnector: boolean;
collections: CollectionAccessSelectionView[] = [];
groups: string[] = [];
groupNames: string[] = [];
collectionNames: string[] = [];
static fromResponse(response: OrganizationUserUserDetailsResponse): OrganizationUserView {
const view = Object.assign(new OrganizationUserView(), response) as OrganizationUserView;
if (response.collections != undefined) {
view.collections = response.collections.map((c) => new CollectionAccessSelectionView(c));
}
if (response.groups != undefined) {
view.groups = response.groups;
}
return view;
}
}

View File

@@ -15,7 +15,7 @@ import { CollectionDetailsResponse } from "@bitwarden/common/admin-console/model
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { DialogService } from "@bitwarden/components";
import { GroupService, GroupView } from "../../../organizations/core";
import { GroupService, GroupView } from "../core";
import {
AccessItemType,
AccessItemValue,

View File

@@ -33,7 +33,7 @@ import { Utils } from "@bitwarden/common/misc/utils";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { DialogService } from "@bitwarden/components";
import { GroupService, GroupView } from "../../../organizations/core";
import { GroupService, GroupView } from "../core";
import {
GroupAddEditDialogResultType,

View File

@@ -0,0 +1,111 @@
<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">
<h1 class="modal-title" id="bulkTitle">
{{ "confirmUsers" | i18n }}
</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="card-body text-center" *ngIf="loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
{{ "loading" | i18n }}
</div>
<app-callout type="danger" *ngIf="filteredUsers.length <= 0">
{{ "noSelectedUsersApplicable" | i18n }}
</app-callout>
<app-callout type="error" *ngIf="error">
{{ error }}
</app-callout>
<ng-container *ngIf="!loading && !done">
<p>
{{ "fingerprintEnsureIntegrityVerify" | i18n }}
<a href="https://bitwarden.com/help/fingerprint-phrase/" target="_blank" rel="noopener">
{{ "learnMore" | i18n }}</a
>
</p>
<table class="table table-hover table-list">
<thead>
<tr>
<th colspan="2">{{ "user" | i18n }}</th>
<th>{{ "fingerprint" | i18n }}</th>
</tr>
</thead>
<tr *ngFor="let user of filteredUsers">
<td width="30">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
</td>
<td>
{{ user.email }}
<small class="text-muted d-block" *ngIf="user.name">{{ user.name }}</small>
</td>
<td>
{{ fingerprints.get(user.id) }}
</td>
</tr>
<tr *ngFor="let user of excludedUsers">
<td width="30">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
</td>
<td>
{{ user.email }}
<small class="text-muted d-block" *ngIf="user.name">{{ user.name }}</small>
</td>
<td>
{{ "bulkFilteredMessage" | i18n }}
</td>
</tr>
</table>
</ng-container>
<ng-container *ngIf="!loading && 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 filteredUsers">
<td width="30">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-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"
[disabled]="loading"
(click)="submit()"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "confirm" | i18n }}</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,112 @@
import { Component, Input, OnInit } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import { OrganizationUserBulkConfirmRequest } from "@bitwarden/common/abstractions/organization-user/requests";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums/organization-user-status-type";
import { Utils } from "@bitwarden/common/misc/utils";
import { BulkUserDetails } from "./bulk-status.component";
@Component({
selector: "app-bulk-confirm",
templateUrl: "bulk-confirm.component.html",
})
export class BulkConfirmComponent implements OnInit {
@Input() organizationId: string;
@Input() users: BulkUserDetails[];
excludedUsers: BulkUserDetails[];
filteredUsers: BulkUserDetails[];
publicKeys: Map<string, Uint8Array> = new Map();
fingerprints: Map<string, string> = new Map();
statuses: Map<string, string> = new Map();
loading = true;
done = false;
error: string;
constructor(
protected cryptoService: CryptoService,
protected apiService: ApiService,
private organizationUserService: OrganizationUserService,
private i18nService: I18nService
) {}
async ngOnInit() {
this.excludedUsers = this.users.filter((u) => !this.isAccepted(u));
this.filteredUsers = this.users.filter((u) => this.isAccepted(u));
if (this.filteredUsers.length <= 0) {
this.done = true;
}
const response = await this.getPublicKeys();
for (const entry of response.data) {
const publicKey = Utils.fromB64ToArray(entry.key);
const fingerprint = await this.cryptoService.getFingerprint(entry.userId, publicKey.buffer);
if (fingerprint != null) {
this.publicKeys.set(entry.id, publicKey);
this.fingerprints.set(entry.id, fingerprint.join("-"));
}
}
this.loading = false;
}
async submit() {
this.loading = true;
try {
const key = await this.getCryptoKey();
const userIdsWithKeys: any[] = [];
for (const user of this.filteredUsers) {
const publicKey = this.publicKeys.get(user.id);
if (publicKey == null) {
continue;
}
const encryptedKey = await this.cryptoService.rsaEncrypt(key.key, publicKey.buffer);
userIdsWithKeys.push({
id: user.id,
key: encryptedKey.encryptedString,
});
}
const response = await this.postConfirmRequest(userIdsWithKeys);
response.data.forEach((entry) => {
const error = entry.error !== "" ? entry.error : this.i18nService.t("bulkConfirmMessage");
this.statuses.set(entry.id, error);
});
this.done = true;
} catch (e) {
this.error = e.message;
}
this.loading = false;
}
protected isAccepted(user: BulkUserDetails) {
return user.status === OrganizationUserStatusType.Accepted;
}
protected async getPublicKeys() {
return await this.organizationUserService.postOrganizationUsersPublicKey(
this.organizationId,
this.filteredUsers.map((user) => user.id)
);
}
protected getCryptoKey() {
return this.cryptoService.getOrgKey(this.organizationId);
}
protected async postConfirmRequest(userIdsWithKeys: any[]) {
const request = new OrganizationUserBulkConfirmRequest(userIdsWithKeys);
return await this.organizationUserService.postOrganizationUserBulkConfirm(
this.organizationId,
request
);
}
}

View File

@@ -0,0 +1,88 @@
<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">
<h1 class="modal-title" id="bulkTitle">
{{ "removeUsers" | i18n }}
</h1>
<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">
{{ removeUsersWarning }}
</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">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-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">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-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>{{ "removeUsers" | i18n }}</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,56 @@
import { Component, Input } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import { BulkUserDetails } from "./bulk-status.component";
@Component({
selector: "app-bulk-remove",
templateUrl: "bulk-remove.component.html",
})
export class BulkRemoveComponent {
@Input() organizationId: string;
@Input() users: BulkUserDetails[];
statuses: Map<string, string> = new Map();
loading = false;
done = false;
error: string;
constructor(
protected apiService: ApiService,
protected i18nService: I18nService,
private organizationUserService: OrganizationUserService
) {}
async submit() {
this.loading = true;
try {
const response = await this.deleteUsers();
response.data.forEach((entry) => {
const error = entry.error !== "" ? entry.error : this.i18nService.t("bulkRemovedMessage");
this.statuses.set(entry.id, error);
});
this.done = true;
} catch (e) {
this.error = e.message;
}
this.loading = false;
}
protected async deleteUsers() {
return await this.organizationUserService.deleteManyOrganizationUsers(
this.organizationId,
this.users.map((user) => user.id)
);
}
protected get removeUsersWarning() {
return this.i18nService.t("removeOrgUsersConfirmation");
}
}

View File

@@ -0,0 +1,88 @@
<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">
<h1 class="modal-title" id="bulkTitle">
{{ bulkTitle }}
</h1>
<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 && isRevoking">
{{ "revokeUsersWarning" | i18n }}
</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">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-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">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-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,71 @@
import { Component } from "@angular/core";
import { ModalConfig } from "@bitwarden/angular/services/modal.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import { BulkUserDetails } from "./bulk-status.component";
@Component({
selector: "app-bulk-restore-revoke",
templateUrl: "bulk-restore-revoke.component.html",
})
export class BulkRestoreRevokeComponent {
isRevoking: boolean;
organizationId: string;
users: BulkUserDetails[];
statuses: Map<string, string> = new Map();
loading = false;
done = false;
error: string;
constructor(
protected i18nService: I18nService,
private organizationUserService: OrganizationUserService,
config: ModalConfig
) {
this.isRevoking = config.data.isRevoking;
this.organizationId = config.data.organizationId;
this.users = config.data.users;
}
get bulkTitle() {
const titleKey = this.isRevoking ? "revokeUsers" : "restoreUsers";
return this.i18nService.t(titleKey);
}
async submit() {
this.loading = true;
try {
const response = await this.performBulkUserAction();
const bulkMessage = this.isRevoking ? "bulkRevokedMessage" : "bulkRestoredMessage";
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;
}
protected async performBulkUserAction() {
const userIds = this.users.map((user) => user.id);
if (this.isRevoking) {
return await this.organizationUserService.revokeManyOrganizationUsers(
this.organizationId,
userIds
);
} else {
return await this.organizationUserService.restoreManyOrganizationUsers(
this.organizationId,
userIds
);
}
}
}

View File

@@ -0,0 +1,57 @@
<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">
<h1 class="modal-title" id="bulkTitle">
{{ "bulkConfirmStatus" | i18n }}
</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="card-body text-center" *ngIf="loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
{{ "loading" | i18n }}
</div>
<table class="table table-hover table-list" *ngIf="!loading">
<thead>
<tr>
<th colspan="2">{{ "user" | i18n }}</th>
<th>{{ "status" | i18n }}</th>
</tr>
</thead>
<tr *ngFor="let item of users">
<td width="30">
<bit-avatar
[text]="item.user | userName"
[id]="item.user.id"
size="small"
></bit-avatar>
</td>
<td>
{{ item.user.email }}
<small class="text-muted d-block" *ngIf="item.user.name">{{ item.user.name }}</small>
</td>
<td class="text-danger" *ngIf="item.error">
{{ item.message }}
</td>
<td *ngIf="!item.error">
{{ item.message }}
</td>
</tr>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "close" | i18n }}
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,26 @@
import { Component } from "@angular/core";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums/organization-user-status-type";
import { ProviderUserStatusType } from "@bitwarden/common/admin-console/enums/provider-user-status-type";
export interface BulkUserDetails {
id: string;
name: string;
email: string;
status: OrganizationUserStatusType | ProviderUserStatusType;
}
type BulkStatusEntry = {
user: BulkUserDetails;
error: boolean;
message: string;
};
@Component({
selector: "app-bulk-status",
templateUrl: "bulk-status.component.html",
})
export class BulkStatusComponent {
users: BulkStatusEntry[];
loading = false;
}

View File

@@ -22,7 +22,7 @@ import {
GroupView,
OrganizationUserAdminView,
UserAdminService,
} from "../../../../../organizations/core";
} from "../../../core";
import {
AccessItemType,
AccessItemValue,

View File

@@ -0,0 +1 @@
export * from "./members.module";

View File

@@ -0,0 +1,26 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { canAccessMembersTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationPermissionsGuard } from "../guards/org-permissions.guard";
import { PeopleComponent } from "./people.component";
const routes: Routes = [
{
path: "",
component: PeopleComponent,
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "members",
organizationPermissions: canAccessMembersTab,
},
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class MembersRoutingModule {}

View File

@@ -0,0 +1,31 @@
import { NgModule } from "@angular/core";
import { SharedOrganizationModule } from "../../../organizations/shared";
import { LooseComponentsModule } from "../../../shared";
import { BulkConfirmComponent } from "./components/bulk/bulk-confirm.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 { UserDialogModule } from "./components/member-dialog";
import { ResetPasswordComponent } from "./components/reset-password.component";
import { MembersRoutingModule } from "./members-routing.module";
import { PeopleComponent } from "./people.component";
@NgModule({
imports: [
SharedOrganizationModule,
LooseComponentsModule,
MembersRoutingModule,
UserDialogModule,
],
declarations: [
BulkConfirmComponent,
BulkRemoveComponent,
BulkRestoreRevokeComponent,
BulkStatusComponent,
PeopleComponent,
ResetPasswordComponent,
],
})
export class MembersModule {}

View File

@@ -55,13 +55,13 @@ import {
import { EntityEventsComponent } from "../../../admin-console/organizations/manage/entity-events.component";
import { BasePeopleComponent } from "../../../common/base.people.component";
import { GroupService } from "../../../organizations/core";
import { OrganizationUserView } from "../../../organizations/core/views/organization-user.view";
import { BulkConfirmComponent } from "../../../organizations/members/components/bulk/bulk-confirm.component";
import { BulkRemoveComponent } from "../../../organizations/members/components/bulk/bulk-remove.component";
import { BulkRestoreRevokeComponent } from "../../../organizations/members/components/bulk/bulk-restore-revoke.component";
import { BulkStatusComponent } from "../../../organizations/members/components/bulk/bulk-status.component";
import { GroupService } from "../core";
import { OrganizationUserView } from "../core/views/organization-user.view";
import { BulkConfirmComponent } from "./components/bulk/bulk-confirm.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,

View File

@@ -51,7 +51,7 @@ const routes: Routes = [
},
{
path: "members",
loadChildren: () => import("../../organizations/members").then((m) => m.MembersModule),
loadChildren: () => import("./members").then((m) => m.MembersModule),
},
{
path: "groups",

View File

@@ -1,8 +1,8 @@
import { NgModule } from "@angular/core";
import { CoreOrganizationModule } from "../../organizations/core";
import { SharedOrganizationModule } from "../../organizations/shared";
import { CoreOrganizationModule } from "./core";
import { GroupAddEditComponent } from "./manage/group-add-edit.component";
import { GroupsComponent } from "./manage/groups.component";
import { OrganizationsRoutingModule } from "./organization-routing.module";

View File

@@ -2,7 +2,7 @@ import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enum
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums/organization-user-type";
import { SelectItemView } from "@bitwarden/components";
import { CollectionAccessSelectionView } from "../../../../../organizations/core";
import { CollectionAccessSelectionView } from "../../../core";
/**
* Permission options that replace/correspond with readOnly and hidePassword server fields.

View File

@@ -17,7 +17,7 @@ import {
CollectionAdminView,
GroupService,
GroupView,
} from "../../../../../organizations/core";
} from "../../../core";
import {
AccessItemType,
AccessItemValue,

View File

@@ -0,0 +1,21 @@
<label class="tw-sr-only" [for]="id">{{ "search" | i18n }}</label>
<div class="tw-relative tw-flex tw-items-center">
<label
[for]="id"
aria-hidden="true"
class="tw-absolute tw-left-2 tw-z-20 !tw-mb-0 tw-cursor-text"
>
<i class="bwi bwi-search bwi-fw tw-text-muted"></i>
</label>
<input
bitInput
type="search"
[id]="id"
[placeholder]="placeholder ?? ('search' | i18n)"
class="tw-rounded-l tw-pl-9"
[ngModel]="searchText"
(ngModelChange)="onChange($event)"
(blur)="onTouch()"
[disabled]="disabled"
/>
</div>

View File

@@ -0,0 +1,54 @@
import { Component, Input } from "@angular/core";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
let nextId = 0;
@Component({
selector: "app-search-input",
templateUrl: "./search-input.component.html",
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: SearchInputComponent,
},
],
})
export class SearchInputComponent implements ControlValueAccessor {
private notifyOnChange: (v: string) => void;
private notifyOnTouch: () => void;
protected id = `search-id-${nextId++}`;
protected searchText: string;
@Input() disabled: boolean;
@Input() placeholder: string;
onChange(searchText: string) {
if (this.notifyOnChange != undefined) {
this.notifyOnChange(searchText);
}
}
onTouch() {
if (this.notifyOnTouch != undefined) {
this.notifyOnTouch();
}
}
registerOnChange(fn: (v: string) => void): void {
this.notifyOnChange = fn;
}
registerOnTouched(fn: () => void): void {
this.notifyOnTouch = fn;
}
writeValue(searchText: string): void {
this.searchText = searchText;
}
setDisabledState(isDisabled: boolean) {
this.disabled = isDisabled;
}
}

View File

@@ -0,0 +1,36 @@
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { InputModule } from "@bitwarden/components/src/input/input.module";
import { PreloadedEnglishI18nModule } from "../../../../../tests/preloaded-english-i18n.module";
import { SearchInputComponent } from "./search-input.component";
export default {
title: "Web/Organizations/Search Input",
component: SearchInputComponent,
decorators: [
moduleMetadata({
imports: [
InputModule,
FormsModule,
ReactiveFormsModule,
PreloadedEnglishI18nModule,
JslibModule,
],
providers: [],
}),
],
} as Meta;
const Template: Story<SearchInputComponent> = (args: SearchInputComponent) => ({
props: args,
template: `
<app-search-input [(ngModel)]="searchText" [placeholder]="placeholder" [disabled]="disabled"></app-search-input>
`,
});
export const Default = Template.bind({});
Default.args = {};

View File

@@ -0,0 +1,14 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../../shared/shared.module";
import { AccessSelectorModule } from "./components/access-selector/access-selector.module";
import { CollectionDialogModule } from "./components/collection-dialog";
import { SearchInputComponent } from "./components/search-input/search-input.component";
@NgModule({
imports: [SharedModule, CollectionDialogModule, AccessSelectorModule],
declarations: [SearchInputComponent],
exports: [SharedModule, CollectionDialogModule, AccessSelectorModule, SearchInputComponent],
})
export class SharedOrganizationModule {}