mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 05:13:29 +00:00
[EC-16] Implement new Groups Tab (#3563)
* [EC-16] Cleanup RxJS linting problems * [EC-16] Update Group tab to use table component and show collections. * [EC-16] Extract interface from GroupResponse and use it in the view * [EC-16] Remove heading underline * [EC-16] Cleanup i18n * [EC-16] More i18n cleanup * [EC-16] Fix bulk group request type name * [EC-16] Rename group details type * [EC-86] Clear collectionMap before populating it with new collections * [EC-86] Update initialization/loading logic to make better use of the Observable pattern * [EC-86] Make table cells use a pointer cursor * [EC-86] Use bitIconButton for row menu triggers * [EC-86] Refactor GroupDetailsRow interface to wrap GroupDetailsResponse. Remove response model interfaces. Cleanup GroupsComponent. * [EC-86] Add bit-badge-list component and tweak BadgeModule to support both the component and directive. Update mockI18nService to support templated strings. * [EC-86] Cleanup badge color and bitIconButton classes * [EC-86] Cleanup more styles * [EC-86] Add GroupApiService Add a new GroupApiService to replace Group Api calls in the ApiService. * [EC-86] Revisions for badge-list implementation. - Remove `| null` for maxItems according to ADR-0014 - Remove custom setter for items - Use ngOnChanges to update filteredItems - Fix sr-only tailwind class and show screen reader comma after last item if truncated. * [EC-86] Refactor badge-list module/component - Move the badge list component to its own module. - Extract badge list stories from badge stories. - Cleanup bade stories and module after refactor. * [EC-86] Refactor/rename GroupApiService - Re-name GroupApiService to GroupService as there is no need for a separate Api service (no sync or local data for admin services) - Add GroupView for use in the GroupService instead of raw API models - Update views to use GroupView instead of raw GroupResponse models * [EC-86] Refactor group API request models - Move organizationGroupBulkRequest to group requests folder - Fix relative imports in GroupService * [EC-86] Fix linting errors * Fix tab item text color Tab item text color broke after a merge from master and needs a fix to account for bootstrap styles in Web. * [EC-86] Rename new files using kebab-case * [EC-86] Fix group view file name * [EC-86] Fix group request/response file names * [EC-86] Cleanup badge stories per review suggestions * [EC-86] Use inline-flex for badge list container * [EC-86] Move GroupService and Views to Web org module - Move GroupService and GroupServiceAbstraction to Organization Module - Add GroupService provider to Organization Module - Move collection-add-edit.component, user-groups.component, group-add-edit.component, and groups.component into Organization Module as they now depend on GroupService - Remove moved components from Loose Component module * [EC-86] Fix Group table search Adds the id and name properties to GroupDetailsRow to support using the searchPipe (which cannot access nested values such as details.name for filtering). * [EC-86] Fix badge story controls * [EC-87] Edit Group Dialog (#3651) * [EC-87] Update the edit dialog to use content tabs * [EC-87] WIP FormListSelection abstract controller * [EC-87] WIP FormListSelection for members and collections * [EC-87] More WIP on FormListSelection * [EC-87] WIP Working FormSelectionList with initial value support * [EC-87] WIP SelectionList without FormControls and with i18n support for sorting * [EC-87] Final sorted SelectionList with FormArray support * [EC-87] Extract and document FormSelectionList * [EC-87] Functional edit group modal * [EC-87] Remove button icon padding for bitButton directives * [EC-87] Use new disablePadding attribute for Dialog component * [EC-87] Some more cleanup and finetuning * [EC-87] Move enum declaration to top * [EC-87] Remove inline style from access selector * [EC-87] Move Group components into Organization Module * [EC-87] Add MultiSelectModule to Shared Web module * [EC-87] Integrate AccessSelector component in GroupAddEdit modal - Remove duplicate permission / selection readonly helpers from GroupAddEdit component - Use access item views/values for collection and member lists - Replace access selector HTMl with the AccessSelector component * [EC-87] Update Group collections column to open Collection tab * [EC-87] Remove old FormSelectionList file * [EC-87] Fix missed file import changes after merge * [EC-87] Remove GroupAddEditComponent modal service registration Groups component is now using the DialogService which does not require explicit registration for lazy loaded components. * [EC-87] Use injected DIALOG_DATA for GroupAddEdit component - Add types for the GroupAddEdit dialog params, result, and tab indices - Add strongly typed helper method to open GroupAddEdit dialogs - Remove @Input()/@Output() properties. Replaced with the injected DIALOG_DATA params instead - Use dialogRef.close() and result type instead of event emitters * [EC-87] Rename collection tab type to collections * [EC-87] Refactor postGroup() and putGroup() from ApiService - Move postGroup() and putGroup() methods to GroupService - Remove postGroup() and putGroup() from ApiService - Move GroupResponse and GroupRequest into Web (from lib/common) * [EC-87] Remove required attribute * [EC-87] Use PascalCase for template Enums * [EC-87] Use group modal tab enum in template * [EC-87] Convert dialog result to promise * [EC-87] Refactor dialog positionStrategy - Add .top() to position strategy to allow clicking the backdrop to close the dialog - Move the positionStrategy option into the openGroupAddEditDialog helper * [EC-87] Remove [preserveContent] from tab group * [EC-87] Use new CL async actions - Update handlers to be arrow-functions - Remove old form and delete promises - Use [bitSubmit] directive on form - Use bitFormButton directive and [bitAction] for submit and delete buttons - Remove delete/spinner bwi icons as they are handled by the new async directives * [EC-87] Introduce CollectionAccessSelectionView Use a new view to replace the SelectionReadonlyResponse/Request classes. * [EC-87] Use new access selection view in GroupView - Change the collections type - Add members list to make the view more complete - Update the static fromResponse helper to properly map the GroupDetailsResponse to the new access selection view - Update access selector helpers to use new access selection view instead of response/request models * [EC-87] Update GroupService to have a single save() method that accepts a GroupView - Add save() method that checks for existing group id to determine which API method to use - Make post/put group methods private * [EC-87] Utilize the new save() method in the group modal * [EC-87] Use observables for fetching data - Introduce 3 observables for collections, members, and group details - Combine and subscribe to those observables in ngOnInit - Add destroy$ subject - Inject changeDetectorRef to handle quirk of patching the AccessSelector value before available items are set
This commit is contained in:
@@ -37,7 +37,7 @@
|
||||
</th>
|
||||
<th bitCell id="roleColHeading" *ngIf="showMemberRoles">{{ "role" | i18n }}</th>
|
||||
<th bitCell id="groupColHeading" *ngIf="showGroupColumn">{{ "group" | i18n }}</th>
|
||||
<th bitCell style="width: 50px"></th>
|
||||
<th bitCell class="tw-w-20"></th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<ng-container body formArrayName="items">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType";
|
||||
import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType";
|
||||
import { SelectionReadOnlyRequest } from "@bitwarden/common/models/request/selection-read-only.request";
|
||||
import { SelectionReadOnlyResponse } from "@bitwarden/common/models/response/selection-read-only.response";
|
||||
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
|
||||
|
||||
import { CollectionAccessSelectionView } from "../../views/collection-access-selection.view";
|
||||
|
||||
/**
|
||||
* Permission options that replace/correspond with readOnly and hidePassword server fields.
|
||||
*/
|
||||
@@ -75,11 +75,11 @@ export type AccessItemValue = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts the older SelectionReadOnly interface to one of the new CollectionPermission values
|
||||
* Converts the CollectionAccessSelectionView interface to one of the new CollectionPermission values
|
||||
* for the dropdown in the AccessSelectorComponent
|
||||
* @param value
|
||||
*/
|
||||
export const convertToPermission = (value: SelectionReadOnlyResponse) => {
|
||||
export const convertToPermission = (value: CollectionAccessSelectionView) => {
|
||||
if (value.readOnly) {
|
||||
return value.hidePasswords ? CollectionPermission.ViewExceptPass : CollectionPermission.View;
|
||||
} else {
|
||||
@@ -88,16 +88,16 @@ export const convertToPermission = (value: SelectionReadOnlyResponse) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts an AccessItemValue back into a SelectionReadOnly class using the CollectionPermission
|
||||
* Converts an AccessItemValue back into a CollectionAccessView class using the CollectionPermission
|
||||
* to determine the values for `readOnly` and `hidePassword`
|
||||
* @param value
|
||||
*/
|
||||
export const convertToSelectionReadOnly = (value: AccessItemValue) => {
|
||||
return new SelectionReadOnlyRequest(
|
||||
value.id,
|
||||
readOnly(value.permission),
|
||||
hidePassword(value.permission)
|
||||
);
|
||||
export const convertToSelectionView = (value: AccessItemValue) => {
|
||||
return new CollectionAccessSelectionView({
|
||||
id: value.id,
|
||||
readOnly: readOnly(value.permission),
|
||||
hidePasswords: hidePassword(value.permission),
|
||||
});
|
||||
};
|
||||
|
||||
const readOnly = (perm: CollectionPermission) =>
|
||||
|
||||
@@ -11,7 +11,9 @@ import { EncString } from "@bitwarden/common/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key";
|
||||
import { CollectionRequest } from "@bitwarden/common/models/request/collection.request";
|
||||
import { SelectionReadOnlyRequest } from "@bitwarden/common/models/request/selection-read-only.request";
|
||||
import { GroupResponse } from "@bitwarden/common/models/response/group.response";
|
||||
|
||||
import { GroupServiceAbstraction } from "../services/abstractions/group";
|
||||
import { GroupView } from "../views/group.view";
|
||||
|
||||
@Component({
|
||||
selector: "app-collection-add-edit",
|
||||
@@ -31,7 +33,7 @@ export class CollectionAddEditComponent implements OnInit {
|
||||
title: string;
|
||||
name: string;
|
||||
externalId: string;
|
||||
groups: GroupResponse[] = [];
|
||||
groups: GroupView[] = [];
|
||||
formPromise: Promise<any>;
|
||||
deletePromise: Promise<any>;
|
||||
|
||||
@@ -39,6 +41,7 @@ export class CollectionAddEditComponent implements OnInit {
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private groupApiService: GroupServiceAbstraction,
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private cryptoService: CryptoService,
|
||||
@@ -51,10 +54,8 @@ export class CollectionAddEditComponent implements OnInit {
|
||||
this.accessGroups = organization.useGroups;
|
||||
this.editMode = this.loading = this.collectionId != null;
|
||||
if (this.accessGroups) {
|
||||
const groupsResponse = await this.apiService.getGroups(this.organizationId);
|
||||
this.groups = groupsResponse.data
|
||||
.map((r) => r)
|
||||
.sort(Utils.getSortFunction(this.i18nService, "name"));
|
||||
const groupsResponse = await this.groupApiService.getAll(this.organizationId);
|
||||
this.groups = groupsResponse.sort(Utils.getSortFunction(this.i18nService, "name"));
|
||||
}
|
||||
this.orgKey = await this.cryptoService.getOrgKey(this.organizationId);
|
||||
|
||||
@@ -97,7 +98,7 @@ export class CollectionAddEditComponent implements OnInit {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
check(g: GroupResponse, select?: boolean) {
|
||||
check(g: GroupView, select?: boolean) {
|
||||
if (g.accessAll) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="groupAddEditTitle">
|
||||
<div class="modal-dialog modal-dialog-scrollable modal-lg" role="document">
|
||||
<form
|
||||
class="modal-content"
|
||||
#form
|
||||
(ngSubmit)="submit()"
|
||||
[appApiAction]="formPromise"
|
||||
ngNativeValidate
|
||||
>
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title" id="groupAddEditTitle">{{ title }}</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="close"
|
||||
data-dismiss="modal"
|
||||
appA11yTitle="{{ 'close' | i18n }}"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" *ngIf="loading">
|
||||
<form [formGroup]="groupForm" [bitSubmit]="submit">
|
||||
<bit-dialog dialogSize="large" [disablePadding]="!loading">
|
||||
<span bitDialogTitle>
|
||||
{{ title }}
|
||||
<span *ngIf="editMode" class="tw-text-sm tw-normal-case tw-text-muted">{{
|
||||
group?.name
|
||||
}}</span>
|
||||
</span>
|
||||
<div bitDialogContent>
|
||||
<div *ngIf="loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
@@ -26,161 +15,77 @@
|
||||
></i>
|
||||
<span class="sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
<div class="modal-body" *ngIf="!loading">
|
||||
<div class="form-group">
|
||||
<label for="name">{{ "name" | i18n }}</label>
|
||||
<input
|
||||
id="name"
|
||||
class="form-control"
|
||||
type="text"
|
||||
name="Name"
|
||||
[(ngModel)]="name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="externalId">{{ "externalId" | i18n }}</label>
|
||||
<input
|
||||
id="externalId"
|
||||
class="form-control"
|
||||
type="text"
|
||||
name="ExternalId"
|
||||
[(ngModel)]="externalId"
|
||||
/>
|
||||
<small class="form-text text-muted">{{ "externalIdDesc" | i18n }}</small>
|
||||
</div>
|
||||
<h3 class="mt-4 d-flex">
|
||||
<div class="mb-2">
|
||||
{{ "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>
|
||||
|
||||
<bit-tab-group *ngIf="!loading" [selectedIndex]="tabIndex">
|
||||
<bit-tab label="{{ 'groupInfo' | i18n }}">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "name" | i18n }}</bit-label>
|
||||
<input bitInput type="text" formControlName="name" />
|
||||
</bit-form-field>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "externalId" | i18n }}</bit-label>
|
||||
<input bitInput type="text" formControlName="externalId" />
|
||||
<bit-hint>{{ "externalIdDesc" | i18n }}</bit-hint>
|
||||
</bit-form-field>
|
||||
</bit-tab>
|
||||
|
||||
<bit-tab label="{{ 'members' | i18n }}">
|
||||
<p>{{ "editGroupMembersDesc" | i18n }}</p>
|
||||
<bit-access-selector
|
||||
formControlName="members"
|
||||
[items]="members"
|
||||
[showMemberRoles]="true"
|
||||
[permissionMode]="PermissionMode.Hidden"
|
||||
[columnHeader]="'member' | i18n"
|
||||
[selectorLabelText]="'selectMembers' | i18n"
|
||||
[emptySelectionText]="'noMembersAdded' | i18n"
|
||||
></bit-access-selector>
|
||||
</bit-tab>
|
||||
|
||||
<bit-tab label="{{ 'collections' | i18n }}">
|
||||
<p>{{ "editGroupCollectionsDesc" | i18n }}</p>
|
||||
<div class="tw-my-3">
|
||||
<input type="checkbox" formControlName="accessAll" id="accessAll" />
|
||||
<label class="tw-mb-0 tw-text-lg" for="accessAll">{{
|
||||
"accessAllCollectionsDesc" | i18n
|
||||
}}</label>
|
||||
<p class="tw-my-0 tw-text-muted">{{ "accessAllCollectionsHelp" | i18n }}</p>
|
||||
</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"
|
||||
[(ngModel)]="access"
|
||||
/>
|
||||
<label class="form-check-label" for="accessAll">
|
||||
{{ "groupAccessAllItems" | i18n }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
name="access"
|
||||
id="accessSelected"
|
||||
value="selected"
|
||||
[(ngModel)]="access"
|
||||
/>
|
||||
<label class="form-check-label" for="accessSelected">
|
||||
{{ "groupAccessSelectedCollections" | 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> </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"
|
||||
[(ngModel)]="c.checked"
|
||||
name="Collection[{{ i }}].Checked"
|
||||
appStopProp
|
||||
/>
|
||||
</td>
|
||||
<td (click)="check(c)">
|
||||
{{ c.name }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="c.hidePasswords"
|
||||
name="Collection[{{ i }}].HidePasswords"
|
||||
[disabled]="!c.checked"
|
||||
/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="c.readOnly"
|
||||
name="Collection[{{ i }}].ReadOnly"
|
||||
[disabled]="!c.checked"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
|
||||
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
|
||||
<span>{{ "save" | i18n }}</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
<div class="ml-auto">
|
||||
<button
|
||||
#deleteBtn
|
||||
type="button"
|
||||
(click)="delete()"
|
||||
class="btn btn-outline-danger"
|
||||
appA11yTitle="{{ 'delete' | i18n }}"
|
||||
*ngIf="editMode"
|
||||
[disabled]="deleteBtn.loading"
|
||||
[appApiAction]="deletePromise"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-trash bwi-lg bwi-fw"
|
||||
[hidden]="deleteBtn.loading"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-lg bwi-fw"
|
||||
[hidden]="!deleteBtn.loading"
|
||||
aria-hidden="true"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<ng-container *ngIf="!groupForm.value.accessAll">
|
||||
<bit-access-selector
|
||||
formControlName="collections"
|
||||
[items]="collections"
|
||||
[permissionMode]="PermissionMode.Edit"
|
||||
[columnHeader]="'collection' | i18n"
|
||||
[selectorLabelText]="'selectCollections' | i18n"
|
||||
[emptySelectionText]="'noCollectionsAdded' | i18n"
|
||||
></bit-access-selector>
|
||||
</ng-container>
|
||||
</bit-tab>
|
||||
</bit-tab-group>
|
||||
</div>
|
||||
<div bitDialogFooter class="tw-flex tw-flex-row tw-gap-2">
|
||||
<button bitButton buttonType="primary" bitFormButton type="submit">
|
||||
{{ "save" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
type="button"
|
||||
bitDialogClose
|
||||
[bit-dialog-close]="ResultType.Canceled"
|
||||
>
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
class="tw-ml-auto"
|
||||
type="button"
|
||||
buttonType="danger"
|
||||
bitIconButton="bwi-trash"
|
||||
bitFormButton
|
||||
[bitAction]="delete"
|
||||
[appA11yTitle]="'delete' | i18n"
|
||||
></button>
|
||||
</div>
|
||||
</bit-dialog>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import { Overlay } from "@angular/cdk/overlay";
|
||||
import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormBuilder, FormControl, Validators } from "@angular/forms";
|
||||
import { catchError, combineLatest, from, map, of, Subject, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { CollectionService } from "@bitwarden/common/abstractions/collection.service";
|
||||
@@ -7,127 +11,267 @@ import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
import { CollectionData } from "@bitwarden/common/models/data/collection.data";
|
||||
import { Collection } from "@bitwarden/common/models/domain/collection";
|
||||
import { GroupRequest } from "@bitwarden/common/models/request/group.request";
|
||||
import { SelectionReadOnlyRequest } from "@bitwarden/common/models/request/selection-read-only.request";
|
||||
import { CollectionDetailsResponse } from "@bitwarden/common/models/response/collection.response";
|
||||
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import {
|
||||
AccessItemType,
|
||||
AccessItemValue,
|
||||
AccessItemView,
|
||||
convertToPermission,
|
||||
convertToSelectionView,
|
||||
PermissionMode,
|
||||
} from "../components/access-selector";
|
||||
import { GroupServiceAbstraction } from "../services/abstractions/group";
|
||||
import { GroupView } from "../views/group.view";
|
||||
|
||||
/**
|
||||
* Indices for the available tabs in the dialog
|
||||
*/
|
||||
export enum GroupAddEditTabType {
|
||||
Info = 0,
|
||||
Members = 1,
|
||||
Collections = 2,
|
||||
}
|
||||
|
||||
export interface GroupAddEditDialogParams {
|
||||
/**
|
||||
* ID of the organization the group belongs to
|
||||
*/
|
||||
organizationId: string;
|
||||
|
||||
/**
|
||||
* Optional ID of the group being modified
|
||||
*/
|
||||
groupId?: string;
|
||||
|
||||
/**
|
||||
* Tab to open when the dialog is open.
|
||||
* Defaults to Group Info
|
||||
*/
|
||||
initialTab?: GroupAddEditTabType;
|
||||
}
|
||||
|
||||
export enum GroupAddEditDialogResultType {
|
||||
Saved = "saved",
|
||||
Canceled = "canceled",
|
||||
Deleted = "deleted",
|
||||
}
|
||||
|
||||
/**
|
||||
* Strongly typed helper to open a groupAddEditDialog
|
||||
* @param dialogService Instance of the dialog service that will be used to open the dialog
|
||||
* @param overlay Instance of the CDK Overlay service
|
||||
* @param config Configuration for the dialog
|
||||
*/
|
||||
export const openGroupAddEditDialog = (
|
||||
dialogService: DialogService,
|
||||
overlay: Overlay,
|
||||
config: DialogConfig<GroupAddEditDialogParams>
|
||||
) => {
|
||||
return dialogService.open<GroupAddEditDialogResultType, GroupAddEditDialogParams>(
|
||||
GroupAddEditComponent,
|
||||
{
|
||||
positionStrategy: overlay.position().global().centerHorizontally().top(),
|
||||
...config,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "app-group-add-edit",
|
||||
templateUrl: "group-add-edit.component.html",
|
||||
})
|
||||
export class GroupAddEditComponent implements OnInit {
|
||||
@Input() groupId: string;
|
||||
@Input() organizationId: string;
|
||||
@Output() onSavedGroup = new EventEmitter();
|
||||
@Output() onDeletedGroup = new EventEmitter();
|
||||
export class GroupAddEditComponent implements OnInit, OnDestroy {
|
||||
protected PermissionMode = PermissionMode;
|
||||
protected ResultType = GroupAddEditDialogResultType;
|
||||
|
||||
tabIndex: GroupAddEditTabType;
|
||||
loading = true;
|
||||
editMode = false;
|
||||
title: string;
|
||||
name: string;
|
||||
externalId: string;
|
||||
access: "all" | "selected" = "selected";
|
||||
collections: CollectionView[] = [];
|
||||
formPromise: Promise<any>;
|
||||
deletePromise: Promise<any>;
|
||||
collections: AccessItemView[] = [];
|
||||
members: AccessItemView[] = [];
|
||||
group: GroupView;
|
||||
|
||||
groupForm = this.formBuilder.group({
|
||||
accessAll: new FormControl(false),
|
||||
name: new FormControl("", Validators.required),
|
||||
externalId: new FormControl(""),
|
||||
members: new FormControl<AccessItemValue[]>([]),
|
||||
collections: new FormControl<AccessItemValue[]>([]),
|
||||
});
|
||||
|
||||
get groupId(): string | undefined {
|
||||
return this.params.groupId;
|
||||
}
|
||||
|
||||
get organizationId(): string {
|
||||
return this.params.organizationId;
|
||||
}
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
private get orgCollections$() {
|
||||
return from(this.apiService.getCollections(this.organizationId)).pipe(
|
||||
switchMap((response) => {
|
||||
return from(
|
||||
this.collectionService.decryptMany(
|
||||
response.data.map(
|
||||
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse))
|
||||
)
|
||||
)
|
||||
);
|
||||
}),
|
||||
map((collections) =>
|
||||
collections.map<AccessItemView>((c) => ({
|
||||
id: c.id,
|
||||
type: AccessItemType.Collection,
|
||||
labelName: c.name,
|
||||
listName: c.name,
|
||||
}))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private get orgMembers$() {
|
||||
return from(this.apiService.getOrganizationUsers(this.organizationId)).pipe(
|
||||
map((response) =>
|
||||
response.data.map((m) => ({
|
||||
id: m.id,
|
||||
type: AccessItemType.Member,
|
||||
email: m.email,
|
||||
role: m.type,
|
||||
listName: m.name?.length > 0 ? `${m.name} (${m.email})` : m.email,
|
||||
labelName: m.name || m.email,
|
||||
status: m.status,
|
||||
}))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private get groupDetails$() {
|
||||
if (!this.editMode) {
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
return combineLatest([
|
||||
this.groupService.get(this.organizationId, this.groupId),
|
||||
this.apiService.getGroupUsers(this.organizationId, this.groupId),
|
||||
]).pipe(
|
||||
map(([groupView, users]) => {
|
||||
groupView.members = users;
|
||||
return groupView;
|
||||
}),
|
||||
catchError((e: unknown) => {
|
||||
if (e instanceof ErrorResponse) {
|
||||
this.logService.error(e.message);
|
||||
} else {
|
||||
this.logService.error(e.toString());
|
||||
}
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) private params: GroupAddEditDialogParams,
|
||||
private dialogRef: DialogRef<GroupAddEditDialogResultType>,
|
||||
private apiService: ApiService,
|
||||
private groupService: GroupServiceAbstraction,
|
||||
private i18nService: I18nService,
|
||||
private collectionService: CollectionService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private logService: LogService
|
||||
) {}
|
||||
private logService: LogService,
|
||||
private formBuilder: FormBuilder,
|
||||
private changeDetectorRef: ChangeDetectorRef
|
||||
) {
|
||||
this.tabIndex = params.initialTab ?? GroupAddEditTabType.Info;
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
ngOnInit() {
|
||||
this.editMode = this.loading = this.groupId != null;
|
||||
await this.loadCollections();
|
||||
this.title = this.i18nService.t(this.editMode ? "editGroup" : "addGroup");
|
||||
|
||||
if (this.editMode) {
|
||||
this.editMode = true;
|
||||
this.title = this.i18nService.t("editGroup");
|
||||
try {
|
||||
const group = await this.apiService.getGroupDetails(this.organizationId, this.groupId);
|
||||
this.access = group.accessAll ? "all" : "selected";
|
||||
this.name = group.name;
|
||||
this.externalId = group.externalId;
|
||||
if (group.collections != null && this.collections != null) {
|
||||
group.collections.forEach((s) => {
|
||||
const collection = this.collections.filter((c) => c.id === s.id);
|
||||
if (collection != null && collection.length > 0) {
|
||||
(collection[0] as any).checked = true;
|
||||
collection[0].readOnly = s.readOnly;
|
||||
collection[0].hidePasswords = s.hidePasswords;
|
||||
}
|
||||
combineLatest([this.orgCollections$, this.orgMembers$, this.groupDetails$])
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(([collections, members, group]) => {
|
||||
this.collections = collections;
|
||||
this.members = members;
|
||||
this.group = group;
|
||||
|
||||
if (this.group != undefined) {
|
||||
// Must detect changes so that AccessSelector @Inputs() are aware of the latest
|
||||
// collections/members set above, otherwise no selected values will be patched below
|
||||
this.changeDetectorRef.detectChanges();
|
||||
|
||||
this.groupForm.patchValue({
|
||||
name: this.group.name,
|
||||
externalId: this.group.externalId,
|
||||
accessAll: this.group.accessAll,
|
||||
members: this.group.members.map((m) => ({
|
||||
id: m,
|
||||
type: AccessItemType.Member,
|
||||
})),
|
||||
collections: this.group.collections.map((gc) => ({
|
||||
id: gc.id,
|
||||
type: AccessItemType.Collection,
|
||||
permission: convertToPermission(gc),
|
||||
})),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
} else {
|
||||
this.title = this.i18nService.t("addGroup");
|
||||
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
if (this.groupForm.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
const groupView = new GroupView();
|
||||
groupView.id = this.groupId;
|
||||
groupView.organizationId = this.organizationId;
|
||||
|
||||
async loadCollections() {
|
||||
const response = await this.apiService.getCollections(this.organizationId);
|
||||
const collections = response.data.map(
|
||||
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse))
|
||||
);
|
||||
this.collections = await this.collectionService.decryptMany(collections);
|
||||
}
|
||||
const formValue = this.groupForm.value;
|
||||
groupView.name = formValue.name;
|
||||
groupView.externalId = formValue.externalId;
|
||||
groupView.accessAll = formValue.accessAll;
|
||||
groupView.members = formValue.members?.map((m) => m.id) ?? [];
|
||||
|
||||
check(c: CollectionView, select?: boolean) {
|
||||
(c as any).checked = select == null ? !(c as any).checked : select;
|
||||
if (!(c as any).checked) {
|
||||
c.readOnly = false;
|
||||
}
|
||||
}
|
||||
|
||||
selectAll(select: boolean) {
|
||||
this.collections.forEach((c) => this.check(c, select));
|
||||
}
|
||||
|
||||
async submit() {
|
||||
const request = new GroupRequest();
|
||||
request.name = this.name;
|
||||
request.externalId = this.externalId;
|
||||
request.accessAll = this.access === "all";
|
||||
if (!request.accessAll) {
|
||||
request.collections = this.collections
|
||||
.filter((c) => (c as any).checked)
|
||||
.map((c) => new SelectionReadOnlyRequest(c.id, !!c.readOnly, !!c.hidePasswords));
|
||||
if (!groupView.accessAll) {
|
||||
groupView.collections = formValue.collections.map((c) => convertToSelectionView(c));
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.editMode) {
|
||||
this.formPromise = this.apiService.putGroup(this.organizationId, this.groupId, request);
|
||||
} else {
|
||||
this.formPromise = this.apiService.postGroup(this.organizationId, request);
|
||||
}
|
||||
await this.formPromise;
|
||||
await this.groupService.save(groupView);
|
||||
|
||||
this.platformUtilsService.showToast(
|
||||
"success",
|
||||
null,
|
||||
this.i18nService.t(this.editMode ? "editedGroupId" : "createdGroupId", this.name)
|
||||
this.i18nService.t(this.editMode ? "editedGroupId" : "createdGroupId", formValue.name)
|
||||
);
|
||||
this.onSavedGroup.emit();
|
||||
this.dialogRef.close(GroupAddEditDialogResultType.Saved);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async delete() {
|
||||
delete = async () => {
|
||||
if (!this.editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await this.platformUtilsService.showDialog(
|
||||
this.i18nService.t("deleteGroupConfirmation"),
|
||||
this.name,
|
||||
this.group.name,
|
||||
this.i18nService.t("yes"),
|
||||
this.i18nService.t("no"),
|
||||
"warning"
|
||||
@@ -137,16 +281,16 @@ export class GroupAddEditComponent implements OnInit {
|
||||
}
|
||||
|
||||
try {
|
||||
this.deletePromise = this.apiService.deleteGroup(this.organizationId, this.groupId);
|
||||
await this.deletePromise;
|
||||
await this.groupService.delete(this.organizationId, this.groupId);
|
||||
|
||||
this.platformUtilsService.showToast(
|
||||
"success",
|
||||
null,
|
||||
this.i18nService.t("deletedGroupId", this.name)
|
||||
this.i18nService.t("deletedGroupId", this.group.name)
|
||||
);
|
||||
this.onDeletedGroup.emit();
|
||||
this.dialogRef.close(GroupAddEditDialogResultType.Deleted);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
<div class="container page-content">
|
||||
<div class="page-header d-flex">
|
||||
<div class="tw-mb-4 tw-flex">
|
||||
<h1>{{ "groups" | i18n }}</h1>
|
||||
<div class="ml-auto d-flex">
|
||||
<div>
|
||||
<label class="sr-only" for="search">{{ "search" | i18n }}</label>
|
||||
<input
|
||||
type="search"
|
||||
class="form-control form-control-sm"
|
||||
id="search"
|
||||
placeholder="{{ 'search' | i18n }}"
|
||||
[(ngModel)]="searchText"
|
||||
/>
|
||||
<div class="tw-ml-auto tw-flex tw-items-center">
|
||||
<div class="tw-mr-2">
|
||||
<label class="sr-only">{{ "search" | i18n }}</label>
|
||||
<div class="tw-flex tw-items-center">
|
||||
<i class="bwi bwi-search bwi-fw tw-z-20 -tw-mr-7 tw-text-muted" aria-hidden="true"></i>
|
||||
<input
|
||||
bitInput
|
||||
type="search"
|
||||
placeholder="{{ 'search' | i18n }}"
|
||||
class="tw-rounded-l tw-pl-9"
|
||||
[(ngModel)]="searchText"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ml-3" (click)="add()">
|
||||
<button bitButton type="button" buttonType="primary" (click)="add()">
|
||||
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
|
||||
{{ "newGroup" | i18n }}
|
||||
</button>
|
||||
@@ -26,54 +29,95 @@
|
||||
></i>
|
||||
<span class="sr-only">{{ "loading" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container
|
||||
*ngIf="
|
||||
!loading &&
|
||||
(isPaging() ? pagedGroups : (groups | search: searchText:'name':'id')) as searchedGroups
|
||||
"
|
||||
>
|
||||
<p *ngIf="!searchedGroups.length">{{ "noGroupsInList" | i18n }}</p>
|
||||
<table
|
||||
class="table table-hover table-list"
|
||||
*ngIf="searchedGroups.length"
|
||||
infiniteScroll
|
||||
<ng-container *ngIf="!loading && visibleGroups">
|
||||
<p *ngIf="!visibleGroups.length">{{ "noGroupsInList" | i18n }}</p>
|
||||
<bit-table
|
||||
*ngIf="visibleGroups.length"
|
||||
infinite-scroll
|
||||
[infiniteScrollDistance]="1"
|
||||
[infiniteScrollDisabled]="!isPaging()"
|
||||
(scrolled)="loadMore()"
|
||||
>
|
||||
<tbody>
|
||||
<tr *ngFor="let g of searchedGroups">
|
||||
<td>
|
||||
<a href="#" appStopClick (click)="edit(g)">{{ g.name }}</a>
|
||||
</td>
|
||||
<td class="table-list-options">
|
||||
<div class="dropdown" appListDropdown>
|
||||
<button
|
||||
class="btn btn-outline-secondary dropdown-toggle"
|
||||
type="button"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
appA11yTitle="{{ 'options' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell class="tw-w-20">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="tw-mr-2"
|
||||
(change)="toggleAllVisible($event)"
|
||||
id="selectAll"
|
||||
/>
|
||||
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{
|
||||
"all" | i18n
|
||||
}}</label>
|
||||
</th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "collections" | i18n }}</th>
|
||||
<th bitCell class="tw-w-10">
|
||||
<button
|
||||
[bitMenuTriggerFor]="headerMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
appA11yTitle="{{ 'options' | i18n }}"
|
||||
></button>
|
||||
|
||||
<bit-menu #headerMenu>
|
||||
<button type="button" bitMenuItem (click)="deleteAllSelected()">
|
||||
<span class="tw-text-danger"
|
||||
><i aria-hidden="true" class="bwi bwi-trash"></i> {{ "delete" | i18n }}</span
|
||||
>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-right">
|
||||
<a class="dropdown-item" href="#" appStopClick (click)="users(g)">
|
||||
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
|
||||
{{ "users" | i18n }}
|
||||
</a>
|
||||
<a class="dropdown-item text-danger" href="#" appStopClick (click)="delete(g)">
|
||||
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
|
||||
{{ "delete" | i18n }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</bit-menu>
|
||||
</th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<ng-container body>
|
||||
<tr bitRow *ngFor="let g of visibleGroups">
|
||||
<td bitCell (click)="check(g)" class="tw-cursor-pointer">
|
||||
<input type="checkbox" [(ngModel)]="g.checked" />
|
||||
</td>
|
||||
<td bitCell class="tw-cursor-pointer tw-font-bold" (click)="edit(g)">
|
||||
<button (click)="edit(g)" bitLink>
|
||||
{{ g.details.name }}
|
||||
</button>
|
||||
</td>
|
||||
<td bitCell (click)="edit(g, ModalTabType.Collections)" class="tw-cursor-pointer">
|
||||
<bit-badge-list
|
||||
[items]="g.collectionNames"
|
||||
[maxItems]="2"
|
||||
badgeType="secondary"
|
||||
></bit-badge-list>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<button
|
||||
[bitMenuTriggerFor]="rowMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
appA11yTitle="{{ 'options' | i18n }}"
|
||||
></button>
|
||||
|
||||
<bit-menu #rowMenu>
|
||||
<button type="button" bitMenuItem (click)="edit(g)">
|
||||
<i aria-hidden="true" class="bwi bwi-pencil-square"></i> {{ "editInfo" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="edit(g, ModalTabType.Members)">
|
||||
<i aria-hidden="true" class="bwi bwi-user"></i> {{ "members" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="edit(g, ModalTabType.Collections)">
|
||||
<i aria-hidden="true" class="bwi bwi-collection"></i> {{ "collections" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="delete(g)">
|
||||
<span class="tw-text-danger"
|
||||
><i aria-hidden="true" class="bwi bwi-trash"></i> {{ "delete" | i18n }}</span
|
||||
>
|
||||
</button>
|
||||
</bit-menu>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</ng-container>
|
||||
</bit-table>
|
||||
</ng-container>
|
||||
<ng-template #addEdit></ng-template>
|
||||
<ng-template #usersTemplate></ng-template>
|
||||
</div>
|
||||
|
||||
@@ -1,69 +1,195 @@
|
||||
import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
|
||||
import { Overlay } from "@angular/cdk/overlay";
|
||||
import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
concatMap,
|
||||
from,
|
||||
lastValueFrom,
|
||||
map,
|
||||
Subject,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import { first } from "rxjs/operators";
|
||||
|
||||
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
|
||||
import { ModalService } from "@bitwarden/angular/services/modal.service";
|
||||
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 { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { Utils } from "@bitwarden/common/misc/utils";
|
||||
import { GroupResponse } from "@bitwarden/common/models/response/group.response";
|
||||
import { CollectionData } from "@bitwarden/common/models/data/collection.data";
|
||||
import { Collection } from "@bitwarden/common/models/domain/collection";
|
||||
import {
|
||||
CollectionDetailsResponse,
|
||||
CollectionResponse,
|
||||
} from "@bitwarden/common/models/response/collection.response";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { EntityUsersComponent } from "./entity-users.component";
|
||||
import { GroupAddEditComponent } from "./group-add-edit.component";
|
||||
import { GroupServiceAbstraction } from "../services/abstractions/group";
|
||||
import { GroupView } from "../views/group.view";
|
||||
|
||||
import {
|
||||
GroupAddEditDialogResultType,
|
||||
GroupAddEditTabType,
|
||||
openGroupAddEditDialog,
|
||||
} from "./group-add-edit.component";
|
||||
|
||||
type CollectionViewMap = {
|
||||
[id: string]: CollectionView;
|
||||
};
|
||||
|
||||
type GroupDetailsRow = {
|
||||
/**
|
||||
* Group Id (used for searching)
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Group name (used for searching)
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Details used for displaying group information
|
||||
*/
|
||||
details: GroupView;
|
||||
|
||||
/**
|
||||
* True if the group is selected in the table
|
||||
*/
|
||||
checked?: boolean;
|
||||
|
||||
/**
|
||||
* A list of collection names the group has access to
|
||||
*/
|
||||
collectionNames?: string[];
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "app-org-groups",
|
||||
templateUrl: "groups.component.html",
|
||||
})
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
export class GroupsComponent implements OnInit {
|
||||
export class GroupsComponent implements OnInit, OnDestroy {
|
||||
@ViewChild("addEdit", { read: ViewContainerRef, static: true }) addEditModalRef: ViewContainerRef;
|
||||
@ViewChild("usersTemplate", { read: ViewContainerRef, static: true })
|
||||
usersModalRef: ViewContainerRef;
|
||||
|
||||
loading = true;
|
||||
organizationId: string;
|
||||
groups: GroupResponse[];
|
||||
pagedGroups: GroupResponse[];
|
||||
searchText: string;
|
||||
groups: GroupDetailsRow[];
|
||||
|
||||
protected didScroll = false;
|
||||
protected pageSize = 100;
|
||||
protected ModalTabType = GroupAddEditTabType;
|
||||
|
||||
private pagedGroupsCount = 0;
|
||||
private pagedGroups: GroupDetailsRow[];
|
||||
private searchedGroups: GroupDetailsRow[];
|
||||
private _searchText: string;
|
||||
private destroy$ = new Subject<void>();
|
||||
private refreshGroups$ = new BehaviorSubject<void>(null);
|
||||
|
||||
get searchText() {
|
||||
return this._searchText;
|
||||
}
|
||||
set searchText(value: string) {
|
||||
this._searchText = value;
|
||||
// Manually update as we are not using the search pipe in the template
|
||||
this.updateSearchedGroups();
|
||||
}
|
||||
|
||||
/**
|
||||
* The list of groups that should be visible in the table.
|
||||
* This is needed as there are two modes (paging/searching) and
|
||||
* we need a reference to the currently visible groups for
|
||||
* the Select All checkbox
|
||||
*/
|
||||
get visibleGroups(): GroupDetailsRow[] {
|
||||
if (this.isPaging()) {
|
||||
return this.pagedGroups;
|
||||
}
|
||||
if (this.isSearching()) {
|
||||
return this.searchedGroups;
|
||||
}
|
||||
return this.groups;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private groupApiService: GroupServiceAbstraction,
|
||||
private route: ActivatedRoute,
|
||||
private i18nService: I18nService,
|
||||
private modalService: ModalService,
|
||||
private dialogService: DialogService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private searchService: SearchService,
|
||||
private logService: LogService
|
||||
private logService: LogService,
|
||||
private collectionService: CollectionService,
|
||||
private searchPipe: SearchPipe,
|
||||
private overlay: Overlay
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||
this.route.parent.params.subscribe(async (params) => {
|
||||
this.organizationId = params.organizationId;
|
||||
await this.load();
|
||||
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
|
||||
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
|
||||
this.searchText = qParams.search;
|
||||
this.route.params
|
||||
.pipe(
|
||||
tap((params) => (this.organizationId = params.organizationId)),
|
||||
switchMap(() =>
|
||||
combineLatest([
|
||||
// collectionMap
|
||||
from(this.apiService.getCollections(this.organizationId)).pipe(
|
||||
concatMap((response) => this.toCollectionMap(response))
|
||||
),
|
||||
// groups
|
||||
this.refreshGroups$.pipe(
|
||||
switchMap(() => this.groupApiService.getAll(this.organizationId))
|
||||
),
|
||||
])
|
||||
),
|
||||
map(([collectionMap, groups]) => {
|
||||
return groups
|
||||
.sort(Utils.getSortFunction(this.i18nService, "name"))
|
||||
.map<GroupDetailsRow>((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
details: g,
|
||||
checked: false,
|
||||
collectionNames: g.collections
|
||||
.map((c) => collectionMap[c.id]?.name)
|
||||
.sort(this.i18nService.collator?.compare),
|
||||
}));
|
||||
}),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe((groups) => {
|
||||
this.groups = groups;
|
||||
this.resetPaging();
|
||||
this.updateSearchedGroups();
|
||||
this.loading = false;
|
||||
});
|
||||
});
|
||||
|
||||
this.route.queryParams
|
||||
.pipe(
|
||||
first(),
|
||||
concatMap(async (qParams) => {
|
||||
this.searchText = qParams.search;
|
||||
}),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
async load() {
|
||||
const response = await this.apiService.getGroups(this.organizationId);
|
||||
const groups = response.data != null && response.data.length > 0 ? response.data : [];
|
||||
groups.sort(Utils.getSortFunction(this.i18nService, "name"));
|
||||
this.groups = groups;
|
||||
this.resetPaging();
|
||||
this.loading = false;
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
loadMore() {
|
||||
@@ -84,35 +210,35 @@ export class GroupsComponent implements OnInit {
|
||||
this.didScroll = this.pagedGroups.length > this.pageSize;
|
||||
}
|
||||
|
||||
async edit(group: GroupResponse) {
|
||||
const [modal] = await this.modalService.openViewRef(
|
||||
GroupAddEditComponent,
|
||||
this.addEditModalRef,
|
||||
(comp) => {
|
||||
comp.organizationId = this.organizationId;
|
||||
comp.groupId = group != null ? group.id : null;
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
comp.onSavedGroup.subscribe(() => {
|
||||
modal.close();
|
||||
this.load();
|
||||
});
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
comp.onDeletedGroup.subscribe(() => {
|
||||
modal.close();
|
||||
this.removeGroup(group);
|
||||
});
|
||||
}
|
||||
);
|
||||
async edit(
|
||||
group: GroupDetailsRow,
|
||||
startingTabIndex: GroupAddEditTabType = GroupAddEditTabType.Info
|
||||
) {
|
||||
const dialogRef = openGroupAddEditDialog(this.dialogService, this.overlay, {
|
||||
data: {
|
||||
initialTab: startingTabIndex,
|
||||
organizationId: this.organizationId,
|
||||
groupId: group != null ? group.details.id : null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
|
||||
if (result == GroupAddEditDialogResultType.Saved) {
|
||||
this.refreshGroups$.next();
|
||||
} else if (result == GroupAddEditDialogResultType.Deleted) {
|
||||
this.removeGroup(group.details.id);
|
||||
}
|
||||
}
|
||||
|
||||
add() {
|
||||
this.edit(null);
|
||||
}
|
||||
|
||||
async delete(group: GroupResponse) {
|
||||
async delete(groupRow: GroupDetailsRow) {
|
||||
const confirmed = await this.platformUtilsService.showDialog(
|
||||
this.i18nService.t("deleteGroupConfirmation"),
|
||||
group.name,
|
||||
groupRow.details.name,
|
||||
this.i18nService.t("yes"),
|
||||
this.i18nService.t("no"),
|
||||
"warning"
|
||||
@@ -122,37 +248,55 @@ export class GroupsComponent implements OnInit {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.apiService.deleteGroup(this.organizationId, group.id);
|
||||
await this.groupApiService.delete(this.organizationId, groupRow.details.id);
|
||||
this.platformUtilsService.showToast(
|
||||
"success",
|
||||
null,
|
||||
this.i18nService.t("deletedGroupId", group.name)
|
||||
this.i18nService.t("deletedGroupId", groupRow.details.name)
|
||||
);
|
||||
this.removeGroup(group);
|
||||
this.removeGroup(groupRow.details.id);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async users(group: GroupResponse) {
|
||||
const [modal] = await this.modalService.openViewRef(
|
||||
EntityUsersComponent,
|
||||
this.usersModalRef,
|
||||
(comp) => {
|
||||
comp.organizationId = this.organizationId;
|
||||
comp.entity = "group";
|
||||
comp.entityId = group.id;
|
||||
comp.entityName = group.name;
|
||||
async deleteAllSelected() {
|
||||
const groupsToDelete = this.groups.filter((g) => g.checked);
|
||||
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
comp.onEditedUsers.subscribe(() => {
|
||||
modal.close();
|
||||
});
|
||||
}
|
||||
if (groupsToDelete.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteMessage = groupsToDelete.map((g) => g.details.name).join(", ");
|
||||
const confirmed = await this.platformUtilsService.showDialog(
|
||||
deleteMessage,
|
||||
this.i18nService.t("deleteMultipleGroupsConfirmation", groupsToDelete.length.toString()),
|
||||
this.i18nService.t("yes"),
|
||||
this.i18nService.t("no"),
|
||||
"warning"
|
||||
);
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.groupApiService.deleteMany(
|
||||
this.organizationId,
|
||||
groupsToDelete.map((g) => g.details.id)
|
||||
);
|
||||
this.platformUtilsService.showToast(
|
||||
"success",
|
||||
null,
|
||||
this.i18nService.t("deletedManyGroups", result.length.toString())
|
||||
);
|
||||
|
||||
groupsToDelete.forEach((g) => this.removeGroup(g.details.id));
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async resetPaging() {
|
||||
resetPaging() {
|
||||
this.pagedGroups = [];
|
||||
this.loadMore();
|
||||
}
|
||||
@@ -161,6 +305,14 @@ export class GroupsComponent implements OnInit {
|
||||
return this.searchService.isSearchable(this.searchText);
|
||||
}
|
||||
|
||||
check(groupRow: GroupDetailsRow) {
|
||||
groupRow.checked = !groupRow.checked;
|
||||
}
|
||||
|
||||
toggleAllVisible(event: Event) {
|
||||
this.visibleGroups.forEach((g) => (g.checked = (event.target as HTMLInputElement).checked));
|
||||
}
|
||||
|
||||
isPaging() {
|
||||
const searching = this.isSearching();
|
||||
if (searching && this.didScroll) {
|
||||
@@ -169,11 +321,32 @@ export class GroupsComponent implements OnInit {
|
||||
return !searching && this.groups && this.groups.length > this.pageSize;
|
||||
}
|
||||
|
||||
private removeGroup(group: GroupResponse) {
|
||||
const index = this.groups.indexOf(group);
|
||||
private removeGroup(id: string) {
|
||||
const index = this.groups.findIndex((g) => g.details.id === id);
|
||||
if (index > -1) {
|
||||
this.groups.splice(index, 1);
|
||||
this.resetPaging();
|
||||
this.updateSearchedGroups();
|
||||
}
|
||||
}
|
||||
|
||||
private async toCollectionMap(response: ListResponse<CollectionResponse>) {
|
||||
const collections = response.data.map(
|
||||
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse))
|
||||
);
|
||||
const decryptedCollections = await this.collectionService.decryptMany(collections);
|
||||
|
||||
// Convert to an object using collection Ids as keys for faster name lookups
|
||||
const collectionMap: CollectionViewMap = {};
|
||||
decryptedCollections.forEach((c) => (collectionMap[c.id] = c));
|
||||
|
||||
return collectionMap;
|
||||
}
|
||||
|
||||
private updateSearchedGroups() {
|
||||
if (this.searchService.isSearchable(this.searchText)) {
|
||||
// Making use of the pipe in the component as we need know which groups where filtered
|
||||
this.searchedGroups = this.searchPipe.transform(this.groups, this.searchText, "name", "id");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
import { Utils } from "@bitwarden/common/misc/utils";
|
||||
import { OrganizationUserUpdateGroupsRequest } from "@bitwarden/common/models/request/organization-user-update-groups.request";
|
||||
import { GroupResponse } from "@bitwarden/common/models/response/group.response";
|
||||
|
||||
import { GroupServiceAbstraction } from "../services/abstractions/group";
|
||||
import { GroupView } from "../views/group.view";
|
||||
|
||||
@Component({
|
||||
selector: "app-user-groups",
|
||||
@@ -19,19 +21,19 @@ export class UserGroupsComponent implements OnInit {
|
||||
@Output() onSavedUser = new EventEmitter();
|
||||
|
||||
loading = true;
|
||||
groups: GroupResponse[] = [];
|
||||
groups: GroupView[] = [];
|
||||
formPromise: Promise<any>;
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private groupApiService: GroupServiceAbstraction,
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private logService: LogService
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
const groupsResponse = await this.apiService.getGroups(this.organizationId);
|
||||
const groups = groupsResponse.data.map((r) => r);
|
||||
const groups = await this.groupApiService.getAll(this.organizationId);
|
||||
groups.sort(Utils.getSortFunction(this.i18nService, "name"));
|
||||
this.groups = groups;
|
||||
|
||||
@@ -55,7 +57,7 @@ export class UserGroupsComponent implements OnInit {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
check(g: GroupResponse, select?: boolean) {
|
||||
check(g: GroupView, select?: boolean) {
|
||||
(g as any).checked = select == null ? !(g as any).checked : select;
|
||||
if (!(g as any).checked) {
|
||||
(g as any).readOnly = false;
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
|
||||
|
||||
import { SharedModule } from "../shared";
|
||||
|
||||
import { AccessSelectorModule } from "./components/access-selector";
|
||||
import { CollectionAddEditComponent } from "./manage/collection-add-edit.component";
|
||||
import { GroupAddEditComponent } from "./manage/group-add-edit.component";
|
||||
import { GroupsComponent } from "./manage/groups.component";
|
||||
import { UserGroupsComponent } from "./manage/user-groups.component";
|
||||
import { OrganizationsRoutingModule } from "./organization-routing.module";
|
||||
import { GroupServiceAbstraction } from "./services/abstractions/group";
|
||||
import { GroupService } from "./services/group/group.service";
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule, AccessSelectorModule, OrganizationsRoutingModule],
|
||||
declarations: [
|
||||
GroupsComponent,
|
||||
GroupAddEditComponent,
|
||||
CollectionAddEditComponent,
|
||||
UserGroupsComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: GroupServiceAbstraction,
|
||||
useClass: GroupService,
|
||||
deps: [ApiServiceAbstraction],
|
||||
},
|
||||
],
|
||||
})
|
||||
export class OrganizationModule {}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { GroupView } from "../../../views/group.view";
|
||||
|
||||
export class GroupServiceAbstraction {
|
||||
delete: (orgId: string, groupId: string) => Promise<void>;
|
||||
deleteMany: (orgId: string, groupIds: string[]) => Promise<GroupView[]>;
|
||||
|
||||
get: (orgId: string, groupId: string) => Promise<GroupView>;
|
||||
getAll: (orgId: string) => Promise<GroupView[]>;
|
||||
|
||||
save: (group: GroupView) => Promise<GroupView>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./group.service.abstraction";
|
||||
export * from "./requests/group.request";
|
||||
@@ -1,8 +1,9 @@
|
||||
import { SelectionReadOnlyRequest } from "./selection-read-only.request";
|
||||
import { SelectionReadOnlyRequest } from "@bitwarden/common/models/request/selection-read-only.request";
|
||||
|
||||
export class GroupRequest {
|
||||
name: string;
|
||||
accessAll: boolean;
|
||||
externalId: string;
|
||||
collections: SelectionReadOnlyRequest[] = [];
|
||||
users: string[] = [];
|
||||
}
|
||||
110
apps/web/src/app/organizations/services/group/group.service.ts
Normal file
110
apps/web/src/app/organizations/services/group/group.service.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { SelectionReadOnlyRequest } from "@bitwarden/common/models/request/selection-read-only.request";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
|
||||
import { GroupView } from "../../views/group.view";
|
||||
import { GroupRequest, GroupServiceAbstraction } from "../abstractions/group";
|
||||
|
||||
import { OrganizationGroupBulkRequest } from "./requests/organization-group-bulk.request";
|
||||
import { GroupDetailsResponse, GroupResponse } from "./responses/group.response";
|
||||
|
||||
@Injectable()
|
||||
export class GroupService implements GroupServiceAbstraction {
|
||||
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<GroupView[]> {
|
||||
const request = new OrganizationGroupBulkRequest(groupIds);
|
||||
|
||||
const r = await this.apiService.send(
|
||||
"DELETE",
|
||||
"/organizations/" + orgId + "/groups",
|
||||
request,
|
||||
true,
|
||||
true
|
||||
);
|
||||
const listResponse = new ListResponse(r, GroupResponse);
|
||||
|
||||
return listResponse.data?.map((gr) => GroupView.fromResponse(gr)) ?? [];
|
||||
}
|
||||
|
||||
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.externalId = group.externalId;
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class OrganizationGroupBulkRequest {
|
||||
ids: string[];
|
||||
|
||||
constructor(ids: string[]) {
|
||||
this.ids = ids == null ? [] : ids;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BaseResponse } from "./base.response";
|
||||
import { SelectionReadOnlyResponse } from "./selection-read-only.response";
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { SelectionReadOnlyResponse } from "@bitwarden/common/models/response/selection-read-only.response";
|
||||
|
||||
export class GroupResponse extends BaseResponse {
|
||||
id: string;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { View } from "@bitwarden/common/models/view/view";
|
||||
|
||||
interface SelectionResponseLike {
|
||||
id: string;
|
||||
readOnly: boolean;
|
||||
hidePasswords: boolean;
|
||||
}
|
||||
|
||||
export class CollectionAccessSelectionView extends View {
|
||||
id: string;
|
||||
readOnly: boolean;
|
||||
hidePasswords: boolean;
|
||||
|
||||
constructor(source?: SelectionResponseLike) {
|
||||
super();
|
||||
|
||||
if (source == undefined) {
|
||||
return;
|
||||
}
|
||||
this.id = source.id;
|
||||
this.readOnly = source.readOnly;
|
||||
this.hidePasswords = source.hidePasswords;
|
||||
}
|
||||
}
|
||||
24
apps/web/src/app/organizations/views/group.view.ts
Normal file
24
apps/web/src/app/organizations/views/group.view.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { View } from "@bitwarden/common/models/view/view";
|
||||
|
||||
import { GroupDetailsResponse, GroupResponse } from "../services/group/responses/group.response";
|
||||
import { CollectionAccessSelectionView } from "../views/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;
|
||||
}
|
||||
}
|
||||
@@ -34,18 +34,14 @@ import { BulkConfirmComponent as OrgBulkConfirmComponent } from "../organization
|
||||
import { BulkRemoveComponent as OrgBulkRemoveComponent } from "../organizations/manage/bulk/bulk-remove.component";
|
||||
import { BulkRestoreRevokeComponent as OrgBulkRestoreRevokeComponent } from "../organizations/manage/bulk/bulk-restore-revoke.component";
|
||||
import { BulkStatusComponent as OrgBulkStatusComponent } from "../organizations/manage/bulk/bulk-status.component";
|
||||
import { CollectionAddEditComponent as OrgCollectionAddEditComponent } from "../organizations/manage/collection-add-edit.component";
|
||||
import { CollectionsComponent as OrgManageCollectionsComponent } from "../organizations/manage/collections.component";
|
||||
import { EntityEventsComponent as OrgEntityEventsComponent } from "../organizations/manage/entity-events.component";
|
||||
import { EventsComponent as OrgEventsComponent } from "../organizations/manage/events.component";
|
||||
import { GroupAddEditComponent as OrgGroupAddEditComponent } from "../organizations/manage/group-add-edit.component";
|
||||
import { GroupsComponent as OrgGroupsComponent } from "../organizations/manage/groups.component";
|
||||
import { ManageComponent as OrgManageComponent } from "../organizations/manage/manage.component";
|
||||
import { PeopleComponent as OrgPeopleComponent } from "../organizations/manage/people.component";
|
||||
import { ResetPasswordComponent as OrgResetPasswordComponent } from "../organizations/manage/reset-password.component";
|
||||
import { UserAddEditComponent as OrgUserAddEditComponent } from "../organizations/manage/user-add-edit.component";
|
||||
import { UserConfirmComponent as OrgUserConfirmComponent } from "../organizations/manage/user-confirm.component";
|
||||
import { UserGroupsComponent as OrgUserGroupsComponent } from "../organizations/manage/user-groups.component";
|
||||
import { AcceptFamilySponsorshipComponent } from "../organizations/sponsorships/accept-family-sponsorship.component";
|
||||
import { FamiliesForEnterpriseSetupComponent } from "../organizations/sponsorships/families-for-enterprise-setup.component";
|
||||
import { ExposedPasswordsReportComponent as OrgExposedPasswordsReportComponent } from "../organizations/tools/exposed-passwords-report.component";
|
||||
@@ -186,13 +182,10 @@ import { SharedModule } from ".";
|
||||
OrgBulkRestoreRevokeComponent,
|
||||
OrgBulkRemoveComponent,
|
||||
OrgBulkStatusComponent,
|
||||
OrgCollectionAddEditComponent,
|
||||
OrgCollectionsComponent,
|
||||
OrgEntityEventsComponent,
|
||||
OrgEventsComponent,
|
||||
OrgExposedPasswordsReportComponent,
|
||||
OrgGroupAddEditComponent,
|
||||
OrgGroupsComponent,
|
||||
OrgInactiveTwoFactorReportComponent,
|
||||
OrgManageCollectionsComponent,
|
||||
OrgManageComponent,
|
||||
@@ -203,7 +196,6 @@ import { SharedModule } from ".";
|
||||
OrgUnsecuredWebsitesReportComponent,
|
||||
OrgUserAddEditComponent,
|
||||
OrgUserConfirmComponent,
|
||||
OrgUserGroupsComponent,
|
||||
OrgWeakPasswordsReportComponent,
|
||||
GeneratorComponent,
|
||||
PasswordGeneratorHistoryComponent,
|
||||
@@ -310,13 +302,10 @@ import { SharedModule } from ".";
|
||||
OrgBulkRestoreRevokeComponent,
|
||||
OrgBulkRemoveComponent,
|
||||
OrgBulkStatusComponent,
|
||||
OrgCollectionAddEditComponent,
|
||||
OrgCollectionsComponent,
|
||||
OrgEntityEventsComponent,
|
||||
OrgEventsComponent,
|
||||
OrgExposedPasswordsReportComponent,
|
||||
OrgGroupAddEditComponent,
|
||||
OrgGroupsComponent,
|
||||
OrgInactiveTwoFactorReportComponent,
|
||||
OrgManageCollectionsComponent,
|
||||
OrgManageComponent,
|
||||
@@ -327,7 +316,6 @@ import { SharedModule } from ".";
|
||||
OrgUnsecuredWebsitesReportComponent,
|
||||
OrgUserAddEditComponent,
|
||||
OrgUserConfirmComponent,
|
||||
OrgUserGroupsComponent,
|
||||
OrgWeakPasswordsReportComponent,
|
||||
GeneratorComponent,
|
||||
PasswordGeneratorHistoryComponent,
|
||||
|
||||
@@ -10,13 +10,17 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
AvatarModule,
|
||||
BadgeListModule,
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
CalloutModule,
|
||||
DialogModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
IconModule,
|
||||
LinkModule,
|
||||
MenuModule,
|
||||
MultiSelectModule,
|
||||
TableModule,
|
||||
TabsModule,
|
||||
} from "@bitwarden/components";
|
||||
@@ -45,14 +49,18 @@ import "./locales";
|
||||
CalloutModule,
|
||||
ToastrModule,
|
||||
BadgeModule,
|
||||
BadgeListModule,
|
||||
ButtonModule,
|
||||
MenuModule,
|
||||
MultiSelectModule,
|
||||
FormFieldModule,
|
||||
IconModule,
|
||||
TabsModule,
|
||||
TableModule,
|
||||
AvatarModule,
|
||||
IconButtonModule,
|
||||
LinkModule,
|
||||
DialogModule,
|
||||
],
|
||||
exports: [
|
||||
CommonModule,
|
||||
@@ -64,18 +72,20 @@ import "./locales";
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
BadgeModule,
|
||||
BadgeListModule,
|
||||
ButtonModule,
|
||||
CalloutModule,
|
||||
ToastrModule,
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
MenuModule,
|
||||
MultiSelectModule,
|
||||
FormFieldModule,
|
||||
IconModule,
|
||||
TabsModule,
|
||||
TableModule,
|
||||
AvatarModule,
|
||||
IconButtonModule,
|
||||
LinkModule,
|
||||
DialogModule,
|
||||
],
|
||||
providers: [DatePipe],
|
||||
bootstrap: [],
|
||||
|
||||
@@ -2374,6 +2374,15 @@
|
||||
"deleteGroupConfirmation": {
|
||||
"message": "Are you sure you want to delete this group?"
|
||||
},
|
||||
"deleteMultipleGroupsConfirmation": {
|
||||
"message": "Are you sure you want to delete the following $QUANTITY$ group(s)?",
|
||||
"placeholders": {
|
||||
"quantity": {
|
||||
"content": "$1",
|
||||
"example": "3"
|
||||
}
|
||||
}
|
||||
},
|
||||
"removeUserConfirmation": {
|
||||
"message": "Are you sure you want to remove this user?"
|
||||
},
|
||||
@@ -2390,7 +2399,7 @@
|
||||
"message": "External id"
|
||||
},
|
||||
"externalIdDesc": {
|
||||
"message": "The external id can be used as a reference or to link this resource to an external system such as a user directory."
|
||||
"message": "External ID is an unencrypted reference used by the Bitwarden Directory Connector or API."
|
||||
},
|
||||
"accessControl": {
|
||||
"message": "Access control"
|
||||
@@ -2731,6 +2740,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"deletedManyGroups": {
|
||||
"message": "Deleted $QUANTITY$ group(s).",
|
||||
"placeholders": {
|
||||
"quantity": {
|
||||
"content": "$1",
|
||||
"example": "3"
|
||||
}
|
||||
}
|
||||
},
|
||||
"removedUserId": {
|
||||
"message": "Removed user $ID$.",
|
||||
"placeholders": {
|
||||
@@ -5502,9 +5520,48 @@
|
||||
"update": {
|
||||
"message": "Update"
|
||||
},
|
||||
"plusNMore": {
|
||||
"message": "+ $QUANTITY$ more",
|
||||
"placeholders": {
|
||||
"quantity": {
|
||||
"content": "$1",
|
||||
"example": "5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editInfo": {
|
||||
"message": "Edit Info"
|
||||
},
|
||||
"groupInfo": {
|
||||
"message": "Group info"
|
||||
},
|
||||
"editGroupMembersDesc": {
|
||||
"message": "Grant members access to the group's assigned collections."
|
||||
},
|
||||
"editGroupCollectionsDesc": {
|
||||
"message": "Grant access to collections by adding them to this group."
|
||||
},
|
||||
"accessAllCollectionsDesc": {
|
||||
"message": "Grant access to all current and future collections."
|
||||
},
|
||||
"accessAllCollectionsHelp": {
|
||||
"message": "If checked, this will replace all other collection permissions."
|
||||
},
|
||||
"selectMembers": {
|
||||
"message": "Select members"
|
||||
},
|
||||
"selectCollections": {
|
||||
"message": "Select collections"
|
||||
},
|
||||
"role": {
|
||||
"message": "Role"
|
||||
},
|
||||
"removeMember": {
|
||||
"message": "Remove member"
|
||||
},
|
||||
"collection": {
|
||||
"message": "Collection"
|
||||
},
|
||||
"canView": {
|
||||
"message": "Can view"
|
||||
},
|
||||
@@ -5517,6 +5574,12 @@
|
||||
"canEditExceptPass": {
|
||||
"message": "Can edit, except passwords"
|
||||
},
|
||||
"noCollectionsAdded": {
|
||||
"message": "No collections added"
|
||||
},
|
||||
"noMembersAdded": {
|
||||
"message": "No members added"
|
||||
},
|
||||
"group": {
|
||||
"message": "Group"
|
||||
},
|
||||
|
||||
@@ -302,7 +302,7 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
button i.bwi,
|
||||
button:not([bitbutton]):not([biticonbutton]) i.bwi,
|
||||
a i.bwi {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { I18nPipe } from "./pipes/i18n.pipe";
|
||||
import { SearchCiphersPipe } from "./pipes/search-ciphers.pipe";
|
||||
import { SearchPipe } from "./pipes/search.pipe";
|
||||
import { UserNamePipe } from "./pipes/user-name.pipe";
|
||||
import { UserTypePipe } from "./pipes/user-type.pipe";
|
||||
import { PasswordStrengthComponent } from "./shared/components/password-strength/password-strength.component";
|
||||
|
||||
@NgModule({
|
||||
@@ -70,6 +71,7 @@ import { PasswordStrengthComponent } from "./shared/components/password-strength
|
||||
LaunchClickDirective,
|
||||
UserNamePipe,
|
||||
PasswordStrengthComponent,
|
||||
UserTypePipe,
|
||||
],
|
||||
exports: [
|
||||
A11yInvalidDirective,
|
||||
@@ -100,7 +102,8 @@ import { PasswordStrengthComponent } from "./shared/components/password-strength
|
||||
LaunchClickDirective,
|
||||
UserNamePipe,
|
||||
PasswordStrengthComponent,
|
||||
UserTypePipe,
|
||||
],
|
||||
providers: [CreditCardNumberPipe, DatePipe, I18nPipe, SearchPipe, UserNamePipe],
|
||||
providers: [CreditCardNumberPipe, DatePipe, I18nPipe, SearchPipe, UserNamePipe, UserTypePipe],
|
||||
})
|
||||
export class JslibModule {}
|
||||
|
||||
29
libs/angular/src/pipes/user-type.pipe.ts
Normal file
29
libs/angular/src/pipes/user-type.pipe.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Pipe, PipeTransform } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType";
|
||||
|
||||
@Pipe({
|
||||
name: "userType",
|
||||
})
|
||||
export class UserTypePipe implements PipeTransform {
|
||||
constructor(private i18nService: I18nService) {}
|
||||
|
||||
transform(value?: OrganizationUserType): string {
|
||||
if (value == null) {
|
||||
return this.i18nService.t("unknown");
|
||||
}
|
||||
switch (value) {
|
||||
case OrganizationUserType.Owner:
|
||||
return this.i18nService.t("owner");
|
||||
case OrganizationUserType.Admin:
|
||||
return this.i18nService.t("admin");
|
||||
case OrganizationUserType.User:
|
||||
return this.i18nService.t("user");
|
||||
case OrganizationUserType.Manager:
|
||||
return this.i18nService.t("manager");
|
||||
case OrganizationUserType.Custom:
|
||||
return this.i18nService.t("custom");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { Injector, LOCALE_ID, NgModule } from "@angular/core";
|
||||
|
||||
import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/abstractions/account/account-api.service";
|
||||
import {
|
||||
InternalAccountService,
|
||||
AccountService as AccountServiceAbstraction,
|
||||
InternalAccountService,
|
||||
} from "@bitwarden/common/abstractions/account/account.service";
|
||||
import { AnonymousHubService as AnonymousHubServiceAbstraction } from "@bitwarden/common/abstractions/anonymousHub.service";
|
||||
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
|
||||
@@ -41,8 +41,8 @@ import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from "@
|
||||
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/abstractions/policy/policy-api.service.abstraction";
|
||||
import {
|
||||
PolicyService as PolicyServiceAbstraction,
|
||||
InternalPolicyService,
|
||||
PolicyService as PolicyServiceAbstraction,
|
||||
} from "@bitwarden/common/abstractions/policy/policy.service.abstraction";
|
||||
import { ProviderService as ProviderServiceAbstraction } from "@bitwarden/common/abstractions/provider.service";
|
||||
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service";
|
||||
@@ -121,16 +121,16 @@ import { UnauthGuard } from "../guards/unauth.guard";
|
||||
|
||||
import { BroadcasterService } from "./broadcaster.service";
|
||||
import {
|
||||
WINDOW,
|
||||
LOCALES_DIRECTORY,
|
||||
LOCKED_CALLBACK,
|
||||
LOG_MAC_FAILURES,
|
||||
LOGOUT_CALLBACK,
|
||||
MEMORY_STORAGE,
|
||||
SECURE_STORAGE,
|
||||
STATE_FACTORY,
|
||||
STATE_SERVICE_USE_CACHE,
|
||||
LOGOUT_CALLBACK,
|
||||
LOCKED_CALLBACK,
|
||||
LOCALES_DIRECTORY,
|
||||
SYSTEM_LANGUAGE,
|
||||
LOG_MAC_FAILURES,
|
||||
WINDOW,
|
||||
} from "./injection-tokens";
|
||||
import { ModalService } from "./modal.service";
|
||||
import { PasswordRepromptService } from "./passwordReprompt.service";
|
||||
|
||||
@@ -22,7 +22,6 @@ import { EmergencyAccessInviteRequest } from "../models/request/emergency-access
|
||||
import { EmergencyAccessPasswordRequest } from "../models/request/emergency-access-password.request";
|
||||
import { EmergencyAccessUpdateRequest } from "../models/request/emergency-access-update.request";
|
||||
import { EventRequest } from "../models/request/event.request";
|
||||
import { GroupRequest } from "../models/request/group.request";
|
||||
import { IapCheckRequest } from "../models/request/iap-check.request";
|
||||
import { PasswordTokenRequest } from "../models/request/identity-token/password-token.request";
|
||||
import { SsoTokenRequest } from "../models/request/identity-token/sso-token.request";
|
||||
@@ -105,7 +104,6 @@ import {
|
||||
EmergencyAccessViewResponse,
|
||||
} from "../models/response/emergency-access.response";
|
||||
import { EventResponse } from "../models/response/event.response";
|
||||
import { GroupDetailsResponse, GroupResponse } from "../models/response/group.response";
|
||||
import { IdentityCaptchaResponse } from "../models/response/identity-captcha.response";
|
||||
import { IdentityTokenResponse } from "../models/response/identity-token.response";
|
||||
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
|
||||
@@ -121,8 +119,8 @@ import { OrganizationUserBulkPublicKeyResponse } from "../models/response/organi
|
||||
import { OrganizationUserBulkResponse } from "../models/response/organization-user-bulk.response";
|
||||
import {
|
||||
OrganizationUserDetailsResponse,
|
||||
OrganizationUserUserDetailsResponse,
|
||||
OrganizationUserResetPasswordDetailsReponse,
|
||||
OrganizationUserUserDetailsResponse,
|
||||
} from "../models/response/organization-user.response";
|
||||
import { PaymentResponse } from "../models/response/payment.response";
|
||||
import { PlanResponse } from "../models/response/plan.response";
|
||||
@@ -136,8 +134,8 @@ import {
|
||||
import { ProviderUserBulkPublicKeyResponse } from "../models/response/provider/provider-user-bulk-public-key.response";
|
||||
import { ProviderUserBulkResponse } from "../models/response/provider/provider-user-bulk.response";
|
||||
import {
|
||||
ProviderUserUserDetailsResponse,
|
||||
ProviderUserResponse,
|
||||
ProviderUserUserDetailsResponse,
|
||||
} from "../models/response/provider/provider-user.response";
|
||||
import { ProviderResponse } from "../models/response/provider/provider.response";
|
||||
import { SelectionReadOnlyResponse } from "../models/response/selection-read-only.response";
|
||||
@@ -156,8 +154,8 @@ import { TwoFactorEmailResponse } from "../models/response/two-factor-email.resp
|
||||
import { TwoFactorProviderResponse } from "../models/response/two-factor-provider.response";
|
||||
import { TwoFactorRecoverResponse } from "../models/response/two-factor-recover.response";
|
||||
import {
|
||||
TwoFactorWebAuthnResponse,
|
||||
ChallengeResponse,
|
||||
TwoFactorWebAuthnResponse,
|
||||
} from "../models/response/two-factor-web-authn.response";
|
||||
import { TwoFactorYubiKeyResponse } from "../models/response/two-factor-yubi-key.response";
|
||||
import { UserKeyResponse } from "../models/response/user-key.response";
|
||||
@@ -341,13 +339,8 @@ export abstract class ApiService {
|
||||
organizationUserId: string
|
||||
) => Promise<any>;
|
||||
|
||||
getGroupDetails: (organizationId: string, id: string) => Promise<GroupDetailsResponse>;
|
||||
getGroups: (organizationId: string) => Promise<ListResponse<GroupResponse>>;
|
||||
getGroupUsers: (organizationId: string, id: string) => Promise<string[]>;
|
||||
postGroup: (organizationId: string, request: GroupRequest) => Promise<GroupResponse>;
|
||||
putGroup: (organizationId: string, id: string, request: GroupRequest) => Promise<GroupResponse>;
|
||||
putGroupUsers: (organizationId: string, id: string, request: string[]) => Promise<any>;
|
||||
deleteGroup: (organizationId: string, id: string) => Promise<any>;
|
||||
deleteGroupUser: (organizationId: string, id: string, organizationUserId: string) => Promise<any>;
|
||||
|
||||
getOrganizationUser: (
|
||||
|
||||
@@ -29,7 +29,6 @@ import { EmergencyAccessInviteRequest } from "../models/request/emergency-access
|
||||
import { EmergencyAccessPasswordRequest } from "../models/request/emergency-access-password.request";
|
||||
import { EmergencyAccessUpdateRequest } from "../models/request/emergency-access-update.request";
|
||||
import { EventRequest } from "../models/request/event.request";
|
||||
import { GroupRequest } from "../models/request/group.request";
|
||||
import { IapCheckRequest } from "../models/request/iap-check.request";
|
||||
import { PasswordTokenRequest } from "../models/request/identity-token/password-token.request";
|
||||
import { SsoTokenRequest } from "../models/request/identity-token/sso-token.request";
|
||||
@@ -114,7 +113,6 @@ import {
|
||||
} from "../models/response/emergency-access.response";
|
||||
import { ErrorResponse } from "../models/response/error.response";
|
||||
import { EventResponse } from "../models/response/event.response";
|
||||
import { GroupDetailsResponse, GroupResponse } from "../models/response/group.response";
|
||||
import { IdentityCaptchaResponse } from "../models/response/identity-captcha.response";
|
||||
import { IdentityTokenResponse } from "../models/response/identity-token.response";
|
||||
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
|
||||
@@ -130,8 +128,8 @@ import { OrganizationUserBulkPublicKeyResponse } from "../models/response/organi
|
||||
import { OrganizationUserBulkResponse } from "../models/response/organization-user-bulk.response";
|
||||
import {
|
||||
OrganizationUserDetailsResponse,
|
||||
OrganizationUserUserDetailsResponse,
|
||||
OrganizationUserResetPasswordDetailsReponse,
|
||||
OrganizationUserUserDetailsResponse,
|
||||
} from "../models/response/organization-user.response";
|
||||
import { PaymentResponse } from "../models/response/payment.response";
|
||||
import { PlanResponse } from "../models/response/plan.response";
|
||||
@@ -165,8 +163,8 @@ import { TwoFactorEmailResponse } from "../models/response/two-factor-email.resp
|
||||
import { TwoFactorProviderResponse } from "../models/response/two-factor-provider.response";
|
||||
import { TwoFactorRecoverResponse } from "../models/response/two-factor-recover.response";
|
||||
import {
|
||||
TwoFactorWebAuthnResponse,
|
||||
ChallengeResponse,
|
||||
TwoFactorWebAuthnResponse,
|
||||
} from "../models/response/two-factor-web-authn.response";
|
||||
import { TwoFactorYubiKeyResponse } from "../models/response/two-factor-yubi-key.response";
|
||||
import { UserKeyResponse } from "../models/response/user-key.response";
|
||||
@@ -922,28 +920,6 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
|
||||
// Groups APIs
|
||||
|
||||
async getGroupDetails(organizationId: string, id: string): Promise<GroupDetailsResponse> {
|
||||
const r = await this.send(
|
||||
"GET",
|
||||
"/organizations/" + organizationId + "/groups/" + id + "/details",
|
||||
null,
|
||||
true,
|
||||
true
|
||||
);
|
||||
return new GroupDetailsResponse(r);
|
||||
}
|
||||
|
||||
async getGroups(organizationId: string): Promise<ListResponse<GroupResponse>> {
|
||||
const r = await this.send(
|
||||
"GET",
|
||||
"/organizations/" + organizationId + "/groups",
|
||||
null,
|
||||
true,
|
||||
true
|
||||
);
|
||||
return new ListResponse(r, GroupResponse);
|
||||
}
|
||||
|
||||
async getGroupUsers(organizationId: string, id: string): Promise<string[]> {
|
||||
const r = await this.send(
|
||||
"GET",
|
||||
@@ -955,32 +931,6 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
return r;
|
||||
}
|
||||
|
||||
async postGroup(organizationId: string, request: GroupRequest): Promise<GroupResponse> {
|
||||
const r = await this.send(
|
||||
"POST",
|
||||
"/organizations/" + organizationId + "/groups",
|
||||
request,
|
||||
true,
|
||||
true
|
||||
);
|
||||
return new GroupResponse(r);
|
||||
}
|
||||
|
||||
async putGroup(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
request: GroupRequest
|
||||
): Promise<GroupResponse> {
|
||||
const r = await this.send(
|
||||
"PUT",
|
||||
"/organizations/" + organizationId + "/groups/" + id,
|
||||
request,
|
||||
true,
|
||||
true
|
||||
);
|
||||
return new GroupResponse(r);
|
||||
}
|
||||
|
||||
async putGroupUsers(organizationId: string, id: string, request: string[]): Promise<any> {
|
||||
await this.send(
|
||||
"PUT",
|
||||
@@ -991,16 +941,6 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
);
|
||||
}
|
||||
|
||||
deleteGroup(organizationId: string, id: string): Promise<any> {
|
||||
return this.send(
|
||||
"DELETE",
|
||||
"/organizations/" + organizationId + "/groups/" + id,
|
||||
null,
|
||||
true,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
deleteGroupUser(organizationId: string, id: string, organizationUserId: string): Promise<any> {
|
||||
return this.send(
|
||||
"DELETE",
|
||||
|
||||
9
libs/components/src/badge-list/badge-list.component.html
Normal file
9
libs/components/src/badge-list/badge-list.component.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="tw-inline-flex tw-gap-2">
|
||||
<span *ngFor="let item of filteredItems; let last = last" bitBadge [badgeType]="badgeType">
|
||||
{{ item }}
|
||||
<span class="tw-sr-only" *ngIf="!last || isFiltered">, </span>
|
||||
</span>
|
||||
<span *ngIf="isFiltered" bitBadge [badgeType]="badgeType">
|
||||
{{ "plusNMore" | i18n: (items.length - filteredItems.length).toString() }}
|
||||
</span>
|
||||
</div>
|
||||
35
libs/components/src/badge-list/badge-list.component.ts
Normal file
35
libs/components/src/badge-list/badge-list.component.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Component, Input, OnChanges } from "@angular/core";
|
||||
|
||||
import { BadgeTypes } from "../badge";
|
||||
|
||||
@Component({
|
||||
selector: "bit-badge-list",
|
||||
templateUrl: "badge-list.component.html",
|
||||
})
|
||||
export class BadgeListComponent implements OnChanges {
|
||||
private _maxItems: number;
|
||||
|
||||
protected filteredItems: string[] = [];
|
||||
protected isFiltered = false;
|
||||
|
||||
@Input() badgeType: BadgeTypes = "primary";
|
||||
@Input() items: string[] = [];
|
||||
|
||||
@Input()
|
||||
get maxItems(): number | undefined {
|
||||
return this._maxItems;
|
||||
}
|
||||
|
||||
set maxItems(value: number | undefined) {
|
||||
this._maxItems = value == undefined ? undefined : Math.max(1, value);
|
||||
}
|
||||
|
||||
ngOnChanges() {
|
||||
if (this.maxItems == undefined) {
|
||||
this.filteredItems = this.items;
|
||||
} else {
|
||||
this.filteredItems = this.items.slice(0, this.maxItems);
|
||||
}
|
||||
this.isFiltered = this.items.length > this.filteredItems.length;
|
||||
}
|
||||
}
|
||||
13
libs/components/src/badge-list/badge-list.module.ts
Normal file
13
libs/components/src/badge-list/badge-list.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { BadgeModule } from "../badge";
|
||||
import { SharedModule } from "../shared";
|
||||
|
||||
import { BadgeListComponent } from "./badge-list.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule, BadgeModule],
|
||||
exports: [BadgeListComponent],
|
||||
declarations: [BadgeListComponent],
|
||||
})
|
||||
export class BadgeListModule {}
|
||||
53
libs/components/src/badge-list/badge-list.stories.ts
Normal file
53
libs/components/src/badge-list/badge-list.stories.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
import { BadgeModule } from "../badge";
|
||||
import { SharedModule } from "../shared";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
import { BadgeListComponent } from "./badge-list.component";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Badge/List",
|
||||
component: BadgeListComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [SharedModule, BadgeModule],
|
||||
declarations: [BadgeListComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
plusNMore: (n) => `+ ${n} more`,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
args: {
|
||||
badgeType: "primary",
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/file/f32LSg3jaegICkMu7rPARm/Tailwind-Component-Library-Update?node-id=1881%3A16956",
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const ListTemplate: Story<BadgeListComponent> = (args: BadgeListComponent) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-badge-list [badgeType]="badgeType" [maxItems]="maxItems" [items]="items"></bit-badge-list>
|
||||
`,
|
||||
});
|
||||
|
||||
export const Default = ListTemplate.bind({});
|
||||
Default.args = {
|
||||
badgeType: "info",
|
||||
maxItems: 3,
|
||||
items: ["Badge 1", "Badge 2", "Badge 3", "Badge 4", "Badge 5"],
|
||||
};
|
||||
1
libs/components/src/badge-list/index.ts
Normal file
1
libs/components/src/badge-list/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./badge-list.module";
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Directive, ElementRef, HostBinding, Input } from "@angular/core";
|
||||
|
||||
type BadgeTypes = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
|
||||
export type BadgeTypes = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
|
||||
|
||||
const styles: Record<BadgeTypes, string[]> = {
|
||||
primary: ["tw-bg-primary-500"],
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { Meta, Story } from "@storybook/angular";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
|
||||
import { BadgeDirective } from "./badge.directive";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Badge",
|
||||
component: BadgeDirective,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [CommonModule],
|
||||
declarations: [BadgeDirective],
|
||||
}),
|
||||
],
|
||||
args: {
|
||||
badgeType: "primary",
|
||||
},
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./badge.directive";
|
||||
export { BadgeDirective, BadgeTypes } from "./badge.directive";
|
||||
export * from "./badge.module";
|
||||
|
||||
@@ -18,7 +18,7 @@ import { SimpleDialogComponent } from "./simple-dialog/simple-dialog.component";
|
||||
DialogComponent,
|
||||
SimpleDialogComponent,
|
||||
],
|
||||
exports: [CdkDialogModule, DialogComponent, SimpleDialogComponent],
|
||||
exports: [CdkDialogModule, DialogComponent, SimpleDialogComponent, DialogCloseDirective],
|
||||
providers: [DialogService],
|
||||
})
|
||||
export class DialogModule {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./async-actions";
|
||||
export * from "./avatar";
|
||||
export * from "./badge";
|
||||
export * from "./badge-list";
|
||||
export * from "./banner";
|
||||
export * from "./button";
|
||||
export * from "./callout";
|
||||
|
||||
@@ -9,10 +9,14 @@ export class I18nMockService implements I18nService {
|
||||
collator: Intl.Collator;
|
||||
localeNames: Map<string, string>;
|
||||
|
||||
constructor(private lookupTable: Record<string, string>) {}
|
||||
constructor(private lookupTable: Record<string, string | ((...args: string[]) => string)>) {}
|
||||
|
||||
t(id: string, p1?: string, p2?: string, p3?: string) {
|
||||
return this.lookupTable[id];
|
||||
const value = this.lookupTable[id];
|
||||
if (typeof value == "string") {
|
||||
return value;
|
||||
}
|
||||
return value(p1, p2, p3);
|
||||
}
|
||||
|
||||
translate(id: string, p1?: string, p2?: string, p3?: string) {
|
||||
|
||||
Reference in New Issue
Block a user