From 683b7fea77c0a1d199c34b9c2e518124b7a8534a Mon Sep 17 00:00:00 2001 From: Robyn MacCallum Date: Tue, 27 Jun 2023 11:36:48 -0400 Subject: [PATCH] [AC-1120] Implement 'New' button dropdown in Individual Vault (#5235) * Change 'New' button to dropdown with folders and collections * Individual vault changes to support adding collections * Add org selector to CollectionDialogComponent * Implement CollectionService.upsert() in CollectionAdminService.save() * Filter collections to ones that users can create collections in * Filter organizations by ones the user can create a collection in * CollectionDialog observable updates * Remove CollectionService.upsert from CollectionAdminService and return collection on save from CollectionDialog. * Filter out collections that the user does not have access to in collection dialog for Individual Vault. * Remove add folder action from vault filter * Remove add button from filters as it is no longer used * Update comment to reference future ticket * Change CollectionDialogResult from a class to an interface * Remove extra call to loadOrg() in the case of opening the modal from the individual vault * Use async pipe instead of subscribe for organizations --- .../collection-dialog.component.html | 13 +++ .../collection-dialog.component.ts | 81 +++++++++++++++---- .../vault/core/collection-admin.service.ts | 6 +- .../components/vault-filter.component.ts | 9 --- .../vault-filter-section.component.html | 10 --- .../vault-filter-section.component.ts | 4 - .../vault-header/vault-header.component.html | 30 ++++++- .../vault-header/vault-header.component.ts | 23 ++++++ .../individual-vault/vault.component.html | 4 +- .../vault/individual-vault/vault.component.ts | 39 +++++++-- .../app/vault/org-vault/vault.component.ts | 12 ++- 11 files changed, 174 insertions(+), 57 deletions(-) diff --git a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.html b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.html index 785afc0e95..e39dcb12ef 100644 --- a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.html +++ b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.html @@ -22,6 +22,19 @@ + + {{ "organization" | i18n }} + + + + + + {{ "externalId" | i18n }} diff --git a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts index ac3c62a5b8..74b7d0fdd3 100644 --- a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts @@ -1,7 +1,16 @@ import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; -import { combineLatest, of, shareReplay, Subject, switchMap, takeUntil } from "rxjs"; +import { + combineLatest, + map, + Observable, + of, + shareReplay, + Subject, + switchMap, + takeUntil, +} from "rxjs"; import { DialogServiceAbstraction, SimpleDialogType } from "@bitwarden/angular/services/dialog"; import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service"; @@ -10,6 +19,8 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { CollectionResponse } from "@bitwarden/common/vault/models/response/collection.response"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { BitValidators } from "@bitwarden/components"; @@ -35,9 +46,16 @@ export interface CollectionDialogParams { organizationId: string; initialTab?: CollectionDialogTabType; parentCollectionId?: string; + showOrgSelector?: boolean; + collectionIds?: string[]; } -export enum CollectionDialogResult { +export interface CollectionDialogResult { + action: CollectionDialogAction; + collection: CollectionResponse; +} + +export enum CollectionDialogAction { Saved = "saved", Canceled = "canceled", Deleted = "deleted", @@ -48,6 +66,7 @@ export enum CollectionDialogResult { }) export class CollectionDialogComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); + protected organizations$: Observable; protected tabIndex: CollectionDialogTabType; protected loading = true; @@ -56,11 +75,13 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { protected nestOptions: CollectionView[] = []; protected accessItems: AccessItemView[] = []; protected deletedParentName: string | undefined; + protected showOrgSelector = false; protected formGroup = this.formBuilder.group({ name: ["", [Validators.required, BitValidators.forbiddenCharacters(["/"])]], externalId: "", parent: undefined as string | undefined, access: [[] as AccessItemValue[]], + selectedOrg: "", }); protected PermissionMode = PermissionMode; @@ -79,8 +100,31 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { this.tabIndex = params.initialTab ?? CollectionDialogTabType.Info; } - ngOnInit() { - const organization$ = of(this.organizationService.get(this.params.organizationId)).pipe( + async ngOnInit() { + // Opened from the individual vault + if (this.params.showOrgSelector) { + this.showOrgSelector = true; + this.formGroup.controls.selectedOrg.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe((id) => this.loadOrg(id, this.params.collectionIds)); + this.organizations$ = this.organizationService.organizations$.pipe( + map((orgs) => + orgs + .filter((o) => o.canCreateNewCollections) + .sort(Utils.getSortFunction(this.i18nService, "name")) + ) + ); + // patchValue will trigger a call to loadOrg() in this case, so no need to call it again here + this.formGroup.patchValue({ selectedOrg: this.params.organizationId }); + } else { + // Opened from the org vault + this.formGroup.patchValue({ selectedOrg: this.params.organizationId }); + this.loadOrg(this.params.organizationId, this.params.collectionIds); + } + } + + async loadOrg(orgId: string, collectionIds: string[]) { + const organization$ = of(this.organizationService.get(orgId)).pipe( shareReplay({ refCount: true, bufferSize: 1 }) ); const groups$ = organization$.pipe( @@ -89,20 +133,19 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { return of([] as GroupView[]); } - return this.groupService.getAll(this.params.organizationId); + return this.groupService.getAll(orgId); }) ); - combineLatest({ organization: organization$, - collections: this.collectionService.getAll(this.params.organizationId), + collections: this.collectionService.getAll(orgId), collectionDetails: this.params.collectionId - ? this.collectionService.get(this.params.organizationId, this.params.collectionId) + ? this.collectionService.get(orgId, this.params.collectionId) : of(null), groups: groups$, - users: this.organizationUserService.getAllUsers(this.params.organizationId), + users: this.organizationUserService.getAllUsers(orgId), }) - .pipe(takeUntil(this.destroy$)) + .pipe(takeUntil(this.formGroup.controls.selectedOrg.valueChanges), takeUntil(this.destroy$)) .subscribe(({ organization, collections, collectionDetails, groups, users }) => { this.organization = organization; this.accessItems = [].concat( @@ -110,6 +153,10 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { users.data.map(mapUserToAccessItemView) ); + if (collectionIds) { + collections = collections.filter((c) => collectionIds.includes(c.id)); + } + if (this.params.collectionId) { this.collection = collections.find((c) => c.id === this.collectionId); this.nestOptions = collections.filter((c) => c.id !== this.collectionId); @@ -149,7 +196,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { } protected async cancel() { - this.close(CollectionDialogResult.Canceled); + this.close(CollectionDialogAction.Canceled); } protected submit = async () => { @@ -168,7 +215,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { const collectionView = new CollectionAdminView(); collectionView.id = this.params.collectionId; - collectionView.organizationId = this.params.organizationId; + collectionView.organizationId = this.formGroup.controls.selectedOrg.value; collectionView.externalId = this.formGroup.controls.externalId.value; collectionView.groups = this.formGroup.controls.access.value .filter((v) => v.type === AccessItemType.Group) @@ -184,7 +231,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { collectionView.name = this.formGroup.controls.name.value; } - await this.collectionService.save(collectionView); + const savedCollection = await this.collectionService.save(collectionView); this.platformUtilsService.showToast( "success", @@ -195,7 +242,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { ) ); - this.close(CollectionDialogResult.Saved); + this.close(CollectionDialogAction.Saved, savedCollection); }; protected delete = async () => { @@ -217,7 +264,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { this.i18nService.t("deletedCollectionId", this.collection?.name) ); - this.close(CollectionDialogResult.Deleted); + this.close(CollectionDialogAction.Deleted); }; ngOnDestroy(): void { @@ -225,8 +272,8 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { this.destroy$.complete(); } - private close(result: CollectionDialogResult) { - this.dialogRef.close(result); + private close(action: CollectionDialogAction, collection?: CollectionResponse) { + this.dialogRef.close({ action, collection } as CollectionDialogResult); } } diff --git a/apps/web/src/app/vault/core/collection-admin.service.ts b/apps/web/src/app/vault/core/collection-admin.service.ts index efeb3817ea..08e94e58bd 100644 --- a/apps/web/src/app/vault/core/collection-admin.service.ts +++ b/apps/web/src/app/vault/core/collection-admin.service.ts @@ -46,7 +46,7 @@ export class CollectionAdminService { return view; } - async save(collection: CollectionAdminView): Promise { + async save(collection: CollectionAdminView): Promise { const request = await this.encrypt(collection); let response: CollectionResponse; @@ -61,9 +61,7 @@ export class CollectionAdminService { ); } - // TODO: Implement upsert when in PS-1083: Collection Service refactors - // await this.collectionService.upsert(data); - return; + return response; } async delete(organizationId: string, collectionId: string): Promise { diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index 0cce8df38b..084573446f 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -32,7 +32,6 @@ import { OrganizationOptionsComponent } from "./organization-options.component"; export class VaultFilterComponent implements OnInit, OnDestroy { filters?: VaultFilterList; @Input() activeFilter: VaultFilter = new VaultFilter(); - @Output() onAddFolder = new EventEmitter(); @Output() onEditFolder = new EventEmitter(); @Input() searchText = ""; @@ -142,10 +141,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy { filter.selectedCollectionNode = collectionNode; }; - addFolder = async (): Promise => { - this.onAddFolder.emit(); - }; - editFolder = async (folder: FolderFilter): Promise => { this.onEditFolder.emit(folder); }; @@ -249,10 +244,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy { text: "editFolder", action: this.editFolder, }, - add: { - text: "Add Folder", - action: this.addFolder, - }, }; return folderFilterSection; } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html b/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html index b26b827274..bcd151193a 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html @@ -34,16 +34,6 @@

 {{ headerNode.node.name | i18n }}

- -
    - +
    + + + + + + +
    diff --git a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts index 092f9e76cd..3fbd546191 100644 --- a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts @@ -39,11 +39,26 @@ export class VaultHeaderComponent { */ @Input() collection?: TreeNode; + /** + * Whether 'Collection' option is shown in the 'New' dropdown + */ + @Input() canCreateCollections: boolean; + /** * Emits an event when the new item button is clicked in the header */ @Output() onAddCipher = new EventEmitter(); + /** + * Emits an event when the new collection button is clicked in the 'New' dropdown menu + */ + @Output() onAddCollection = new EventEmitter(); + + /** + * Emits an event when the new folder button is clicked in the 'New' dropdown menu + */ + @Output() onAddFolder = new EventEmitter(); + constructor(private i18nService: I18nService) {} /** @@ -115,4 +130,12 @@ export class VaultHeaderComponent { protected addCipher() { this.onAddCipher.emit(); } + + async addFolder(): Promise { + this.onAddFolder.emit(); + } + + async addCollection(): Promise { + this.onAddCollection.emit(); + } } diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index 9e8d9ab7b4..dece0ea10f 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -9,7 +9,6 @@ [activeFilter]="activeFilter" [searchText]="currentSearchText$ | async" (searchTextChanged)="filterSearchText($event)" - (onAddFolder)="addFolder()" (onEditFolder)="editFolder($event)" > @@ -21,8 +20,11 @@ [filter]="filter" [loading]="refreshing && !performingInitialLoad" [organizations]="allOrganizations" + [canCreateCollections]="canCreateCollections" [collection]="selectedCollection" (onAddCipher)="addCipher()" + (onAddCollection)="addCollection()" + (onAddFolder)="addFolder()" > {{ trashCleanupWarning }} diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 6e07915662..7e5366dfd4 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -54,11 +54,14 @@ import { CollectionService } from "@bitwarden/common/vault/abstractions/collecti import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data"; +import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/response/collection.response"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { Icons } from "@bitwarden/components"; import { UpdateKeyComponent } from "../../settings/update-key.component"; +import { CollectionDialogAction, openCollectionDialog } from "../components/collection-dialog"; import { VaultItemEvent } from "../components/vault-items/vault-item-event"; import { getNestedCollectionTree } from "../utils/collection-utils"; @@ -140,6 +143,7 @@ export class VaultComponent implements OnInit, OnDestroy { protected collections: CollectionView[]; protected isEmpty: boolean; protected selectedCollection: TreeNode | undefined; + protected canCreateCollections = false; protected currentSearchText$: Observable; private searchText$ = new Subject(); @@ -234,12 +238,9 @@ export class VaultComponent implements OnInit, OnDestroy { const canAccessPremium$ = Utils.asyncToObservable(() => this.stateService.getCanAccessPremium() ).pipe(shareReplay({ refCount: true, bufferSize: 1 })); - const allCollections$ = Utils.asyncToObservable(() => - this.collectionService.getAllDecrypted() - ).pipe(shareReplay({ refCount: true, bufferSize: 1 })); + const allCollections$ = Utils.asyncToObservable(() => this.collectionService.getAllDecrypted()); const nestedCollections$ = allCollections$.pipe( - map((collections) => getNestedCollectionTree(collections)), - shareReplay({ refCount: true, bufferSize: 1 }) + map((collections) => getNestedCollectionTree(collections)) ); this.searchText$ @@ -384,6 +385,8 @@ export class VaultComponent implements OnInit, OnDestroy { this.collections = collections; this.selectedCollection = selectedCollection; + this.canCreateCollections = allOrganizations?.some((o) => o.canCreateNewCollections); + this.showBulkMove = filter.type !== "trash" && (filter.organizationId === undefined || filter.organizationId === Unassigned); @@ -639,6 +642,32 @@ export class VaultComponent implements OnInit, OnDestroy { return childComponent; } + async addCollection() { + const dialog = openCollectionDialog(this.dialogService, { + data: { + organizationId: this.allOrganizations + .filter((o) => o.canCreateNewCollections) + .sort(Utils.getSortFunction(this.i18nService, "name"))[0].id, + parentCollectionId: this.filter.collectionId, + showOrgSelector: true, + collectionIds: this.allCollections.map((c) => c.id), + }, + }); + const result = await lastValueFrom(dialog.closed); + if (result.action === CollectionDialogAction.Saved) { + if (result.collection) { + // Update CollectionService with the new collection + const c = new CollectionData(result.collection as CollectionDetailsResponse); + await this.collectionService.upsert(c); + } + this.refresh(); + } else if (result.action === CollectionDialogAction.Deleted) { + // TODO: Remove collection from collectionService when collection + // deletion is implemented in the individual vault in AC-1347 + this.refresh(); + } + } + async cloneCipher(cipher: CipherView) { const component = await this.editCipher(cipher); component.cloneMode = true; diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index 7b6b926e49..106d8f9ff8 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -60,7 +60,7 @@ import { openEntityEventsDialog } from "../../admin-console/organizations/manage import { VaultFilterService } from "../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service"; import { VaultFilter } from "../../vault/individual-vault/vault-filter/shared/models/vault-filter.model"; import { - CollectionDialogResult, + CollectionDialogAction, CollectionDialogTabType, openCollectionDialog, } from "../components/collection-dialog"; @@ -866,7 +866,10 @@ export class VaultComponent implements OnInit, OnDestroy { }); const result = await lastValueFrom(dialog.closed); - if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) { + if ( + result.action === CollectionDialogAction.Saved || + result.action === CollectionDialogAction.Deleted + ) { this.refresh(); } } @@ -877,7 +880,10 @@ export class VaultComponent implements OnInit, OnDestroy { }); const result = await lastValueFrom(dialog.closed); - if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) { + if ( + result.action === CollectionDialogAction.Saved || + result.action === CollectionDialogAction.Deleted + ) { this.refresh(); } }