[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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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();
}
}

View File

@ -1060,7 +1060,7 @@
"message": "Are you sure you want to continue?"
},
"moveSelectedItemsDesc": {
"message": "Choose a folder that you would like to move the $COUNT$ selected item(s) to.",
"message": "Choose a folder that you would like to add the $COUNT$ selected item(s) to.",
"placeholders": {
"count": {
"content": "$1",
@ -7883,7 +7883,7 @@
"message": "Assign to these collections"
},
"bulkCollectionAssignmentDialogDescription": {
"message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items."
"message": "Only organization members with access to these collections will be able to see the items."
},
"selectCollectionsToAssign": {
"message": "Select collections to assign"
@ -8547,5 +8547,33 @@
},
"licenseAndBillingManagementDesc": {
"message": "After making updates in the Bitwarden cloud server, upload your license file to apply the most recent changes."
},
"addToFolder": {
"message": "Add to folder"
},
"selectFolder": {
"message": "Select folder"
},
"personalItemsTransferWarning": {
"message": "$PERSONAL_ITEMS_COUNT$ will be permanently transferred to the selected organization. You will no longer own these items.",
"placeholders": {
"personal_items_count": {
"content": "$1",
"example": "2 items"
}
}
},
"personalItemsWithOrgTransferWarning": {
"message": "$PERSONAL_ITEMS_COUNT$ will be permanently transferred to $ORG$. You will no longer own these items.",
"placeholders": {
"personal_items_count": {
"content": "$1",
"example": "2 items"
},
"org": {
"content": "$2",
"example": "Organization name"
}
}
}
}

View File

@ -46,6 +46,7 @@ import { StopClickDirective } from "./directives/stop-click.directive";
import { StopPropDirective } from "./directives/stop-prop.directive";
import { TrueFalseValueDirective } from "./directives/true-false-value.directive";
import { CreditCardNumberPipe } from "./pipes/credit-card-number.pipe";
import { PluralizePipe } from "./pipes/pluralize.pipe";
import { SearchCiphersPipe } from "./pipes/search-ciphers.pipe";
import { SearchPipe } from "./pipes/search.pipe";
import { UserNamePipe } from "./pipes/user-name.pipe";
@ -162,6 +163,7 @@ import { IconComponent } from "./vault/components/icon.component";
UserNamePipe,
UserTypePipe,
FingerprintPipe,
PluralizePipe,
],
})
export class JslibModule {}

View File

@ -0,0 +1,11 @@
import { Pipe, PipeTransform } from "@angular/core";
@Pipe({
name: "pluralize",
standalone: true,
})
export class PluralizePipe implements PipeTransform {
transform(count: number, singular: string, plural: string): string {
return `${count} ${count === 1 ? singular : plural}`;
}
}

View File

@ -20,6 +20,7 @@ import { DialogService } from "@bitwarden/components";
@Directive()
export class AttachmentsComponent implements OnInit {
@Input() cipherId: string;
@Input() viewOnly: boolean;
@Output() onUploadedAttachment = new EventEmitter();
@Output() onDeletedAttachment = new EventEmitter();
@Output() onReuploadedAttachment = new EventEmitter();

View File

@ -23,6 +23,7 @@ export enum FeatureFlag {
EnableTimeThreshold = "PM-5864-dollar-threshold",
GroupsComponentRefactor = "groups-component-refactor",
ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner",
VaultBulkManagementAction = "vault-bulk-management-action",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@ -56,6 +57,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.EnableTimeThreshold]: FALSE,
[FeatureFlag.GroupsComponentRefactor]: FALSE,
[FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE,
[FeatureFlag.VaultBulkManagementAction]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@ -0,0 +1,42 @@
<form [formGroup]="formGroup" [bitSubmit]="submit" id="assign_collections_form">
<p>{{ "bulkCollectionAssignmentDialogDescription" | i18n }}</p>
<ul class="tw-list-disc tw-pl-5 tw-space-y-2">
<li *ngIf="readonlyItemCount > 0">
<p>
{{ "bulkCollectionAssignmentWarning" | i18n: totalItemCount : readonlyItemCount }}
</p>
</li>
<li *ngIf="personalItemsCount > 0">
<p>
{{ transferWarningText(orgName, personalItemsCount) }}
</p>
</li>
</ul>
<div class="tw-flex" *ngIf="showOrgSelector">
<bit-form-field class="tw-grow">
<bit-label>{{ "moveToOrganization" | i18n }}</bit-label>
<bit-select formControlName="selectedOrg">
<bit-option
*ngFor="let org of organizations$ | async"
icon="bwi-business"
[value]="org.id"
[label]="org.name"
>
</bit-option>
</bit-select>
</bit-form-field>
</div>
<div class="tw-flex">
<bit-form-field class="tw-grow">
<bit-label>{{ "selectCollectionsToAssign" | i18n }}</bit-label>
<bit-multi-select
class="tw-w-full"
formControlName="collections"
[baseItems]="availableCollections"
></bit-multi-select>
</bit-form-field>
</div>
</form>

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

View File

@ -4,3 +4,8 @@ export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directi
export * from "./cipher-view";
export * from "./cipher-form";
export {
AssignCollectionsComponent,
CollectionAssignmentParams,
CollectionAssignmentResult,
} from "./components/assign-collections.component";