1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 15:23:33 +00:00

[EC-549] Member details collections tab (#4207)

* [EC-784] Introduce OrganizationUserService and abstraction

* [EC-784] Move API response models into abstraction folder

* [EC-784] Register OrganizationUserService in JsLib

* [EC-784] Add OrganizationUserService to CLI Main

* [EC-784] Move getOrganizationUser()

- Move getOrganizationUser() implementation to OrganizationUserService
- Update any references to the API service in the CLI and Web projects

* [EC-784] Move getOrganizationUserGroups()

* [EC-784] Move and rename getOrganizationUsers()

* [EC-784] Move getOrganizationUserResetPasswordDetails()

* [EC-784] Move OrganizationUser API request models into abstraction folder

* [EC-784] Move postOrganizationUserInvite()

* [EC-784] Move postOrganizationUserReinvite()

* [EC-784] Move postManyOrganizationUserReinvite()

Also tweak the signature to avoid exposing the API request model

* [EC-784] Move postOrganizationUserAccept()

* [EC-784] Move postOrganizationUserConfirm()

* [EC-784] Move postOrganizationUsersPublicKey()

Also modify signature to avoid exposing API request model

* [EC-784] Move postOrganizationUserBulkConfirm()

* [EC-784] Move putOrganizationUser()

* [EC-784] Move putOrganizationUserGroups()

* [EC-784] Update abstraction method definitions to use abstract keyword

* [EC-784] Move putOrganizationUserResetPasswordEnrollment()

* [EC-784] Move putOrganizationUserResetPassword()

* [EC-784] Move deleteOrganizationUser()

* [EC-784] Move deleteManyOrganizationUsers()

* [EC-784] Move revokeOrganizationUser()

* [EC-784] Move revokeManyOrganizationUsers()

* [EC-784] Move restoreOrganizationUser()

* [EC-784] Move restoreManyOrganizationUsers()

* [EC-784] Move internal OrganizationUserBulkRequest model out of service abstraction

* [EC-784] Rename organizationUser folder to organization-user

* [EC-549] feat: add unconnected access selector

* [EC-549] fix: old user group dialog not working

* [EC-549] feat: add support for showing collections

* [EC-549] feat: rewrite and implement saving and inviting

* [EC-549] feat: implement support for access all collections

* [EC-549] feat: remove collection form from role tab

* [EC-549] chore: clean up comments

* [EC-549] fix: revert changes to access selector story

* [EC-549] feat: handle organizations that dont use groups

Co-authored-by: Shane Melton <smelton@bitwarden.com>
This commit is contained in:
Andreas Coroiu
2022-12-20 16:08:35 +01:00
committed by GitHub
parent fb64cd3cb4
commit 8585b0e1eb
8 changed files with 298 additions and 158 deletions

View File

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

View File

@@ -0,0 +1,82 @@
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/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
);
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;
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;
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.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,
}));
return view;
});
return await Promise.all(promises);
}
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large" [disablePadding]="!loading">
<bit-dialog [disablePadding]="!loading">
<span bitDialogTitle>
{{ title }}
<span class="tw-text-sm tw-normal-case tw-text-muted" *ngIf="!loading && params.name">{{
@@ -114,6 +114,7 @@
id="userTypeCustom"
[value]="organizationUserType.Custom"
[(ngModel)]="type"
[ngModelOptions]="{ standalone: true }"
[attr.disabled]="!canUseCustomPermissions || null"
/>
<label class="form-check-label" for="userTypeCustom">
@@ -283,112 +284,41 @@
</div>
</div>
</ng-container>
<h3 class="mt-4 d-flex">
<div class="mb-3">
{{ "accessControl" | i18n }}
<a
target="_blank"
rel="noopener"
appA11yTitle="{{ 'learnMore' | i18n }}"
href="https://bitwarden.com/help/user-types-access-control/#access-control"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</div>
<div class="ml-auto" *ngIf="access === 'selected' && collections && collections.length">
<button type="button" (click)="selectAll(true)" class="btn btn-link btn-sm py-0">
{{ "selectAll" | i18n }}
</button>
<button type="button" (click)="selectAll(false)" class="btn btn-link btn-sm py-0">
{{ "unselectAll" | i18n }}
</button>
</div>
</h3>
<div class="form-group" [ngClass]="{ 'mb-0': access !== 'selected' }">
<div class="form-check">
<input
class="form-check-input"
type="radio"
name="access"
id="accessAll"
value="all"
[ngModelOptions]="{ standalone: true }"
[(ngModel)]="access"
/>
<label class="form-check-label" for="accessAll">
{{ "userAccessAllItems" | i18n }}
</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="radio"
name="access"
id="accessSelected"
value="selected"
[ngModelOptions]="{ standalone: true }"
[(ngModel)]="access"
/>
<label class="form-check-label" for="accessSelected">
{{ "userAccessSelectedCollections" | i18n }}
</label>
</div>
</div>
<ng-container *ngIf="access === 'selected'">
<div *ngIf="!collections || !collections.length">
{{ "noCollectionsInList" | i18n }}
</div>
<table
class="table table-hover table-list mb-0"
*ngIf="collections && collections.length"
>
<thead>
<tr>
<th>&nbsp;</th>
<th>{{ "name" | i18n }}</th>
<th width="100" class="text-center">{{ "hidePasswords" | i18n }}</th>
<th width="100" class="text-center">{{ "readOnly" | i18n }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let c of collections; let i = index">
<td class="table-list-checkbox" (click)="check(c)">
<input
type="checkbox"
[ngModelOptions]="{ standalone: true }"
[(ngModel)]="$any(c).checked"
name="Collection[{{ i }}].Checked"
appStopProp
/>
</td>
<td (click)="check(c)">
{{ c.name }}
</td>
<td class="text-center">
<input
type="checkbox"
[ngModelOptions]="{ standalone: true }"
[(ngModel)]="c.hidePasswords"
name="Collection[{{ i }}].HidePasswords"
[disabled]="!$any(c).checked"
/>
</td>
<td class="text-center">
<input
type="checkbox"
[ngModelOptions]="{ standalone: true }"
[(ngModel)]="c.readOnly"
name="Collection[{{ i }}].ReadOnly"
[disabled]="!$any(c).checked"
/>
</td>
</tr>
</tbody>
</table>
</ng-container>
</bit-tab>
<bit-tab [label]="'groups' | i18n">Groups</bit-tab>
<bit-tab [label]="'collections' | i18n">Collections</bit-tab>
<bit-tab *ngIf="organization.useGroups" [label]="'groups' | i18n"> Groups </bit-tab>
<bit-tab [label]="'collections' | i18n">
<div *ngIf="organization.useGroups" class="tw-mb-6">
{{ "userPermissionOverrideHelper" | i18n }}
</div>
<div class="tw-mb-6">
<!-- TODO: Replace with bit-checkbox when feature has been merged into feature branch -->
<label>
<input type="checkbox" formControlName="accessAllCollections" />
<span class="tw-bold"
>{{ "accessAllCollectionsDesc" | i18n }}
<a
target="_blank"
rel="noopener"
appA11yTitle="{{ 'learnMore' | i18n }}"
href="https://bitwarden.com/help/user-types-access-control/#access-control"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</span>
</label>
<div class="tw-text-muted">{{ "accessAllCollectionsHelp" | i18n }}</div>
</div>
<bit-access-selector
*ngIf="!accessAllCollections"
[permissionMode]="PermissionMode.Edit"
formControlName="access"
[showGroupColumn]="organization.useGroups"
[items]="accessItems"
[columnHeader]="'collection' | i18n"
[selectorLabelText]="'selectCollections' | i18n"
[emptySelectionText]="'noCollectionsAdded' | i18n"
></bit-access-selector
></bit-tab>
</bit-tab-group>
</div>
<div bitDialogFooter class="tw-flex tw-flex-row tw-gap-2">

View File

@@ -1,16 +1,13 @@
import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject, OnInit } from "@angular/core";
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { combineLatest, of, shareReplay, Subject, switchMap, takeUntil } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CollectionService } from "@bitwarden/common/abstractions/collection.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import {
OrganizationUserInviteRequest,
OrganizationUserUpdateRequest,
} from "@bitwarden/common/abstractions/organization-user/requests";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType";
@@ -18,11 +15,28 @@ import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserTy
import { PermissionsApi } from "@bitwarden/common/models/api/permissions.api";
import { CollectionData } from "@bitwarden/common/models/data/collection.data";
import { Collection } from "@bitwarden/common/models/domain/collection";
import { SelectionReadOnlyRequest } from "@bitwarden/common/models/request/selection-read-only.request";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { CollectionDetailsResponse } from "@bitwarden/common/models/response/collection.response";
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
import { DialogService } from "@bitwarden/components";
import {
CollectionAccessSelectionView,
CollectionAdminService,
GroupService,
GroupView,
OrganizationUserAdminView,
UserAdminService,
} from "../../../core";
import {
AccessItemType,
AccessItemValue,
AccessItemView,
convertToPermission,
convertToSelectionView,
PermissionMode,
} from "../../../shared/components/access-selector";
export enum MemberDialogTab {
Role = 0,
Groups = 1,
@@ -49,7 +63,7 @@ export enum MemberDialogResult {
selector: "app-member-dialog",
templateUrl: "member-dialog.component.html",
})
export class MemberDialogComponent implements OnInit {
export class MemberDialogComponent implements OnInit, OnDestroy {
loading = true;
editMode = false;
isRevoked = false;
@@ -57,15 +71,22 @@ export class MemberDialogComponent implements OnInit {
emails: string;
type: OrganizationUserType = OrganizationUserType.User;
permissions = new PermissionsApi();
showCustom = false;
access: "all" | "selected" = "selected";
collections: CollectionView[] = [];
organizationUserType = OrganizationUserType;
canUseCustomPermissions: boolean;
PermissionMode = PermissionMode;
protected organization: Organization;
protected accessItems: AccessItemView[] = [];
protected tabIndex: MemberDialogTab;
// Stub, to be filled out in upcoming PRs
protected formGroup = this.formBuilder.group({});
protected formGroup = this.formBuilder.group({
accessAllCollections: false,
access: [[] as AccessItemValue[]],
});
private destroy$ = new Subject<void>();
manageAllCollectionsCheckboxes = [
{
@@ -102,6 +123,10 @@ export class MemberDialogComponent implements OnInit {
return this.type === OrganizationUserType.Custom;
}
get accessAllCollections(): boolean {
return this.formGroup.value.accessAllCollections;
}
constructor(
@Inject(DIALOG_DATA) protected params: MemberDialogParams,
private dialogRef: DialogRef<MemberDialogResult>,
@@ -111,8 +136,12 @@ export class MemberDialogComponent implements OnInit {
private platformUtilsService: PlatformUtilsService,
private organizationService: OrganizationService,
private logService: LogService,
private organizationUserService: OrganizationUserService,
private formBuilder: FormBuilder
private formBuilder: FormBuilder,
// TODO: We should really look into consolidating naming conventions for these services
private collectionAdminService: CollectionAdminService,
private groupService: GroupService,
private userService: UserAdminService,
private organizationUserService: OrganizationUserService
) {}
async ngOnInit() {
@@ -154,7 +183,70 @@ export class MemberDialogComponent implements OnInit {
this.title = this.i18nService.t("inviteMember");
}
this.loading = false;
// ----------- New data fetching below ---------------
const organization$ = of(this.organizationService.get(this.params.organizationId)).pipe(
shareReplay({ refCount: true, bufferSize: 1 })
);
const groups$ = organization$.pipe(
switchMap((organization) => {
if (!organization.useGroups) {
return of([] as GroupView[]);
}
return this.groupService.getAll(this.params.organizationId);
})
);
const userGroups$ = this.params.organizationUserId
? of(
await this.organizationUserService.getOrganizationUserGroups(
this.params.organizationId,
this.params.organizationUserId
)
)
: of([]);
combineLatest({
organization: organization$,
collections: this.collectionAdminService.getAll(this.params.organizationId),
userDetails: this.params.organizationUserId
? this.userService.get(this.params.organizationId, this.params.organizationUserId)
: of(null),
groups: groups$,
userGroups: userGroups$,
})
.pipe(takeUntil(this.destroy$))
.subscribe(({ organization, collections, userDetails, groups, userGroups }) => {
this.organization = organization;
const collectionsFromGroups = groups
.filter((group) => userGroups.includes(group.id))
.flatMap((group) =>
group.collections.map((accessSelection) => {
const collection = collections.find((c) => c.id === accessSelection.id);
return { group, collection, accessSelection };
})
);
this.accessItems = [].concat(
collectionsFromGroups.map(({ collection, accessSelection, group }) =>
mapCollectionToAccessItemView(collection, accessSelection, group)
),
collections.map((c) => mapCollectionToAccessItemView(c))
);
if (this.params.organizationUserId) {
if (!userDetails) {
throw new Error("Could not find user to edit.");
}
const accessSelections = mapToAccessSelections(userDetails);
this.formGroup.patchValue({
accessAllCollections: userDetails.accessAll,
access: accessSelections,
});
}
this.loading = false;
});
}
async loadCollections() {
@@ -195,6 +287,10 @@ export class MemberDialogComponent implements OnInit {
}
submit = async () => {
if (this.formGroup.invalid) {
return;
}
if (!this.canUseCustomPermissions && this.type === OrganizationUserType.Custom) {
this.platformUtilsService.showToast(
"error",
@@ -203,18 +299,27 @@ export class MemberDialogComponent implements OnInit {
);
return;
}
let collections: SelectionReadOnlyRequest[] = null;
if (this.access !== "all") {
collections = this.collections
.filter((c) => (c as any).checked)
.map((c) => new SelectionReadOnlyRequest(c.id, !!c.readOnly, !!c.hidePasswords));
}
try {
const userView = new OrganizationUserAdminView();
userView.id = this.params.organizationUserId;
userView.organizationId = this.params.organizationId;
userView.accessAll = this.accessAllCollections;
userView.type = this.type;
userView.permissions = this.setRequestPermissions(
userView.permissions ?? new PermissionsApi(),
userView.type !== OrganizationUserType.Custom
);
userView.collections = this.formGroup.controls.access.value
.filter((v) => v.type === AccessItemType.Collection)
.map(convertToSelectionView);
if (this.editMode) {
await this.updateUser(collections);
await this.userService.save(userView);
} else {
await this.inviteUser(collections);
userView.id = this.params.organizationUserId;
const emails = [...new Set(this.emails.trim().split(/\s*,\s*/))];
await this.userService.invite(emails, userView);
}
this.platformUtilsService.showToast(
@@ -325,6 +430,11 @@ export class MemberDialogComponent implements OnInit {
}
};
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
protected async cancel() {
this.close(MemberDialogResult.Canceled);
}
@@ -332,38 +442,35 @@ export class MemberDialogComponent implements OnInit {
private close(result: MemberDialogResult) {
this.dialogRef.close(result);
}
}
async updateUser(collections: SelectionReadOnlyRequest[]) {
const request = new OrganizationUserUpdateRequest();
request.accessAll = this.access === "all";
request.type = this.type;
request.collections = collections;
request.permissions = this.setRequestPermissions(
request.permissions ?? new PermissionsApi(),
request.type !== OrganizationUserType.Custom
);
await this.organizationUserService.putOrganizationUser(
this.params.organizationId,
this.params.organizationUserId,
request
);
}
function mapCollectionToAccessItemView(
collection: CollectionView,
accessSelection?: CollectionAccessSelectionView,
group?: GroupView
): AccessItemView {
return {
type: AccessItemType.Collection,
id: group ? `${collection.id}-${group.id}` : collection.id,
labelName: collection.name,
listName: collection.name,
readonly: accessSelection !== undefined,
readonlyPermission: accessSelection ? convertToPermission(accessSelection) : undefined,
viaGroupName: group?.name,
};
}
async inviteUser(collections: SelectionReadOnlyRequest[]) {
const request = new OrganizationUserInviteRequest();
request.emails = [...new Set(this.emails.trim().split(/\s*,\s*/))];
request.accessAll = this.access === "all";
request.type = this.type;
request.permissions = this.setRequestPermissions(
request.permissions ?? new PermissionsApi(),
request.type !== OrganizationUserType.Custom
);
request.collections = collections;
await this.organizationUserService.postOrganizationUserInvite(
this.params.organizationId,
request
);
function mapToAccessSelections(user: OrganizationUserAdminView): AccessItemValue[] {
if (user == undefined) {
return [];
}
return [].concat(
user.collections.map<AccessItemValue>((selection) => ({
id: selection.id,
type: AccessItemType.Collection,
permission: convertToPermission(selection),
}))
);
}
/**

View File

@@ -1,13 +1,13 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../../../shared/shared.module";
import { SharedOrganizationModule } from "../../../shared";
import { MemberDialogComponent } from "./member-dialog.component";
import { NestedCheckboxComponent } from "./nested-checkbox.component";
@NgModule({
declarations: [MemberDialogComponent, NestedCheckboxComponent],
imports: [SharedModule],
imports: [SharedOrganizationModule],
exports: [MemberDialogComponent],
})
export class UserDialogModule {}