diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 3fba511ded0d..7fd54f8c4cd7 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -614,6 +614,29 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "archiveItemsPlural": { + "message": "Archive $NUM$ items", + "description": "Title of the archive items dialog when multiple items are selected", + "placeholders": { + "num": { + "content": "$1", + "example": "5" + } + } + }, + "archiveItemsSingular": { + "message": "Archive $NUM$ item", + "description": "Title of the archive items dialog when a single item is selected", + "placeholders": { + "num": { + "content": "$1", + "example": "1" + } + } + }, + "archiveItemsPluralDescription": { + "message": "Once archived, these items will be excluded from search results and autofill suggestions." + }, "itemRestored": { "message": "Item has been restored" }, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 1c6db0197033..4ea55844bce2 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -135,9 +135,21 @@ "attachments": { "message": "Attachments" }, + "attachmentsNeedFix": { + "message": "This item has old file attachments that need to be fixed." + }, "viewItem": { "message": "View item" }, + "viewCollectionWithName": { + "message": "View collection - $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "Collection1" + } + } + }, "viewItemHeaderLogin": { "message": "View Login", "description": "Header for view login item type" @@ -341,6 +353,15 @@ "editItem": { "message": "Edit item" }, + "editItemWithName": { + "message": "Edit item - $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "Google Login" + } + } + }, "emailAddress": { "message": "Email address" }, @@ -809,6 +830,45 @@ "deleteItem": { "message": "Delete item" }, + "deleteItemsCount": { + "message": "Delete $COUNT$ items", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "deleteItemDesc": { + "message": "This item will be sent to your trash. After 30 days there, it will be permanently deleted." + }, + "deleteItemsDesc": { + "message": "These items will be sent to your trash. After 30 days there, they will be permanently deleted." + }, + "deleteItemPermanently": { + "message": "Delete item permanently" + }, + "deleteItemsPermanentlyCount": { + "message": "Delete $COUNT$ items permanently", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "deleteItemPermanentlyDesc": { + "message": "You will not be able to recover this item." + }, + "deleteItemsPermanentlyDesc": { + "message": "You will not be able to recover these items." + }, + "deleteSelection": { + "message": "Delete selection" + }, + "deleteItemsAndCollectionsDesc": { + "message": "The selected items and collections will be permanently deleted." + }, "deleteFolder": { "message": "Delete folder" }, @@ -1812,15 +1872,6 @@ "message": "Copy security code", "description": "Copy credit card security code (CVV)" }, - "copyPrivateKey": { - "message": "Copy private key" - }, - "copyPublicKey": { - "message": "Copy public key" - }, - "copyFingerprint": { - "message": "Copy fingerprint" - }, "cardNumber": { "message": "card number" }, @@ -2420,12 +2471,36 @@ "archivedItemRestored": { "message": "Archived item restored" }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, + "restoredItems": { + "message": "Items restored" + }, "permanentlyDelete": { "message": "Permanently delete" }, + "permanentlyDeletedItems": { + "message": "Items permanently deleted" + }, + "deletedItems": { + "message": "Items sent to trash" + }, + "deletedCollections": { + "message": "Deleted collections" + }, + "collectionDeleted": { + "message": "Collection deleted" + }, + "collectionsDeleted": { + "message": "Collections deleted" + }, + "movedItems": { + "message": "Items moved" + }, "vaultTimeoutLogOutConfirmation": { "message": "Logging out will remove all access to your vault and requires online authentication after the timeout period. Are you sure you want to use this setting?" }, @@ -4650,9 +4725,6 @@ } } }, - "personalItemTransferWarningSingular": { - "message": "1 item will be permanently transferred to the selected organization. You will no longer own this item." - }, "personalItemWithOrgTransferWarningSingular": { "message": "1 item will be permanently transferred to $ORG$. You will no longer own this item.", "placeholders": { @@ -4662,6 +4734,31 @@ } } }, + "personalItemTransferWarningSingular": { + "message": "1 item will be permanently transferred to the selected organization. You will no longer own this item." + }, + "personalItemsTransferWarningPlural": { + "message": "$PERSONAL_ITEMS_COUNT$ items will be permanently transferred to the selected organization. You will no longer own these items.", + "placeholders": { + "personal_items_count": { + "content": "$1", + "example": "2" + } + } + }, + "personalItemsWithOrgTransferWarningPlural": { + "message": "$PERSONAL_ITEMS_COUNT$ items will be permanently transferred to $ORG$. You will no longer own these items.", + "placeholders": { + "personal_items_count": { + "content": "$1", + "example": "2" + }, + "org": { + "content": "$2", + "example": "Organization name" + } + } + }, "successfullyAssignedCollections": { "message": "Successfully assigned collections" }, @@ -4814,6 +4911,47 @@ "unArchiveAndSave": { "message": "Unarchive and save" }, + "archiveItemsPlural": { + "message": "Archive $NUM$ items", + "description": "Title of the archive items dialog when multiple items are selected", + "placeholders": { + "num": { + "content": "$1", + "example": "5" + } + } + }, + "archiveItemsSingular": { + "message": "Archive $NUM$ item", + "description": "Title of the archive items dialog when a single item is selected", + "placeholders": { + "num": { + "content": "$1", + "example": "1" + } + } + }, + "archiveItemsPluralDescription": { + "message": "Once archived, these items will be excluded from search results and autofill suggestions." + }, + "bulkArchiveItems": { + "message": "Items archived" + }, + "bulkUnarchiveItems": { + "message": "Items unarchived" + }, + "moveSelectedItemsDesc": { + "message": "Choose a folder that you would like to add the $COUNT$ selected item(s) to.", + "placeholders": { + "count": { + "content": "$1", + "example": "150" + } + } + }, + "selectFolder": { + "message": "Select folder" + }, "restartPremium": { "message": "Restart Premium" }, @@ -5366,5 +5504,26 @@ }, "cannotSaveItemNoConfirmedOrgs": { "message": "Unable to save item. You must be confirmed to the organization to save items." + }, + "addToFolder": { + "message": "Add to folder" + }, + "deleteCollection": { + "message": "Delete collection" + }, + "deleteCollectionsCount": { + "message": "Delete $COUNT$ collections", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "deleteCollectionDesc": { + "message": "This collection will be permanently deleted." + }, + "deleteCollectionsDesc": { + "message": "These collections will be permanently deleted." } } diff --git a/apps/desktop/src/vault/app/vault-v3/bulk-action-dialogs/assign-collections-desktop-dialog.adapter.ts b/apps/desktop/src/vault/app/vault-v3/bulk-action-dialogs/assign-collections-desktop-dialog.adapter.ts new file mode 100644 index 000000000000..e25f992cad21 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/bulk-action-dialogs/assign-collections-desktop-dialog.adapter.ts @@ -0,0 +1,28 @@ +import { Injectable, inject } from "@angular/core"; +import { lastValueFrom } from "rxjs"; + +import { DialogService } from "@bitwarden/components"; +import { + AssignCollectionsDialogRef, + AssignCollectionsParams, + AssignCollectionsResult, + CollectionAssignmentParams, + CollectionAssignmentResult, +} from "@bitwarden/vault"; + +import { AssignCollectionsDesktopComponent } from "../../vault/assign-collections"; + +@Injectable() +export class AssignCollectionsDesktopDialogAdapter implements AssignCollectionsDialogRef { + private readonly dialogService = inject(DialogService); + + async open(params: AssignCollectionsParams): Promise { + const dialog = AssignCollectionsDesktopComponent.open(this.dialogService, { + data: params as CollectionAssignmentParams, + }); + const result = await lastValueFrom(dialog.closed); + return result === CollectionAssignmentResult.Saved + ? AssignCollectionsResult.Saved + : AssignCollectionsResult.Canceled; + } +} diff --git a/apps/desktop/src/vault/app/vault-v3/bulk-action-dialogs/bulk-delete-dialog-desktop.adapter.ts b/apps/desktop/src/vault/app/vault-v3/bulk-action-dialogs/bulk-delete-dialog-desktop.adapter.ts new file mode 100644 index 000000000000..f075b63dd594 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/bulk-action-dialogs/bulk-delete-dialog-desktop.adapter.ts @@ -0,0 +1,165 @@ +import { Injectable, inject } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService, ToastService, Translation } from "@bitwarden/components"; +import { + BulkDeleteDialogParams, + BulkDeleteDialogRef, + BulkDeleteDialogResult, + BulkDeleteService, +} from "@bitwarden/vault"; + +@Injectable() +export class BulkDeleteDialogDesktopAdapter implements BulkDeleteDialogRef { + private readonly dialogService = inject(DialogService); + private readonly toastService = inject(ToastService); + private readonly i18nService = inject(I18nService); + private readonly bulkDelete = inject(BulkDeleteService); + + async open(params: BulkDeleteDialogParams): Promise { + if (this.hasItems(params) && this.hasCollections(params)) { + return this.confirmAndDeleteMixed(params); + } + if (this.hasCollections(params)) { + return this.confirmAndDeleteCollections(params); + } + if (this.hasItems(params)) { + return this.confirmAndDeleteItems(params); + } + return BulkDeleteDialogResult.Canceled; + } + + private hasItems(params: BulkDeleteDialogParams): boolean { + return (params.cipherIds?.length ?? 0) + (params.unassignedCiphers?.length ?? 0) > 0; + } + + private hasCollections(params: BulkDeleteDialogParams): boolean { + return (params.collections?.length ?? 0) > 0; + } + + private async confirmAndDeleteItems( + params: BulkDeleteDialogParams, + ): Promise { + const cipherIds = params.cipherIds ?? []; + const unassignedCiphers = params.unassignedCiphers ?? []; + const count = cipherIds.length + unassignedCiphers.length; + const permanent = params.permanent ?? false; + + const confirmed = await this.dialogService.openSimpleDialog({ + type: "danger", + title: this.itemDeleteTitle(permanent, count), + content: this.itemDeleteContent(permanent, count), + acceptButtonText: { key: "delete" }, + cancelButtonText: { key: "cancel" }, + }); + + if (!confirmed) { + return BulkDeleteDialogResult.Canceled; + } + + await this.bulkDelete.deleteCiphers({ + cipherIds, + unassignedCiphers, + permanent, + organization: params.organization, + }); + + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t(permanent ? "permanentlyDeletedItems" : "deletedItems"), + }); + + return BulkDeleteDialogResult.Deleted; + } + + private async confirmAndDeleteCollections( + params: BulkDeleteDialogParams, + ): Promise { + const collections = params.collections ?? []; + const count = collections.length; + + const confirmed = await this.dialogService.openSimpleDialog({ + type: "danger", + title: + count === 1 + ? { key: "deleteCollection" } + : { key: "deleteCollectionsCount", placeholders: [count] }, + content: { key: count === 1 ? "deleteCollectionDesc" : "deleteCollectionsDesc" }, + acceptButtonText: { key: "delete" }, + cancelButtonText: { key: "cancel" }, + }); + + if (!confirmed) { + return BulkDeleteDialogResult.Canceled; + } + + await this.bulkDelete.deleteCollections(collections); + + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t(count === 1 ? "collectionDeleted" : "collectionsDeleted"), + }); + + return BulkDeleteDialogResult.Deleted; + } + + private async confirmAndDeleteMixed( + params: BulkDeleteDialogParams, + ): Promise { + const cipherIds = params.cipherIds ?? []; + const unassignedCiphers = params.unassignedCiphers ?? []; + const collections = params.collections ?? []; + + const confirmed = await this.dialogService.openSimpleDialog({ + type: "danger", + title: { key: "deleteSelection" }, + content: { key: "deleteItemsAndCollectionsDesc" }, + acceptButtonText: { key: "delete" }, + cancelButtonText: { key: "cancel" }, + }); + + if (!confirmed) { + return BulkDeleteDialogResult.Canceled; + } + + await Promise.all([ + this.bulkDelete.deleteCiphers({ + cipherIds, + unassignedCiphers, + permanent: false, + organization: params.organization, + }), + this.bulkDelete.deleteCollections(collections), + ]); + + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("deletedItems"), + }); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t( + collections.length === 1 ? "collectionDeleted" : "collectionsDeleted", + ), + }); + + return BulkDeleteDialogResult.Deleted; + } + + private itemDeleteTitle(permanent: boolean, count: number): Translation { + if (count === 1) { + return { key: permanent ? "deleteItemPermanently" : "deleteItem" }; + } + return { + key: permanent ? "deleteItemsPermanentlyCount" : "deleteItemsCount", + placeholders: [count], + }; + } + + private itemDeleteContent(permanent: boolean, count: number): Translation { + if (permanent) { + return { key: count === 1 ? "deleteItemPermanentlyDesc" : "deleteItemsPermanentlyDesc" }; + } + return { key: count === 1 ? "deleteItemDesc" : "deleteItemsDesc" }; + } +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.html b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.html index 43bbe080553e..dda7317f03b8 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.html +++ b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.html @@ -1,3 +1,14 @@ +@if (showBatchBar()) { + + + +}
diff --git a/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.spec.ts b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.spec.ts index b2d57acce534..f74976d459ea 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.spec.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.spec.ts @@ -169,4 +169,67 @@ describe("VaultCipherRowComponent", () => { expect(copyBtn).toBeTruthy(); }); }); + + describe("batch bar checkbox", () => { + beforeEach(() => { + fixture.componentRef.setInput("cipher", createLoginCipher()); + fixture.componentRef.setInput("disabled", false); + }); + + it("does not render when showBatchBar is false", () => { + fixture.componentRef.setInput("showBatchBar", false); + + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('input[type="checkbox"]')).toBeNull(); + }); + + it("renders when showBatchBar is true", () => { + fixture.componentRef.setInput("showBatchBar", true); + + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('input[type="checkbox"]')).not.toBeNull(); + }); + + it("sets aria-label to the cipher name", () => { + fixture.componentRef.setInput("showBatchBar", true); + + fixture.detectChanges(); + + const checkbox = fixture.nativeElement.querySelector( + 'input[type="checkbox"]', + ) as HTMLInputElement; + + expect(checkbox.getAttribute("aria-label")).toBe("Test Login"); + }); + + it("reflects the selected state on the checkbox", () => { + fixture.componentRef.setInput("showBatchBar", true); + fixture.componentRef.setInput("selected", true); + + fixture.detectChanges(); + + const checkbox = fixture.nativeElement.querySelector( + 'input[type="checkbox"]', + ) as HTMLInputElement; + expect(checkbox.checked).toBe(true); + }); + + it("emits checkboxChange when the checkbox changes", () => { + fixture.componentRef.setInput("showBatchBar", true); + + fixture.detectChanges(); + + const spy = jest.spyOn((fixture.componentInstance as any).checkboxChange, "emit"); + + const checkbox = fixture.nativeElement.querySelector( + 'input[type="checkbox"]', + ) as HTMLInputElement; + + checkbox.dispatchEvent(new Event("change")); + + expect(spy).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.ts index 19201d53540d..ea8f9ac652f4 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.ts @@ -15,6 +15,7 @@ import { } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { BitIconButtonComponent, + CheckboxModule, MenuModule, MenuTriggerForDirective, TableModule, @@ -55,6 +56,7 @@ interface CopyFieldConfig { IconComponent, LinkModule, IconModule, + CheckboxModule, ], }) export class VaultCipherRowComponent { @@ -88,6 +90,9 @@ export class VaultCipherRowComponent { * Enforce Org Data Ownership Policy Status */ protected readonly enforceOrgDataOwnershipPolicy = input(); + protected readonly showBatchBar = input(false); + protected readonly selected = input(false); + protected readonly checkboxChange = output(); protected readonly onEvent = output>(); protected CipherType = CipherType; diff --git a/apps/desktop/src/vault/app/vault-v3/vault-items/vault-collection-row.component.html b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-collection-row.component.html index 4de8202076d1..7b89f59286c9 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault-items/vault-collection-row.component.html +++ b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-collection-row.component.html @@ -1,3 +1,14 @@ +@if (showBatchBar()) { + + + +}
} - +
+ @if (showBatchBar()) { + + + + } } @if (item.cipher) { @@ -73,6 +93,9 @@ (onEvent)="event($event)" [userCanArchive]="userCanArchive()" [enforceOrgDataOwnershipPolicy]="enforceOrgDataOwnershipPolicy()" + [showBatchBar]="showBatchBar()" + [selected]="selection.isSelected(item)" + (checkboxChange)="selection.toggle(item)" > } diff --git a/apps/desktop/src/vault/app/vault-v3/vault-list.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-list.component.ts index 610e799543ef..a200ac06267e 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault-list.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault-list.component.ts @@ -1,14 +1,18 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { SelectionModel } from "@angular/cdk/collections"; import { ScrollingModule } from "@angular/cdk/scrolling"; -import { AsyncPipe } from "@angular/common"; +import { AsyncPipe, NgClass } from "@angular/common"; import { Component, input, output, effect, inject, computed } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { Observable, of, switchMap } from "rxjs"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; +import { combineLatest, Observable, of, switchMap } from "rxjs"; +import { map } from "rxjs/operators"; import { BitSvg } from "@bitwarden/assets/svg"; import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; 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 { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; @@ -29,9 +33,10 @@ import { IconButtonModule, NoItemsModule, CalloutComponent, + CheckboxModule, } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; -import { NewCipherMenuComponent, VaultItem } from "@bitwarden/vault"; +import { NewCipherMenuComponent, VaultBatchBarService, VaultItem } from "@bitwarden/vault"; import { VaultCipherRowComponent } from "./vault-items/vault-cipher-row.component"; import { VaultCollectionRowComponent } from "./vault-items/vault-collection-row.component"; @@ -56,6 +61,7 @@ type EmptyStateItem = { TableModule, I18nPipe, AsyncPipe, + NgClass, MenuModule, ButtonModule, IconButtonModule, @@ -64,6 +70,7 @@ type EmptyStateItem = { NoItemsModule, NewCipherMenuComponent, CalloutComponent, + CheckboxModule, ], }) export class VaultListComponent { @@ -92,10 +99,44 @@ export class VaultListComponent { protected cipherAuthorizationService = inject(CipherAuthorizationService); protected restrictedItemTypesService = inject(RestrictedItemTypesService); private premiumUpgradePromptService = inject(PremiumUpgradePromptService); + private configService = inject(ConfigService); + private batchBarService = inject>(VaultBatchBarService, { + optional: true, + }); + + protected readonly showBatchBar = toSignal( + combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.PM37785_VaultBatchBar), + this.configService.getFeatureFlag$(FeatureFlag.PM37785_DesktopVaultBatchBar), + ]).pipe(map(([batchBarFlag, desktopBatchBarFlag]) => batchBarFlag && desktopBatchBarFlag)), + { initialValue: false }, + ); + + protected readonly barVisible = computed( + () => this.showBatchBar() && (this.batchBarService?.selectedCount() ?? 0) > 0, + ); protected dataSource = new TableDataSource>(); private restrictedTypes: RestrictedCipherType[] = []; + get selection(): SelectionModel> { + return this.batchBarService?.selection; + } + + get isAllSelected(): boolean { + const cipherItems = this.dataSource.data?.filter((i) => i.cipher) ?? []; + return cipherItems.length > 0 && cipherItems.every((i) => this.selection.isSelected(i)); + } + + protected toggleAll(): void { + if (this.isAllSelected) { + this.selection.clear(); + } else { + const cipherItems = this.dataSource.data?.filter((i) => i.cipher) ?? []; + this.selection.select(...cipherItems); + } + } + constructor() { this.restrictedItemTypesService.restricted$.pipe(takeUntilDestroyed()).subscribe((types) => { this.restrictedTypes = types; diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.html b/apps/desktop/src/vault/app/vault-v3/vault.component.html index 4e03d5ec2006..ebe329f2c31f 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.html +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.html @@ -43,4 +43,7 @@ (onAddFolder)="addFolder()" (onAddItemDialog)="openAddItemDialog()" /> + @if (vaultBatchBarFeatureFlag()) { + + }
diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.ts b/apps/desktop/src/vault/app/vault-v3/vault.component.ts index c83a020e563f..ff34a1639de7 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts @@ -106,12 +106,18 @@ import { All, VaultItemsTransferService, NewCipherMenuComponent, + ASSIGN_COLLECTIONS_DIALOG, + BULK_DELETE_DIALOG, + VaultBatchActionComponent, + VaultBatchBarService, VaultOrganizationUserNotificationsComponent, } from "@bitwarden/vault"; import { DesktopHeaderComponent } from "../../../app/layout/header/desktop-header.component"; import { AssignCollectionsDesktopComponent } from "../vault/assign-collections"; +import { AssignCollectionsDesktopDialogAdapter } from "./bulk-action-dialogs/assign-collections-desktop-dialog.adapter"; +import { BulkDeleteDialogDesktopAdapter } from "./bulk-action-dialogs/bulk-delete-dialog-desktop.adapter"; import { VaultItemEvent } from "./vault-items/vault-item-event"; import { VaultListComponent } from "./vault-list.component"; @@ -139,11 +145,15 @@ type EmptyStateMap = Record; NewCipherMenuComponent, SearchModule, FormsModule, + VaultBatchActionComponent, VaultOrganizationUserNotificationsComponent, ], providers: [ { provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService }, { provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }, + VaultBatchBarService, + { provide: ASSIGN_COLLECTIONS_DIALOG, useClass: AssignCollectionsDesktopDialogAdapter }, + { provide: BULK_DELETE_DIALOG, useClass: BulkDeleteDialogDesktopAdapter }, ], }) export class VaultComponent implements OnInit, OnDestroy { @@ -182,6 +192,7 @@ export class VaultComponent implements OnInit, OnDestr private destroyRef = inject(DestroyRef); private cipherFormConfigService = inject(CipherFormConfigService); + private vaultBatchBarService = inject(VaultBatchBarService, { optional: true }); private activeDrawerRef?: DialogRef; protected activeFilter: VaultFilter = new VaultFilter(); @@ -212,6 +223,14 @@ export class VaultComponent implements OnInit, OnDestr { initialValue: false }, ); + protected readonly vaultBatchBarFeatureFlag = toSignal( + combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.PM37785_VaultBatchBar), + this.configService.getFeatureFlag$(FeatureFlag.PM37785_DesktopVaultBatchBar), + ]).pipe(map(([batchBarFlag, desktopBatchBarFlag]) => batchBarFlag && desktopBatchBarFlag)), + { initialValue: false }, + ); + private organizations$: Observable = this.accountService.activeAccount$.pipe( map((a) => a?.id), filterOutNullish(), @@ -564,6 +583,16 @@ export class VaultComponent implements OnInit, OnDestr this.changeDetectorRef.markForCheck(); }); + combineLatest([allCollections$, ciphers$.pipe(map((c) => c.length > 0))]) + .pipe(takeUntil(this.destroy$)) + .subscribe(([allCollections, hasCiphers]) => + this.vaultBatchBarService?.setConfig({ isOrgVault: false, allCollections, hasCiphers }), + ); + + this.vaultBatchBarService?.completed$ + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.refresh()); + void this.vaultItemTransferService.enforceOrganizationDataOwnership(this.activeUserId); } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 89697ecf228d..7edc2bdfc45c 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -13264,9 +13264,32 @@ "message": "Archive item", "description": "Verb" }, + "archiveItemsPlural": { + "message": "Archive $NUM$ items", + "description": "Title of the archive items dialog when multiple items are selected", + "placeholders": { + "num": { + "content": "$1", + "example": "5" + } + } + }, + "archiveItemsSingular": { + "message": "Archive $NUM$ item", + "description": "Title of the archive items dialog when a single item is selected", + "placeholders": { + "num": { + "content": "$1", + "example": "1" + } + } + }, "archiveItemDialogContent": { "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, + "archiveItemsPluralDescription": { + "message": "Once archived, these items will be excluded from search results and autofill suggestions." + }, "archiveBulkItems": { "message": "Archive items", "description": "Verb" diff --git a/libs/vault/src/services/vault-batch-bar.service.ts b/libs/vault/src/services/vault-batch-bar.service.ts index 04718984cc37..6bb71b976d2c 100644 --- a/libs/vault/src/services/vault-batch-bar.service.ts +++ b/libs/vault/src/services/vault-batch-bar.service.ts @@ -391,9 +391,15 @@ export class VaultBatchBarService { return; } + const titleKey = ciphers.length === 1 ? "archiveItemsSingular" : "archiveItemsPlural"; + const contentKey = + ciphers.length === 1 ? "archiveItemDialogContent" : "archiveItemsPluralDescription"; + const successKey = ciphers.length === 1 ? "itemArchiveToast" : "bulkArchiveItems"; + const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "archiveBulkItems" }, - content: { key: "archiveBulkItemsConfirmDesc" }, + title: { key: titleKey, placeholders: [ciphers.length] }, + content: { key: contentKey }, + acceptButtonText: { key: "archiveVerb" }, type: "info", }); @@ -407,7 +413,7 @@ export class VaultBatchBarService { await this.cipherArchiveService.archiveWithServer(cipherIds, userId); this.toastService.showToast({ variant: "success", - message: this.i18nService.t("bulkArchiveItems"), + message: this.i18nService.t(successKey), }); this.selection.clear(); this._completed$.next();