1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-13 23:03:32 +00:00

[AC-1347] Allow editing of collections in individual vault (#6081)

* Rename Collection events to be more explicit

* Implement edit collection for individual vault row

* Implement edit and delete collection from individual vault header

* Implement bulk delete for collections in individual vault

* Clean up CollectionDialogResult properties

* Centralize canEdit and canDelete logic to Collection models

* Check orgId in canEdit and canDelete and add clarifying comments

---------

Co-authored-by: Shane Melton <smelton@bitwarden.com>
This commit is contained in:
Robyn MacCallum
2023-10-04 17:15:20 -04:00
committed by GitHub
parent f43c3220dc
commit d40f996e71
17 changed files with 302 additions and 107 deletions

View File

@@ -33,10 +33,10 @@ import { DialogService } from "@bitwarden/components";
import { SearchBarService } from "../../../app/layout/search/search-bar.service"; import { SearchBarService } from "../../../app/layout/search/search-bar.service";
import { GeneratorComponent } from "../../../app/tools/generator.component"; import { GeneratorComponent } from "../../../app/tools/generator.component";
import { invokeMenu, RendererMenuItem } from "../../../utils"; import { invokeMenu, RendererMenuItem } from "../../../utils";
import { CollectionsComponent } from "../../../vault/app/vault/collections.component";
import { AddEditComponent } from "./add-edit.component"; import { AddEditComponent } from "./add-edit.component";
import { AttachmentsComponent } from "./attachments.component"; import { AttachmentsComponent } from "./attachments.component";
import { CollectionsComponent } from "./collections.component";
import { FolderAddEditComponent } from "./folder-add-edit.component"; import { FolderAddEditComponent } from "./folder-add-edit.component";
import { PasswordHistoryComponent } from "./password-history.component"; import { PasswordHistoryComponent } from "./password-history.component";
import { ShareComponent } from "./share.component"; import { ShareComponent } from "./share.component";

View File

@@ -51,7 +51,7 @@ export interface CollectionDialogParams {
export interface CollectionDialogResult { export interface CollectionDialogResult {
action: CollectionDialogAction; action: CollectionDialogAction;
collection: CollectionResponse; collection: CollectionResponse | CollectionView;
} }
export enum CollectionDialogAction { export enum CollectionDialogAction {
@@ -263,7 +263,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.i18nService.t("deletedCollectionId", this.collection?.name) this.i18nService.t("deletedCollectionId", this.collection?.name)
); );
this.close(CollectionDialogAction.Deleted); this.close(CollectionDialogAction.Deleted, this.collection);
}; };
ngOnDestroy(): void { ngOnDestroy(): void {
@@ -271,7 +271,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.destroy$.complete(); this.destroy$.complete();
} }
private close(action: CollectionDialogAction, collection?: CollectionResponse) { private close(action: CollectionDialogAction, collection?: CollectionResponse | CollectionView) {
this.dialogRef.close({ action, collection } as CollectionDialogResult); this.dialogRef.close({ action, collection } as CollectionDialogResult);
} }
} }

View File

