1
0
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:
Shane Melton
2022-11-21 08:40:27 -08:00
committed by GitHub
parent 1dd9fbab72
commit 21a9f84956
37 changed files with 1129 additions and 511 deletions

View File

@@ -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">

View File

@@ -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) =>

View File

@@ -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;
}

View File

@@ -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">&times;</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>&nbsp;</th>
<th>{{ "name" | i18n }}</th>
<th width="100" class="text-center">{{ "hidePasswords" | i18n }}</th>
<th width="100" class="text-center">{{ "readOnly" | i18n }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let c of collections; let i = index">
<td class="table-list-checkbox" (click)="check(c)">
<input
type="checkbox"
[(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>

View File

@@ -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);
}
}
};
}

View File

@@ -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>

View File

@@ -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");
}
}
}

View File

@@ -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;

View File

@@ -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 {}

View File

@@ -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>;
}

View File

@@ -0,0 +1,2 @@
export * from "./group.service.abstraction";
export * from "./requests/group.request";

View File

@@ -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[] = [];
}

View 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));
}
}

View File

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

View File

@@ -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;

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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,

View File

@@ -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: [],

View File

@@ -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"
},

View File

@@ -302,7 +302,7 @@ code {
}
}
button i.bwi,
button:not([bitbutton]):not([biticonbutton]) i.bwi,
a i.bwi {
margin-right: 0.25rem;
}

View File

@@ -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 {}

View 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");
}
}
}

View File

@@ -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";

View File

@@ -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: (

View File

@@ -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",

View 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>

View 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;
}
}

View 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 {}

View 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"],
};

View File

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

View File

@@ -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"],

View File

@@ -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",
},

View File

@@ -1,2 +1,2 @@
export * from "./badge.directive";
export { BadgeDirective, BadgeTypes } from "./badge.directive";
export * from "./badge.module";

View File

@@ -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 {}

View File

@@ -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";

View File

@@ -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) {