1
0
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:
SmithThe4th
2024-07-11 17:39:49 -04:00
committed by GitHub
parent a723038b44
commit 050f8f4bdc
24 changed files with 919 additions and 295 deletions

View File

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

View File

@@ -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,
);
}
}

View File

@@ -0,0 +1 @@
export * from "./assign-collections-web.component";

View File

@@ -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 }}

View File

@@ -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] });
}
}

View File

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

View File

@@ -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 ?? []));
}
}

View File

@@ -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(

View File

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

View File

@@ -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 ?? [];
}

View File

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

View File

@@ -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 = {

View File

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

View File

@@ -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);
}
}

View File

@@ -1 +0,0 @@
export * from "./bulk-collection-assignment-dialog.component";

View File

@@ -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();
}
}