@@ -60,11 +60,11 @@ export class VaultCollectionRowComponent {
} }
protected edit() { protected edit() {
this.onEvent.next({ type: "edit", item: this.collection }); this.onEvent.next({ type: "editCollection", item: this.collection });
} }
protected access() { protected access() {
this.onEvent.next({ type: "viewAccess", item: this.collection }); this.onEvent.next({ type: "viewCollectionAccess", item: this.collection });
} }
protected deleteCollection() { protected deleteCollection() {

View File

@@ -6,9 +6,9 @@ import { VaultItem } from "./vault-item";
export type VaultItemEvent = export type VaultItemEvent =
| { type: "viewAttachments"; item: CipherView } | { type: "viewAttachments"; item: CipherView }
| { type: "viewCollections"; item: CipherView } | { type: "viewCollections"; item: CipherView }
| { type: "viewAccess"; item: CollectionView } | { type: "viewCollectionAccess"; item: CollectionView }
| { type: "viewEvents"; item: CipherView } | { type: "viewEvents"; item: CipherView }
| { type: "edit"; item: CollectionView } | { type: "editCollection"; item: CollectionView }
| { type: "clone"; item: CipherView } | { type: "clone"; item: CipherView }
| { type: "restore"; items: CipherView[] } | { type: "restore"; items: CipherView[] }
| { type: "delete"; items: VaultItem[] } | { type: "delete"; items: VaultItem[] }

View File

@@ -30,12 +30,12 @@
appA11yTitle="{{ 'options' | i18n }}" appA11yTitle="{{ 'options' | i18n }}"
></button> ></button>
<bit-menu #headerMenu> <bit-menu #headerMenu>
<button *ngIf="showBulkMove" type="button" bitMenuItem (click)="bulkMoveToFolder()"> <button *ngIf="bulkMoveAllowed" type="button" bitMenuItem (click)="bulkMoveToFolder()">
<i class="bwi bwi-fw bwi-folder" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-folder" aria-hidden="true"></i>
{{ "moveSelected" | i18n }} {{ "moveSelected" | i18n }}
</button> </button>
<button <button
*ngIf="showBulkMove" *ngIf="bulkMoveAllowed"
type="button" type="button"
bitMenuItem bitMenuItem
(click)="bulkMoveToOrganization()" (click)="bulkMoveToOrganization()"

View File

@@ -7,7 +7,6 @@ import { CollectionView } from "@bitwarden/common/vault/models/view/collection.v
import { TableDataSource } from "@bitwarden/components"; import { TableDataSource } from "@bitwarden/components";
import { GroupView } from "../../../admin-console/organizations/core"; import { GroupView } from "../../../admin-console/organizations/core";
import { CollectionAdminView } from "../../core/views/collection-admin.view";
import { Unassigned } from "../../individual-vault/vault-filter/shared/models/routed-vault-filter.model"; import { Unassigned } from "../../individual-vault/vault-filter/shared/models/routed-vault-filter.model";
import { VaultItem } from "./vault-item"; import { VaultItem } from "./vault-item";
@@ -33,7 +32,6 @@ export class VaultItemsComponent {
@Input() showCollections: boolean; @Input() showCollections: boolean;
@Input() showGroups: boolean; @Input() showGroups: boolean;
@Input() useEvents: boolean; @Input() useEvents: boolean;
@Input() editableCollections: boolean;
@Input() cloneableOrganizationCiphers: boolean; @Input() cloneableOrganizationCiphers: boolean;
@Input() showPremiumFeatures: boolean; @Input() showPremiumFeatures: boolean;
@Input() showBulkMove: boolean; @Input() showBulkMove: boolean;
@@ -80,44 +78,30 @@ export class VaultItemsComponent {
return this.dataSource.data.length === 0; return this.dataSource.data.length === 0;
} }
protected canEditCollection(collection: CollectionView): boolean { get bulkMoveAllowed() {
// We currently don't support editing collections from individual vault
if (!(collection instanceof CollectionAdminView)) {
return false;
}
// Only allow allow deletion if collection editing is enabled and not deleting "Unassigned"
if (!this.editableCollections || collection.id === Unassigned) {
return false;
}
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
// Otherwise, check if we can edit the specified collection
return ( return (
organization?.canEditAnyCollection || this.showBulkMove && this.selection.selected.filter((item) => item.collection).length === 0
(organization?.canEditAssignedCollections && collection.assigned)
); );
} }
protected canDeleteCollection(collection: CollectionView): boolean { protected canEditCollection(collection: CollectionView): boolean {
// We currently don't support editing collections from individual vault
if (!(collection instanceof CollectionAdminView)) {
return false;
}
// Only allow allow deletion if collection editing is enabled and not deleting "Unassigned" // Only allow allow deletion if collection editing is enabled and not deleting "Unassigned"
if (!this.editableCollections || collection.id === Unassigned) { if (collection.id === Unassigned) {
return false; return false;
} }
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId); const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
return collection.canEdit(organization);
}
// Otherwise, check if we can delete the specified collection protected canDeleteCollection(collection: CollectionView): boolean {
return ( // Only allow allow deletion if collection editing is enabled and not deleting "Unassigned"
organization?.canDeleteAnyCollection || if (collection.id === Unassigned) {
(organization?.canDeleteAssignedCollections && collection.assigned) return false;
); }
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
return collection.canDelete(organization);
} }
protected toggleAll() { protected toggleAll() {

View File

@@ -125,7 +125,6 @@ Individual.args = {
showBulkMove: true, showBulkMove: true,
showBulkTrashOptions: false, showBulkTrashOptions: false,
useEvents: false, useEvents: false,
editableCollections: false,
cloneableOrganizationCiphers: false, cloneableOrganizationCiphers: false,
}; };
@@ -141,7 +140,6 @@ IndividualDisabled.args = {
showBulkMove: true, showBulkMove: true,
showBulkTrashOptions: false, showBulkTrashOptions: false,
useEvents: false, useEvents: false,
editableCollections: false,
cloneableOrganizationCiphers: false, cloneableOrganizationCiphers: false,
}; };
@@ -156,7 +154,6 @@ IndividualTrash.args = {
showBulkMove: false, showBulkMove: false,
showBulkTrashOptions: true, showBulkTrashOptions: true,
useEvents: false, useEvents: false,
editableCollections: false,
cloneableOrganizationCiphers: false, cloneableOrganizationCiphers: false,
}; };
@@ -171,7 +168,6 @@ IndividualTopLevelCollection.args = {
showBulkMove: false, showBulkMove: false,
showBulkTrashOptions: false, showBulkTrashOptions: false,
useEvents: false, useEvents: false,
editableCollections: false,
cloneableOrganizationCiphers: false, cloneableOrganizationCiphers: false,
}; };
@@ -186,7 +182,6 @@ IndividualSecondLevelCollection.args = {
showBulkMove: true, showBulkMove: true,
showBulkTrashOptions: false, showBulkTrashOptions: false,
useEvents: false, useEvents: false,
editableCollections: false,
cloneableOrganizationCiphers: false, cloneableOrganizationCiphers: false,
}; };
@@ -201,7 +196,6 @@ OrganizationVault.args = {
showBulkMove: false, showBulkMove: false,
showBulkTrashOptions: false, showBulkTrashOptions: false,
useEvents: true, useEvents: true,
editableCollections: true,
cloneableOrganizationCiphers: true, cloneableOrganizationCiphers: true,
}; };
@@ -216,7 +210,6 @@ OrganizationTrash.args = {
showBulkMove: false, showBulkMove: false,
showBulkTrashOptions: true, showBulkTrashOptions: true,
useEvents: true, useEvents: true,
editableCollections: true,
cloneableOrganizationCiphers: true, cloneableOrganizationCiphers: true,
}; };
@@ -234,7 +227,6 @@ OrganizationTopLevelCollection.args = {
showBulkMove: false, showBulkMove: false,
showBulkTrashOptions: false, showBulkTrashOptions: false,
useEvents: true, useEvents: true,
editableCollections: true,
cloneableOrganizationCiphers: true, cloneableOrganizationCiphers: true,
}; };
@@ -249,7 +241,6 @@ OrganizationSecondLevelCollection.args = {
showBulkMove: false, showBulkMove: false,
showBulkTrashOptions: false, showBulkTrashOptions: false,
useEvents: true, useEvents: true,
editableCollections: true,
cloneableOrganizationCiphers: true, cloneableOrganizationCiphers: true,
}; };

