1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-06-26 10:35:48 +02:00

[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> <i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
</button> </button>
<div class="dropdown-menu dropdown-menu-right"> <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 <a
class="dropdown-item" class="dropdown-item"
href="#" href="#"

View File

@ -1,5 +1,7 @@
import { Overlay } from "@angular/cdk/overlay";
import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from "@angular/router";
import { lastValueFrom } from "rxjs";
import { first } from "rxjs/operators"; import { first } from "rxjs/operators";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
@ -19,8 +21,10 @@ import {
} from "@bitwarden/common/models/response/collection.response"; } from "@bitwarden/common/models/response/collection.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { CollectionView } from "@bitwarden/common/models/view/collection.view"; 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"; import { EntityUsersComponent } from "./entity-users.component";
@Component({ @Component({
@ -56,7 +60,9 @@ export class CollectionsComponent implements OnInit {
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private searchService: SearchService, private searchService: SearchService,
private logService: LogService, private logService: LogService,
private organizationService: OrganizationService private organizationService: OrganizationService,
private dialogService: DialogService,
private overlay: Overlay
) {} ) {}
async ngOnInit() { async ngOnInit() {
@ -116,36 +122,24 @@ export class CollectionsComponent implements OnInit {
this.didScroll = this.pagedCollections.length > this.pageSize; this.didScroll = this.pagedCollections.length > this.pageSize;
} }
async edit(collection: CollectionView) { async edit(collection?: CollectionView) {
const canCreate = collection == null && this.canCreate; const canCreate = collection == undefined && this.canCreate;
const canEdit = collection != null && this.canEdit(collection); const canEdit = collection != undefined && this.canEdit(collection);
const canDelete = collection != null && this.canDelete(collection); const canDelete = collection != undefined && this.canDelete(collection);
if (!(canCreate || canEdit || canDelete)) { if (!(canCreate || canEdit || canDelete)) {
this.platformUtilsService.showToast("error", null, this.i18nService.t("missingPermissions")); this.platformUtilsService.showToast("error", null, this.i18nService.t("missingPermissions"));
return; return;
} }
const [modal] = await this.modalService.openViewRef( const dialog = openCollectionDialog(this.dialogService, this.overlay, {
CollectionAddEditComponent, data: { collectionId: collection?.id, organizationId: this.organizationId },
this.addEditModalRef, });
(comp) => {
comp.organizationId = this.organizationId; const result = await lastValueFrom(dialog.closed);
comp.collectionId = collection != null ? collection.id : null; if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) {
comp.canSave = canCreate || canEdit; this.load();
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);
});
}
);
} }
add() { 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 { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { GroupServiceAbstraction } from "../services/abstractions/group";
import { import {
AccessItemType, AccessItemType,
AccessItemValue, AccessItemValue,
@ -22,8 +23,7 @@ import {
convertToPermission, convertToPermission,
convertToSelectionView, convertToSelectionView,
PermissionMode, PermissionMode,
} from "../components/access-selector"; } from "../shared/components/access-selector";
import { GroupServiceAbstraction } from "../services/abstractions/group";
import { GroupView } from "../views/group.view"; import { GroupView } from "../views/group.view";
/** /**
@ -179,6 +179,7 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
@Inject(DIALOG_DATA) private params: GroupAddEditDialogParams, @Inject(DIALOG_DATA) private params: GroupAddEditDialogParams,
private dialogRef: DialogRef<GroupAddEditDialogResultType>, private dialogRef: DialogRef<GroupAddEditDialogResultType>,
private apiService: ApiService, private apiService: ApiService,
private groupApiService: GroupServiceAbstraction,
private groupService: GroupServiceAbstraction, private groupService: GroupServiceAbstraction,
private i18nService: I18nService, private i18nService: I18nService,
private collectionService: CollectionService, private collectionService: CollectionService,

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@ import {
} from "@bitwarden/components"; } from "@bitwarden/components";
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view"; 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 { AccessSelectorComponent, PermissionMode } from "./access-selector.component";
import { AccessItemType, CollectionPermission } from "./access-selector.models"; 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 { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType";
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view"; 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. * Permission options that replace/correspond with readOnly and hidePassword server fields.

View File

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

View File

@ -15,7 +15,7 @@ import {
TabsModule, TabsModule,
} from "@bitwarden/components"; } 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 { AccessSelectorComponent } from "./access-selector.component";
import { AccessItemType, AccessItemView, CollectionPermission } from "./access-selector.models"; 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 { OrganizationBadgeModule } from "../vault/organization-badge/organization-badge.module";
import { ShareComponent } from "../vault/share.component"; 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. // 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. // 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 { import {
AsyncActionsModule, AsyncActionsModule,
AvatarModule, AvatarModule,
BadgeListModule,
BadgeModule, BadgeModule,
ButtonModule, ButtonModule,
CalloutModule, CalloutModule,
@ -23,6 +22,7 @@ import {
MultiSelectModule, MultiSelectModule,
TableModule, TableModule,
TabsModule, TabsModule,
BadgeListModule,
} from "@bitwarden/components"; } from "@bitwarden/components";
// Register the locales for the application // Register the locales for the application
@ -39,6 +39,7 @@ import "./locales";
imports: [ imports: [
CommonModule, CommonModule,
DragDropModule, DragDropModule,
DialogModule,
FormsModule, FormsModule,
InfiniteScrollModule, InfiniteScrollModule,
JslibModule, JslibModule,
@ -66,6 +67,7 @@ import "./locales";
CommonModule, CommonModule,
AsyncActionsModule, AsyncActionsModule,
DragDropModule, DragDropModule,
DialogModule,
FormsModule, FormsModule,
InfiniteScrollModule, InfiniteScrollModule,
JslibModule, JslibModule,
@ -80,6 +82,7 @@ import "./locales";
MultiSelectModule, MultiSelectModule,
FormFieldModule, FormFieldModule,
IconModule, IconModule,
IconButtonModule,
TabsModule, TabsModule,
TableModule, TableModule,
AvatarModule, 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?" "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": { "externalId": {
"message": "External id" "message": "External ID"
}, },
"externalIdDesc": { "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": { "accessControl": {
"message": "Access control" "message": "Access control"
@ -2422,6 +2425,12 @@
"editCollection": { "editCollection": {
"message": "Edit collection" "message": "Edit collection"
}, },
"collectionInfo": {
"message": "Collection info"
},
"access": {
"message": "Access"
},
"deleteCollectionConfirmation": { "deleteCollectionConfirmation": {
"message": "Are you sure you want to delete this collection?" "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": { "inputMaxLength": {
"message": "Input must not exceed $COUNT$ characters in length.", "message": "Input must not exceed $COUNT$ characters in length.",
"placeholders": { "placeholders": {
@ -5547,9 +5565,6 @@
"accessAllCollectionsHelp": { "accessAllCollectionsHelp": {
"message": "If checked, this will replace all other collection permissions." "message": "If checked, this will replace all other collection permissions."
}, },
"selectMembers": {
"message": "Select members"
},
"selectCollections": { "selectCollections": {
"message": "Select collections" "message": "Select collections"
}, },
@ -5588,5 +5603,26 @@
}, },
"memberAccessAll": { "memberAccessAll": {
"message": "This member can access and modify all items." "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, InternalFolderService,
} from "@bitwarden/common/abstractions/folder/folder.service.abstraction"; } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "@bitwarden/common/abstractions/formValidationErrors.service"; 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 { I18nService as I18nServiceAbstraction } from "@bitwarden/common/abstractions/i18n.service";
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/abstractions/keyConnector.service"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/abstractions/keyConnector.service";
import { LogService } from "@bitwarden/common/abstractions/log.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 { FolderApiService } from "@bitwarden/common/services/folder/folder-api.service";
import { FolderService } from "@bitwarden/common/services/folder/folder.service"; import { FolderService } from "@bitwarden/common/services/folder/folder.service";
import { FormValidationErrorsService } from "@bitwarden/common/services/formValidationErrors.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 { KeyConnectorService } from "@bitwarden/common/services/keyConnector.service";
import { LoginService } from "@bitwarden/common/services/login.service"; import { LoginService } from "@bitwarden/common/services/login.service";
import { NotificationsService } from "@bitwarden/common/services/notifications.service"; import { NotificationsService } from "@bitwarden/common/services/notifications.service";
@ -580,6 +582,11 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
useClass: ValidationService, useClass: ValidationService,
deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction], deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction],
}, },
{
provide: GroupServiceAbstraction,
useClass: GroupService,
deps: [ApiServiceAbstraction],
},
{ {
provide: LoginServiceAbstraction, provide: LoginServiceAbstraction,
useClass: LoginService, 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 { BreachAccountResponse } from "../models/response/breach-account.response";
import { CipherResponse } from "../models/response/cipher.response"; import { CipherResponse } from "../models/response/cipher.response";
import { import {
CollectionGroupDetailsResponse, CollectionAccessDetailsResponse,
CollectionResponse, CollectionResponse,
} from "../models/response/collection.response"; } from "../models/response/collection.response";
import { DeviceVerificationResponse } from "../models/response/device-verification.response"; import { DeviceVerificationResponse } from "../models/response/device-verification.response";
@ -314,7 +314,7 @@ export abstract class ApiService {
getCollectionDetails: ( getCollectionDetails: (
organizationId: string, organizationId: string,
id: string id: string
) => Promise<CollectionGroupDetailsResponse>; ) => Promise<CollectionAccessDetailsResponse>;
getUserCollections: () => Promise<ListResponse<CollectionResponse>>; getUserCollections: () => Promise<ListResponse<CollectionResponse>>;
getCollections: (organizationId: string) => Promise<ListResponse<CollectionResponse>>; getCollections: (organizationId: string) => Promise<ListResponse<CollectionResponse>>;
getCollectionUsers: (organizationId: string, id: string) => Promise<SelectionReadOnlyResponse[]>; 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; 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) { private static isAppleMobile(win: Window) {
return ( return (
win.navigator.userAgent.match(/iPhone/i) != null || win.navigator.userAgent.match(/iPhone/i) != null ||

View File

@ -6,6 +6,7 @@ export class CollectionRequest {
name: string; name: string;
externalId: string; externalId: string;
groups: SelectionReadOnlyRequest[] = []; groups: SelectionReadOnlyRequest[] = [];
users: SelectionReadOnlyRequest[] = [];
constructor(collection?: Collection) { constructor(collection?: Collection) {
if (collection == null) { 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[] = []; groups: SelectionReadOnlyResponse[] = [];
users: SelectionReadOnlyResponse[] = [];
constructor(response: any) { constructor(response: any) {
super(response); super(response);
@ -34,5 +35,10 @@ export class CollectionGroupDetailsResponse extends CollectionResponse {
if (groups != null) { if (groups != null) {
this.groups = groups.map((g: any) => new SelectionReadOnlyResponse(g)); 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 { Collection } from "../domain/collection";
import { ITreeNodeObject } from "../domain/tree-node"; import { ITreeNodeObject } from "../domain/tree-node";
import { CollectionGroupDetailsResponse } from "../response/collection.response"; import { CollectionResponse } from "../response/collection.response";
import { View } from "./view"; import { View } from "./view";
@ -12,7 +12,7 @@ export class CollectionView implements View, ITreeNodeObject {
readOnly: boolean = null; readOnly: boolean = null;
hidePasswords: boolean = null; hidePasswords: boolean = null;
constructor(c?: Collection | CollectionGroupDetailsResponse) { constructor(c?: Collection | CollectionResponse) {
if (!c) { if (!c) {
return; 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 { BreachAccountResponse } from "../models/response/breach-account.response";
import { CipherResponse } from "../models/response/cipher.response"; import { CipherResponse } from "../models/response/cipher.response";
import { import {
CollectionGroupDetailsResponse, CollectionAccessDetailsResponse,
CollectionResponse, CollectionResponse,
} from "../models/response/collection.response"; } from "../models/response/collection.response";
import { DeviceVerificationResponse } from "../models/response/device-verification.response"; import { DeviceVerificationResponse } from "../models/response/device-verification.response";
@ -810,7 +810,7 @@ export class ApiService implements ApiServiceAbstraction {
async getCollectionDetails( async getCollectionDetails(
organizationId: string, organizationId: string,
id: string id: string
): Promise<CollectionGroupDetailsResponse> { ): Promise<CollectionAccessDetailsResponse> {
const r = await this.send( const r = await this.send(
"GET", "GET",
"/organizations/" + organizationId + "/collections/" + id + "/details", "/organizations/" + organizationId + "/collections/" + id + "/details",
@ -818,7 +818,7 @@ export class ApiService implements ApiServiceAbstraction {
true, true,
true true
); );
return new CollectionGroupDetailsResponse(r); return new CollectionAccessDetailsResponse(r);
} }
async getUserCollections(): Promise<ListResponse<CollectionResponse>> { 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 { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
import { BitActionDirective } from "./bit-action.directive";
import { BitSubmitDirective } from "./bit-submit.directive"; import { BitSubmitDirective } from "./bit-submit.directive";
import { BitActionDirective } from ".";
/** /**
* This directive has two purposes: * This directive has two purposes:
* *

View File

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

View File

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

View File

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

View File

@ -1,2 +1,2 @@
export { BadgeDirective, BadgeTypes } from "./badge.directive"; export { BadgeDirective, BadgeType } from "./badge.directive";
export * from "./badge.module"; 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 { I18nMockService } from "../utils/i18n-mock.service";
import { CalloutComponent } from "."; import { CalloutComponent } from "./callout.component";
describe("Callout", () => { describe("Callout", () => {
let component: CalloutComponent; 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); return this.i18nService.t("inputMinLength", this.error[1]?.requiredLength);
case "maxlength": case "maxlength":
return this.i18nService.t("inputMaxLength", this.error[1]?.requiredLength); return this.i18nService.t("inputMaxLength", this.error[1]?.requiredLength);
case "forbiddenCharacters":
return this.i18nService.t("inputForbiddenCharacters", this.error[1]?.characters.join(", "));
default: default:
// Attempt to show a custom error message. // Attempt to show a custom error message.
if (this.error[1]?.message) { if (this.error[1]?.message) {

View File

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

View File

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