mirror of
https://github.com/bitwarden/browser
synced 2025-12-20 10:13:31 +00:00
[PM-7624] [PM-7625] Bulk management actions on individual vault (#9507)
* fixed issue with clearing search index state * clear user index before account is totally cleaned up * added logout clear on option * removed redundant clear index from logout * added feature flag * added new menu drop down and put behind feature flag * added permanentlyDeleteSelected to the menu * added permanentlyDeleteSelected to the menu * wired up logic to show to hide menu drop down items * modified the bulk collection assignment to work with end user vault * wired up delete and move to folder * merged bulk management actions header into old leveraging the feature flag * added ability to move personal items to an organization and set active collection when user is on a collection * made collection required by default * handled organization cipher share when personal items and org items are selected * moved logic to determine warning text to component class * moved logic to determine warning text to component class * Improved hide or show logic for menu * added bullet point to bulk assignment dialog content * changed description for move to folder * Fixed issue were all collections are retrived instead of only can manage, and added logic to get collections associated with a cipher * added inline assign to collections * added logic to disable three dot to template * Updated logic to retreive shared collection ids between ciphers * Added logic to make attachment view only, show or hide * Only show menu options when there are options available * Comments cleanup * update cipher row to disable menu instead of hide * Put add to folder behind feature flag * ensured old menu behaviour is shown when feature flag is turned off * refactored code base on code review suggestions * fixed bug with available collections * Made assign to collections resuable made pluralize a pipe instead * Utilized the resuable assign to collections component on the web * changed description message for collection assignment * fixed bug with ExpressionChangedAfterItHasBeenCheckedError * Added changedetectorref markForCheck * removed redundant startwith as seed value has been added * made code review suggestions * fixed bug where assign to collections shows up in trash filter * removed bitInput * refactored based on code review comments * added reference ticket * [PM-9341] Cannot assign to collections when filtering by My Vault (#9862) * Add checks for org id myvault * made myvault id a constant * show bulk move is set by individual vault and it is needed so assign to collections does not show up in trash filter (#9876) * Fixed issue where selectedOrgId is null (#9879) * Fix bug introduced with assigning items to a collection (#9897) * [PM-9601] [PM-9602] When collection management setting is turned on view only collections and assign to collections menu option show up (#10047) * Only show collections with edit access on individual vault * remove unused arguments
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
<bit-dialog dialogSize="large">
|
||||
<span bitDialogTitle>
|
||||
{{ "assignToCollections" | i18n }}
|
||||
<span class="tw-text-sm tw-normal-case tw-text-muted">
|
||||
{{ editableItemCount | pluralize: ("item" | i18n) : ("items" | i18n) }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div bitDialogContent>
|
||||
<assign-collections
|
||||
[params]="params"
|
||||
(formDisabled)="disabled = $event"
|
||||
(formLoading)="loading = $event"
|
||||
(onCollectionAssign)="onCollectionAssign($event)"
|
||||
(editableItemCountChange)="editableItemCount = $event"
|
||||
></assign-collections>
|
||||
</div>
|
||||
|
||||
<ng-container bitDialogFooter>
|
||||
<button
|
||||
[disabled]="disabled"
|
||||
[loading]="loading"
|
||||
form="assign_collections_form"
|
||||
type="submit"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="primary"
|
||||
>
|
||||
{{ "assign" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitButton buttonType="secondary" bitDialogClose>
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
|
||||
import { PluralizePipe } from "@bitwarden/angular/pipes/pluralize.pipe";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
AssignCollectionsComponent,
|
||||
CollectionAssignmentParams,
|
||||
CollectionAssignmentResult,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
@Component({
|
||||
imports: [SharedModule, AssignCollectionsComponent, PluralizePipe],
|
||||
templateUrl: "./assign-collections-web.component.html",
|
||||
standalone: true,
|
||||
})
|
||||
export class AssignCollectionsWebComponent {
|
||||
protected loading = false;
|
||||
protected disabled = false;
|
||||
protected editableItemCount: number;
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) public params: CollectionAssignmentParams,
|
||||
private dialogRef: DialogRef<CollectionAssignmentResult>,
|
||||
) {}
|
||||
|
||||
protected async onCollectionAssign(result: CollectionAssignmentResult) {
|
||||
this.dialogRef.close(result);
|
||||
}
|
||||
|
||||
static open(dialogService: DialogService, config: DialogConfig<CollectionAssignmentParams>) {
|
||||
return dialogService.open<CollectionAssignmentResult, CollectionAssignmentParams>(
|
||||
AssignCollectionsWebComponent,
|
||||
config,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./assign-collections-web.component";
|
||||
@@ -69,8 +69,9 @@
|
||||
<td bitCell [ngClass]="RowHeightClass" *ngIf="viewingOrgVault"></td>
|
||||
<td bitCell [ngClass]="RowHeightClass" class="tw-text-right">
|
||||
<button
|
||||
[disabled]="disabled"
|
||||
[disabled]="disabled || disableMenu"
|
||||
[bitMenuTriggerFor]="cipherOptions"
|
||||
[attr.title]="disableMenu ? ('missingPermissions' | i18n) : ''"
|
||||
size="small"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
type="button"
|
||||
@@ -78,7 +79,7 @@
|
||||
appStopProp
|
||||
></button>
|
||||
<bit-menu #cipherOptions>
|
||||
<ng-container *ngIf="cipher.type === CipherType.Login && !cipher.isDeleted">
|
||||
<ng-container *ngIf="isNotDeletedLoginCipher">
|
||||
<button bitMenuItem type="button" (click)="copy('username')">
|
||||
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||
{{ "copyUsername" | i18n }}
|
||||
@@ -104,33 +105,49 @@
|
||||
</a>
|
||||
</ng-container>
|
||||
|
||||
<button bitMenuItem type="button" (click)="attachments()">
|
||||
<button
|
||||
bitMenuItem
|
||||
*ngIf="showAttachments || !vaultBulkManagementActionEnabled"
|
||||
type="button"
|
||||
(click)="attachments()"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-paperclip" aria-hidden="true"></i>
|
||||
{{ "attachments" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem *ngIf="cloneable && !cipher.isDeleted" type="button" (click)="clone()">
|
||||
<button bitMenuItem *ngIf="showClone" type="button" (click)="clone()">
|
||||
<i class="bwi bwi-fw bwi-files" aria-hidden="true"></i>
|
||||
{{ "clone" | i18n }}
|
||||
</button>
|
||||
<!-- This option will be phased out in future releases -->
|
||||
<button
|
||||
bitMenuItem
|
||||
*ngIf="!cipher.organizationId && !cipher.isDeleted"
|
||||
*ngIf="!cipher.organizationId && !cipher.isDeleted && !vaultBulkManagementActionEnabled"
|
||||
type="button"
|
||||
(click)="moveToOrganization()"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-arrow-circle-right" aria-hidden="true"></i>
|
||||
{{ "moveToOrganization" | i18n }}
|
||||
</button>
|
||||
<!-- This option will be phased out in future releases -->
|
||||
<button
|
||||
bitMenuItem
|
||||
*ngIf="cipher.organizationId && !cipher.isDeleted"
|
||||
*ngIf="cipher.organizationId && !cipher.isDeleted && !vaultBulkManagementActionEnabled"
|
||||
type="button"
|
||||
(click)="editCollections()"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-collection" aria-hidden="true"></i>
|
||||
{{ "collections" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem *ngIf="cipher.organizationId && useEvents" type="button" (click)="events()">
|
||||
<button
|
||||
bitMenuItem
|
||||
*ngIf="showAssignToCollections"
|
||||
type="button"
|
||||
(click)="assignToCollections()"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-collection" aria-hidden="true"></i>
|
||||
{{ "assignToCollections" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem *ngIf="showEventLogs" type="button" (click)="events()">
|
||||
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
|
||||
{{ "eventLogs" | i18n }}
|
||||
</button>
|
||||
@@ -138,7 +155,12 @@
|
||||
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
|
||||
{{ "restore" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem (click)="deleteCipher()" type="button">
|
||||
<button
|
||||
bitMenuItem
|
||||
*ngIf="canEditCipher || !vaultBulkManagementActionEnabled"
|
||||
(click)="deleteCipher()"
|
||||
type="button"
|
||||
>
|
||||
<span class="tw-text-danger">
|
||||
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
|
||||
{{ (cipher.isDeleted ? "permanentlyDelete" : "delete") | i18n }}
|
||||
|
||||
@@ -26,6 +26,8 @@ export class VaultCipherRowComponent {
|
||||
@Input() organizations: Organization[];
|
||||
@Input() collections: CollectionView[];
|
||||
@Input() viewingOrgVault: boolean;
|
||||
@Input() canEditCipher: boolean;
|
||||
@Input() vaultBulkManagementActionEnabled: boolean;
|
||||
|
||||
@Output() onEvent = new EventEmitter<VaultItemEvent>();
|
||||
|
||||
@@ -45,6 +47,53 @@ export class VaultCipherRowComponent {
|
||||
return this.cipher.hasOldAttachments && this.cipher.organizationId == null;
|
||||
}
|
||||
|
||||
protected get showAttachments() {
|
||||
return this.canEditCipher || this.cipher.attachments?.length > 0;
|
||||
}
|
||||
|
||||
protected get showAssignToCollections() {
|
||||
return this.canEditCipher && !this.cipher.isDeleted;
|
||||
}
|
||||
|
||||
protected get showClone() {
|
||||
return this.cloneable && !this.cipher.isDeleted;
|
||||
}
|
||||
|
||||
protected get showEventLogs() {
|
||||
return this.useEvents && this.cipher.organizationId;
|
||||
}
|
||||
|
||||
protected get isNotDeletedLoginCipher() {
|
||||
return this.cipher.type === this.CipherType.Login && !this.cipher.isDeleted;
|
||||
}
|
||||
|
||||
protected get showCopyPassword(): boolean {
|
||||
return this.isNotDeletedLoginCipher && this.cipher.viewPassword;
|
||||
}
|
||||
|
||||
protected get showCopyTotp(): boolean {
|
||||
return this.isNotDeletedLoginCipher && this.showTotpCopyButton;
|
||||
}
|
||||
|
||||
protected get showLaunchUri(): boolean {
|
||||
return this.isNotDeletedLoginCipher && this.cipher.login.canLaunch;
|
||||
}
|
||||
|
||||
protected get disableMenu() {
|
||||
return (
|
||||
!(
|
||||
this.isNotDeletedLoginCipher ||
|
||||
this.showCopyPassword ||
|
||||
this.showCopyTotp ||
|
||||
this.showLaunchUri ||
|
||||
this.showAttachments ||
|
||||
this.showClone ||
|
||||
this.canEditCipher ||
|
||||
this.cipher.isDeleted
|
||||
) && this.vaultBulkManagementActionEnabled
|
||||
);
|
||||
}
|
||||
|
||||
protected copy(field: "username" | "password" | "totp") {
|
||||
this.onEvent.emit({ type: "copyField", item: this.cipher, field });
|
||||
}
|
||||
@@ -76,4 +125,8 @@ export class VaultCipherRowComponent {
|
||||
protected attachments() {
|
||||
this.onEvent.emit({ type: "viewAttachments", item: this.cipher });
|
||||
}
|
||||
|
||||
protected assignToCollections() {
|
||||
this.onEvent.emit({ type: "assignToCollections", items: [this.cipher] });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,9 @@
|
||||
</th>
|
||||
<th bitCell class="tw-w-12 tw-text-right">
|
||||
<button
|
||||
[disabled]="disabled || isEmpty"
|
||||
[disabled]="disabled || isEmpty || disableMenu"
|
||||
[bitMenuTriggerFor]="headerMenu"
|
||||
[attr.title]="disableMenu ? ('missingPermissions' | i18n) : ''"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
type="button"
|
||||
@@ -37,7 +38,7 @@
|
||||
<bit-menu #headerMenu>
|
||||
<button *ngIf="bulkMoveAllowed" type="button" bitMenuItem (click)="bulkMoveToFolder()">
|
||||
<i class="bwi bwi-fw bwi-folder" aria-hidden="true"></i>
|
||||
{{ "moveSelected" | i18n }}
|
||||
{{ (vaultBulkManagementActionEnabled ? "addToFolder" : "moveSelected") | i18n }}
|
||||
</button>
|
||||
<button
|
||||
*ngIf="showAdminActions && showBulkEditCollectionAccess"
|
||||
@@ -49,7 +50,9 @@
|
||||
{{ "access" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
*ngIf="showAdminActions && bulkAssignToCollectionsAllowed"
|
||||
*ngIf="
|
||||
(showAdminActions || showAssignToCollections()) && bulkAssignToCollectionsAllowed
|
||||
"
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="assignToCollections()"
|
||||
@@ -58,7 +61,7 @@
|
||||
{{ "assignToCollections" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
*ngIf="bulkMoveAllowed"
|
||||
*ngIf="bulkMoveAllowed && !vaultBulkManagementActionEnabled"
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="bulkMoveToOrganization()"
|
||||
@@ -70,10 +73,22 @@
|
||||
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
|
||||
{{ "restoreSelected" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="bulkDelete()">
|
||||
<button
|
||||
*ngIf="deleteAllowed || showBulkTrashOptions"
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="bulkDelete()"
|
||||
>
|
||||
<span class="tw-text-danger">
|
||||
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
|
||||
{{ (showBulkTrashOptions ? "permanentlyDeleteSelected" : "deleteSelected") | i18n }}
|
||||
{{
|
||||
(showBulkTrashOptions
|
||||
? "permanentlyDeleteSelected"
|
||||
: vaultBulkManagementActionEnabled
|
||||
? "delete"
|
||||
: "deleteSelected"
|
||||
) | i18n
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</bit-menu>
|
||||
@@ -125,6 +140,8 @@
|
||||
[organizations]="allOrganizations"
|
||||
[collections]="allCollections"
|
||||
[checked]="selection.isSelected(item)"
|
||||
[canEditCipher]="canEditCipher(item.cipher) && vaultBulkManagementActionEnabled"
|
||||
[vaultBulkManagementActionEnabled]="vaultBulkManagementActionEnabled"
|
||||
(checkedToggled)="selection.toggle(item)"
|
||||
(onEvent)="event($event)"
|
||||
></tr>
|
||||
|
||||
@@ -48,6 +48,7 @@ export class VaultItemsComponent {
|
||||
@Input() addAccessStatus: number;
|
||||
@Input() addAccessToggle: boolean;
|
||||
@Input() restrictProviderAccess: boolean;
|
||||
@Input() vaultBulkManagementActionEnabled = false;
|
||||
|
||||
private _ciphers?: CipherView[] = [];
|
||||
@Input() get ciphers(): CipherView[] {
|
||||
@@ -93,10 +94,24 @@ export class VaultItemsComponent {
|
||||
);
|
||||
}
|
||||
|
||||
get disableMenu() {
|
||||
return (
|
||||
this.vaultBulkManagementActionEnabled &&
|
||||
!this.bulkMoveAllowed &&
|
||||
!this.showAssignToCollections() &&
|
||||
!this.showDelete()
|
||||
);
|
||||
}
|
||||
|
||||
get bulkAssignToCollectionsAllowed() {
|
||||
return this.showBulkAddToCollections && this.ciphers.length > 0;
|
||||
}
|
||||
|
||||
// Use new bulk management delete if vaultBulkManagementActionEnabled feature flag is enabled
|
||||
get deleteAllowed() {
|
||||
return this.vaultBulkManagementActionEnabled ? this.showDelete() : true;
|
||||
}
|
||||
|
||||
protected canEditCollection(collection: CollectionView): boolean {
|
||||
// Only allow allow deletion if collection editing is enabled and not deleting "Unassigned"
|
||||
if (collection.id === Unassigned) {
|
||||
@@ -192,6 +207,22 @@ export class VaultItemsComponent {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected canEditCipher(cipher: CipherView) {
|
||||
if (cipher.organizationId == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const organization = this.allOrganizations.find((o) => o.id === cipher.organizationId);
|
||||
return (
|
||||
(organization.canEditAllCiphers(
|
||||
this.flexibleCollectionsV1Enabled,
|
||||
this.restrictProviderAccess,
|
||||
) &&
|
||||
this.viewingOrgVault) ||
|
||||
cipher.edit
|
||||
);
|
||||
}
|
||||
|
||||
private refreshItems() {
|
||||
const collections: VaultItem[] = this.collections.map((collection) => ({ collection }));
|
||||
const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher }));
|
||||
@@ -235,4 +266,89 @@ export class VaultItemsComponent {
|
||||
.map((item) => item.cipher),
|
||||
});
|
||||
}
|
||||
|
||||
protected showAssignToCollections(): boolean {
|
||||
if (!this.showBulkMove) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.selection.selected.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasPersonalItems = this.hasPersonalItems();
|
||||
const uniqueCipherOrgIds = this.getUniqueOrganizationIds();
|
||||
|
||||
// Return false if items are from different organizations
|
||||
if (uniqueCipherOrgIds.size > 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If all items are personal, return based on personal items
|
||||
if (uniqueCipherOrgIds.size === 0) {
|
||||
return hasPersonalItems;
|
||||
}
|
||||
|
||||
const [orgId] = uniqueCipherOrgIds;
|
||||
const organization = this.allOrganizations.find((o) => o.id === orgId);
|
||||
|
||||
const canEditOrManageAllCiphers =
|
||||
organization?.canEditAllCiphers(
|
||||
this.flexibleCollectionsV1Enabled,
|
||||
this.restrictProviderAccess,
|
||||
) && this.viewingOrgVault;
|
||||
|
||||
const collectionNotSelected =
|
||||
this.selection.selected.filter((item) => item.collection).length === 0;
|
||||
|
||||
return (canEditOrManageAllCiphers || this.allCiphersHaveEditAccess()) && collectionNotSelected;
|
||||
}
|
||||
|
||||
protected showDelete(): boolean {
|
||||
if (this.selection.selected.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasPersonalItems = this.hasPersonalItems();
|
||||
const uniqueCipherOrgIds = this.getUniqueOrganizationIds();
|
||||
const organizations = Array.from(uniqueCipherOrgIds, (orgId) =>
|
||||
this.allOrganizations.find((o) => o.id === orgId),
|
||||
);
|
||||
|
||||
const canEditOrManageAllCiphers =
|
||||
organizations.length > 0 &&
|
||||
organizations.every((org) =>
|
||||
org?.canEditAllCiphers(this.flexibleCollectionsV1Enabled, this.restrictProviderAccess),
|
||||
);
|
||||
|
||||
const canDeleteCollections = this.selection.selected
|
||||
.filter((item) => item.collection)
|
||||
.every((item) => item.collection && this.canDeleteCollection(item.collection));
|
||||
|
||||
const userCanDeleteAccess =
|
||||
(canEditOrManageAllCiphers || this.allCiphersHaveEditAccess()) && canDeleteCollections;
|
||||
|
||||
if (
|
||||
userCanDeleteAccess ||
|
||||
(hasPersonalItems && (!uniqueCipherOrgIds.size || userCanDeleteAccess))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private hasPersonalItems(): boolean {
|
||||
return this.selection.selected.some(({ cipher }) => cipher?.organizationId === null);
|
||||
}
|
||||
|
||||
private allCiphersHaveEditAccess(): boolean {
|
||||
return this.selection.selected
|
||||
.filter(({ cipher }) => cipher)
|
||||
.every(({ cipher }) => cipher?.edit);
|
||||
}
|
||||
|
||||
private getUniqueOrganizationIds(): Set<string> {
|
||||
return new Set(this.selection.selected.flatMap((i) => i.cipher?.organizationId ?? []));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { DialogService } from "@bitwarden/components";
|
||||
templateUrl: "attachments.component.html",
|
||||
})
|
||||
export class AttachmentsComponent extends BaseAttachmentsComponent {
|
||||
viewOnly = false;
|
||||
protected override componentName = "app-vault-attachments";
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-dialog dialogSize="small">
|
||||
<span bitDialogTitle>
|
||||
{{ "moveSelected" | i18n }}
|
||||
{{ ((vaultBulkManagementActionEnabled$ | async) ? "addToFolder" : "moveSelected") | i18n }}
|
||||
</span>
|
||||
<span bitDialogContent>
|
||||
<p>{{ "moveSelectedItemsDesc" | i18n: cipherIds.length }}</p>
|
||||
<bit-form-field>
|
||||
<bit-label for="folder">{{ "folder" | i18n }}</bit-label>
|
||||
<select bitInput formControlName="folderId">
|
||||
<option *ngFor="let f of folders$ | async" [ngValue]="f.id">{{ f.name }}</option>
|
||||
</select>
|
||||
<bit-label for="folder">{{ "selectFolder" | i18n }}</bit-label>
|
||||
<bit-select formControlName="folderId">
|
||||
<bit-option *ngFor="let f of folders$ | async" [value]="f.id" [label]="f.name">
|
||||
</bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
</span>
|
||||
<ng-container bitDialogFooter>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Component, Inject, OnInit } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { firstValueFrom, Observable } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
@@ -45,6 +47,10 @@ export class BulkMoveDialogComponent implements OnInit {
|
||||
});
|
||||
folders$: Observable<FolderView[]>;
|
||||
|
||||
protected vaultBulkManagementActionEnabled$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.VaultBulkManagementAction,
|
||||
);
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) params: BulkMoveDialogParams,
|
||||
private dialogRef: DialogRef<BulkMoveDialogResult>,
|
||||
@@ -53,6 +59,7 @@ export class BulkMoveDialogComponent implements OnInit {
|
||||
private i18nService: I18nService,
|
||||
private folderService: FolderService,
|
||||
private formBuilder: FormBuilder,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.cipherIds = params.cipherIds ?? [];
|
||||
}
|
||||
|
||||
@@ -50,8 +50,10 @@
|
||||
[showBulkTrashOptions]="filter.type === 'trash'"
|
||||
[useEvents]="false"
|
||||
[showAdminActions]="false"
|
||||
[showBulkAddToCollections]="vaultBulkManagementActionEnabled$ | async"
|
||||
(onEvent)="onVaultItemsEvent($event)"
|
||||
[flexibleCollectionsV1Enabled]="flexibleCollectionsV1Enabled$ | async"
|
||||
[vaultBulkManagementActionEnabled]="vaultBulkManagementActionEnabled$ | async"
|
||||
>
|
||||
</app-vault-items>
|
||||
<div
|
||||
|
||||
@@ -46,6 +46,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
|
||||
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||
@@ -57,8 +58,9 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
|
||||
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
|
||||
import { DialogService, Icons, ToastService } from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
import { CollectionAssignmentResult, PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import { AssignCollectionsWebComponent } from "../components/assign-collections";
|
||||
import {
|
||||
CollectionDialogAction,
|
||||
CollectionDialogTabType,
|
||||
@@ -140,6 +142,9 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
protected flexibleCollectionsV1Enabled$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.FlexibleCollectionsV1,
|
||||
);
|
||||
protected vaultBulkManagementActionEnabled$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.VaultBulkManagementAction,
|
||||
);
|
||||
|
||||
private searchText$ = new Subject<string>();
|
||||
private refresh$ = new BehaviorSubject<void>(null);
|
||||
@@ -379,9 +384,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
(o) => o.canCreateNewCollections && !o.isProviderUser,
|
||||
);
|
||||
|
||||
this.showBulkMove =
|
||||
filter.type !== "trash" &&
|
||||
(filter.organizationId === undefined || filter.organizationId === Unassigned);
|
||||
this.showBulkMove = filter.type !== "trash";
|
||||
this.isEmpty = collections?.length === 0 && ciphers?.length === 0;
|
||||
|
||||
this.performingInitialLoad = false;
|
||||
@@ -428,6 +431,8 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
await this.editCollection(event.item, CollectionDialogTabType.Info);
|
||||
} else if (event.type === "viewCollectionAccess") {
|
||||
await this.editCollection(event.item, CollectionDialogTabType.Access);
|
||||
} else if (event.type === "assignToCollections") {
|
||||
await this.bulkAssignToCollections(event.items);
|
||||
}
|
||||
} finally {
|
||||
this.processingEvent = false;
|
||||
@@ -492,12 +497,18 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
const canEditAttachments = await this.canEditAttachments(cipher);
|
||||
const vaultBulkManagementActionEnabled = await firstValueFrom(
|
||||
this.vaultBulkManagementActionEnabled$,
|
||||
);
|
||||
|
||||
let madeAttachmentChanges = false;
|
||||
const [modal] = await this.modalService.openViewRef(
|
||||
AttachmentsComponent,
|
||||
this.attachmentsModalRef,
|
||||
(comp) => {
|
||||
comp.cipherId = cipher.id;
|
||||
comp.viewOnly = !canEditAttachments && vaultBulkManagementActionEnabled;
|
||||
comp.onUploadedAttachment
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => (madeAttachmentChanges = true));
|
||||
@@ -707,6 +718,47 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async bulkAssignToCollections(ciphers: CipherView[]) {
|
||||
if (!(await this.repromptCipher(ciphers))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ciphers.length === 0) {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
this.i18nService.t("errorOccurred"),
|
||||
this.i18nService.t("nothingSelected"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let availableCollections: CollectionView[] = [];
|
||||
const orgId =
|
||||
this.activeFilter.organizationId ||
|
||||
ciphers.find((c) => c.organizationId !== null)?.organizationId;
|
||||
|
||||
if (orgId && orgId !== "MyVault") {
|
||||
const organization = this.allOrganizations.find((o) => o.id === orgId);
|
||||
availableCollections = this.allCollections.filter(
|
||||
(c) => c.organizationId === organization.id && !c.readOnly,
|
||||
);
|
||||
}
|
||||
|
||||
const dialog = AssignCollectionsWebComponent.open(this.dialogService, {
|
||||
data: {
|
||||
ciphers,
|
||||
organizationId: orgId as OrganizationId,
|
||||
availableCollections,
|
||||
activeCollection: this.activeFilter?.selectedCollectionNode?.node,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(dialog.closed);
|
||||
if (result === CollectionAssignmentResult.Saved) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async cloneCipher(cipher: CipherView) {
|
||||
if (cipher.login?.hasFido2Credentials) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
@@ -984,6 +1036,17 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
this.refresh$.next();
|
||||
}
|
||||
|
||||
private async canEditAttachments(cipher: CipherView) {
|
||||
if (cipher.organizationId == null || cipher.edit) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const flexibleCollectionsV1Enabled = await this.flexibleCollectionsV1Enabled();
|
||||
|
||||
const organization = this.allOrganizations.find((o) => o.id === cipher.organizationId);
|
||||
return organization.canEditAllCiphers(flexibleCollectionsV1Enabled, false);
|
||||
}
|
||||
|
||||
private go(queryParams: any = null) {
|
||||
if (queryParams == null) {
|
||||
queryParams = {
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
<bit-dialog dialogSize="large">
|
||||
<span bitDialogTitle>
|
||||
{{ "assignToCollections" | i18n }}
|
||||
<span class="tw-text-sm tw-normal-case tw-text-muted">
|
||||
{{ pluralize(editableItemCount, "item", "items") }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div bitDialogContent>
|
||||
<p>{{ "bulkCollectionAssignmentDialogDescription" | i18n }}</p>
|
||||
|
||||
<p *ngIf="readonlyItemCount > 0">
|
||||
{{ "bulkCollectionAssignmentWarning" | i18n: totalItemCount : readonlyItemCount }}
|
||||
</p>
|
||||
|
||||
<div class="tw-flex">
|
||||
<bit-form-field class="tw-grow">
|
||||
<bit-label>{{ "selectCollectionsToAssign" | i18n }}</bit-label>
|
||||
<bit-multi-select
|
||||
class="tw-w-full"
|
||||
[baseItems]="availableCollections"
|
||||
[removeSelectedItems]="true"
|
||||
(onItemsConfirmed)="selectCollections($event)"
|
||||
></bit-multi-select>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
|
||||
<bit-table>
|
||||
<ng-container header>
|
||||
<td bitCell>{{ "assignToTheseCollections" | i18n }}</td>
|
||||
<td bitCell class="tw-w-20"></td>
|
||||
</ng-container>
|
||||
<ng-template body>
|
||||
<tr bitRow *ngFor="let item of selectedCollections; let i = index">
|
||||
<td bitCell>
|
||||
<i class="bwi bwi-collection" aria-hidden="true"></i>
|
||||
{{ item.labelName }}
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="muted"
|
||||
appA11yTitle="{{ 'remove' | i18n }} {{ item.labelName }}"
|
||||
(click)="unselectCollection(i)"
|
||||
></button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="selectedCollections.length == 0">
|
||||
<td bitCell>
|
||||
{{ "noCollectionsAssigned" | i18n }}
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
</div>
|
||||
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="submit" bitButton buttonType="primary" [bitAction]="submit" [disabled]="!isValid">
|
||||
{{ "assign" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitButton buttonType="secondary" bitDialogClose>
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
@@ -1,195 +0,0 @@
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
|
||||
import { DialogService, SelectItemView } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
export interface BulkCollectionAssignmentDialogParams {
|
||||
organizationId: OrganizationId;
|
||||
|
||||
/**
|
||||
* The ciphers to be assigned to the collections selected in the dialog.
|
||||
*/
|
||||
ciphers: CipherView[];
|
||||
|
||||
/**
|
||||
* The collections available to assign the ciphers to.
|
||||
*/
|
||||
availableCollections: CollectionView[];
|
||||
|
||||
/**
|
||||
* The currently filtered collection. Selected by default. If the user deselects it in the dialog then it will be
|
||||
* removed from the ciphers upon submission.
|
||||
*/
|
||||
activeCollection?: CollectionView;
|
||||
}
|
||||
|
||||
export enum BulkCollectionAssignmentDialogResult {
|
||||
Saved = "saved",
|
||||
Canceled = "canceled",
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [SharedModule],
|
||||
selector: "app-bulk-collection-assignment-dialog",
|
||||
templateUrl: "./bulk-collection-assignment-dialog.component.html",
|
||||
standalone: true,
|
||||
})
|
||||
export class BulkCollectionAssignmentDialogComponent implements OnDestroy, OnInit {
|
||||
protected totalItemCount: number;
|
||||
protected editableItemCount: number;
|
||||
protected readonlyItemCount: number;
|
||||
protected availableCollections: SelectItemView[] = [];
|
||||
protected selectedCollections: SelectItemView[] = [];
|
||||
|
||||
private editableItems: CipherView[] = [];
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
protected pluralize = (count: number, singular: string, plural: string) =>
|
||||
`${count} ${this.i18nService.t(count === 1 ? singular : plural)}`;
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) private params: BulkCollectionAssignmentDialogParams,
|
||||
private dialogRef: DialogRef<BulkCollectionAssignmentDialogResult>,
|
||||
private cipherService: CipherService,
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private configService: ConfigService,
|
||||
private organizationService: OrganizationService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
// If no ciphers are passed in, close the dialog
|
||||
if (this.params.ciphers == null || this.params.ciphers.length < 1) {
|
||||
this.platformUtilsService.showToast("error", null, this.i18nService.t("nothingSelected"));
|
||||
this.dialogRef.close(BulkCollectionAssignmentDialogResult.Canceled);
|
||||
return;
|
||||
}
|
||||
|
||||
const v1FCEnabled = await this.configService.getFeatureFlag(FeatureFlag.FlexibleCollectionsV1);
|
||||
const restrictProviderAccess = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.RestrictProviderAccess,
|
||||
);
|
||||
const org = await this.organizationService.get(this.params.organizationId);
|
||||
|
||||
if (org.canEditAllCiphers(v1FCEnabled, restrictProviderAccess)) {
|
||||
this.editableItems = this.params.ciphers;
|
||||
} else {
|
||||
this.editableItems = this.params.ciphers.filter((c) => c.edit);
|
||||
}
|
||||
|
||||
this.editableItemCount = this.editableItems.length;
|
||||
|
||||
// If no ciphers are editable, close the dialog
|
||||
if (this.editableItemCount == 0) {
|
||||
this.platformUtilsService.showToast("error", null, this.i18nService.t("missingPermissions"));
|
||||
this.dialogRef.close(BulkCollectionAssignmentDialogResult.Canceled);
|
||||
return;
|
||||
}
|
||||
|
||||
this.totalItemCount = this.params.ciphers.length;
|
||||
this.readonlyItemCount = this.totalItemCount - this.editableItemCount;
|
||||
|
||||
this.availableCollections = this.params.availableCollections.map((c) => ({
|
||||
icon: "bwi-collection",
|
||||
id: c.id,
|
||||
labelName: c.name,
|
||||
listName: c.name,
|
||||
}));
|
||||
|
||||
// If the active collection is set, select it by default
|
||||
if (this.params.activeCollection) {
|
||||
this.selectCollections([
|
||||
{
|
||||
icon: "bwi-collection",
|
||||
id: this.params.activeCollection.id,
|
||||
labelName: this.params.activeCollection.name,
|
||||
listName: this.params.activeCollection.name,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private sortItems = (a: SelectItemView, b: SelectItemView) =>
|
||||
this.i18nService.collator.compare(a.labelName, b.labelName);
|
||||
|
||||
selectCollections(items: SelectItemView[]) {
|
||||
this.selectedCollections = [...this.selectedCollections, ...items].sort(this.sortItems);
|
||||
|
||||
this.availableCollections = this.availableCollections.filter(
|
||||
(item) => !items.find((i) => i.id === item.id),
|
||||
);
|
||||
}
|
||||
|
||||
unselectCollection(i: number) {
|
||||
const removed = this.selectedCollections.splice(i, 1);
|
||||
this.availableCollections = [...this.availableCollections, ...removed].sort(this.sortItems);
|
||||
}
|
||||
|
||||
get isValid() {
|
||||
return this.params.activeCollection != null || this.selectedCollections.length > 0;
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
if (!this.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cipherIds = this.editableItems.map((i) => i.id as CipherId);
|
||||
|
||||
if (this.selectedCollections.length > 0) {
|
||||
await this.cipherService.bulkUpdateCollectionsWithServer(
|
||||
this.params.organizationId,
|
||||
cipherIds,
|
||||
this.selectedCollections.map((i) => i.id as CollectionId),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.params.activeCollection != null &&
|
||||
this.selectedCollections.find((c) => c.id === this.params.activeCollection.id) == null
|
||||
) {
|
||||
await this.cipherService.bulkUpdateCollectionsWithServer(
|
||||
this.params.organizationId,
|
||||
cipherIds,
|
||||
[this.params.activeCollection.id as CollectionId],
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
this.platformUtilsService.showToast(
|
||||
"success",
|
||||
null,
|
||||
this.i18nService.t("successfullyAssignedCollections"),
|
||||
);
|
||||
|
||||
this.dialogRef.close(BulkCollectionAssignmentDialogResult.Saved);
|
||||
};
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
static open(
|
||||
dialogService: DialogService,
|
||||
config: DialogConfig<BulkCollectionAssignmentDialogParams>,
|
||||
) {
|
||||
return dialogService.open<
|
||||
BulkCollectionAssignmentDialogResult,
|
||||
BulkCollectionAssignmentDialogParams
|
||||
>(BulkCollectionAssignmentDialogComponent, config);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./bulk-collection-assignment-dialog.component";
|
||||
@@ -59,12 +59,13 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
|
||||
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
|
||||
import { DialogService, Icons, ToastService } from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
import { CollectionAssignmentResult, PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import { GroupService, GroupView } from "../../admin-console/organizations/core";
|
||||
import { openEntityEventsDialog } from "../../admin-console/organizations/manage/entity-events.component";
|
||||
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 { AssignCollectionsWebComponent } from "../components/assign-collections";
|
||||
import {
|
||||
CollectionDialogAction,
|
||||
CollectionDialogTabType,
|
||||
@@ -90,10 +91,6 @@ import { getNestedCollectionTree } from "../utils/collection-utils";
|
||||
|
||||
import { AddEditComponent } from "./add-edit.component";
|
||||
import { AttachmentsComponent } from "./attachments.component";
|
||||
import {
|
||||
BulkCollectionAssignmentDialogComponent,
|
||||
BulkCollectionAssignmentDialogResult,
|
||||
} from "./bulk-collection-assignment-dialog";
|
||||
import {
|
||||
BulkCollectionsDialogComponent,
|
||||
BulkCollectionsDialogResult,
|
||||
@@ -1327,7 +1324,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
).filter((c) => c.id != Unassigned);
|
||||
}
|
||||
|
||||
const dialog = BulkCollectionAssignmentDialogComponent.open(this.dialogService, {
|
||||
const dialog = AssignCollectionsWebComponent.open(this.dialogService, {
|
||||
data: {
|
||||
ciphers: items,
|
||||
organizationId: this.organization?.id as OrganizationId,
|
||||
@@ -1337,7 +1334,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(dialog.closed);
|
||||
if (result === BulkCollectionAssignmentDialogResult.Saved) {
|
||||
if (result === CollectionAssignmentResult.Saved) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user