View File

@@ -1,3 +1,4 @@
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionAccessDetailsResponse } from "@bitwarden/common/src/vault/models/response/collection.response"; import { CollectionAccessDetailsResponse } from "@bitwarden/common/src/vault/models/response/collection.response";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
@@ -29,4 +30,12 @@ export class CollectionAdminView extends CollectionView {
this.assigned = response.assigned; this.assigned = response.assigned;
} }
override canEdit(org: Organization): boolean {
return org?.canEditAnyCollection || (org?.canEditAssignedCollections && this.assigned);
}
override canDelete(org: Organization): boolean {
return org?.canDeleteAnyCollection || (org?.canDeleteAssignedCollections && this.assigned);
}
} }

View File

@@ -7,7 +7,9 @@ import { CollectionBulkDeleteRequest } from "@bitwarden/common/models/request/co
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { CipherBulkDeleteRequest } from "@bitwarden/common/vault/models/request/cipher-bulk-delete.request"; import { CipherBulkDeleteRequest } from "@bitwarden/common/vault/models/request/cipher-bulk-delete.request";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
export interface BulkDeleteDialogParams { export interface BulkDeleteDialogParams {
@@ -15,6 +17,8 @@ export interface BulkDeleteDialogParams {
collectionIds?: string[]; collectionIds?: string[];
permanent?: boolean; permanent?: boolean;
organization?: Organization; organization?: Organization;
organizations?: Organization[];
collections?: CollectionView[];
} }
export enum BulkDeleteDialogResult { export enum BulkDeleteDialogResult {
@@ -45,6 +49,8 @@ export class BulkDeleteDialogComponent {
collectionIds: string[]; collectionIds: string[];
permanent = false; permanent = false;
organization: Organization; organization: Organization;
organizations: Organization[];
collections: CollectionView[];
constructor( constructor(
@Inject(DIALOG_DATA) params: BulkDeleteDialogParams, @Inject(DIALOG_DATA) params: BulkDeleteDialogParams,
@@ -52,12 +58,15 @@ export class BulkDeleteDialogComponent {
private cipherService: CipherService, private cipherService: CipherService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService, private i18nService: I18nService,
private apiService: ApiService private apiService: ApiService,
private collectionService: CollectionService
) { ) {
this.cipherIds = params.cipherIds ?? []; this.cipherIds = params.cipherIds ?? [];
this.collectionIds = params.collectionIds ?? []; this.collectionIds = params.collectionIds ?? [];
this.permanent = params.permanent; this.permanent = params.permanent;
this.organization = params.organization; this.organization = params.organization;
this.organizations = params.organizations;
this.collections = params.collections;
} }
protected async cancel() { protected async cancel() {
@@ -74,7 +83,7 @@ export class BulkDeleteDialogComponent {
} }
} }
if (this.collectionIds.length && this.organization) { if (this.collectionIds.length) {
deletePromises.push(this.deleteCollections()); deletePromises.push(this.deleteCollections());
} }
@@ -88,6 +97,7 @@ export class BulkDeleteDialogComponent {
); );
} }
if (this.collectionIds.length) { if (this.collectionIds.length) {
await this.collectionService.delete(this.collectionIds);
this.platformUtilsService.showToast( this.platformUtilsService.showToast(
"success", "success",
null, null,
@@ -116,19 +126,44 @@ export class BulkDeleteDialogComponent {
} }
private async deleteCollections(): Promise<any> { private async deleteCollections(): Promise<any> {
if ( // From org vault
!this.organization.canDeleteAssignedCollections && if (this.organization) {
!this.organization.canDeleteAnyCollection if (
) { !this.organization.canDeleteAssignedCollections &&
this.platformUtilsService.showToast( !this.organization.canDeleteAnyCollection
"error", ) {
this.i18nService.t("errorOccurred"), this.platformUtilsService.showToast(
this.i18nService.t("missingPermissions") "error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("missingPermissions")
);
return;
}
const deleteRequest = new CollectionBulkDeleteRequest(
this.collectionIds,
this.organization.id
); );
return; return await this.apiService.deleteManyCollections(deleteRequest);
// From individual vault, so there can be multiple organizations
} else if (this.organizations && this.collections) {
const deletePromises: Promise<any>[] = [];
for (const organization of this.organizations) {
if (!organization.canDeleteAssignedCollections && !organization.canDeleteAnyCollection) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("missingPermissions")
);
return;
}
const orgCollections = this.collections
.filter((o) => o.organizationId === organization.id)
.map((c) => c.id);
const deleteRequest = new CollectionBulkDeleteRequest(orgCollections, organization.id);
deletePromises.push(this.apiService.deleteManyCollections(deleteRequest));
}
return await Promise.all(deletePromises);
} }
const deleteRequest = new CollectionBulkDeleteRequest(this.collectionIds, this.organization.id);
return await this.apiService.deleteManyCollections(deleteRequest);
} }
private close(result: BulkDeleteDialogResult) { private close(result: BulkDeleteDialogResult) {

View File

@@ -28,6 +28,46 @@
aria-hidden="true" aria-hidden="true"
></i> ></i>
<span>{{ title }}</span> <span>{{ title }}</span>
<ng-container *ngIf="collection !== undefined && (canEditCollection || canDeleteCollection)">
<button
bitIconButton="bwi-angle-down"
[bitMenuTriggerFor]="editCollectionMenu"
size="small"
type="button"
aria-haspopup
></button>
<bit-menu #editCollectionMenu>
<button
type="button"
*ngIf="canEditCollection"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Info)"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "editInfo" | i18n }}
</button>
<button
type="button"
*ngIf="canEditCollection"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Access)"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
<button
type="button"
*ngIf="canDeleteCollection"
bitMenuItem
(click)="deleteCollection()"
>
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</ng-container>
<small *ngIf="loading"> <small *ngIf="loading">
<i <i
class="bwi bwi-spinner bwi-spin text-muted" class="bwi bwi-spinner bwi-spin text-muted"

View File

@@ -5,6 +5,7 @@ import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { CollectionDialogTabType } from "../../components/collection-dialog";
import { import {
All, All,
RoutedVaultFilterModel, RoutedVaultFilterModel,
@@ -19,6 +20,7 @@ import {
export class VaultHeaderComponent { export class VaultHeaderComponent {
protected Unassigned = Unassigned; protected Unassigned = Unassigned;
protected All = All; protected All = All;
protected CollectionDialogTabType = CollectionDialogTabType;
/** /**
* Boolean to determine the loading state of the header. * Boolean to determine the loading state of the header.
@@ -29,36 +31,30 @@ export class VaultHeaderComponent {
/** Current active filter */ /** Current active filter */
@Input() filter: RoutedVaultFilterModel; @Input() filter: RoutedVaultFilterModel;
/** /** All organizations that can be shown */
* All organizations that can be shown
*/
@Input() organizations: Organization[] = []; @Input() organizations: Organization[] = [];
/** /** Currently selected collection */
* Currently selected collection
*/
@Input() collection?: TreeNode<CollectionView>; @Input() collection?: TreeNode<CollectionView>;
/** /** Whether 'Collection' option is shown in the 'New' dropdown */
* Whether 'Collection' option is shown in the 'New' dropdown
*/
@Input() canCreateCollections: boolean; @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<void>(); @Output() onAddCipher = new EventEmitter<void>();
/** /** 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<null>(); @Output() onAddCollection = new EventEmitter<null>();
/** /** 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<null>(); @Output() onAddFolder = new EventEmitter<null>();
/** 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<void>();
constructor(private i18nService: I18nService) {} constructor(private i18nService: I18nService) {}
/** /**
@@ -127,6 +123,40 @@ export class VaultHeaderComponent {
.map((treeNode) => treeNode.node); .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<void> {
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() { protected addCipher() {
this.onAddCipher.emit(); this.onAddCipher.emit();
} }

View File

@@ -25,6 +25,8 @@
(onAddCipher)="addCipher()" (onAddCipher)="addCipher()"
(onAddCollection)="addCollection()" (onAddCollection)="addCollection()"
(onAddFolder)="addFolder()" (onAddFolder)="addFolder()"
(onEditCollection)="editCollection(selectedCollection.node, $event.tab)"
(onDeleteCollection)="deleteCollection(selectedCollection.node)"
></app-vault-header> ></app-vault-header>
<app-callout type="warning" *ngIf="activeFilter.isDeleted" icon="bwi-exclamation-triangle"> <app-callout type="warning" *ngIf="activeFilter.isDeleted" icon="bwi-exclamation-triangle">
{{ trashCleanupWarning }} {{ trashCleanupWarning }}
@@ -42,7 +44,6 @@
[showBulkMove]="showBulkMove" [showBulkMove]="showBulkMove"
[showBulkTrashOptions]="filter.type === 'trash'" [showBulkTrashOptions]="filter.type === 'trash'"
[useEvents]="false" [useEvents]="false"
[editableCollections]="false"
[cloneableOrganizationCiphers]="false" [cloneableOrganizationCiphers]="false"
(onEvent)="onVaultItemsEvent($event)" (onEvent)="onVaultItemsEvent($event)"
> >

View File

@@ -30,6 +30,7 @@ import {
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service"; 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 { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { TotpService } from "@bitwarden/common/abstractions/totp.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 { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { DialogService, Icons } from "@bitwarden/components"; 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 { VaultItemEvent } from "../components/vault-items/vault-item-event";
import { getNestedCollectionTree } from "../utils/collection-utils"; import { getNestedCollectionTree } from "../utils/collection-utils";
@@ -130,7 +136,7 @@ export class VaultComponent implements OnInit, OnDestroy {
protected showBulkMove: boolean; protected showBulkMove: boolean;
protected canAccessPremium: boolean; protected canAccessPremium: boolean;
protected allCollections: CollectionView[]; protected allCollections: CollectionView[];
protected allOrganizations: Organization[]; protected allOrganizations: Organization[] = [];
protected ciphers: CipherView[]; protected ciphers: CipherView[];
protected collections: CollectionView[]; protected collections: CollectionView[];
protected isEmpty: boolean; protected isEmpty: boolean;
@@ -170,6 +176,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private searchService: SearchService, private searchService: SearchService,
private searchPipe: SearchPipe, private searchPipe: SearchPipe,
private configService: ConfigServiceAbstraction, private configService: ConfigServiceAbstraction,
private apiService: ApiService,
private userVerificationService: UserVerificationService private userVerificationService: UserVerificationService
) {} ) {}
@@ -430,12 +437,7 @@ export class VaultComponent implements OnInit, OnDestroy {
await this.bulkRestore(event.items); await this.bulkRestore(event.items);
} }
} else if (event.type === "delete") { } else if (event.type === "delete") {
const ciphers = event.items.filter((i) => i.collection === undefined).map((i) => i.cipher); await this.handleDeleteEvent(event.items);
if (ciphers.length === 1) {
await this.deleteCipher(ciphers[0]);
} else {
await this.bulkDelete(ciphers);
}
} else if (event.type === "moveToFolder") { } else if (event.type === "moveToFolder") {
await this.bulkMove(event.items); await this.bulkMove(event.items);
} else if (event.type === "moveToOrganization") { } else if (event.type === "moveToOrganization") {
@@ -446,6 +448,10 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
} else if (event.type === "copyField") { } else if (event.type === "copyField") {
await this.copy(event.item, event.field); 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 { } finally {
this.processingEvent = false; this.processingEvent = false;
@@ -652,10 +658,65 @@ export class VaultComponent implements OnInit, OnDestroy {
await this.collectionService.upsert(c); await this.collectionService.upsert(c);
} }
this.refresh(); 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<void> {
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(); this.refresh();
} else if (result.action === CollectionDialogAction.Deleted) {
await this.collectionService.delete(result.collection?.id);
this.refresh();
}
}
async deleteCollection(collection: CollectionView): Promise<void> {
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(); 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<boolean> { async deleteCipher(c: CipherView): Promise<boolean> {
if (!(await this.repromptCipher([c]))) { if (!(await this.repromptCipher([c]))) {
return; 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))) { if (!(await this.repromptCipher(ciphers))) {
return; return;
} }
const selectedIds = ciphers.map((cipher) => cipher.id); if (ciphers.length === 0 && collections.length === 0) {
if (selectedIds.length === 0) {
this.platformUtilsService.showToast( this.platformUtilsService.showToast(
"error", "error",
this.i18nService.t("errorOccurred"), this.i18nService.t("errorOccurred"),
@@ -747,7 +831,13 @@ export class VaultComponent implements OnInit, OnDestroy {
return; return;
} }
const dialog = openBulkDeleteDialog(this.dialogService, { 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); const result = await lastValueFrom(dialog.closed);

View File

@@ -141,10 +141,7 @@ export class VaultHeaderComponent {
} }
// Otherwise, check if we can edit the specified collection // Otherwise, check if we can edit the specified collection
return ( return this.collection.node.canEdit(this.organization);
this.organization.canEditAnyCollection ||
(this.organization.canEditAssignedCollections && this.collection?.node.assigned)
);
} }
addCipher() { addCipher() {
@@ -174,10 +171,7 @@ export class VaultHeaderComponent {
} }
// Otherwise, check if we can delete the specified collection // Otherwise, check if we can delete the specified collection
return ( return this.collection.node.canDelete(this.organization);
this.organization?.canDeleteAnyCollection ||
(this.organization?.canDeleteAssignedCollections && this.collection.node.assigned)
);
} }
deleteCollection() { deleteCollection() {

View File

@@ -51,7 +51,6 @@
[showBulkMove]="false" [showBulkMove]="false"
[showBulkTrashOptions]="filter.type === 'trash'" [showBulkTrashOptions]="filter.type === 'trash'"
[useEvents]="organization?.useEvents" [useEvents]="organization?.useEvents"
[editableCollections]="true"
[cloneableOrganizationCiphers]="true" [cloneableOrganizationCiphers]="true"
(onEvent)="onVaultItemsEvent($event)" (onEvent)="onVaultItemsEvent($event)"
> >

View File

@@ -495,9 +495,9 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
} else if (event.type === "copyField") { } else if (event.type === "copyField") {
await this.copy(event.item, event.field); await this.copy(event.item, event.field);
} else if (event.type === "edit") { } else if (event.type === "editCollection") {
await this.editCollection(event.item, CollectionDialogTabType.Info); await this.editCollection(event.item, CollectionDialogTabType.Info);
} else if (event.type === "viewAccess") { } else if (event.type === "viewCollectionAccess") {
await this.editCollection(event.item, CollectionDialogTabType.Access); await this.editCollection(event.item, CollectionDialogTabType.Access);
} else if (event.type === "viewEvents") { } else if (event.type === "viewEvents") {
await this.viewEvents(event.item); await this.viewEvents(event.item);

View File

@@ -1,3 +1,4 @@
import { Organization } from "../../../admin-console/models/domain/organization";
import { ITreeNodeObject } from "../../../models/domain/tree-node"; import { ITreeNodeObject } from "../../../models/domain/tree-node";
import { View } from "../../../models/view/view"; import { View } from "../../../models/view/view";
import { Collection } from "../domain/collection"; import { Collection } from "../domain/collection";
@@ -10,6 +11,7 @@ export class CollectionView implements View, ITreeNodeObject {
organizationId: string = null; organizationId: string = null;
name: string = null; name: string = null;
externalId: string = null; externalId: string = null;
// readOnly applies to the items within a collection
readOnly: boolean = null; readOnly: boolean = null;
hidePasswords: boolean = null; hidePasswords: boolean = null;
@@ -26,4 +28,24 @@ export class CollectionView implements View, ITreeNodeObject {
this.hidePasswords = c.hidePasswords; 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;
}
} }