[EC-73] edit collection modal (#3638)

* [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-73] feat: add inital version of modal using dialog service

* [EC-73] feat: create story for dialog

* [EC-73] feat: setup story with support for injected data

* [EC-73] feat: add inital version of subtitle

* [EC-73] feat: add tabs

* [EC-73] feat: initial version of collection info form

* [EC-73] feat: start of working form

* [EC-73] feat: add custom form validator

* [EC-73] fix: dialog directive names after rebase

* [EC-73] feat: use custom validator

* [EC-73] fix: story

* [EC-73] feat: allow parent picking

* [EC-73] feat: remove tabs to allow for merging

* [EC-73] feat: extend story with new and edit dialogs

* [EC-73] feat: change title depending on if editing or not

* [EC-73] fix: parent not connected to form

* [EC-73] feat: add organizationId to dialog data

* [EC-73] feat: only allow nesting within collections with access

* [EC-73] feat: handle loading with spinner

* [EC-73] feat: update collections on submit

* [EC-73] feat: reload on save

* [EC-73] feat: update story to work with latest changes

* [EC-73] feat: always fetch collections from server

* [EC-73] fix: do not submit if form invalid

* [EC-73] feat: create new collections using new ui

* [EC-73] fix: external id not being saved

* [EC-73] chore: move calls to separete collection admin service

* [EC-73] feat: use new admin views

* [EC-73] feat: implement deletion

* [EC-73] feat: add support for collection details in service

* [EC-73] fix: story

* [EC-73] fix: cancel button

* [EC-73] feat: re-add tabs

* [EC-73] fix: jslib service collection deps

* [EC-73] chore: rename component to collection-dialog

* [EC-73] chore: clean up collection api service which was replaced

* [EC-73] chore: restore collection.service

* [EC-73] chore: restore dialog component changes

* [EC-73] fix: move subscription to ngOnInit

* [EC-73] feat: disable padding when using tabbed content

* [EC-73] fix: new lint rules after merge

* Add Access Selector Component and Stories

* Cherry pick FormSelectionList

* Fix some problems caused from cherry-pick

* Fix some Web module problems caused from cherry-pick

* Move AccessSelector out of the root components directory.

Move UserType pipe to AccessSelectorModule

* Fix broken member access selector story

* Add organization feature module

* Undo changes to messages.json

* Fix messages.json

* Remove redundant CommonModule

* [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-599] Fix avatar/icon sizing

* [EC-599] Remove padding in  permission column

* [EC-599] Make FormSelectionList operations immutable

* [EC-599] Integrate the multi-select component

* [EC-599] Handle readonly/access all edge cases

* [EC-599] Add initial unit tests

Also cleans up public interface for the AccessSelectorComponent. Fixes a bug found during unit test creation.

* [EC-599] Include item name in control labels

* [EC-599] Cleanup member email display

* [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-599] Review suggestions

- Change PermissionMode to Enum
- Rename permControl to permissionControl to be more clear
- Rename FormSelectionList file to kebab case.
- Move permission row boolean logic to named function for readability

* [EC-599] Cleanup AccessSelectorComponent tests

- Clarify test states
- Add tests for column rendering
- Add tests for permission mode
- Add id to column headers for testing
- Fix small permissionControl bug found during testing

* [EC-599] Add FormSelectionList unit tests

* [EC-73] chore: re-add collections page

* [EC-86] Rename new files using kebab-case

* [EC-73] chore: move component to shared org module

* Fix MultiSelect component styles and CSP error (#3841)

* Update Web styles and CSP to support MultiSelect component

- Include the MultiSelect module in the CL barrel file of exports
- Import the MultiSelect scss into the Web styles.scss
- Add the necessary sha256 hash to webpack CSP policy to support ngSelect inline styles

* Undo removal of 127.0.0.1 from webpack CSP

(cherry picked from commit 3ed1221f7f)

* [EC-73] feat: add empty access selector

* [EC-73] feat: add groups to access selector

* [EC-73] chore: improve storybook support

* [EC-73] feat: tweak item assignment

* [EC-73] feat: add support for showing users

* [EC-73] feat: use async actions

* [EC-73] chore: clean up casting

* [EC-73] fix: permissions not loading correctly in access selector

* [EC-73] feat: implement saving group permissions

* [EC-73] feat: rename to collection access selection view

* [EC-73] feat: save users as well

* [EC-73] fix: access selector usage

* [EC-73] feat: new collection creation

* [EC-73] feat: fetch users from collection details

* [EC-73] chore: clean up

* [EC-73] fix: circular dependency issues

* [EC-73] fix: import shared module directly to workaround build issues

* [EC-73] fix: missing dependencies in story

* [EC-73] chore: move story

* [EC-73] fix: manual cherry pick permission bug fix

* [EC-73] feat: hide delete button if no permission

* [EC-73] feat: properly handle orgs without groups

* [EC-73] fix: use correct functions in template

* [EC-73] feat: properly handle non-existing parent

* [EC-73] chore: use double ngIf instead of else template

* [EC-73] fix: add type to dialog ref

* [EC-73] fix: restrict field modifiers

* [EC-73] fix: use result enum directly

* [EC-73] fix: simplify mapping logic

* [EC-73]

* [EC-73] feat: add story for free orgs without groups

* [EC-73] fix: parametrized i18n

* [EC-73] feat: create new shared org module

* [EC-73] feat: move collection dialog to shared

* [EC-73] feat: move access selector to shared

* [EC-73] feat: create core organization module

* [EC-73] feat: move collection admin service to web

* [EC-73] feat: move collection admin views to web

* [EC-73] fix: missing i18n

* [EC-73] fix: refactor for type safety

* [EC-73] fix: storybook not compiling again

* [EC-73] feat: use helper function to open dialog

* [EC-73] chore: remove comment

* [EC-73] fix: revert permission fix

* [EC-73] fix: only show delete if in edit mode

* [EC-73] chore: remove ngIf else in template

* [EC-73] fix: add missing appA11yTitle

* [EC-73] chore: rename remove to delete

* [EC-73] chore: refactor ngOnInit

* [EC-73] fix: dialog position strategy

* [EC-73] fix: revert spinner to old way of doing it

Signed-off-by: Jacob Fink <jfink@bitwarden.com>
Co-authored-by: Shane Melton <smelton@bitwarden.com>
Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
Co-authored-by: Thomas Rittson <eliykat@users.noreply.github.com>
This commit is contained in:
Andreas Coroiu 2022-11-22 14:33:47 +01:00 committed by GitHub
parent 21a9f84956
commit 39655ebe29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 1225 additions and 420 deletions

View File

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

View File

@ -0,0 +1,4 @@
export * from "./core-organization.module";
export * from "./services/collection-admin.service";
export * from "./views/collection-access-selection-view";
export * from "./views/collection-admin-view";

View File

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

View File

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

View File

@ -0,0 +1,25 @@
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
import { CollectionAccessDetailsResponse } from "@bitwarden/common/src/models/response/collection.response";
import { CollectionAccessSelectionView } from "./collection-access-selection-view";
export class CollectionAdminView extends CollectionView {
groups: CollectionAccessSelectionView[] = [];
users: CollectionAccessSelectionView[] = [];
constructor(response?: CollectionAccessDetailsResponse) {
super(response);
if (!response) {
return;
}
this.groups = response.groups
? response.groups.map((g) => new CollectionAccessSelectionView(g))
: [];
this.users = response.users
? response.users.map((g) => new CollectionAccessSelectionView(g))
: [];
}
}

View File

@ -1,162 +0,0 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="collectionAddEditTitle">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<form
class="modal-content"
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
>
<div class="modal-header">
<h1 class="modal-title" id="collectionAddEditTitle">{{ 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">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></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
appAutofocus
[disabled]="!this.canSave"
/>
</div>
<div class="form-group">
<label for="externalId">{{ "externalId" | i18n }}</label>
<input
id="externalId"
class="form-control"
type="text"
name="ExternalId"
[(ngModel)]="externalId"
[disabled]="!this.canSave"
/>
<small class="form-text text-muted">{{ "externalIdDesc" | i18n }}</small>
</div>
<ng-container *ngIf="accessGroups">
<h3 class="mt-4 d-flex mb-0">
{{ "groupAccess" | i18n }}
<div class="ml-auto" *ngIf="groups && groups.length && this.canSave">
<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 *ngIf="!groups || !groups.length">
{{ "noGroupsInList" | i18n }}
</div>
<table class="table table-hover table-list mb-0" *ngIf="groups && groups.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 g of groups; let i = index">
<td class="table-list-checkbox" (click)="check(g)">
<input
type="checkbox"
[(ngModel)]="g.checked"
name="Groups[{{ i }}].Checked"
[disabled]="g.accessAll || !this.canSave"
appStopProp
/>
</td>
<td (click)="check(g)">
{{ g.name }}
<ng-container *ngIf="g.accessAll">
<i
class="bwi bwi-filter text-muted bwi-fw"
title="{{ 'groupAccessAllItems' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "groupAccessAllItems" | i18n }}</span>
</ng-container>
</td>
<td class="text-center">
<input
type="checkbox"
[(ngModel)]="g.hidePasswords"
name="Groups[{{ i }}].HidePasswords"
[disabled]="!g.checked || g.accessAll || !this.canSave"
/>
</td>
<td class="text-center">
<input
type="checkbox"
[(ngModel)]="g.readOnly"
name="Groups[{{ i }}].ReadOnly"
[disabled]="!g.checked || g.accessAll || !this.canSave"
/>
</td>
</tr>
</tbody>
</table>
</ng-container>
</div>
<div class="modal-footer">
<button
type="submit"
class="btn btn-primary btn-submit"
[disabled]="form.loading"
*ngIf="this.canSave"
>
<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" *ngIf="this.canDelete">
<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"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
</button>
</div>
</div>
</form>
</div>
</div>

View File

@ -1,181 +0,0 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { Utils } from "@bitwarden/common/misc/utils";
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 { GroupServiceAbstraction } from "../services/abstractions/group";
import { GroupView } from "../views/group.view";
@Component({
selector: "app-collection-add-edit",
templateUrl: "collection-add-edit.component.html",
})
export class CollectionAddEditComponent implements OnInit {
@Input() collectionId: string;
@Input() organizationId: string;
@Input() canSave: boolean;
@Input() canDelete: boolean;
@Output() onSavedCollection = new EventEmitter();
@Output() onDeletedCollection = new EventEmitter();
loading = true;
editMode = false;
accessGroups = false;
title: string;
name: string;
externalId: string;
groups: GroupView[] = [];
formPromise: Promise<any>;
deletePromise: Promise<any>;
private orgKey: SymmetricCryptoKey;
constructor(
private apiService: ApiService,
private groupApiService: GroupServiceAbstraction,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private cryptoService: CryptoService,
private logService: LogService,
private organizationService: OrganizationService
) {}
async ngOnInit() {
const organization = await this.organizationService.get(this.organizationId);
this.accessGroups = organization.useGroups;
this.editMode = this.loading = this.collectionId != null;
if (this.accessGroups) {
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);
if (this.editMode) {
this.editMode = true;
this.title = this.i18nService.t("editCollection");
try {
const collection = await this.apiService.getCollectionDetails(
this.organizationId,
this.collectionId
);
this.name = await this.cryptoService.decryptToUtf8(
new EncString(collection.name),
this.orgKey
);
this.externalId = collection.externalId;
if (collection.groups != null && this.groups.length > 0) {
collection.groups.forEach((s) => {
const group = this.groups.filter((g) => !g.accessAll && g.id === s.id);
if (group != null && group.length > 0) {
(group[0] as any).checked = true;
(group[0] as any).readOnly = s.readOnly;
(group[0] as any).hidePasswords = s.hidePasswords;
}
});
}
} catch (e) {
this.logService.error(e);
}
} else {
this.title = this.i18nService.t("addCollection");
}
this.groups.forEach((g) => {
if (g.accessAll) {
(g as any).checked = true;
}
});
this.loading = false;
}
check(g: GroupView, select?: boolean) {
if (g.accessAll) {
return;
}
(g as any).checked = select == null ? !(g as any).checked : select;
if (!(g as any).checked) {
(g as any).readOnly = false;
(g as any).hidePasswords = false;
}
}
selectAll(select: boolean) {
this.groups.forEach((g) => this.check(g, select));
}
async submit() {
if (this.orgKey == null) {
throw new Error("No encryption key for this organization.");
}
const request = new CollectionRequest();
request.name = (await this.cryptoService.encrypt(this.name, this.orgKey)).encryptedString;
request.externalId = this.externalId;
request.groups = this.groups
.filter((g) => (g as any).checked && !g.accessAll)
.map(
(g) => new SelectionReadOnlyRequest(g.id, !!(g as any).readOnly, !!(g as any).hidePasswords)
);
try {
if (this.editMode) {
this.formPromise = this.apiService.putCollection(
this.organizationId,
this.collectionId,
request
);
} else {
this.formPromise = this.apiService.postCollection(this.organizationId, request);
}
await this.formPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t(this.editMode ? "editedCollectionId" : "createdCollectionId", this.name)
);
this.onSavedCollection.emit();
} catch (e) {
this.logService.error(e);
}
}
async delete() {
if (!this.editMode) {
return;
}
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t("deleteCollectionConfirmation"),
this.name,
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!confirmed) {
return false;
}
try {
this.deletePromise = this.apiService.deleteCollection(this.organizationId, this.collectionId);
await this.deletePromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("deletedCollectionId", this.name)
);
this.onDeletedCollection.emit();
} catch (e) {
this.logService.error(e);
}
}
}

View File

@ -65,6 +65,16 @@
<i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a
class="dropdown-item"
href="#"
appStopClick
*ngIf="this.canEdit(c)"
(click)="edit(c)"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "edit" | i18n }}
</a>
<a
class="dropdown-item"
href="#"

