diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index c18a645c110..231637d7af3 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1437,6 +1437,15 @@ "collections": { "message": "Collections" }, + "nCollections": { + "message": "$COUNT$ collections", + "placeholders": { + "count": { + "content": "$1", + "example": "2" + } + } + }, "favorites": { "message": "Favorites" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts index c00e585e739..9a4670bb4c8 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts @@ -3,12 +3,12 @@ import { Component } from "@angular/core"; import { combineLatest, map, Observable } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { IconButtonModule, SectionComponent, TypographyModule } from "@bitwarden/components"; import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component"; import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; +import { PopupCipherView } from "../../../views/popup-cipher.view"; import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component"; @Component({ @@ -30,7 +30,7 @@ export class AutofillVaultListItemsComponent { * The list of ciphers that can be used to autofill the current page. * @protected */ - protected autofillCiphers$: Observable = + protected autofillCiphers$: Observable = this.vaultPopupItemsService.autoFillCiphers$; /** diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index 91c1f521632..74ee1af1ff5 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -21,6 +21,12 @@ > {{ cipher.name }} + {{ cipher.subTitle }} diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts index 095d0155c67..311619fa0ff 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts @@ -3,7 +3,7 @@ import { booleanAttribute, Component, EventEmitter, Input, Output } from "@angul import { RouterLink } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { BadgeModule, ButtonModule, @@ -14,6 +14,7 @@ import { } from "@bitwarden/components"; import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component"; +import { PopupCipherView } from "../../../views/popup-cipher.view"; import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component"; import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options.component"; @@ -41,7 +42,7 @@ export class VaultListItemsContainerComponent { * The list of ciphers to display. */ @Input() - ciphers: CipherView[]; + ciphers: PopupCipherView[] = []; /** * Title for the vault list item section. @@ -66,4 +67,18 @@ export class VaultListItemsContainerComponent { */ @Input({ transform: booleanAttribute }) showAutofillButton: boolean; + + /** + * The tooltip text for the organization icon for ciphers that belong to an organization. + * @param cipher + */ + orgIconTooltip(cipher: PopupCipherView) { + if (cipher.collectionIds.length > 1) { + return this.i18nService.t("nCollections", cipher.collectionIds.length); + } + + return cipher.collections[0]?.name; + } + + constructor(private i18nService: I18nService) {} } diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts index aa8b7c74d10..6a7cbbfc1a9 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts @@ -4,11 +4,15 @@ import { BehaviorSubject } from "rxjs"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductType } from "@bitwarden/common/enums"; import { CipherId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { BrowserApi } from "../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; @@ -22,11 +26,15 @@ describe("VaultPopupItemsService", () => { let allCiphers: Record; let autoFillCiphers: CipherView[]; + let mockOrg: Organization; + let mockCollections: CollectionView[]; + const cipherServiceMock = mock(); const vaultSettingsServiceMock = mock(); const organizationServiceMock = mock(); const vaultPopupListFiltersServiceMock = mock(); const searchService = mock(); + const collectionService = mock(); beforeEach(() => { allCiphers = cipherFactory(10); @@ -43,8 +51,10 @@ describe("VaultPopupItemsService", () => { cipherServiceMock.getAllDecrypted.mockResolvedValue(cipherList); cipherServiceMock.ciphers$ = new BehaviorSubject(null).asObservable(); - searchService.searchCiphers.mockImplementation(async () => cipherList); - cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => autoFillCiphers); + searchService.searchCiphers.mockImplementation(async (_, __, ciphers) => ciphers); + cipherServiceMock.filterCiphersForUrl.mockImplementation(async (ciphers) => + ciphers.filter((c) => ["0", "1"].includes(c.id)), + ); vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false); vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false); @@ -63,6 +73,20 @@ describe("VaultPopupItemsService", () => { .spyOn(BrowserApi, "getTabFromCurrentWindow") .mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab); + mockOrg = { + id: "org1", + name: "Organization 1", + planProductType: ProductType.Enterprise, + } as Organization; + + mockCollections = [ + { id: "col1", name: "Collection 1" } as CollectionView, + { id: "col2", name: "Collection 2" } as CollectionView, + ]; + + organizationServiceMock.organizations$ = new BehaviorSubject([mockOrg]); + collectionService.decryptedCollections$ = new BehaviorSubject(mockCollections); + testBed = TestBed.configureTestingModule({ providers: [ { provide: CipherService, useValue: cipherServiceMock }, @@ -70,17 +94,35 @@ describe("VaultPopupItemsService", () => { { provide: SearchService, useValue: searchService }, { provide: OrganizationService, useValue: organizationServiceMock }, { provide: VaultPopupListFiltersService, useValue: vaultPopupListFiltersServiceMock }, + { provide: CollectionService, useValue: collectionService }, ], }); service = testBed.inject(VaultPopupItemsService); }); + afterEach(() => { + jest.clearAllMocks(); + }); + it("should be created", () => { service = testBed.inject(VaultPopupItemsService); expect(service).toBeTruthy(); }); + it("should merge cipher views with collections and organization", (done) => { + const cipherList = Object.values(allCiphers); + cipherList[0].organizationId = "org1"; + cipherList[0].collectionIds = ["col1", "col2"]; + + service.autoFillCiphers$.subscribe((ciphers) => { + expect(ciphers[0].organization).toEqual(mockOrg); + expect(ciphers[0].collections).toContain(mockCollections[0]); + expect(ciphers[0].collections).toContain(mockCollections[1]); + done(); + }); + }); + describe("autoFillCiphers$", () => { it("should return empty array if there is no current tab", (done) => { jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null); @@ -144,19 +186,18 @@ describe("VaultPopupItemsService", () => { }); it("should filter autoFillCiphers$ down to search term", (done) => { - const cipherList = Object.values(allCiphers); const searchText = "Login"; - searchService.searchCiphers.mockImplementation(async () => { - return cipherList.filter((cipher) => { + searchService.searchCiphers.mockImplementation(async (q, _, ciphers) => { + return ciphers.filter((cipher) => { return cipher.name.includes(searchText); }); }); - // there is only 1 Login returned for filteredCiphers. but two results expected because of other autofill types + // there is only 1 Login returned for filteredCiphers. service.autoFillCiphers$.subscribe((ciphers) => { expect(ciphers[0].name.includes(searchText)).toBe(true); - expect(ciphers.length).toBe(2); + expect(ciphers.length).toBe(1); done(); }); }); diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index 3fe509dc452..eacb8e013ef 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -16,7 +16,9 @@ import { import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { 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 { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -24,6 +26,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BrowserApi } from "../../../platform/browser/browser-api"; import { runInsideAngular } from "../../../platform/browser/run-inside-angular.operator"; import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; +import { PopupCipherView } from "../views/popup-cipher.view"; import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; @@ -74,14 +77,33 @@ export class VaultPopupItemsService { * Observable that contains the list of all decrypted ciphers. * @private */ - private _cipherList$: Observable = this.cipherService.ciphers$.pipe( + private _cipherList$: Observable = this.cipherService.ciphers$.pipe( runInsideAngular(inject(NgZone)), // Workaround to ensure cipher$ state provider emissions are run inside Angular switchMap(() => Utils.asyncToObservable(() => this.cipherService.getAllDecrypted())), map((ciphers) => Object.values(ciphers)), + switchMap((ciphers) => + combineLatest([ + this.organizationService.organizations$, + this.collectionService.decryptedCollections$, + ]).pipe( + map(([organizations, collections]) => { + const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org])); + const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col])); + return ciphers.map( + (cipher) => + new PopupCipherView( + cipher, + cipher.collectionIds?.map((colId) => collectionMap[colId as CollectionId]), + orgMap[cipher.organizationId as OrganizationId], + ), + ); + }), + ), + ), shareReplay({ refCount: true, bufferSize: 1 }), ); - private _filteredCipherList$: Observable = combineLatest([ + private _filteredCipherList$: Observable = combineLatest([ this._cipherList$, this.searchText$, this.vaultPopupListFiltersService.filterFunction$, @@ -90,8 +112,9 @@ export class VaultPopupItemsService { filterFunction(ciphers), searchText, ]), - switchMap(([ciphers, searchText]) => - this.searchService.searchCiphers(searchText, null, ciphers), + switchMap( + ([ciphers, searchText]) => + this.searchService.searchCiphers(searchText, null, ciphers) as Promise, ), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -102,7 +125,7 @@ export class VaultPopupItemsService { * * See {@link refreshCurrentTab} to trigger re-evaluation of the current tab. */ - autoFillCiphers$: Observable = combineLatest([ + autoFillCiphers$: Observable = combineLatest([ this._filteredCipherList$, this._otherAutoFillTypes$, this._currentAutofillTab$, @@ -121,7 +144,7 @@ export class VaultPopupItemsService { * List of favorite ciphers that are not currently suggested for autofill. * Ciphers are sorted by last used date, then by name. */ - favoriteCiphers$: Observable = combineLatest([ + favoriteCiphers$: Observable = combineLatest([ this.autoFillCiphers$, this._filteredCipherList$, ]).pipe( @@ -138,7 +161,7 @@ export class VaultPopupItemsService { * List of all remaining ciphers that are not currently suggested for autofill or marked as favorite. * Ciphers are sorted by name. */ - remainingCiphers$: Observable = combineLatest([ + remainingCiphers$: Observable = combineLatest([ this.autoFillCiphers$, this.favoriteCiphers$, this._filteredCipherList$, @@ -208,6 +231,7 @@ export class VaultPopupItemsService { private vaultPopupListFiltersService: VaultPopupListFiltersService, private organizationService: OrganizationService, private searchService: SearchService, + private collectionService: CollectionService, ) {} /** diff --git a/apps/browser/src/vault/popup/views/popup-cipher.view.ts b/apps/browser/src/vault/popup/views/popup-cipher.view.ts new file mode 100644 index 00000000000..4707eb9eb0f --- /dev/null +++ b/apps/browser/src/vault/popup/views/popup-cipher.view.ts @@ -0,0 +1,41 @@ +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductType } from "@bitwarden/common/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; + +/** + * Extended cipher view for the popup. Includes the associated collections and organization + * if applicable. + */ +export class PopupCipherView extends CipherView { + collections?: CollectionView[]; + organization?: Organization; + + constructor( + cipher: CipherView, + collections: CollectionView[] = null, + organization: Organization = null, + ) { + super(); + Object.assign(this, cipher); + this.collections = collections; + this.organization = organization; + } + + /** + * Get the bwi icon for the cipher according to the organization type. + */ + get orgIcon(): "bwi-family" | "bwi-business" | null { + switch (this.organization?.planProductType) { + case ProductType.Free: + case ProductType.Families: + return "bwi-family"; + case ProductType.Teams: + case ProductType.Enterprise: + case ProductType.TeamsStarter: + return "bwi-business"; + default: + return null; + } + } +} diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 9b9841169fa..ff99c8b1d84 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1,4 +1,4 @@ -import { firstValueFrom, map, Observable, share, skipWhile, switchMap } from "rxjs"; +import { firstValueFrom, map, Observable, skipWhile, switchMap } from "rxjs"; import { SemVer } from "semver"; import { ApiService } from "../../abstractions/api.service"; @@ -125,13 +125,7 @@ export class CipherService implements CipherServiceAbstraction { switchMap(() => this.encryptedCiphersState.state$), map((ciphers) => ciphers ?? {}), ); - this.cipherViews$ = this.decryptedCiphersState.state$.pipe( - map((views) => views ?? {}), - - share({ - resetOnRefCountZero: true, - }), - ); + this.cipherViews$ = this.decryptedCiphersState.state$.pipe(map((views) => views ?? {})); this.addEditCipherInfo$ = this.addEditCipherInfoState.state$; }