mirror of
https://github.com/bitwarden/browser
synced 2025-12-13 06:43:35 +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:
443
libs/vault/src/components/assign-collections.component.ts
Normal file
443
libs/vault/src/components/assign-collections.component.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core";
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import {
|
||||
Observable,
|
||||
Subject,
|
||||
combineLatest,
|
||||
map,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { PluralizePipe } from "@bitwarden/angular/pipes/pluralize.pipe";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
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 { CipherId, CollectionId, 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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
BitSubmitDirective,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
FormFieldModule,
|
||||
MultiSelectModule,
|
||||
SelectItemView,
|
||||
SelectModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
export interface CollectionAssignmentParams {
|
||||
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 CollectionAssignmentResult {
|
||||
Saved = "saved",
|
||||
Canceled = "canceled",
|
||||
}
|
||||
|
||||
const MY_VAULT_ID = "MyVault";
|
||||
|
||||
@Component({
|
||||
selector: "assign-collections",
|
||||
templateUrl: "assign-collections.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
FormFieldModule,
|
||||
AsyncActionsModule,
|
||||
MultiSelectModule,
|
||||
SelectModule,
|
||||
ReactiveFormsModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
],
|
||||
})
|
||||
export class AssignCollectionsComponent implements OnInit {
|
||||
@ViewChild(BitSubmitDirective)
|
||||
private bitSubmit: BitSubmitDirective;
|
||||
|
||||
@Input() params: CollectionAssignmentParams;
|
||||
|
||||
@Output()
|
||||
formLoading = new EventEmitter<boolean>();
|
||||
|
||||
@Output()
|
||||
formDisabled = new EventEmitter<boolean>();
|
||||
|
||||
@Output()
|
||||
editableItemCountChange = new EventEmitter<number>();
|
||||
|
||||
@Output() onCollectionAssign = new EventEmitter<CollectionAssignmentResult>();
|
||||
|
||||
formGroup = this.formBuilder.group({
|
||||
selectedOrg: [null],
|
||||
collections: [<SelectItemView[]>[], [Validators.required]],
|
||||
});
|
||||
|
||||
protected totalItemCount: number;
|
||||
protected editableItemCount: number;
|
||||
protected readonlyItemCount: number;
|
||||
protected personalItemsCount: number;
|
||||
protected availableCollections: SelectItemView[] = [];
|
||||
protected orgName: string;
|
||||
protected showOrgSelector: boolean = false;
|
||||
|
||||
protected organizations$: Observable<Organization[]> =
|
||||
this.organizationService.organizations$.pipe(
|
||||
map((orgs) =>
|
||||
orgs
|
||||
.filter((o) => o.enabled && o.status === OrganizationUserStatusType.Confirmed)
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
),
|
||||
tap((orgs) => {
|
||||
if (orgs.length > 0 && this.showOrgSelector) {
|
||||
// Using setTimeout to defer the patchValue call until the next event loop cycle
|
||||
setTimeout(() => {
|
||||
this.formGroup.patchValue({ selectedOrg: orgs[0].id });
|
||||
this.setFormValidators();
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
protected transferWarningText = (orgName: string, itemsCount: number) => {
|
||||
const pluralizedItems = this.pluralizePipe.transform(itemsCount, "item", "items");
|
||||
return orgName
|
||||
? this.i18nService.t("personalItemsWithOrgTransferWarning", pluralizedItems, orgName)
|
||||
: this.i18nService.t("personalItemsTransferWarning", pluralizedItems);
|
||||
};
|
||||
|
||||
private editableItems: CipherView[] = [];
|
||||
// Get the selected organization ID. If the user has not selected an organization from the form,
|
||||
// fallback to use the organization ID from the params.
|
||||
private get selectedOrgId(): OrganizationId {
|
||||
return this.formGroup.value.selectedOrg || this.params.organizationId;
|
||||
}
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private cipherService: CipherService,
|
||||
private i18nService: I18nService,
|
||||
private configService: ConfigService,
|
||||
private organizationService: OrganizationService,
|
||||
private collectionService: CollectionService,
|
||||
private formBuilder: FormBuilder,
|
||||
private pluralizePipe: PluralizePipe,
|
||||
private toastService: ToastService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
const v1FCEnabled = await this.configService.getFeatureFlag(FeatureFlag.FlexibleCollectionsV1);
|
||||
const restrictProviderAccess = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.RestrictProviderAccess,
|
||||
);
|
||||
|
||||
const onlyPersonalItems = this.params.ciphers.every((c) => c.organizationId == null);
|
||||
|
||||
if (this.selectedOrgId === MY_VAULT_ID || onlyPersonalItems) {
|
||||
this.showOrgSelector = true;
|
||||
}
|
||||
|
||||
await this.initializeItems(this.selectedOrgId, v1FCEnabled, restrictProviderAccess);
|
||||
|
||||
if (this.selectedOrgId && this.selectedOrgId !== MY_VAULT_ID) {
|
||||
await this.handleOrganizationCiphers();
|
||||
}
|
||||
|
||||
this.setupFormSubscriptions();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.bitSubmit.loading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => {
|
||||
this.formLoading.emit(loading);
|
||||
});
|
||||
|
||||
this.bitSubmit.disabled$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => {
|
||||
this.formDisabled.emit(disabled);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
selectCollections(items: SelectItemView[]) {
|
||||
const currentCollections = this.formGroup.controls.collections.value as SelectItemView[];
|
||||
const updatedCollections = [...currentCollections, ...items].sort(this.sortItems);
|
||||
this.formGroup.patchValue({ collections: updatedCollections });
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
this.formGroup.markAllAsTouched();
|
||||
|
||||
if (this.formGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Retrieve ciphers that belong to an organization
|
||||
const cipherIds = this.editableItems
|
||||
.filter((i) => i.organizationId)
|
||||
.map((i) => i.id as CipherId);
|
||||
|
||||
// Move personal items to the organization
|
||||
if (this.personalItemsCount > 0) {
|
||||
await this.moveToOrganization(
|
||||
this.selectedOrgId,
|
||||
this.params.ciphers.filter((c) => c.organizationId == null),
|
||||
this.formGroup.controls.collections.value.map((i) => i.id as CollectionId),
|
||||
);
|
||||
}
|
||||
|
||||
if (cipherIds.length > 0) {
|
||||
const isSingleOrgCipher = cipherIds.length === 1 && this.personalItemsCount === 0;
|
||||
|
||||
// Update assigned collections for single org cipher or bulk update collections for multiple org ciphers
|
||||
await (isSingleOrgCipher
|
||||
? this.updateAssignedCollections(this.editableItems[0])
|
||||
: this.bulkUpdateCollections(cipherIds));
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("successfullyAssignedCollections"),
|
||||
});
|
||||
}
|
||||
|
||||
this.onCollectionAssign.emit(CollectionAssignmentResult.Saved);
|
||||
};
|
||||
|
||||
private sortItems = (a: SelectItemView, b: SelectItemView) =>
|
||||
this.i18nService.collator.compare(a.labelName, b.labelName);
|
||||
|
||||
private async handleOrganizationCiphers() {
|
||||
// If no ciphers are editable, cancel the operation
|
||||
if (this.editableItemCount == 0) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("nothingSelected"),
|
||||
});
|
||||
this.onCollectionAssign.emit(CollectionAssignmentResult.Canceled);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.availableCollections = this.params.availableCollections.map((c) => ({
|
||||
icon: "bwi-collection",
|
||||
id: c.id,
|
||||
labelName: c.name,
|
||||
listName: c.name,
|
||||
}));
|
||||
|
||||
// Select assigned collections for a single cipher.
|
||||
this.selectCollectionsAssignedToSingleCipher();
|
||||
|
||||
// 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,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the collections that are assigned to a single cipher,
|
||||
* excluding the active collection.
|
||||
*/
|
||||
private selectCollectionsAssignedToSingleCipher() {
|
||||
if (this.params.ciphers.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assignedCollectionIds = this.params.ciphers[0].collectionIds;
|
||||
|
||||
// Filter the available collections to select only those that are associated with the ciphers, excluding the active collection
|
||||
const assignedCollections = this.availableCollections
|
||||
.filter(
|
||||
(collection) =>
|
||||
assignedCollectionIds.includes(collection.id) &&
|
||||
collection.id !== this.params.activeCollection?.id,
|
||||
)
|
||||
.map((collection) => ({
|
||||
icon: "bwi-collection",
|
||||
id: collection.id,
|
||||
labelName: collection.labelName,
|
||||
listName: collection.listName,
|
||||
}));
|
||||
|
||||
if (assignedCollections.length > 0) {
|
||||
this.selectCollections(assignedCollections);
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeItems(
|
||||
organizationId: OrganizationId,
|
||||
v1FCEnabled: boolean,
|
||||
restrictProviderAccess: boolean,
|
||||
) {
|
||||
this.totalItemCount = this.params.ciphers.length;
|
||||
|
||||
// If organizationId is not present or organizationId is MyVault, then all ciphers are considered personal items
|
||||
if (!organizationId || organizationId === MY_VAULT_ID) {
|
||||
this.editableItems = this.params.ciphers;
|
||||
this.editableItemCount = this.params.ciphers.length;
|
||||
this.personalItemsCount = this.params.ciphers.length;
|
||||
this.editableItemCountChange.emit(this.editableItemCount);
|
||||
return;
|
||||
}
|
||||
|
||||
const org = await this.organizationService.get(organizationId);
|
||||
this.orgName = org.name;
|
||||
|
||||
this.editableItems = org.canEditAllCiphers(v1FCEnabled, restrictProviderAccess)
|
||||
? this.params.ciphers
|
||||
: this.params.ciphers.filter((c) => c.edit);
|
||||
|
||||
this.editableItemCount = this.editableItems.length;
|
||||
// TODO: https://bitwarden.atlassian.net/browse/PM-9307,
|
||||
// clean up editableItemCountChange when the org vault is updated to filter editable ciphers
|
||||
this.editableItemCountChange.emit(this.editableItemCount);
|
||||
this.personalItemsCount = this.params.ciphers.filter((c) => c.organizationId == null).length;
|
||||
this.readonlyItemCount = this.totalItemCount - this.editableItemCount;
|
||||
}
|
||||
|
||||
private setFormValidators() {
|
||||
const selectedOrgControl = this.formGroup.get("selectedOrg");
|
||||
selectedOrgControl?.setValidators([Validators.required]);
|
||||
selectedOrgControl?.updateValueAndValidity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up form subscriptions for selected organizations.
|
||||
*/
|
||||
private setupFormSubscriptions() {
|
||||
// Listen to changes in selected organization and update collections
|
||||
this.formGroup.controls.selectedOrg.valueChanges
|
||||
.pipe(
|
||||
tap(() => {
|
||||
this.formGroup.controls.collections.setValue([], { emitEvent: false });
|
||||
}),
|
||||
switchMap((orgId) => {
|
||||
return this.getCollectionsForOrganization(orgId as OrganizationId);
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe((collections) => {
|
||||
this.availableCollections = collections.map((c) => ({
|
||||
icon: "bwi-collection",
|
||||
id: c.id,
|
||||
labelName: c.name,
|
||||
listName: c.name,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the collections for the organization with the given ID.
|
||||
* @param orgId
|
||||
* @returns An observable of the collections for the organization.
|
||||
*/
|
||||
private getCollectionsForOrganization(orgId: OrganizationId): Observable<CollectionView[]> {
|
||||
return combineLatest([
|
||||
this.collectionService.decryptedCollections$,
|
||||
this.organizationService.organizations$,
|
||||
]).pipe(
|
||||
map(([collections, organizations]) => {
|
||||
const org = organizations.find((o) => o.id === orgId);
|
||||
this.orgName = org.name;
|
||||
|
||||
return collections.filter((c) => {
|
||||
return c.organizationId === orgId && !c.readOnly;
|
||||
});
|
||||
}),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
}
|
||||
|
||||
private async moveToOrganization(
|
||||
organizationId: OrganizationId,
|
||||
shareableCiphers: CipherView[],
|
||||
selectedCollectionIds: CollectionId[],
|
||||
) {
|
||||
await this.cipherService.shareManyWithServer(
|
||||
shareableCiphers,
|
||||
organizationId,
|
||||
selectedCollectionIds,
|
||||
);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t(
|
||||
"movedItemsToOrg",
|
||||
this.orgName ?? this.i18nService.t("organization"),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
private async bulkUpdateCollections(cipherIds: CipherId[]) {
|
||||
if (this.formGroup.controls.collections.value.length > 0) {
|
||||
await this.cipherService.bulkUpdateCollectionsWithServer(
|
||||
this.selectedOrgId,
|
||||
cipherIds,
|
||||
this.formGroup.controls.collections.value.map((i) => i.id as CollectionId),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.params.activeCollection != null &&
|
||||
this.formGroup.controls.collections.value.find(
|
||||
(c) => c.id === this.params.activeCollection.id,
|
||||
) == null
|
||||
) {
|
||||
await this.cipherService.bulkUpdateCollectionsWithServer(
|
||||
this.selectedOrgId,
|
||||
cipherIds,
|
||||
[this.params.activeCollection.id as CollectionId],
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateAssignedCollections(cipherView: CipherView) {
|
||||
const { collections } = this.formGroup.getRawValue();
|
||||
cipherView.collectionIds = collections.map((i) => i.id as CollectionId);
|
||||
const cipher = await this.cipherService.encrypt(cipherView);
|
||||
await this.cipherService.saveCollectionsWithServer(cipher);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user