From 3d0e0d261e84843100a71ccb1461210e478f0c55 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Tue, 21 May 2024 14:05:02 -0700 Subject: [PATCH] [PM-6825] Browser Refresh - Initial List Items (#9199) * [PM-6825] Add temporary vault page header * [PM-6825] Expose cipherViews$ observable * [PM-6825] Refactor getAllDecryptedForUrl to expose filter functionality for reuse * [PM-6825] Introduce VaultPopupItemsService * [PM-6825] Introduce initial VaultListItem and VaultListItemsContainer components * [PM-6825] Add VaultListItems to VaultV2 component * [PM-6825] Introduce autofill-vault-list-items.component to encapsulate autofill logic * [PM-6825] Add temporary Vault icon * [PM-6825] Add empty and no results states to Vault tab * [PM-6825] Add unit tests for vault popup items service * [PM-6825] Negate noFilteredResults placeholder * [PM-6825] Cleanup new Vault components * [PM-6825] Move new components into its own module * [PM-6825] Fix missing button type * [PM-6825] Add booleanAttribute to showAutofill input * [PM-6825] Replace empty refresh BehaviorSubject with Subject * [PM-6825] Combine *ngIfs for vault list items container * [PM-6825] Use popup-section-header component * [PM-6825] Use small variant for icon buttons * [PM-6825] Use anchor tag for vault items * [PM-6825] Consolidate vault-list-items-container to include list item component functionality directly * [PM-6825] Add Tailwind classes to new Vault icon * [PM-6825] Remove temporary header comment * [PM-6825] Fix auto fill suggestion font size and padding * [PM-6825] Use tailwind for vault icon styling * [PM-6825] Add libs/angular to tailwind.config content * [PM-6825] Cleanup missing i18n * [PM-6825] Make VaultV2 standalone and cleanup Browser App module * [PM-6825] Use explicit type annotation * [PM-6825] Use property binding instead of interpolation --- apps/browser/src/_locales/en/messages.json | 35 +++ apps/browser/src/popup/app.module.ts | 2 - .../autofill-vault-list-items.component.html | 14 + .../autofill-vault-list-items.component.ts | 51 ++++ .../vault/popup/components/vault-v2/index.ts | 2 + .../vault-list-items-container.component.html | 35 +++ .../vault-list-items-container.component.ts | 44 ++++ .../components/vault/vault-v2.component.html | 36 +++ .../components/vault/vault-v2.component.ts | 44 +++- .../vault-popup-items.service.spec.ts | 248 ++++++++++++++++++ .../services/vault-popup-items.service.ts | 186 +++++++++++++ apps/browser/tailwind.config.js | 6 +- apps/desktop/src/scss/misc.scss | 11 + apps/desktop/tailwind.config.js | 6 +- apps/web/tailwind.config.js | 1 + .../src/vault/components/icon.component.html | 3 +- .../src/vault/abstractions/cipher.service.ts | 7 + .../src/vault/services/cipher.service.ts | 21 +- libs/components/src/icon/icons/index.ts | 1 + libs/components/src/icon/icons/vault.ts | 17 ++ libs/components/tailwind.config.base.js | 1 + 21 files changed, 759 insertions(+), 12 deletions(-) create mode 100644 apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html create mode 100644 apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts create mode 100644 apps/browser/src/vault/popup/components/vault-v2/index.ts create mode 100644 apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html create mode 100644 apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts create mode 100644 apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts create mode 100644 apps/browser/src/vault/popup/services/vault-popup-items.service.ts create mode 100644 libs/components/src/icon/icons/vault.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 28ad7b2d42..553055439c 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3137,6 +3137,41 @@ "message": "to make them visible.", "description": "This will be part of a larger sentence, which will read like so: Assign these items to a collection from the Admin Console to make them visible." }, + "autofillSuggestions": { + "message": "Auto-fill suggestions" + }, + "autofillSuggestionsTip": { + "message": "Save a login item for this site to auto-fill" + }, + "yourVaultIsEmpty": { + "message": "Your vault is empty" + }, + "noItemsMatchSearch": { + "message": "No items match your search" + }, + "clearFiltersOrTryAnother": { + "message": "Clear filters or try another search term" + }, + "copyInfo": { + "message": "Copy info, $ITEMNAME$", + "description": "Aria label for a button that opens a menu with options to copy information from an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + } + } + }, + "moreOptions": { + "message": "More options, $ITEMNAME$", + "description": "Aria label for a button that opens a menu with more options for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + } + } + }, "adminConsole": { "message": "Admin Console" }, diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index 2f69d8253f..cbe7025e58 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -72,7 +72,6 @@ import { ShareComponent } from "../vault/popup/components/vault/share.component" import { VaultFilterComponent } from "../vault/popup/components/vault/vault-filter.component"; import { VaultItemsComponent } from "../vault/popup/components/vault/vault-items.component"; import { VaultSelectComponent } from "../vault/popup/components/vault/vault-select.component"; -import { VaultV2Component } from "../vault/popup/components/vault/vault-v2.component"; import { ViewCustomFieldsComponent } from "../vault/popup/components/vault/view-custom-fields.component"; import { ViewComponent } from "../vault/popup/components/vault/view.component"; import { AppearanceComponent } from "../vault/popup/settings/appearance.component"; @@ -190,7 +189,6 @@ import "../platform/popup/locales"; AutofillComponent, EnvironmentSelectorComponent, AccountSwitcherComponent, - VaultV2Component, ], providers: [CurrencyPipe, DatePipe], bootstrap: [AppComponent], diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html new file mode 100644 index 0000000000..d1735a8efe --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html @@ -0,0 +1,14 @@ + + + + + {{ + "autofillSuggestionsTip" | i18n + }} + + 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 new file mode 100644 index 0000000000..99662393bd --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts @@ -0,0 +1,51 @@ +import { CommonModule } from "@angular/common"; +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 { SectionComponent, TypographyModule } from "@bitwarden/components"; + +import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component"; +import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; +import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component"; + +@Component({ + standalone: true, + imports: [ + CommonModule, + SectionComponent, + TypographyModule, + VaultListItemsContainerComponent, + JslibModule, + PopupSectionHeaderComponent, + ], + selector: "app-autofill-vault-list-items", + templateUrl: "autofill-vault-list-items.component.html", +}) +export class AutofillVaultListItemsComponent { + /** + * The list of ciphers that can be used to autofill the current page. + * @protected + */ + protected autofillCiphers$: Observable = + this.vaultPopupItemsService.autoFillCiphers$; + + /** + * Observable that determines whether the empty autofill tip should be shown. + * The tip is shown when there are no ciphers to autofill, no filter is applied, and autofill is allowed in + * the current context (e.g. not in a popout). + * @protected + */ + protected showEmptyAutofillTip$: Observable = combineLatest([ + this.vaultPopupItemsService.hasFilterApplied$, + this.autofillCiphers$, + this.vaultPopupItemsService.autofillAllowed$, + ]).pipe( + map(([hasFilter, ciphers, canAutoFill]) => !hasFilter && canAutoFill && ciphers.length === 0), + ); + + constructor(private vaultPopupItemsService: VaultPopupItemsService) { + // TODO: Migrate logic to show Autofill policy toast PM-8144 + } +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/index.ts b/apps/browser/src/vault/popup/components/vault-v2/index.ts new file mode 100644 index 0000000000..13618d007d --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/index.ts @@ -0,0 +1,2 @@ +export * from "./vault-list-items-container/vault-list-items-container.component"; +export * from "./autofill-vault-list-items/autofill-vault-list-items.component"; 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 new file mode 100644 index 0000000000..55463a85f8 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -0,0 +1,35 @@ + + + {{ ciphers.length }} + + + + + + {{ 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 new file mode 100644 index 0000000000..27ee0a2cc0 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts @@ -0,0 +1,44 @@ +import { CommonModule } from "@angular/common"; +import { booleanAttribute, Component, Input } from "@angular/core"; +import { RouterLink } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + BadgeModule, + ButtonModule, + IconButtonModule, + ItemModule, + SectionComponent, + TypographyModule, +} from "@bitwarden/components"; + +import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component"; + +@Component({ + imports: [ + CommonModule, + ItemModule, + ButtonModule, + BadgeModule, + IconButtonModule, + SectionComponent, + TypographyModule, + JslibModule, + PopupSectionHeaderComponent, + RouterLink, + ], + selector: "app-vault-list-items-container", + templateUrl: "vault-list-items-container.component.html", + standalone: true, +}) +export class VaultListItemsContainerComponent { + @Input() + ciphers: CipherView[]; + + @Input() + title: string; + + @Input({ transform: booleanAttribute }) + showAutoFill: boolean; +} diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html index c36d2d2db9..a9814f892e 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html @@ -10,4 +10,40 @@ + +
+ + {{ "yourVaultIsEmpty" | i18n }} + {{ "autofillSuggestionsTip" | i18n }} + + +
+ + + + +
+ + {{ "noItemsMatchSearch" | i18n }} + {{ "clearFiltersOrTryAnother" | i18n }} + +
+ + + + + + +
diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts index 332e5d1a4e..7e0be4607b 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts @@ -1,13 +1,55 @@ +import { CommonModule } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; +import { Router, RouterLink } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ButtonModule, Icons, NoItemsModule } from "@bitwarden/components"; + +import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component"; +import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; +import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component"; +import { VaultPopupItemsService } from "../../services/vault-popup-items.service"; +import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } from "../vault-v2"; @Component({ selector: "app-vault", templateUrl: "vault-v2.component.html", + standalone: true, + imports: [ + PopupPageComponent, + PopupHeaderComponent, + PopOutComponent, + CurrentAccountComponent, + NoItemsModule, + JslibModule, + CommonModule, + AutofillVaultListItemsComponent, + VaultListItemsContainerComponent, + ButtonModule, + RouterLink, + ], }) export class VaultV2Component implements OnInit, OnDestroy { - constructor() {} + protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$; + protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$; + + protected showEmptyState$ = this.vaultPopupItemsService.emptyVault$; + protected showNoResultsState$ = this.vaultPopupItemsService.noFilteredResults$; + + protected vaultIcon = Icons.Vault; + + constructor( + private vaultPopupItemsService: VaultPopupItemsService, + private router: Router, + ) {} ngOnInit(): void {} ngOnDestroy(): void {} + + addCipher() { + // TODO: Add currently filtered organization to query params if available + void this.router.navigate(["/add-cipher"], {}); + } } 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 new file mode 100644 index 0000000000..1830d4be35 --- /dev/null +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts @@ -0,0 +1,248 @@ +import { mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.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 { BrowserApi } from "../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; + +import { VaultPopupItemsService } from "./vault-popup-items.service"; + +describe("VaultPopupItemsService", () => { + let service: VaultPopupItemsService; + let allCiphers: Record; + let autoFillCiphers: CipherView[]; + + const cipherServiceMock = mock(); + const vaultSettingsServiceMock = mock(); + + beforeEach(() => { + allCiphers = cipherFactory(10); + const cipherList = Object.values(allCiphers); + // First 2 ciphers are autofill + autoFillCiphers = cipherList.slice(0, 2); + + // First autofill cipher is also favorite + autoFillCiphers[0].favorite = true; + + // 3rd and 4th ciphers are favorite + cipherList[2].favorite = true; + cipherList[3].favorite = true; + + cipherServiceMock.cipherViews$ = new BehaviorSubject(allCiphers).asObservable(); + cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => autoFillCiphers); + vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false).asObservable(); + vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false).asObservable(); + jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false); + jest + .spyOn(BrowserApi, "getTabFromCurrentWindow") + .mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab); + service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock); + }); + + it("should be created", () => { + service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock); + expect(service).toBeTruthy(); + }); + + describe("autoFillCiphers$", () => { + it("should return empty array if there is no current tab", (done) => { + jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null); + service.autoFillCiphers$.subscribe((ciphers) => { + expect(ciphers).toEqual([]); + done(); + }); + }); + + it("should return empty array if in Popout window", (done) => { + jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true); + service.autoFillCiphers$.subscribe((ciphers) => { + expect(ciphers).toEqual([]); + done(); + }); + }); + + it("should filter ciphers for the current tab and types", (done) => { + const currentTab = { url: "https://example.com" } as chrome.tabs.Tab; + + vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(true).asObservable(); + vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(true).asObservable(); + jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(currentTab); + + service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock); + + service.autoFillCiphers$.subscribe((ciphers) => { + expect(cipherServiceMock.filterCiphersForUrl.mock.calls.length).toBe(1); + expect(cipherServiceMock.filterCiphersForUrl).toHaveBeenCalledWith( + expect.anything(), + currentTab.url, + [CipherType.Card, CipherType.Identity], + ); + done(); + }); + }); + + it("should return ciphers sorted by type, then by last used date, then by name", (done) => { + const expectedTypeOrder: Record = { + [CipherType.Login]: 1, + [CipherType.Card]: 2, + [CipherType.Identity]: 3, + [CipherType.SecureNote]: 4, + }; + + // Assume all ciphers are autofill ciphers to test sorting + cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => + Object.values(allCiphers), + ); + + service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock); + + service.autoFillCiphers$.subscribe((ciphers) => { + expect(ciphers.length).toBe(10); + + for (let i = 0; i < ciphers.length - 1; i++) { + const current = ciphers[i]; + const next = ciphers[i + 1]; + + expect(expectedTypeOrder[current.type]).toBeLessThanOrEqual(expectedTypeOrder[next.type]); + } + expect(cipherServiceMock.sortCiphersByLastUsedThenName).toHaveBeenCalled(); + done(); + }); + }); + }); + + describe("favoriteCiphers$", () => { + it("should exclude autofill ciphers", (done) => { + service.favoriteCiphers$.subscribe((ciphers) => { + // 2 autofill ciphers, 3 favorite ciphers, 1 favorite cipher is also autofill = 2 favorite ciphers to show + expect(ciphers.length).toBe(2); + done(); + }); + }); + + it("should sort by last used then by name", (done) => { + service.favoriteCiphers$.subscribe((ciphers) => { + expect(cipherServiceMock.sortCiphersByLastUsedThenName).toHaveBeenCalled(); + done(); + }); + }); + }); + + describe("remainingCiphers$", () => { + it("should exclude autofill and favorite ciphers", (done) => { + service.remainingCiphers$.subscribe((ciphers) => { + // 2 autofill ciphers, 2 favorite ciphers = 6 remaining ciphers to show + expect(ciphers.length).toBe(6); + done(); + }); + }); + + it("should sort by last used then by name", (done) => { + service.remainingCiphers$.subscribe((ciphers) => { + expect(cipherServiceMock.getLocaleSortingFunction).toHaveBeenCalled(); + done(); + }); + }); + }); + + describe("emptyVault$", () => { + it("should return true if there are no ciphers", (done) => { + cipherServiceMock.cipherViews$ = new BehaviorSubject({}).asObservable(); + service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock); + service.emptyVault$.subscribe((empty) => { + expect(empty).toBe(true); + done(); + }); + }); + + it("should return false if there are ciphers", (done) => { + service.emptyVault$.subscribe((empty) => { + expect(empty).toBe(false); + done(); + }); + }); + }); + + describe("autoFillAllowed$", () => { + it("should return true if there is a current tab", (done) => { + service.autofillAllowed$.subscribe((allowed) => { + expect(allowed).toBe(true); + done(); + }); + }); + + it("should return false if there is no current tab", (done) => { + jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null); + service.autofillAllowed$.subscribe((allowed) => { + expect(allowed).toBe(false); + done(); + }); + }); + + it("should return false if in a Popout", (done) => { + jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true); + service.autofillAllowed$.subscribe((allowed) => { + expect(allowed).toBe(false); + done(); + }); + }); + }); +}); + +// A function to generate a list of ciphers of different types +function cipherFactory(count: number): Record { + const ciphers: CipherView[] = []; + for (let i = 0; i < count; i++) { + const type = ((i % 4) + 1) as CipherType; + switch (type) { + case CipherType.Login: + ciphers.push({ + id: `${i}`, + type: CipherType.Login, + name: `Login ${i}`, + login: { + username: `username${i}`, + password: `password${i}`, + }, + } as CipherView); + break; + case CipherType.SecureNote: + ciphers.push({ + id: `${i}`, + type: CipherType.SecureNote, + name: `SecureNote ${i}`, + notes: `notes${i}`, + } as CipherView); + break; + case CipherType.Card: + ciphers.push({ + id: `${i}`, + type: CipherType.Card, + name: `Card ${i}`, + card: { + cardholderName: `cardholderName${i}`, + number: `number${i}`, + brand: `brand${i}`, + }, + } as CipherView); + break; + case CipherType.Identity: + ciphers.push({ + id: `${i}`, + type: CipherType.Identity, + name: `Identity ${i}`, + identity: { + firstName: `firstName${i}`, + lastName: `lastName${i}`, + }, + } as CipherView); + break; + } + } + return Object.fromEntries(ciphers.map((c) => [c.id, c])); +} 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 new file mode 100644 index 0000000000..52de117e6b --- /dev/null +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -0,0 +1,186 @@ +import { Injectable } from "@angular/core"; +import { + combineLatest, + map, + Observable, + of, + shareReplay, + startWith, + Subject, + switchMap, +} from "rxjs"; + +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.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 { BrowserApi } from "../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; + +/** + * Service for managing the various item lists on the new Vault tab in the browser popup. + */ +@Injectable({ + providedIn: "root", +}) +export class VaultPopupItemsService { + private _refreshCurrentTab$ = new Subject(); + + /** + * Observable that contains the list of other cipher types that should be shown + * in the autofill section of the Vault tab. Depends on vault settings. + * @private + */ + private _otherAutoFillTypes$: Observable = combineLatest([ + this.vaultSettingsService.showCardsCurrentTab$, + this.vaultSettingsService.showIdentitiesCurrentTab$, + ]).pipe( + map(([showCards, showIdentities]) => { + return [ + ...(showCards ? [CipherType.Card] : []), + ...(showIdentities ? [CipherType.Identity] : []), + ]; + }), + ); + + /** + * Observable that contains the current tab to be considered for autofill. If there is no current tab + * or the popup is in a popout window, this will be null. + * @private + */ + private _currentAutofillTab$: Observable = this._refreshCurrentTab$.pipe( + startWith(null), + switchMap(async () => { + if (BrowserPopupUtils.inPopout(window)) { + return null; + } + return await BrowserApi.getTabFromCurrentWindow(); + }), + shareReplay({ refCount: false, bufferSize: 1 }), + ); + + /** + * Observable that contains the list of all decrypted ciphers. + * @private + */ + private _cipherList$: Observable = this.cipherService.cipherViews$.pipe( + map((ciphers) => Object.values(ciphers)), + shareReplay({ refCount: false, bufferSize: 1 }), + ); + + /** + * List of ciphers that can be used for autofill on the current tab. Includes cards and/or identities + * if enabled in the vault settings. Ciphers are sorted by type, then by last used date, then by name. + * + * See {@link refreshCurrentTab} to trigger re-evaluation of the current tab. + */ + autoFillCiphers$: Observable = combineLatest([ + this._cipherList$, + this._otherAutoFillTypes$, + this._currentAutofillTab$, + ]).pipe( + switchMap(([ciphers, otherTypes, tab]) => { + if (!tab) { + return of([]); + } + return this.cipherService.filterCiphersForUrl(ciphers, tab.url, otherTypes); + }), + map((ciphers) => ciphers.sort(this.sortCiphersForAutofill.bind(this))), + shareReplay({ refCount: false, bufferSize: 1 }), + ); + + /** + * List of favorite ciphers that are not currently suggested for autofill. + * Ciphers are sorted by last used date, then by name. + */ + favoriteCiphers$: Observable = combineLatest([ + this.autoFillCiphers$, + this._cipherList$, + ]).pipe( + map(([autoFillCiphers, ciphers]) => + ciphers.filter((cipher) => cipher.favorite && !autoFillCiphers.includes(cipher)), + ), + map((ciphers) => + ciphers.sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b)), + ), + shareReplay({ refCount: false, bufferSize: 1 }), + ); + + /** + * List of all remaining ciphers that are not currently suggested for autofill or marked as favorite. + * Ciphers are sorted by name. + */ + remainingCiphers$: Observable = combineLatest([ + this.autoFillCiphers$, + this.favoriteCiphers$, + this._cipherList$, + ]).pipe( + map(([autoFillCiphers, favoriteCiphers, ciphers]) => + ciphers.filter( + (cipher) => !autoFillCiphers.includes(cipher) && !favoriteCiphers.includes(cipher), + ), + ), + map((ciphers) => ciphers.sort(this.cipherService.getLocaleSortingFunction())), + shareReplay({ refCount: false, bufferSize: 1 }), + ); + + /** + * Observable that indicates whether a filter is currently applied to the ciphers. + * @todo Implement filter/search functionality in PM-6824 and PM-6826. + */ + hasFilterApplied$: Observable = of(false); + + /** + * Observable that indicates whether autofill is allowed in the current context. + * Autofill is allowed when there is a current tab and the popup is not in a popout window. + */ + autofillAllowed$: Observable = this._currentAutofillTab$.pipe(map((tab) => !!tab)); + + /** + * Observable that indicates whether the user's vault is empty. + */ + emptyVault$: Observable = this._cipherList$.pipe(map((ciphers) => !ciphers.length)); + + /** + * Observable that indicates whether there are no ciphers to show with the current filter. + * @todo Implement filter/search functionality in PM-6824 and PM-6826. + */ + noFilteredResults$: Observable = of(false); + + constructor( + private cipherService: CipherService, + private vaultSettingsService: VaultSettingsService, + ) {} + + /** + * Re-fetch the current tab to trigger a re-evaluation of the autofill ciphers. + */ + refreshCurrentTab() { + this._refreshCurrentTab$.next(null); + } + + /** + * Sort function for ciphers to be used in the autofill section of the Vault tab. + * Sorts by type, then by last used date, and finally by name. + * @private + */ + private sortCiphersForAutofill(a: CipherView, b: CipherView): number { + const typeOrder: Record = { + [CipherType.Login]: 1, + [CipherType.Card]: 2, + [CipherType.Identity]: 3, + [CipherType.SecureNote]: 4, + }; + + // Compare types first + if (typeOrder[a.type] < typeOrder[b.type]) { + return -1; + } else if (typeOrder[a.type] > typeOrder[b.type]) { + return 1; + } + + // If types are the same, then sort by last used then name + return this.cipherService.sortCiphersByLastUsedThenName(a, b); + } +} diff --git a/apps/browser/tailwind.config.js b/apps/browser/tailwind.config.js index affbddf2b2..be5c9ce4d9 100644 --- a/apps/browser/tailwind.config.js +++ b/apps/browser/tailwind.config.js @@ -1,6 +1,10 @@ /* eslint-disable no-undef, @typescript-eslint/no-var-requires */ const config = require("../../libs/components/tailwind.config.base"); -config.content = ["./src/**/*.{html,ts}", "../../libs/components/src/**/*.{html,ts}"]; +config.content = [ + "./src/**/*.{html,ts}", + "../../libs/components/src/**/*.{html,ts}", + "../../libs/angular/src/**/*.{html,ts}", +]; module.exports = config; diff --git a/apps/desktop/src/scss/misc.scss b/apps/desktop/src/scss/misc.scss index 8ed6a9b54b..ccc0af8fa4 100644 --- a/apps/desktop/src/scss/misc.scss +++ b/apps/desktop/src/scss/misc.scss @@ -577,6 +577,17 @@ app-vault-view .box-footer { user-select: auto; } +/* override for vault icon in desktop */ +app-vault-icon > div { + display: flex; + justify-content: center; + align-items: center; + float: left; + height: 36px; + width: 34px; + margin-left: -5px; +} + /* tweak for inconsistent line heights in cipher view */ .box-footer button, .box-footer a { diff --git a/apps/desktop/tailwind.config.js b/apps/desktop/tailwind.config.js index affbddf2b2..be5c9ce4d9 100644 --- a/apps/desktop/tailwind.config.js +++ b/apps/desktop/tailwind.config.js @@ -1,6 +1,10 @@ /* eslint-disable no-undef, @typescript-eslint/no-var-requires */ const config = require("../../libs/components/tailwind.config.base"); -config.content = ["./src/**/*.{html,ts}", "../../libs/components/src/**/*.{html,ts}"]; +config.content = [ + "./src/**/*.{html,ts}", + "../../libs/components/src/**/*.{html,ts}", + "../../libs/angular/src/**/*.{html,ts}", +]; module.exports = config; diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index e80bf6a834..08673c3f9a 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -5,6 +5,7 @@ config.content = [ "./src/**/*.{html,ts}", "../../libs/components/src/**/*.{html,ts}", "../../libs/auth/src/**/*.{html,ts}", + "../../libs/angular/src/**/*.{html,ts}", "../../bitwarden_license/bit-web/src/**/*.{html,ts}", ]; diff --git a/libs/angular/src/vault/components/icon.component.html b/libs/angular/src/vault/components/icon.component.html index fd91d4095c..976c6ea421 100644 --- a/libs/angular/src/vault/components/icon.component.html +++ b/libs/angular/src/vault/components/icon.component.html @@ -1,9 +1,10 @@ -