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 { 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, ButtonComponent, 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; /** * Submit button instance that will be disabled or marked as loading when the form is submitting. */ @Input() submitBtn?: ButtonComponent; @Output() editableItemCountChange = new EventEmitter(); @Output() onCollectionAssign = new EventEmitter(); formGroup = this.formBuilder.group({ selectedOrg: [null], collections: [[], [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 = 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(); // Disable the org selector if there is only one organization if (orgs.length === 1) { this.formGroup.controls.selectedOrg.disable(); } }); } }), ); protected transferWarningText = (orgName: string, itemsCount: number) => { const haveOrgName = !!orgName; if (itemsCount > 1 && haveOrgName) { return this.i18nService.t("personalItemsWithOrgTransferWarningPlural", itemsCount, orgName); } if (itemsCount > 1 && !haveOrgName) { return this.i18nService.t("personalItemsTransferWarningPlural", itemsCount); } if (itemsCount === 1 && haveOrgName) { return this.i18nService.t("personalItemWithOrgTransferWarningSingular", orgName); } return this.i18nService.t("personalItemTransferWarningSingular"); }; 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.getRawValue().selectedOrg || this.params.organizationId; } private destroy$ = new Subject(); constructor( private cipherService: CipherService, private i18nService: I18nService, private configService: ConfigService, private organizationService: OrganizationService, private collectionService: CollectionService, private formBuilder: FormBuilder, 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) => { if (!this.submitBtn) { return; } this.submitBtn.loading = loading; }); this.bitSubmit.disabled$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => { if (!this.submitBtn) { return; } this.submitBtn.disabled = 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 { 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( shareableCiphers.length === 1 ? "itemMovedToOrg" : "itemsMovedToOrg", 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); } }