View File

@ -1,5 +1,7 @@
import { Overlay } from "@angular/cdk/overlay";
import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { lastValueFrom } from "rxjs";
import { first } from "rxjs/operators";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@ -19,8 +21,10 @@ import {
} 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 { CollectionDialogResult, openCollectionDialog } from "../shared";
import { CollectionAddEditComponent } from "./collection-add-edit.component";
import { EntityUsersComponent } from "./entity-users.component";
@Component({
@ -56,7 +60,9 @@ export class CollectionsComponent implements OnInit {
private platformUtilsService: PlatformUtilsService,
private searchService: SearchService,
private logService: LogService,
private organizationService: OrganizationService
private organizationService: OrganizationService,
private dialogService: DialogService,
private overlay: Overlay
) {}
async ngOnInit() {
@ -116,36 +122,24 @@ export class CollectionsComponent implements OnInit {
this.didScroll = this.pagedCollections.length > this.pageSize;
}
async edit(collection: CollectionView) {
const canCreate = collection == null && this.canCreate;
const canEdit = collection != null && this.canEdit(collection);
const canDelete = collection != null && this.canDelete(collection);
async edit(collection?: CollectionView) {
const canCreate = collection == undefined && this.canCreate;
const canEdit = collection != undefined && this.canEdit(collection);
const canDelete = collection != undefined && this.canDelete(collection);
if (!(canCreate || canEdit || canDelete)) {
this.platformUtilsService.showToast("error", null, this.i18nService.t("missingPermissions"));
return;
}
const [modal] = await this.modalService.openViewRef(
CollectionAddEditComponent,
this.addEditModalRef,
(comp) => {
comp.organizationId = this.organizationId;
comp.collectionId = collection != null ? collection.id : null;
comp.canSave = canCreate || canEdit;
comp.canDelete = canDelete;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
comp.onSavedCollection.subscribe(() => {
modal.close();
this.load();
});
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
comp.onDeletedCollection.subscribe(() => {
modal.close();
this.removeCollection(collection);
});
}
);
const dialog = openCollectionDialog(this.dialogService, this.overlay, {
data: { collectionId: collection?.id, organizationId: this.organizationId },
});
const result = await lastValueFrom(dialog.closed);
if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) {
this.load();
}
}
add() {

View File

@ -15,6 +15,7 @@ import { CollectionDetailsResponse } from "@bitwarden/common/models/response/col
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { DialogService } from "@bitwarden/components";
import { GroupServiceAbstraction } from "../services/abstractions/group";
import {
AccessItemType,
AccessItemValue,
@ -22,8 +23,7 @@ import {
convertToPermission,
convertToSelectionView,
PermissionMode,
} from "../components/access-selector";
import { GroupServiceAbstraction } from "../services/abstractions/group";
} from "../shared/components/access-selector";
import { GroupView } from "../views/group.view";
/**
@ -179,6 +179,7 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
@Inject(DIALOG_DATA) private params: GroupAddEditDialogParams,
private dialogRef: DialogRef<GroupAddEditDialogResultType>,
private apiService: ApiService,
private groupApiService: GroupServiceAbstraction,
private groupService: GroupServiceAbstraction,
private i18nService: I18nService,
private collectionService: CollectionService,

View File

@ -48,16 +48,6 @@ type CollectionViewMap = {
};
type GroupDetailsRow = {
/**
* Group Id (used for searching)
*/
id: string;
/**
* Group name (used for searching)
*/
name: string;
/**
* Details used for displaying group information
*/

View File

@ -10,7 +10,9 @@ import {
import { OrganizationPermissionsGuard } from "./guards/org-permissions.guard";
import { OrganizationLayoutComponent } from "./layouts/organization-layout.component";
import { CollectionsComponent } from "./manage/collections.component";
import { GroupsComponent } from "./manage/groups.component";
import { ManageComponent } from "./manage/manage.component";
import { PeopleComponent } from "./manage/people.component";
import { VaultModule } from "./vault/vault.module";
@ -50,6 +52,19 @@ const routes: Routes = [
organizationPermissions: canAccessGroupsTab,
},
},
{
path: "manage",
component: ManageComponent,
children: [
{
path: "collections",
component: CollectionsComponent,
data: {
titleId: "collections",
},
},
],
},
{
path: "reporting",
loadChildren: () =>

View File

@ -2,25 +2,29 @@ import { NgModule } from "@angular/core";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
import { SharedModule } from "../shared";
import { SharedModule } from "../shared/shared.module";
import { AccessSelectorModule } from "./components/access-selector";
import { CollectionAddEditComponent } from "./manage/collection-add-edit.component";
import { CoreOrganizationModule } from "./core";
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";
import { SharedOrganizationModule } from "./shared";
import { AccessSelectorModule } from "./shared/components/access-selector";
@NgModule({
imports: [SharedModule, AccessSelectorModule, OrganizationsRoutingModule],
declarations: [
GroupsComponent,
GroupAddEditComponent,
CollectionAddEditComponent,
UserGroupsComponent,
imports: [
SharedModule,
OrganizationsRoutingModule,
SharedOrganizationModule,
CoreOrganizationModule,
SharedModule,
AccessSelectorModule,
OrganizationsRoutingModule,
],
declarations: [GroupsComponent, GroupAddEditComponent, UserGroupsComponent],
providers: [
{
provide: GroupServiceAbstraction,

View File

@ -15,7 +15,7 @@ import {
} from "@bitwarden/components";
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
import { PreloadedEnglishI18nModule } from "../../../tests/preloaded-english-i18n.module";
import { PreloadedEnglishI18nModule } from "../../../../tests/preloaded-english-i18n.module";
import { AccessSelectorComponent, PermissionMode } from "./access-selector.component";
import { AccessItemType, CollectionPermission } from "./access-selector.models";

View File

@ -2,7 +2,7 @@ import { OrganizationUserStatusType } from "@bitwarden/common/enums/organization
import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType";
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
import { CollectionAccessSelectionView } from "../../views/collection-access-selection.view";
import { CollectionAccessSelectionView } from "../../../core";
/**
* Permission options that replace/correspond with readOnly and hidePassword server fields.

View File

@ -1,6 +1,6 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../../shared";
import { SharedModule } from "../../../../shared/shared.module";
import { AccessSelectorComponent } from "./access-selector.component";
import { UserTypePipe } from "./user-type.pipe";

View File

@ -15,7 +15,7 @@ import {
TabsModule,
} from "@bitwarden/components";
import { PreloadedEnglishI18nModule } from "../../../tests/preloaded-english-i18n.module";
import { PreloadedEnglishI18nModule } from "../../../../tests/preloaded-english-i18n.module";
import { AccessSelectorComponent } from "./access-selector.component";
import { AccessItemType, AccessItemView, CollectionPermission } from "./access-selector.models";

View File

@ -0,0 +1,94 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [disablePadding]="!loading">
<span bitDialogTitle>
<ng-container *ngIf="editMode">
{{ "editCollection" | i18n }}
<span class="tw-text-sm tw-normal-case tw-text-muted" *ngIf="!loading">{{
collection.name
}}</span>
</ng-container>
<ng-container *ngIf="!editMode">
{{ "newCollection" | i18n }}
</ng-container>
</span>
<div bitDialogContent>
<ng-container *ngIf="loading" #spinner>
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
</ng-container>
<bit-tab-group *ngIf="!loading">
<bit-tab label="{{ 'collectionInfo' | i18n }}">
<bit-form-field>
<bit-label>{{ "name" | i18n }}</bit-label>
<input bitInput formControlName="name" required />
</bit-form-field>
<bit-form-field>
<bit-label>{{ "externalId" | i18n }}</bit-label>
<input bitInput formControlName="externalId" />
<bit-hint>{{ "externalIdDesc" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "nestCollectionUnder" | i18n }}</bit-label>
<select bitInput formControlName="parent">
<option [ngValue]="null">-</option>
<option *ngIf="deletedParentName" disabled [ngValue]="deletedParentName">
{{ deletedParentName }} ({{ "deleted" | i18n }})
</option>
<option *ngFor="let collection of nestOptions" [ngValue]="collection.name">
{{ collection.name }}
</option>
</select>
</bit-form-field>
</bit-tab>
<bit-tab label="{{ 'access' | i18n }}">
<bit-access-selector
*ngIf="organization.useGroups"
permissionMode="edit"
formControlName="access"
[items]="accessItems"
[columnHeader]="'groupAndMemberColumnHeader' | i18n"
[selectorLabelText]="'selectGroupsAndMembers' | i18n"
[selectorHelpText]="'userPermissionOverrideHelper' | i18n"
[emptySelectionText]="'noMembersOrGroupsAdded' | i18n"
></bit-access-selector>
<bit-access-selector
*ngIf="!organization.useGroups"
permissionMode="edit"
formControlName="access"
[items]="accessItems"
[columnHeader]="'memberColumnHeader' | i18n"
[selectorLabelText]="'selectMembers' | i18n"
[emptySelectionText]="'noMembersAdded' | i18n"
></bit-access-selector>
</bit-tab>
</bit-tab-group>
</div>
<div bitDialogFooter class="tw-flex tw-flex-row tw-gap-2">
<button type="submit" bitButton bitFormButton buttonType="primary" [disabled]="loading">
{{ "save" | i18n }}
</button>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
(click)="cancel()"
[disabled]="loading"
>
{{ "cancel" | i18n }}
</button>
<button
*ngIf="editMode && organization?.canDeleteAssignedCollections"
type="button"
bitIconButton="bwi-trash"
buttonType="danger"
class="tw-ml-auto"
bitFormButton
[appA11yTitle]="'delete' | i18n"
[bitAction]="delete"
[disabled]="loading"
></button>
</div>
</bit-dialog>
</form>

View File

@ -0,0 +1,266 @@
import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Overlay } from "@angular/cdk/overlay";
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { takeUntil, Subject, of, combineLatest, shareReplay, switchMap } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { GroupServiceAbstraction } from "@bitwarden/common/abstractions/group/group.service.abstraction";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { GroupView } from "@bitwarden/common/models/view/group-view";
import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/src/models/response/organization-user.response";
import { CollectionView } from "@bitwarden/common/src/models/view/collection.view";
import { BitValidators, DialogService } from "@bitwarden/components";
import { CollectionAdminView, CollectionAdminService } from "../../../core";
import {
AccessItemType,
AccessItemValue,
AccessItemView,
convertToPermission,
convertToSelectionView,
} from "../access-selector";
export interface CollectionDialogParams {
collectionId?: string;
organizationId: string;
}
export enum CollectionDialogResult {
Saved = "saved",
Canceled = "canceled",
Deleted = "deleted",
}
@Component({
selector: "app-collection-dialog",
templateUrl: "collection-dialog.component.html",
})
export class CollectionDialogComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected loading = true;
protected organization?: Organization;
protected collection?: CollectionView;
protected nestOptions: CollectionView[] = [];
protected accessItems: AccessItemView[] = [];
protected deletedParentName: string | undefined;
protected formGroup = this.formBuilder.group({
name: ["", BitValidators.forbiddenCharacters(["/"])],
externalId: "",
parent: null as string | null,
access: [[] as AccessItemValue[]],
});
constructor(
@Inject(DIALOG_DATA) private params: CollectionDialogParams,
private formBuilder: FormBuilder,
private dialogRef: DialogRef<CollectionDialogResult>,
private apiService: ApiService,
private organizationService: OrganizationService,
private groupService: GroupServiceAbstraction,
private collectionService: CollectionAdminService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService
) {}
ngOnInit() {
const organization$ = of(this.organizationService.get(this.params.organizationId)).pipe(
shareReplay({ refCount: true, bufferSize: 1 })
);
const groups$ = organization$.pipe(
switchMap((organization) => {
if (!organization.useGroups) {
return of([] as GroupView[]);
}
return this.groupService.getAll(this.params.organizationId);
})
);
combineLatest({
organization: organization$,
collections: this.collectionService.getAll(this.params.organizationId),
collectionDetails: this.params.collectionId
? this.collectionService.get(this.params.organizationId, this.params.collectionId)
: of(null),
groups: groups$,
users: this.apiService.getOrganizationUsers(this.params.organizationId),
})
.pipe(takeUntil(this.destroy$))
.subscribe(({ organization, collections, collectionDetails, groups, users }) => {
this.organization = organization;
this.accessItems = [].concat(
groups.map(mapGroupToAccessItemView),
users.data.map(mapUserToAccessItemView)
);
if (this.params.collectionId) {
this.collection = collections.find((c) => c.id === this.collectionId);
this.nestOptions = collections.filter((c) => c.id !== this.collectionId);
if (!this.collection) {
throw new Error("Could not find collection to edit.");
}
const { name, parent } = parseName(this.collection);
if (parent !== null && !this.nestOptions.find((c) => c.name === parent)) {
this.deletedParentName = parent;
}
const accessSelections = mapToAccessSelections(collectionDetails);
this.formGroup.patchValue({
name,
externalId: this.collection.externalId,
parent,
access: accessSelections,
});
} else {
this.nestOptions = collections;
}
this.loading = false;
});
}
protected get collectionId() {
return this.params.collectionId;
}
protected get editMode() {
return this.params.collectionId != undefined;
}
protected async cancel() {
this.close(CollectionDialogResult.Canceled);
}
protected submit = async () => {
if (this.formGroup.invalid) {
return;
}
const collectionView = new CollectionAdminView();
collectionView.id = this.params.collectionId;
collectionView.organizationId = this.params.organizationId;
collectionView.externalId = this.formGroup.controls.externalId.value;
collectionView.groups = this.formGroup.controls.access.value
.filter((v) => v.type === AccessItemType.Group)
.map(convertToSelectionView);
collectionView.users = this.formGroup.controls.access.value
.filter((v) => v.type === AccessItemType.Member)
.map(convertToSelectionView);
const parent = this.formGroup.controls.parent.value;
if (parent) {
collectionView.name = `${parent}/${this.formGroup.controls.name.value}`;
} else {
collectionView.name = this.formGroup.controls.name.value;
}
await this.collectionService.save(collectionView);
this.close(CollectionDialogResult.Saved);
};
protected delete = async () => {
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t("deleteCollectionConfirmation"),
this.collection?.name,
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!confirmed && this.params.collectionId) {
return false;
}
await this.collectionService.delete(this.params.organizationId, this.params.collectionId);
this.close(CollectionDialogResult.Deleted);
};
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private close(result: CollectionDialogResult) {
this.dialogRef.close(result);
}
}
function parseName(collection: CollectionView) {
const nameParts = collection.name?.split("/");
const name = nameParts[nameParts.length - 1];
const parent = nameParts.length > 1 ? nameParts.slice(0, -1).join("/") : null;
return { name, parent };
}
function mapGroupToAccessItemView(group: GroupView): AccessItemView {
return {
id: group.id,
type: AccessItemType.Group,
listName: group.name,
labelName: group.name,
accessAllItems: group.accessAll,
readonly: group.accessAll,
};
}
// TODO: Use view when user apis are migrated to a service
function mapUserToAccessItemView(user: OrganizationUserUserDetailsResponse): AccessItemView {
return {
id: user.id,
type: AccessItemType.Member,
email: user.email,
role: user.type,
listName: user.name?.length > 0 ? `${user.name} (${user.email})` : user.email,
labelName: user.name ?? user.email,
status: user.status,
accessAllItems: user.accessAll,
readonly: user.accessAll,
};
}
function mapToAccessSelections(collectionDetails: CollectionAdminView): AccessItemValue[] {
if (collectionDetails == undefined) {
return [];
}
return [].concat(
collectionDetails.groups.map<AccessItemValue>((selection) => ({
id: selection.id,
type: AccessItemType.Group,
permission: convertToPermission(selection),
})),
collectionDetails.users.map<AccessItemValue>((selection) => ({
id: selection.id,
type: AccessItemType.Member,
permission: convertToPermission(selection),
}))
);
}
/**
* Strongly typed helper to open a CollectionDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export function openCollectionDialog(
dialogService: DialogService,
overlay: Overlay,
config: DialogConfig<CollectionDialogParams>
) {
return dialogService.open<CollectionDialogResult, CollectionDialogParams>(
CollectionDialogComponent,
{
positionStrategy: overlay.position().global().centerHorizontally().top(),
...config,
}
);
}

View File

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

View File

@ -0,0 +1,245 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Provider } from "@angular/core";
import { ReactiveFormsModule } from "@angular/forms";
import { action } from "@storybook/addon-actions";
import { Meta, Story, moduleMetadata } from "@storybook/angular";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { GroupServiceAbstraction } from "@bitwarden/common/abstractions/group";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType";
import { Utils } from "@bitwarden/common/misc/utils";
import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/models/response/organization-user.response";
import { GroupView } from "@bitwarden/common/models/view/group-view";
import { PlatformUtilsService } from "@bitwarden/common/src/abstractions/platformUtils.service";
import { SharedModule } from "../../../../shared/shared.module";
import { PreloadedEnglishI18nModule } from "../../../../tests/preloaded-english-i18n.module";
import {
CollectionAccessSelectionView,
CollectionAdminView,
CollectionAdminService,
} from "../../../core";
import { AccessSelectorModule } from "../access-selector";
import { CollectionDialogComponent, CollectionDialogParams } from "./collection-dialog.component";
interface ProviderData {
collectionId: string;
organizationId: string;
collections: CollectionAdminView[];
groups: GroupView[];
users: OrganizationUserUserDetailsResponse[];
useGroups: boolean;
}
export default {
title: "Web/Organizations/Collection Dialog",
component: CollectionDialogComponent,
decorators: [
moduleMetadata({
imports: [
JslibModule,
PreloadedEnglishI18nModule,
SharedModule,
ReactiveFormsModule,
AccessSelectorModule,
],
}),
],
} as Meta;
const organizationId = Utils.newGuid();
const groups = Array.from({ length: 10 }, (x, i) => createGroup(`Group ${i}`));
const users = Array.from({ length: 10 }, (x, i) => createUser(i));
const groupSelection = new CollectionAccessSelectionView({
id: groups[0].id,
readOnly: false,
hidePasswords: false,
});
const userSelection = new CollectionAccessSelectionView({
id: users[0].id,
readOnly: false,
hidePasswords: false,
});
let collections = Array.from({ length: 10 }, (x, i) =>
createCollection(`Collection ${i}`, [groupSelection], [userSelection])
);
collections = collections.concat(
collections.map((c, i) =>
createCollection(`${c.name}/Sub-collection ${i}`, [groupSelection], [userSelection])
)
);
function providers(data: ProviderData) {
return [
{
provide: DIALOG_DATA,
useValue: {
collectionId: data.collectionId,
organizationId: data.organizationId,
} as CollectionDialogParams,
},
{
provide: DialogRef,
useClass: MockDialogRef,
},
{
provide: CollectionAdminService,
useValue: new MockCollectionAdminService(data.collections, data.collectionId),
},
{
provide: OrganizationService,
useValue: {
get: () => ({ useGroups: data.useGroups, canDeleteAssignedCollections: true } as any),
} as Partial<OrganizationService>,
},
{
provide: GroupServiceAbstraction,
useValue: { getAll: () => Promise.resolve(data.groups) } as Partial<GroupServiceAbstraction>,
},
{
provide: ApiService,
useValue: {
getOrganizationUsers: () => Promise.resolve({ data: data.users }),
},
},
{
provide: PlatformUtilsService,
useValue: {
showDialog: action("PlatformUtilsService.show") as () => Promise<unknown>,
} as Partial<PlatformUtilsService>,
},
] as Provider[];
}
class MockDialogRef implements Partial<DialogRef> {
close = action("DialogRef.close");
}
class MockCollectionAdminService implements Partial<CollectionAdminService> {
constructor(private collections: CollectionAdminView[], private collectionId: string) {}
private saveAction = action("CollectionApiService.save");
getAll = () => Promise.resolve(this.collections);
get = () => Promise.resolve(this.collections.find((c) => c.id === this.collectionId));
async save(...args: unknown[]) {
this.saveAction(args);
await Utils.delay(1500);
}
}
function createCollection(
name: string,
collectionGroups: CollectionAccessSelectionView[],
collectionUsers: CollectionAccessSelectionView[],
id = Utils.newGuid()
) {
const collection = new CollectionAdminView();
collection.id = id;
collection.name = name;
collection.groups = collectionGroups;
collection.users = collectionUsers;
return collection;
}
function createGroup(name: string, id = Utils.newGuid()) {
const group = new GroupView();
group.id = id;
group.name = name;
return group;
}
function createUser(i: number, id = Utils.newGuid()) {
const user = new OrganizationUserUserDetailsResponse({});
user.name = `User ${i}`;
user.email = `user_${i}@email.com`;
user.twoFactorEnabled = false;
user.usesKeyConnector = false;
user.status = OrganizationUserStatusType.Accepted;
return user;
}
const NewCollectionTemplate: Story<CollectionDialogComponent> = (
args: CollectionDialogComponent
) => ({
moduleMetadata: {
providers: providers({
collectionId: undefined,
organizationId,
collections,
groups,
users,
useGroups: true,
}),
},
template: `<app-collection-dialog></app-collection-dialog>`,
});
export const NewCollection = NewCollectionTemplate.bind({});
const ExistingCollectionTemplate: Story<CollectionDialogComponent> = (
args: CollectionDialogComponent
) => ({
moduleMetadata: {
providers: providers({
collectionId: collections[collections.length - 1].id,
organizationId,
collections,
groups,
users,
useGroups: true,
}),
},
template: `<app-collection-dialog></app-collection-dialog>`,
});
export const ExistingCollection = ExistingCollectionTemplate.bind({});
const NonExistingParentTemplate: Story<CollectionDialogComponent> = (
args: CollectionDialogComponent
) => {
const collection = createCollection(
"Non existing parent/Collection",
[groupSelection],
[userSelection]
);
return {
moduleMetadata: {
providers: providers({
collectionId: collection.id,
organizationId,
collections: [collection, ...collections],
groups,
users,
useGroups: true,
}),
},
template: `<app-collection-dialog></app-collection-dialog>`,
};
};
export const NonExistingParentCollection = NonExistingParentTemplate.bind({});
const FreeOrganizationTemplate: Story<CollectionDialogComponent> = (
args: CollectionDialogComponent
) => ({
moduleMetadata: {
providers: providers({
collectionId: collections[collections.length - 1].id,
organizationId,
collections,
groups,
users,
useGroups: false,
}),
},
template: `<app-collection-dialog></app-collection-dialog>`,
});
export const FreeOrganization = FreeOrganizationTemplate.bind({});

View File

@ -0,0 +1,2 @@
export * from "./collection-dialog.component";
export * from "./collection-dialog.module";

View File

@ -0,0 +1,2 @@
export * from "./shared-organization.module";
export * from "./components/collection-dialog";

View File

@ -0,0 +1,9 @@
import { NgModule } from "@angular/core";
import { CollectionDialogModule } from "./components/collection-dialog";
@NgModule({
imports: [CollectionDialogModule],
exports: [CollectionDialogModule],
})
export class SharedOrganizationModule {}

View File

@ -123,7 +123,7 @@ import { FolderAddEditComponent } from "../vault/folder-add-edit.component";
import { OrganizationBadgeModule } from "../vault/organization-badge/organization-badge.module";
import { ShareComponent } from "../vault/share.component";
import { SharedModule } from ".";
import { SharedModule } from "./shared.module";
// Please do not add to this list of declarations - we should refactor these into modules when doing so makes sense until there are none left.
// If you are building new functionality, please create or extend a feature module instead.

View File

@ -10,7 +10,6 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
AsyncActionsModule,
AvatarModule,
BadgeListModule,
BadgeModule,
ButtonModule,
CalloutModule,
@ -23,6 +22,7 @@ import {
MultiSelectModule,
TableModule,
TabsModule,
BadgeListModule,
} from "@bitwarden/components";
// Register the locales for the application
@ -39,6 +39,7 @@ import "./locales";
imports: [
CommonModule,
DragDropModule,
DialogModule,
FormsModule,
InfiniteScrollModule,
JslibModule,
@ -66,6 +67,7 @@ import "./locales";
CommonModule,
AsyncActionsModule,
DragDropModule,
DialogModule,
FormsModule,
InfiniteScrollModule,
JslibModule,
@ -80,6 +82,7 @@ import "./locales";
MultiSelectModule,
FormFieldModule,
IconModule,
IconButtonModule,
TabsModule,
TableModule,
AvatarModule,

View File

@ -2396,10 +2396,13 @@
"message": "Warning! This user requires Key Connector to manage their encryption. Removing this user from your organization will permanently deactivate their account. This action cannot be undone. Do you want to proceed?"
},
"externalId": {
"message": "External id"
"message": "External ID"
},
"externalIdDesc": {
"message": "External ID is an unencrypted reference used by the Bitwarden Directory Connector or API."
"message": "External ID is an unencrypted reference used by the Bitwarden Directory Connector and API."
},
"nestCollectionUnder": {
"message": "Nest collection under"
},
"accessControl": {
"message": "Access control"
@ -2422,6 +2425,12 @@
"editCollection": {
"message": "Edit collection"
},
"collectionInfo": {
"message": "Collection info"
},
"access": {
"message": "Access"
},
"deleteCollectionConfirmation": {
"message": "Are you sure you want to delete this collection?"
},
@ -5454,6 +5463,15 @@
}
}
},
"inputForbiddenCharacters": {
"message": "The following characters are not allowed: $CHARACTERS$",
"placeholders": {
"characters": {
"content": "$1",
"example": "@, #, $, %"
}
}
},
"inputMaxLength": {
"message": "Input must not exceed $COUNT$ characters in length.",
"placeholders": {
@ -5547,9 +5565,6 @@
"accessAllCollectionsHelp": {
"message": "If checked, this will replace all other collection permissions."
},
"selectMembers": {
"message": "Select members"
},
"selectCollections": {
"message": "Select collections"
},
@ -5588,5 +5603,26 @@
},
"memberAccessAll": {
"message": "This member can access and modify all items."
},
"membersColumnHeader": {
"message": "Member/Group"
},
"groupAndMemberColumnHeader": {
"message": "Member"
},
"selectGroupsAndMembers": {
"message": "Select groups and members"
},
"selectMembers": {
"message": "Select members"
},
"userPermissionOverrideHelper": {
"message": "Permissions set for a member will replace permissions set by that member's group"
},
"noMembersOrGroupsAdded": {
"message": "No members or groups added"
},
"deleted": {
"message": "Deleted"
}
}

View File

@ -28,6 +28,7 @@ import {
InternalFolderService,
} from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "@bitwarden/common/abstractions/formValidationErrors.service";
import { GroupServiceAbstraction } from "@bitwarden/common/abstractions/group";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/abstractions/i18n.service";
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/abstractions/keyConnector.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
@ -88,6 +89,7 @@ import { FileUploadService } from "@bitwarden/common/services/fileUpload.service
import { FolderApiService } from "@bitwarden/common/services/folder/folder-api.service";
import { FolderService } from "@bitwarden/common/services/folder/folder.service";
import { FormValidationErrorsService } from "@bitwarden/common/services/formValidationErrors.service";
import { GroupService } from "@bitwarden/common/services/group/group.service";
import { KeyConnectorService } from "@bitwarden/common/services/keyConnector.service";
import { LoginService } from "@bitwarden/common/services/login.service";
import { NotificationsService } from "@bitwarden/common/services/notifications.service";
@ -580,6 +582,11 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
useClass: ValidationService,
deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction],
},
{
provide: GroupServiceAbstraction,
useClass: GroupService,
deps: [ApiServiceAbstraction],
},
{
provide: LoginServiceAbstraction,
useClass: LoginService,

View File

@ -92,7 +92,7 @@ import { BillingPaymentResponse } from "../models/response/billing-payment.respo
import { BreachAccountResponse } from "../models/response/breach-account.response";
import { CipherResponse } from "../models/response/cipher.response";
import {
CollectionGroupDetailsResponse,
CollectionAccessDetailsResponse,
CollectionResponse,
} from "../models/response/collection.response";
import { DeviceVerificationResponse } from "../models/response/device-verification.response";
@ -314,7 +314,7 @@ export abstract class ApiService {
getCollectionDetails: (
organizationId: string,
id: string
) => Promise<CollectionGroupDetailsResponse>;
) => Promise<CollectionAccessDetailsResponse>;
getUserCollections: () => Promise<ListResponse<CollectionResponse>>;
getCollections: (organizationId: string) => Promise<ListResponse<CollectionResponse>>;
getCollectionUsers: (organizationId: string, id: string) => Promise<SelectionReadOnlyResponse[]>;

View File

@ -0,0 +1,9 @@
import { GroupView } from "../../models/view/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[]>;
}

View File

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

View File

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

View File

@ -440,6 +440,10 @@ export class Utils {
return mobile || win.navigator.userAgent.match(/iPad/i) != null;
}
static delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private static isAppleMobile(win: Window) {
return (
win.navigator.userAgent.match(/iPhone/i) != null ||

View File

@ -6,6 +6,7 @@ export class CollectionRequest {
name: string;
externalId: string;
groups: SelectionReadOnlyRequest[] = [];
users: SelectionReadOnlyRequest[] = [];
constructor(collection?: Collection) {
if (collection == null) {

View File

@ -25,8 +25,9 @@ export class CollectionDetailsResponse extends CollectionResponse {
}
}
export class CollectionGroupDetailsResponse extends CollectionResponse {
export class CollectionAccessDetailsResponse extends CollectionResponse {
groups: SelectionReadOnlyResponse[] = [];
users: SelectionReadOnlyResponse[] = [];
constructor(response: any) {
super(response);
@ -34,5 +35,10 @@ export class CollectionGroupDetailsResponse extends CollectionResponse {
if (groups != null) {
this.groups = groups.map((g: any) => new SelectionReadOnlyResponse(g));
}
const users = this.getResponseProperty("Users");
if (users != null) {
this.users = users.map((g: any) => new SelectionReadOnlyResponse(g));
}
}
}

View File

@ -1,6 +1,6 @@
import { Collection } from "../domain/collection";
import { ITreeNodeObject } from "../domain/tree-node";
import { CollectionGroupDetailsResponse } from "../response/collection.response";
import { CollectionResponse } from "../response/collection.response";
import { View } from "./view";
@ -12,7 +12,7 @@ export class CollectionView implements View, ITreeNodeObject {
readOnly: boolean = null;
hidePasswords: boolean = null;
constructor(c?: Collection | CollectionGroupDetailsResponse) {
constructor(c?: Collection | CollectionResponse) {
if (!c) {
return;
}

View File

@ -0,0 +1,17 @@
import { GroupResponse } from "../../abstractions/group";
import { SelectionReadOnlyResponse } from "../response/selection-read-only.response";
import { View } from "./view";
export class GroupView implements View {
id: string;
organizationId: string;
name: string;
accessAll: boolean;
externalId: string;
collections: SelectionReadOnlyResponse[] = [];
static fromResponse(response: GroupResponse) {
return Object.assign(new GroupView(), response);
}
}

View File

@ -100,7 +100,7 @@ import { BillingPaymentResponse } from "../models/response/billing-payment.respo
import { BreachAccountResponse } from "../models/response/breach-account.response";
import { CipherResponse } from "../models/response/cipher.response";
import {
CollectionGroupDetailsResponse,
CollectionAccessDetailsResponse,
CollectionResponse,
} from "../models/response/collection.response";
import { DeviceVerificationResponse } from "../models/response/device-verification.response";
@ -810,7 +810,7 @@ export class ApiService implements ApiServiceAbstraction {
async getCollectionDetails(
organizationId: string,
id: string
): Promise<CollectionGroupDetailsResponse> {
): Promise<CollectionAccessDetailsResponse> {
const r = await this.send(
"GET",
"/organizations/" + organizationId + "/collections/" + id + "/details",
@ -818,7 +818,7 @@ export class ApiService implements ApiServiceAbstraction {
true,
true
);
return new CollectionGroupDetailsResponse(r);
return new CollectionAccessDetailsResponse(r);
}
async getUserCollections(): Promise<ListResponse<CollectionResponse>> {

View File

@ -0,0 +1,65 @@
import { ApiService } from "../../abstractions/api.service";
import {
GroupDetailsResponse,
GroupResponse,
GroupServiceAbstraction,
} from "../../abstractions/group";
import { ListResponse } from "../../models/response/list.response";
import { GroupView } from "../../models/view/group-view";
import { OrganizationGroupBulkRequest } from "./requests/organization-group-bulk-request";
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)) ?? [];
}
}

View File

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

View File

@ -3,10 +3,9 @@ import { Subject, takeUntil } from "rxjs";
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
import { BitActionDirective } from "./bit-action.directive";
import { BitSubmitDirective } from "./bit-submit.directive";
import { BitActionDirective } from ".";
/**
* This directive has two purposes:
*

View File

@ -1,6 +1,6 @@
import { Component, Input, OnChanges } from "@angular/core";
import { BadgeTypes } from "../badge";
import { BadgeType } from "../badge";
@Component({
selector: "bit-badge-list",
@ -12,7 +12,7 @@ export class BadgeListComponent implements OnChanges {
protected filteredItems: string[] = [];
protected isFiltered = false;
@Input() badgeType: BadgeTypes = "primary";
@Input() badgeType: BadgeType = "primary";
@Input() items: string[] = [];
@Input()

View File

@ -1,8 +1,8 @@
import { Directive, ElementRef, HostBinding, Input } from "@angular/core";
export type BadgeTypes = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
export type BadgeType = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
const styles: Record<BadgeTypes, string[]> = {
const styles: Record<BadgeType, string[]> = {
primary: ["tw-bg-primary-500"],
secondary: ["tw-bg-text-muted"],
success: ["tw-bg-success-500"],
@ -11,7 +11,7 @@ const styles: Record<BadgeTypes, string[]> = {
info: ["tw-bg-info-500"],
};
const hoverStyles: Record<BadgeTypes, string[]> = {
const hoverStyles: Record<BadgeType, string[]> = {
primary: ["hover:tw-bg-primary-700"],
secondary: ["hover:tw-bg-secondary-700"],
success: ["hover:tw-bg-success-700"],
@ -47,7 +47,7 @@ export class BadgeDirective {
.concat(this.hasHoverEffects ? hoverStyles[this.badgeType] : []);
}
@Input() badgeType: BadgeTypes = "primary";
@Input() badgeType: BadgeType = "primary";
private hasHoverEffects = false;

View File

@ -1,7 +1,7 @@
import { CommonModule } from "@angular/common";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { BadgeDirective } from "./badge.directive";
import { BadgeDirective, BadgeType } from "./badge.directive";
export default {
title: "Component Library/Badge",
@ -15,6 +15,12 @@ export default {
args: {
badgeType: "primary",
},
argTypes: {
badgeType: {
options: ["primary", "secondary", "success", "danger", "warning", "info"] as BadgeType[],
control: { type: "inline-radio" },
},
},
parameters: {
design: {
type: "figma",

View File

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

View File

@ -4,7 +4,7 @@ import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { I18nMockService } from "../utils/i18n-mock.service";
import { CalloutComponent } from ".";
import { CalloutComponent } from "./callout.component";
describe("Callout", () => {
let component: CalloutComponent;

View File

@ -0,0 +1,56 @@
import { FormsModule, ReactiveFormsModule, FormBuilder } from "@angular/forms";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { ButtonModule } from "../button";
import { InputModule } from "../input/input.module";
import { I18nMockService } from "../utils/i18n-mock.service";
import { forbiddenCharacters } from "./bit-validators/forbidden-characters.validator";
import { BitFormFieldComponent } from "./form-field.component";
import { FormFieldModule } from "./form-field.module";
export default {
title: "Component Library/Form/Custom Validators",
component: BitFormFieldComponent,
decorators: [
moduleMetadata({
imports: [FormsModule, ReactiveFormsModule, FormFieldModule, InputModule, ButtonModule],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
inputForbiddenCharacters: (chars) =>
`The following characters are not allowed: ${chars}`,
});
},
},
],
}),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/file/f32LSg3jaegICkMu7rPARm/Tailwind-Component-Library-Update?node-id=1881%3A17689",
},
},
} as Meta;
const template = `
<form [formGroup]="formObj">
<bit-form-field>
<bit-label>Name</bit-label>
<input bitInput formControlName="name" />
</bit-form-field>
</form>`;
export const ForbiddenCharacters: Story<BitFormFieldComponent> = (args: BitFormFieldComponent) => ({
props: {
formObj: new FormBuilder().group({
name: ["", forbiddenCharacters(["\\", "/", "@", "#", "$", "%", "^", "&", "*", "(", ")"])],
}),
},
template,
});

View File

@ -0,0 +1,45 @@
import { FormControl } from "@angular/forms";
import { forbiddenCharacters } from "./forbidden-characters.validator";
describe("forbiddenCharacters", () => {
it("should return no error when input is null", () => {
const input = createControl(null);
const validate = forbiddenCharacters(["n", "u", "l", "l"]);
const errors = validate(input);
expect(errors).toBe(null);
});
it("should return no error when no characters are forbidden", () => {
const input = createControl("special characters: \\/@#$%^&*()");
const validate = forbiddenCharacters([]);
const errors = validate(input);
expect(errors).toBe(null);
});
it("should return no error when input does not contain forbidden characters", () => {
const input = createControl("contains no special characters");
const validate = forbiddenCharacters(["\\", "/", "@", "#", "$", "%", "^", "&", "*", "(", ")"]);
const errors = validate(input);
expect(errors).toBe(null);
});
it("should return error when input contains forbidden characters", () => {
const input = createControl("contains / illegal @ characters");
const validate = forbiddenCharacters(["\\", "/", "@", "#", "$", "%", "^", "&", "*", "(", ")"]);
const errors = validate(input);
expect(errors).not.toBe(null);
});
});
function createControl(input: string) {
return new FormControl(input);
}

View File

@ -0,0 +1,23 @@
import { AbstractControl, FormControl, ValidationErrors, ValidatorFn } from "@angular/forms";
export function forbiddenCharacters(characters: string[]): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!(control instanceof FormControl)) {
throw new Error("forbiddenCharacters only supports validating FormControls");
}
if (control.value === null || control.value === undefined) {
return null;
}
const value = String(control.value);
for (const char of value) {
if (characters.includes(char)) {
return { forbiddenCharacters: { value: control.value, characters } };
}
}
return null;
};
}

View File

@ -0,0 +1 @@
export { forbiddenCharacters } from "./forbidden-characters.validator";

View File

@ -30,6 +30,8 @@ export class BitErrorComponent {
return this.i18nService.t("inputMinLength", this.error[1]?.requiredLength);
case "maxlength":
return this.i18nService.t("inputMaxLength", this.error[1]?.requiredLength);
case "forbiddenCharacters":
return this.i18nService.t("inputForbiddenCharacters", this.error[1]?.characters.join(", "));
default:
// Attempt to show a custom error message.
if (this.error[1]?.message) {

View File

@ -1,3 +1,4 @@
export * from "./form-field.module";
export * from "./form-field.component";
export * from "./form-field-control";
export * as BitValidators from "./bit-validators";

View File

@ -1 +1,2 @@
export * from "./multi-select.module";
export * from "./models/select-item-view";