diff --git a/apps/desktop/src/vault/app/vault/vault.component.ts b/apps/desktop/src/vault/app/vault/vault.component.ts index 194cee098f..0b419b5088 100644 --- a/apps/desktop/src/vault/app/vault/vault.component.ts +++ b/apps/desktop/src/vault/app/vault/vault.component.ts @@ -33,10 +33,10 @@ import { DialogService } from "@bitwarden/components"; import { SearchBarService } from "../../../app/layout/search/search-bar.service"; import { GeneratorComponent } from "../../../app/tools/generator.component"; import { invokeMenu, RendererMenuItem } from "../../../utils"; -import { CollectionsComponent } from "../../../vault/app/vault/collections.component"; import { AddEditComponent } from "./add-edit.component"; import { AttachmentsComponent } from "./attachments.component"; +import { CollectionsComponent } from "./collections.component"; import { FolderAddEditComponent } from "./folder-add-edit.component"; import { PasswordHistoryComponent } from "./password-history.component"; import { ShareComponent } from "./share.component"; 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 34829f58b6..0e14d88ef8 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 @@ -51,7 +51,7 @@ export interface CollectionDialogParams { export interface CollectionDialogResult { action: CollectionDialogAction; - collection: CollectionResponse; + collection: CollectionResponse | CollectionView; } export enum CollectionDialogAction { @@ -263,7 +263,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { this.i18nService.t("deletedCollectionId", this.collection?.name) ); - this.close(CollectionDialogAction.Deleted); + this.close(CollectionDialogAction.Deleted, this.collection); }; ngOnDestroy(): void { @@ -271,7 +271,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { this.destroy$.complete(); } - private close(action: CollectionDialogAction, collection?: CollectionResponse) { + private close(action: CollectionDialogAction, collection?: CollectionResponse | CollectionView) { this.dialogRef.close({ action, collection } as CollectionDialogResult); } } diff --git a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts index faee4e030b..cd5af43ae3 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts @@ -60,11 +60,11 @@ export class VaultCollectionRowComponent { } protected edit() { - this.onEvent.next({ type: "edit", item: this.collection }); + this.onEvent.next({ type: "editCollection", item: this.collection }); } protected access() { - this.onEvent.next({ type: "viewAccess", item: this.collection }); + this.onEvent.next({ type: "viewCollectionAccess", item: this.collection }); } protected deleteCollection() { diff --git a/apps/web/src/app/vault/components/vault-items/vault-item-event.ts b/apps/web/src/app/vault/components/vault-items/vault-item-event.ts index cacd13829f..a72734d93d 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-item-event.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-item-event.ts @@ -6,9 +6,9 @@ import { VaultItem } from "./vault-item"; export type VaultItemEvent = | { type: "viewAttachments"; item: CipherView } | { type: "viewCollections"; item: CipherView } - | { type: "viewAccess"; item: CollectionView } + | { type: "viewCollectionAccess"; item: CollectionView } | { type: "viewEvents"; item: CipherView } - | { type: "edit"; item: CollectionView } + | { type: "editCollection"; item: CollectionView } | { type: "clone"; item: CipherView } | { type: "restore"; items: CipherView[] } | { type: "delete"; items: VaultItem[] } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html index a9be8dbf1e..ba8acf6ddf 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html @@ -30,12 +30,12 @@ appA11yTitle="{{ 'options' | i18n }}" > - + + + + + + ; - /** - * Whether 'Collection' option is shown in the 'New' dropdown - */ + /** 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 - */ + /** 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 - */ + /** 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 - */ + /** Emits an event when the new folder button is clicked in the 'New' dropdown menu */ @Output() onAddFolder = new EventEmitter(); + /** Emits an event when the edit collection button is clicked in the header */ + @Output() onEditCollection = new EventEmitter<{ tab: CollectionDialogTabType }>(); + + /** Emits an event when the delete collection button is clicked in the header */ + @Output() onDeleteCollection = new EventEmitter(); + constructor(private i18nService: I18nService) {} /** @@ -127,6 +123,40 @@ export class VaultHeaderComponent { .map((treeNode) => treeNode.node); } + get canEditCollection(): boolean { + // Only edit collections if not editing "Unassigned" + if (this.collection === undefined) { + return false; + } + + // Otherwise, check if we can edit the specified collection + const organization = this.organizations.find( + (o) => o.id === this.collection?.node.organizationId + ); + return this.collection.node.canEdit(organization); + } + + async editCollection(tab: CollectionDialogTabType): Promise { + this.onEditCollection.emit({ tab }); + } + + get canDeleteCollection(): boolean { + // Only delete collections if not deleting "Unassigned" + if (this.collection === undefined) { + return false; + } + + // Otherwise, check if we can edit the specified collection + const organization = this.organizations.find( + (o) => o.id === this.collection?.node.organizationId + ); + return this.collection.node.canDelete(organization); + } + + deleteCollection() { + this.onDeleteCollection.emit(); + } + protected addCipher() { this.onAddCipher.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 265bdec2ce..fdbecb0bcb 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -25,6 +25,8 @@ (onAddCipher)="addCipher()" (onAddCollection)="addCollection()" (onAddFolder)="addFolder()" + (onEditCollection)="editCollection(selectedCollection.node, $event.tab)" + (onDeleteCollection)="deleteCollection(selectedCollection.node)" > {{ trashCleanupWarning }} @@ -42,7 +44,6 @@ [showBulkMove]="showBulkMove" [showBulkTrashOptions]="filter.type === 'trash'" [useEvents]="false" - [editableCollections]="false" [cloneableOrganizationCiphers]="false" (onEvent)="onVaultItemsEvent($event)" > 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 35074c70d2..69cc909cab 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -30,6 +30,7 @@ import { import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { ModalService } from "@bitwarden/angular/services/modal.service"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { TotpService } from "@bitwarden/common/abstractions/totp.service"; @@ -60,7 +61,12 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { DialogService, Icons } from "@bitwarden/components"; -import { CollectionDialogAction, openCollectionDialog } from "../components/collection-dialog"; +import { + CollectionDialogAction, + CollectionDialogTabType, + openCollectionDialog, +} from "../components/collection-dialog"; +import { VaultItem } from "../components/vault-items/vault-item"; import { VaultItemEvent } from "../components/vault-items/vault-item-event"; import { getNestedCollectionTree } from "../utils/collection-utils"; @@ -130,7 +136,7 @@ export class VaultComponent implements OnInit, OnDestroy { protected showBulkMove: boolean; protected canAccessPremium: boolean; protected allCollections: CollectionView[]; - protected allOrganizations: Organization[]; + protected allOrganizations: Organization[] = []; protected ciphers: CipherView[]; protected collections: CollectionView[]; protected isEmpty: boolean; @@ -170,6 +176,7 @@ export class VaultComponent implements OnInit, OnDestroy { private searchService: SearchService, private searchPipe: SearchPipe, private configService: ConfigServiceAbstraction, + private apiService: ApiService, private userVerificationService: UserVerificationService ) {} @@ -430,12 +437,7 @@ export class VaultComponent implements OnInit, OnDestroy { await this.bulkRestore(event.items); } } else if (event.type === "delete") { - const ciphers = event.items.filter((i) => i.collection === undefined).map((i) => i.cipher); - if (ciphers.length === 1) { - await this.deleteCipher(ciphers[0]); - } else { - await this.bulkDelete(ciphers); - } + await this.handleDeleteEvent(event.items); } else if (event.type === "moveToFolder") { await this.bulkMove(event.items); } else if (event.type === "moveToOrganization") { @@ -446,6 +448,10 @@ export class VaultComponent implements OnInit, OnDestroy { } } else if (event.type === "copyField") { await this.copy(event.item, event.field); + } else if (event.type === "editCollection") { + await this.editCollection(event.item, CollectionDialogTabType.Info); + } else if (event.type === "viewCollectionAccess") { + await this.editCollection(event.item, CollectionDialogTabType.Access); } } finally { this.processingEvent = false; @@ -652,10 +658,65 @@ export class VaultComponent implements OnInit, OnDestroy { 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 + } + } + + async editCollection(c: CollectionView, tab: CollectionDialogTabType): Promise { + const dialog = openCollectionDialog(this.dialogService, { + data: { collectionId: c?.id, organizationId: c.organizationId, initialTab: tab }, + }); + + 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) { + await this.collectionService.delete(result.collection?.id); + this.refresh(); + } + } + + async deleteCollection(collection: CollectionView): Promise { + const organization = this.organizationService.get(collection.organizationId); + if (!organization.canDeleteAssignedCollections && !organization.canDeleteAnyCollection) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("missingPermissions") + ); + return; + } + const confirmed = await this.dialogService.openSimpleDialog({ + title: collection.name, + content: { key: "deleteCollectionConfirmation" }, + type: "warning", + }); + if (!confirmed) { + return; + } + try { + await this.apiService.deleteCollection(collection.organizationId, collection.id); + await this.collectionService.delete(collection.id); + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("deletedCollectionId", collection.name) + ); + // Navigate away if we deleted the collection we were viewing + if (this.selectedCollection?.node.id === collection.id) { + this.router.navigate([], { + queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + } + this.refresh(); + } catch (e) { + this.logService.error(e); } } @@ -702,6 +763,26 @@ export class VaultComponent implements OnInit, OnDestroy { this.refresh(); } + private async handleDeleteEvent(items: VaultItem[]) { + const ciphers = items.filter((i) => i.collection === undefined).map((i) => i.cipher); + const collections = items.filter((i) => i.cipher === undefined).map((i) => i.collection); + if (ciphers.length === 1 && collections.length === 0) { + await this.deleteCipher(ciphers[0]); + } else if (ciphers.length === 0 && collections.length === 1) { + await this.deleteCollection(collections[0]); + } else { + const orgIds = items + .filter((i) => i.cipher === undefined) + .map((i) => i.collection.organizationId); + const orgs = await firstValueFrom( + this.organizationService.organizations$.pipe( + map((orgs) => orgs.filter((o) => orgIds.includes(o.id))) + ) + ); + await this.bulkDelete(ciphers, collections, orgs); + } + } + async deleteCipher(c: CipherView): Promise { if (!(await this.repromptCipher([c]))) { return; @@ -732,13 +813,16 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async bulkDelete(ciphers: CipherView[]) { + async bulkDelete( + ciphers: CipherView[], + collections: CollectionView[], + organizations: Organization[] + ) { if (!(await this.repromptCipher(ciphers))) { return; } - const selectedIds = ciphers.map((cipher) => cipher.id); - if (selectedIds.length === 0) { + if (ciphers.length === 0 && collections.length === 0) { this.platformUtilsService.showToast( "error", this.i18nService.t("errorOccurred"), @@ -747,7 +831,13 @@ export class VaultComponent implements OnInit, OnDestroy { return; } const dialog = openBulkDeleteDialog(this.dialogService, { - data: { permanent: this.filter.type === "trash", cipherIds: selectedIds }, + data: { + permanent: this.filter.type === "trash", + cipherIds: ciphers.map((c) => c.id), + collectionIds: collections.map((c) => c.id), + organizations: organizations, + collections: collections, + }, }); const result = await lastValueFrom(dialog.closed); diff --git a/apps/web/src/app/vault/org-vault/vault-header/vault-header.component.ts b/apps/web/src/app/vault/org-vault/vault-header/vault-header.component.ts index 9282e237af..2d3f2c156b 100644 --- a/apps/web/src/app/vault/org-vault/vault-header/vault-header.component.ts +++ b/apps/web/src/app/vault/org-vault/vault-header/vault-header.component.ts @@ -141,10 +141,7 @@ export class VaultHeaderComponent { } // Otherwise, check if we can edit the specified collection - return ( - this.organization.canEditAnyCollection || - (this.organization.canEditAssignedCollections && this.collection?.node.assigned) - ); + return this.collection.node.canEdit(this.organization); } addCipher() { @@ -174,10 +171,7 @@ export class VaultHeaderComponent { } // Otherwise, check if we can delete the specified collection - return ( - this.organization?.canDeleteAnyCollection || - (this.organization?.canDeleteAssignedCollections && this.collection.node.assigned) - ); + return this.collection.node.canDelete(this.organization); } deleteCollection() { diff --git a/apps/web/src/app/vault/org-vault/vault.component.html b/apps/web/src/app/vault/org-vault/vault.component.html index c586a94f7a..360e4c57b5 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.html +++ b/apps/web/src/app/vault/org-vault/vault.component.html @@ -51,7 +51,6 @@ [showBulkMove]="false" [showBulkTrashOptions]="filter.type === 'trash'" [useEvents]="organization?.useEvents" - [editableCollections]="true" [cloneableOrganizationCiphers]="true" (onEvent)="onVaultItemsEvent($event)" > 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 1cb57e188a..48f927e4d0 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -495,9 +495,9 @@ export class VaultComponent implements OnInit, OnDestroy { } } else if (event.type === "copyField") { await this.copy(event.item, event.field); - } else if (event.type === "edit") { + } else if (event.type === "editCollection") { await this.editCollection(event.item, CollectionDialogTabType.Info); - } else if (event.type === "viewAccess") { + } else if (event.type === "viewCollectionAccess") { await this.editCollection(event.item, CollectionDialogTabType.Access); } else if (event.type === "viewEvents") { await this.viewEvents(event.item); diff --git a/libs/common/src/vault/models/view/collection.view.ts b/libs/common/src/vault/models/view/collection.view.ts index 52644035c0..98159c99cc 100644 --- a/libs/common/src/vault/models/view/collection.view.ts +++ b/libs/common/src/vault/models/view/collection.view.ts @@ -1,3 +1,4 @@ +import { Organization } from "../../../admin-console/models/domain/organization"; import { ITreeNodeObject } from "../../../models/domain/tree-node"; import { View } from "../../../models/view/view"; import { Collection } from "../domain/collection"; @@ -10,6 +11,7 @@ export class CollectionView implements View, ITreeNodeObject { organizationId: string = null; name: string = null; externalId: string = null; + // readOnly applies to the items within a collection readOnly: boolean = null; hidePasswords: boolean = null; @@ -26,4 +28,24 @@ export class CollectionView implements View, ITreeNodeObject { this.hidePasswords = c.hidePasswords; } } + + // For editing collection details, not the items within it. + canEdit(org: Organization): boolean { + if (org.id !== this.organizationId) { + throw new Error( + "Id of the organization provided does not match the org id of the collection." + ); + } + return org?.canEditAnyCollection || org?.canEditAssignedCollections; + } + + // For deleting a collection, not the items within it. + canDelete(org: Organization): boolean { + if (org.id !== this.organizationId) { + throw new Error( + "Id of the organization provided does not match the org id of the collection." + ); + } + return org?.canDeleteAnyCollection || org?.canDeleteAssignedCollections; + } }