1
0
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:
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,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);
}
}