1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 00:33:44 +00:00

Merge EC-73 Again After Rebase (#4104)

* [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] chore: re-add collections page

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

* [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] 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: 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

* Fix remaining errors after rebase/merge

* fix: import shared module directly

Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com>
This commit is contained in:
Shane Melton
2022-11-22 13:40:12 -08:00
committed by GitHub
parent cbb22230fc
commit 2fa6f50cd6
45 changed files with 1067 additions and 402 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;
comp.canDelete = canDelete;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
comp.onSavedCollection.subscribe(() => {
modal.close();
this.load(); 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";
/** /**

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

@@ -4,23 +4,23 @@ import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstracti
import { SharedModule } from "../shared"; import { SharedModule } from "../shared";
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";
@NgModule({ @NgModule({
imports: [SharedModule, AccessSelectorModule, OrganizationsRoutingModule], imports: [
declarations: [ SharedModule,
GroupsComponent, OrganizationsRoutingModule,
GroupAddEditComponent, SharedOrganizationModule,
CollectionAddEditComponent, CoreOrganizationModule,
UserGroupsComponent,
], ],
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

@@ -1,8 +1,8 @@
import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType"; import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType";
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";
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 { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Overlay } from "@angular/cdk/overlay";
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { combineLatest, of, shareReplay, Subject, switchMap, takeUntil } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { 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 { 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 { CollectionAdminService, CollectionAdminView } from "../../../core";
import { GroupServiceAbstraction } from "../../../services/abstractions/group";
import { GroupView } from "../../../views/group.view";
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 { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { Provider } from "@angular/core";
import { ReactiveFormsModule } from "@angular/forms";
import { action } from "@storybook/addon-actions";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
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 { PlatformUtilsService } from "@bitwarden/common/src/abstractions/platformUtils.service";
import { SharedModule } from "../../../../shared/shared.module";
import { PreloadedEnglishI18nModule } from "../../../../tests/preloaded-english-i18n.module";
import {
CollectionAccessSelectionView,
CollectionAdminService,
CollectionAdminView,
} from "../../../core";
import { GroupServiceAbstraction } from "../../../services/abstractions/group/group.service.abstraction";
import { GroupView } from "../../../views/group.view";
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

@@ -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?"
}, },
@@ -5463,6 +5472,15 @@
} }
} }
}, },
"inputForbiddenCharacters": {
"message": "The following characters are not allowed: $CHARACTERS$",
"placeholders": {
"characters": {
"content": "$1",
"example": "@, #, $, %"
}
}
},
"fieldsNeedAttention": { "fieldsNeedAttention": {
"message": "$COUNT$ field(s) above need your attention.", "message": "$COUNT$ field(s) above need your attention.",
"placeholders": { "placeholders": {
@@ -5588,5 +5606,29 @@
}, },
"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"
},
"noMembersAdded": {
"message": "No members added"
},
"deleted": {
"message": "Deleted"
} }
} }

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

@@ -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 { CollectionAccessDetailsResponse } 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 | CollectionAccessDetailsResponse) {
if (!c) { if (!c) {
return; return;
} }

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

@@ -91,10 +91,6 @@ export class CollectionService implements CollectionServiceAbstraction {
return decryptedCollections; return decryptedCollections;
} }
/**
* @deprecated August 30 2022: Moved to new Vault Filter Service
* Remove when Desktop and Browser are updated
*/
async getAllNested(collections: CollectionView[] = null): Promise<TreeNode<CollectionView>[]> { async getAllNested(collections: CollectionView[] = null): Promise<TreeNode<CollectionView>[]> {
if (collections == null) { if (collections == null) {
collections = await this.getAllDecrypted(); collections = await this.getAllDecrypted();

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

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