1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 21:33:27 +00:00
Files
browser/libs/vault/src/components/assign-collections.component.ts
Nick Krantz f52e4e27a0 [PM-12770] Assign to Collections Hint (#14529)
* allow use of common spec in lib/vault tests

* pass readonly collections to the assign collection component

- The assign to collections component filters them out already.
-They're also needed to display copy within the component

* add hint to assign to collections component when there are read only collections assigned to a cipher already

* add readonly hint to desktop

* only show collection hint for collections that are assigned to the provided ciphers

* consider admin/owner edit everything permission when assigning to collections

* fix icon in test
2025-05-22 11:09:33 -05:00

536 lines
17 KiB
TypeScript

// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import {
AfterViewInit,
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewChild,
} from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import {
combineLatest,
firstValueFrom,
map,
Observable,
shareReplay,
Subject,
switchMap,
takeUntil,
tap,
} from "rxjs";
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
getOrganizationById,
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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId, CollectionId, OrganizationId, UserId } 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 {
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;
/**
* Flag indicating if the user is performing the action as an admin on a SINGLE cipher. When true,
* the `/admin` endpoint will be used to update the cipher's collections. Required when updating
* ciphers an Admin does not normally have access to or for Unassigned ciphers.
*
* The bulk method already handles admin actions internally.
*/
isSingleCipherAdmin?: boolean;
}
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
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, OnDestroy, AfterViewInit {
@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<number>();
@Output() onCollectionAssign = new EventEmitter<CollectionAssignmentResult>();
formGroup = this.formBuilder.group({
selectedOrg: [null],
collections: [<SelectItemView[]>[], [Validators.required]],
});
/**
* Collections that are already assigned to the cipher and are read-only. These cannot be removed.
* @protected
*/
protected readOnlyCollectionNames: string[] = [];
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.accountService.activeAccount$.pipe(
switchMap((account) =>
this.organizationService.organizations$(account?.id).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<void>();
constructor(
private cipherService: CipherService,
private i18nService: I18nService,
private organizationService: OrganizationService,
private collectionService: CollectionService,
private formBuilder: FormBuilder,
private toastService: ToastService,
private accountService: AccountService,
) {}
async ngOnInit() {
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);
if (this.selectedOrgId && this.selectedOrgId !== MY_VAULT_ID) {
await this.handleOrganizationCiphers(this.selectedOrgId);
}
this.setupFormSubscriptions();
}
ngAfterViewInit(): void {
this.bitSubmit.loading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => {
if (!this.submitBtn) {
return;
}
this.submitBtn.loading.set(loading);
});
this.bitSubmit.disabled$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => {
if (!this.submitBtn) {
return;
}
this.submitBtn.disabled.set(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);
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
// 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),
activeUserId,
);
}
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], activeUserId)
: this.bulkUpdateCollections(cipherIds, activeUserId));
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(organizationId: OrganizationId) {
// 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;
}
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const org = await firstValueFrom(
this.organizationService.organizations$(userId).pipe(getOrganizationById(organizationId)),
);
await this.setReadOnlyCollectionNames();
this.availableCollections = this.params.availableCollections
.filter((collection) => {
return collection.canEditItems(org);
})
.map((c) => ({
icon: "bwi-collection-shared",
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-shared",
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-shared",
id: collection.id,
labelName: collection.labelName,
listName: collection.listName,
}));
if (assignedCollections.length > 0) {
this.selectCollections(assignedCollections);
}
}
private async initializeItems(organizationId: OrganizationId) {
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 userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const org = await firstValueFrom(
this.organizationService.organizations$(userId).pipe(getOrganizationById(organizationId)),
);
this.orgName = org.name;
this.editableItems = org.canEditAllCiphers
? 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-shared",
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.accountService.activeAccount$.pipe(
switchMap((account) => this.organizationService.organizations$(account?.id)),
),
]).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[],
userId: UserId,
) {
await this.cipherService.shareManyWithServer(
shareableCiphers,
organizationId,
selectedCollectionIds,
userId,
);
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[], userId: UserId) {
if (this.formGroup.controls.collections.value.length > 0) {
await this.cipherService.bulkUpdateCollectionsWithServer(
this.selectedOrgId,
userId,
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,
userId,
cipherIds,
[this.params.activeCollection.id as CollectionId],
true,
);
}
}
private async updateAssignedCollections(cipherView: CipherView, userId: UserId) {
const { collections } = this.formGroup.getRawValue();
cipherView.collectionIds = collections.map((i) => i.id as CollectionId);
const cipher = await this.cipherService.encrypt(cipherView, userId);
if (this.params.isSingleCipherAdmin) {
await this.cipherService.saveCollectionsWithServerAdmin(cipher);
} else {
await this.cipherService.saveCollectionsWithServer(cipher, userId);
}
}
/**
* Only display collections that are read-only and are assigned to the ciphers.
*/
private async setReadOnlyCollectionNames() {
const { availableCollections, ciphers } = this.params;
const organization = await firstValueFrom(
this.organizations$.pipe(map((orgs) => orgs.find((o) => o.id === this.selectedOrgId))),
);
this.readOnlyCollectionNames = availableCollections
.filter((c) => {
return (
c.readOnly &&
ciphers.some((cipher) => cipher.collectionIds.includes(c.id)) &&
!c.canEditItems(organization)
);
})
.map((c) => c.name);
}
}