From b7a961bf1f98006cc214c55cce7ea1347914c941 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Thu, 27 Jun 2024 13:30:55 -0700 Subject: [PATCH] [PM-8486] Browser Refresh - Autofill functionality (#9616) * [PM-8486] Introduce VaultPopupAutofill service * [PM-8486] Remove moved autofill functionality from VaultPopupItem service * [PM-8486] Add autofill functionality to button and menu options * [PM-8486] Hide Autofill and Save option for Cards/Identities * [PM-8486] Reduce nesting in closePopup * [PM-8486] Breakup doAutofillAndSave method * [PM-8486] Start subscription in autofill service constructor * [PM-8486] Cleanup missed calls to removed methods --- .../autofill-vault-list-items.component.ts | 10 +- .../item-more-options.component.html | 4 +- .../item-more-options.component.ts | 18 +- .../vault-list-items-container.component.html | 1 + .../vault-list-items-container.component.ts | 10 +- .../components/vault/vault-v2.component.ts | 7 +- .../vault-popup-autofill.service.spec.ts | 356 ++++++++++++++++++ .../services/vault-popup-autofill.service.ts | 237 ++++++++++++ .../vault-popup-items.service.spec.ts | 47 +-- .../services/vault-popup-items.service.ts | 36 +- 10 files changed, 640 insertions(+), 86 deletions(-) create mode 100644 apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts create mode 100644 apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts 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 eb8737d513..8e72d84053 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 @@ -12,6 +12,7 @@ import { } from "@bitwarden/components"; import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; +import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; 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"; @@ -53,7 +54,7 @@ export class AutofillVaultListItemsComponent { protected showEmptyAutofillTip$: Observable = combineLatest([ this.vaultPopupItemsService.hasFilterApplied$, this.autofillCiphers$, - this.vaultPopupItemsService.autofillAllowed$, + this.vaultPopupAutofillService.autofillAllowed$, ]).pipe( map( ([hasFilter, ciphers, canAutoFill]) => @@ -61,7 +62,10 @@ export class AutofillVaultListItemsComponent { ), ); - constructor(private vaultPopupItemsService: VaultPopupItemsService) { + constructor( + private vaultPopupItemsService: VaultPopupItemsService, + private vaultPopupAutofillService: VaultPopupAutofillService, + ) { // TODO: Migrate logic to show Autofill policy toast PM-8144 } @@ -70,6 +74,6 @@ export class AutofillVaultListItemsComponent { * @protected */ protected refreshCurrentTab() { - this.vaultPopupItemsService.refreshCurrentTab(); + this.vaultPopupAutofillService.refreshCurrentTab(); } } diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html index ef451bd934..05a6b54d4d 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html @@ -10,10 +10,10 @@ - - diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index c137a51d72..836d4cbbd1 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -18,7 +18,7 @@ import { PasswordRepromptService } from "@bitwarden/vault"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; -import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; +import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; @Component({ standalone: true, @@ -39,16 +39,16 @@ export class ItemMoreOptionsComponent { @Input({ transform: booleanAttribute }) hideAutofillOptions: boolean; - protected autofillAllowed$ = this.vaultPopupItemsService.autofillAllowed$; + protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$; constructor( private cipherService: CipherService, - private vaultPopupItemsService: VaultPopupItemsService, private passwordRepromptService: PasswordRepromptService, private toastService: ToastService, private dialogService: DialogService, private router: Router, private i18nService: I18nService, + private vaultPopupAutofillService: VaultPopupAutofillService, ) {} get canEdit() { @@ -62,10 +62,22 @@ export class ItemMoreOptionsComponent { return [CipherType.Login, CipherType.Card, CipherType.Identity].includes(this.cipher.type); } + get isLogin() { + return this.cipher.type === CipherType.Login; + } + get favoriteText() { return this.cipher.favorite ? "unfavorite" : "favorite"; } + async doAutofill() { + await this.vaultPopupAutofillService.doAutofill(this.cipher); + } + + async doAutofillAndSave() { + await this.vaultPopupAutofillService.doAutofillAndSave(this.cipher); + } + /** * Determines if the login cipher can be launched in a new browser tab. */ 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 9098120402..957747180e 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 @@ -40,6 +40,7 @@ type="button" bitBadge variant="primary" + (click)="doAutofill(cipher)" [title]="'autofillTitle' | i18n: cipher.name" [attr.aria-label]="'autofillTitle' | i18n: cipher.name" > 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 0c81075635..b6ba09fb31 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 @@ -14,6 +14,7 @@ import { TypographyModule, } from "@bitwarden/components"; +import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; 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"; @@ -87,5 +88,12 @@ export class VaultListItemsContainerComponent { return cipher.collections[0]?.name; } - constructor(private i18nService: I18nService) {} + constructor( + private i18nService: I18nService, + private vaultPopupAutofillService: VaultPopupAutofillService, + ) {} + + async doAutofill(cipher: PopupCipherView) { + await this.vaultPopupAutofillService.doAutofill(cipher); + } } 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 9939727806..14df62de12 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,7 +1,7 @@ import { CommonModule } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { Router, RouterLink } from "@angular/router"; +import { RouterLink } from "@angular/router"; import { combineLatest } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -59,10 +59,7 @@ export class VaultV2Component implements OnInit, OnDestroy { protected VaultStateEnum = VaultState; - constructor( - private vaultPopupItemsService: VaultPopupItemsService, - private router: Router, - ) { + constructor(private vaultPopupItemsService: VaultPopupItemsService) { combineLatest([ this.vaultPopupItemsService.emptyVault$, this.vaultPopupItemsService.noFilteredResults$, diff --git a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts new file mode 100644 index 0000000000..6e74fd7c23 --- /dev/null +++ b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts @@ -0,0 +1,356 @@ +import { TestBed } from "@angular/core/testing"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { subscribeTo } from "@bitwarden/common/spec"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; +import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; +import { ToastService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { + AutoFillOptions, + AutofillService, + PageDetail, +} from "../../../autofill/services/abstractions/autofill.service"; +import { BrowserApi } from "../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; + +import { VaultPopupAutofillService } from "./vault-popup-autofill.service"; + +describe("VaultPopupAutofillService", () => { + let testBed: TestBed; + let service: VaultPopupAutofillService; + + const mockCurrentTab = { url: "https://example.com" } as chrome.tabs.Tab; + + // Create mocks for VaultPopupAutofillService + const mockAutofillService = mock(); + const mockI18nService = mock(); + const mockToastService = mock(); + const mockPlatformUtilsService = mock(); + const mockPasswordRepromptService = mock(); + const mockCipherService = mock(); + const mockMessagingService = mock(); + + beforeEach(() => { + jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false); + jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(mockCurrentTab); + + mockAutofillService.collectPageDetailsFromTab$.mockReturnValue(new BehaviorSubject([])); + + testBed = TestBed.configureTestingModule({ + providers: [ + { provide: AutofillService, useValue: mockAutofillService }, + { provide: I18nService, useValue: mockI18nService }, + { provide: ToastService, useValue: mockToastService }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + { provide: PasswordRepromptService, useValue: mockPasswordRepromptService }, + { provide: CipherService, useValue: mockCipherService }, + { provide: MessagingService, useValue: mockMessagingService }, + ], + }); + + service = testBed.inject(VaultPopupAutofillService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + describe("currentAutofillTab$", () => { + it("should return null if in popout", (done) => { + jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true); + service.refreshCurrentTab(); + service.currentAutofillTab$.subscribe((tab) => { + expect(tab).toBeNull(); + done(); + }); + }); + + it("should return BrowserApi.getTabFromCurrentWindow() if not in popout", (done) => { + service.currentAutofillTab$.subscribe((tab) => { + expect(tab).toEqual(mockCurrentTab); + expect(BrowserApi.getTabFromCurrentWindow).toHaveBeenCalled(); + done(); + }); + }); + + it("should only fetch the current tab once when subscribed to multiple times", async () => { + const firstTracked = subscribeTo(service.currentAutofillTab$); + const secondTracked = subscribeTo(service.currentAutofillTab$); + + await firstTracked.pauseUntilReceived(1); + await secondTracked.pauseUntilReceived(1); + + expect(BrowserApi.getTabFromCurrentWindow).toHaveBeenCalledTimes(1); + }); + }); + + 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.refreshCurrentTab(); + 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.refreshCurrentTab(); + service.autofillAllowed$.subscribe((allowed) => { + expect(allowed).toBe(false); + done(); + }); + }); + }); + + describe("refreshCurrentTab()", () => { + it("should refresh currentAutofillTab$", async () => { + const tracked = subscribeTo(service.currentAutofillTab$); + service.refreshCurrentTab(); + await tracked.pauseUntilReceived(2); + }); + }); + + describe("autofill methods", () => { + const mockPageDetails: PageDetail[] = [{ tab: mockCurrentTab, details: {} as any, frameId: 1 }]; + let mockCipher: CipherView; + let expectedAutofillArgs: AutoFillOptions; + let mockPageDetails$: BehaviorSubject; + + beforeEach(() => { + mockCipher = new CipherView(); + mockCipher.type = CipherType.Login; + + mockPageDetails$ = new BehaviorSubject(mockPageDetails); + + mockAutofillService.collectPageDetailsFromTab$.mockReturnValue(mockPageDetails$); + + expectedAutofillArgs = { + tab: mockCurrentTab, + cipher: mockCipher, + pageDetails: mockPageDetails, + doc: expect.any(Document), + fillNewPassword: true, + allowTotpAutofill: true, + }; + + // Refresh the current tab so the mockedPageDetails$ are used + service.refreshCurrentTab(); + }); + + describe("doAutofill()", () => { + it("should return true if autofill is successful", async () => { + mockAutofillService.doAutoFill.mockResolvedValue(null); + const result = await service.doAutofill(mockCipher); + expect(result).toBe(true); + expect(mockAutofillService.doAutoFill).toHaveBeenCalledWith(expectedAutofillArgs); + }); + + it("should return false if autofill is not successful", async () => { + mockAutofillService.doAutoFill.mockRejectedValue(null); + const result = await service.doAutofill(mockCipher); + expect(result).toBe(false); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: null, + message: mockI18nService.t("autofillError"), + }); + }); + + it("should return false if tab is null", async () => { + jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null); + const result = await service.doAutofill(mockCipher); + expect(result).toBe(false); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: null, + message: mockI18nService.t("autofillError"), + }); + }); + + it("should return false if missing page details", async () => { + mockPageDetails$.next([]); + const result = await service.doAutofill(mockCipher); + expect(result).toBe(false); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: null, + message: mockI18nService.t("autofillError"), + }); + }); + + it("should show password prompt if cipher requires reprompt", async () => { + mockCipher.reprompt = CipherRepromptType.Password; + mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(false); + const result = await service.doAutofill(mockCipher); + expect(result).toBe(false); + }); + + it("should copy TOTP code to clipboard if available", async () => { + const totpCode = "123456"; + mockAutofillService.doAutoFill.mockResolvedValue(totpCode); + await service.doAutofill(mockCipher); + expect(mockPlatformUtilsService.copyToClipboard).toHaveBeenCalledWith( + totpCode, + expect.anything(), + ); + }); + + describe("closePopup", () => { + beforeEach(() => { + jest.spyOn(BrowserApi, "closePopup").mockImplementation(); + jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(true); + mockPlatformUtilsService.isFirefox.mockReturnValue(true); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("should close popup by default when in popup", async () => { + await service.doAutofill(mockCipher); + expect(BrowserApi.closePopup).toHaveBeenCalled(); + }); + + it("should not close popup when closePopup is set to false", async () => { + await service.doAutofill(mockCipher, false); + expect(BrowserApi.closePopup).not.toHaveBeenCalled(); + }); + + it("should close popup after a timeout for chromium browsers", async () => { + mockPlatformUtilsService.isFirefox.mockReturnValue(false); + jest.spyOn(global, "setTimeout"); + await service.doAutofill(mockCipher); + jest.advanceTimersByTime(50); + expect(setTimeout).toHaveBeenCalledTimes(1); + expect(BrowserApi.closePopup).toHaveBeenCalled(); + }); + }); + }); + + describe("doAutofillAndSave()", () => { + beforeEach(() => { + // Mocks for service._closePopup() + jest.spyOn(BrowserApi, "closePopup").mockImplementation(); + jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(true); + mockPlatformUtilsService.isFirefox.mockReturnValue(true); + + // Default to happy path + mockAutofillService.doAutoFill.mockResolvedValue(null); + mockCipherService.updateWithServer.mockResolvedValue(null); + }); + + it("should return false if cipher is not login type", async () => { + mockCipher.type = CipherType.Card; + const result = await service.doAutofillAndSave(mockCipher); + expect(result).toBe(false); + expect(mockAutofillService.doAutoFill).not.toHaveBeenCalled(); + }); + + it("should return false if autofill is not successful", async () => { + mockAutofillService.doAutoFill.mockRejectedValue(null); + const result = await service.doAutofillAndSave(mockCipher); + expect(result).toBe(false); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: null, + message: mockI18nService.t("autofillError"), + }); + }); + + it("should return true if the cipher already has a URI for the tab", async () => { + mockCipher.login = new LoginView(); + mockCipher.login.uris = [{ uri: mockCurrentTab.url } as LoginUriView]; + const result = await service.doAutofillAndSave(mockCipher); + expect(result).toBe(true); + expect(BrowserApi.closePopup).toHaveBeenCalled(); + expect(mockCipherService.updateWithServer).not.toHaveBeenCalled(); + }); + + it("should show a success toast if closePopup is false and cipher already has URI for tab", async () => { + mockCipher.login = new LoginView(); + mockCipher.login.uris = [{ uri: mockCurrentTab.url } as LoginUriView]; + const result = await service.doAutofillAndSave(mockCipher, false); + expect(result).toBe(true); + expect(BrowserApi.closePopup).not.toHaveBeenCalled(); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "success", + title: null, + message: mockI18nService.t("autoFillSuccessAndSavedUri"), + }); + expect(mockCipherService.updateWithServer).not.toHaveBeenCalled(); + }); + + it("should add a URI to the cipher and save with the server", async () => { + const mockEncryptedCipher = {} as Cipher; + mockCipherService.encrypt.mockResolvedValue(mockEncryptedCipher); + const result = await service.doAutofillAndSave(mockCipher); + expect(result).toBe(true); + expect(mockCipher.login.uris).toHaveLength(1); + expect(mockCipher.login.uris[0].uri).toBe(mockCurrentTab.url); + expect(mockCipherService.encrypt).toHaveBeenCalledWith(mockCipher); + expect(mockCipherService.updateWithServer).toHaveBeenCalledWith(mockEncryptedCipher); + }); + + it("should add a URI to the cipher when there are no existing URIs", async () => { + mockCipher.login.uris = null; + const result = await service.doAutofillAndSave(mockCipher); + expect(result).toBe(true); + expect(mockCipher.login.uris).toHaveLength(1); + expect(mockCipher.login.uris[0].uri).toBe(mockCurrentTab.url); + }); + + it("should show an error toast if saving the cipher fails", async () => { + mockCipherService.updateWithServer.mockRejectedValue(null); + const result = await service.doAutofillAndSave(mockCipher); + expect(result).toBe(false); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: null, + message: mockI18nService.t("unexpectedError"), + }); + }); + + it("should close the popup after saving the cipher", async () => { + const result = await service.doAutofillAndSave(mockCipher); + expect(result).toBe(true); + expect(BrowserApi.closePopup).toHaveBeenCalled(); + }); + + it("should show success toast after saving the cipher if closePop is false", async () => { + mockAutofillService.doAutoFill.mockResolvedValue(null); + const result = await service.doAutofillAndSave(mockCipher, false); + expect(result).toBe(true); + expect(BrowserApi.closePopup).not.toHaveBeenCalled(); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "success", + title: null, + message: mockI18nService.t("autoFillSuccessAndSavedUri"), + }); + }); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts new file mode 100644 index 0000000000..ca59ffd997 --- /dev/null +++ b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts @@ -0,0 +1,237 @@ +import { Injectable } from "@angular/core"; +import { + firstValueFrom, + map, + Observable, + of, + shareReplay, + startWith, + Subject, + switchMap, +} from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; +import { ToastService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { + AutofillService, + PageDetail, +} from "../../../autofill/services/abstractions/autofill.service"; +import { BrowserApi } from "../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; + +@Injectable({ + providedIn: "root", +}) +export class VaultPopupAutofillService { + private _refreshCurrentTab$ = new Subject(); + + /** + * 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. + */ + 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 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)); + + private _currentPageDetails$: Observable = this.currentAutofillTab$.pipe( + switchMap((tab) => { + if (!tab) { + return of([]); + } + return this.autofillService.collectPageDetailsFromTab$(tab); + }), + shareReplay({ refCount: false, bufferSize: 1 }), + ); + + constructor( + private autofillService: AutofillService, + private i18nService: I18nService, + private toastService: ToastService, + private platformUtilService: PlatformUtilsService, + private passwordRepromptService: PasswordRepromptService, + private cipherService: CipherService, + private messagingService: MessagingService, + ) { + this._currentPageDetails$.subscribe(); + } + + private async _internalDoAutofill( + cipher: CipherView, + tab: chrome.tabs.Tab, + pageDetails: PageDetail[], + ): Promise { + if ( + cipher.reprompt !== CipherRepromptType.None && + !(await this.passwordRepromptService.showPasswordPrompt()) + ) { + return false; + } + + if (tab == null || pageDetails.length === 0) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("autofillError"), + }); + return false; + } + + try { + const totpCode = await this.autofillService.doAutoFill({ + tab, + cipher, + pageDetails, + doc: window.document, + fillNewPassword: true, + allowTotpAutofill: true, + }); + + if (totpCode != null) { + this.platformUtilService.copyToClipboard(totpCode, { window: window }); + } + } catch { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("autofillError"), + }); + return false; + } + + return true; + } + + private _closePopup() { + if (!BrowserPopupUtils.inPopup(window)) { + return; + } + + if (this.platformUtilService.isFirefox() || this.platformUtilService.isSafari()) { + BrowserApi.closePopup(window); + return; + } + + // Slight delay to fix bug in Chromium browsers where popup closes without copying totp to clipboard + setTimeout(() => BrowserApi.closePopup(window), 50); + } + + /** + * Re-fetch the current tab + */ + refreshCurrentTab() { + this._refreshCurrentTab$.next(null); + } + + /** + * Attempts to autofill the given cipher. Returns true if the autofill was successful, false otherwise. + * Will copy any TOTP code to the clipboard if available after successful autofill. + * @param cipher + * @param closePopup If true, will close the popup window after successful autofill. Defaults to true. + */ + async doAutofill(cipher: CipherView, closePopup = true): Promise { + const tab = await firstValueFrom(this.currentAutofillTab$); + const pageDetails = await firstValueFrom(this._currentPageDetails$); + + const didAutofill = await this._internalDoAutofill(cipher, tab, pageDetails); + + if (didAutofill && closePopup) { + this._closePopup(); + } + + return didAutofill; + } + + /** + * Attempts to autofill the given cipher and, upon successful autofill, saves the URI to the cipher. + * Will copy any TOTP code to the clipboard if available after successful autofill. + * @param cipher The cipher to autofill and save. Only Login ciphers are supported. + * @param closePopup If true, will close the popup window after successful autofill. + * If false, will show a success toast instead. Defaults to true. + */ + async doAutofillAndSave(cipher: CipherView, closePopup = true): Promise { + // We can only save URIs for login ciphers + if (cipher.type !== CipherType.Login) { + return false; + } + + const pageDetails = await firstValueFrom(this._currentPageDetails$); + const tab = await firstValueFrom(this.currentAutofillTab$); + + const didAutofill = await this._internalDoAutofill(cipher, tab, pageDetails); + + if (!didAutofill) { + return false; + } + + const didSaveUri = await this._saveNewUri(cipher, tab); + + if (!didSaveUri) { + return false; + } + + if (closePopup) { + this._closePopup(); + } else { + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("autoFillSuccessAndSavedUri"), + }); + } + + return true; + } + + /** + * Saves the current tab's URL as a new URI for the given cipher. If the cipher already has a URI for the tab, + * this method does nothing and returns true. + * @private + */ + private async _saveNewUri(cipher: CipherView, tab: chrome.tabs.Tab): Promise { + cipher.login.uris ??= []; + + if (cipher.login.uris.some((uri) => uri.uri === tab.url)) { + // Cipher already has a URI for this tab + return true; + } + + const loginUri = new LoginUriView(); + loginUri.uri = tab.url; + cipher.login.uris.push(loginUri); + + try { + const encCipher = await this.cipherService.encrypt(cipher); + await this.cipherService.updateWithServer(encCipher); + this.messagingService.send("editedCipher"); + return true; + } catch { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("unexpectedError"), + }); + return false; + } + } +} 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 0b40b136ab..e9abe7bff2 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 @@ -16,8 +16,8 @@ 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"; +import { VaultPopupAutofillService } from "./vault-popup-autofill.service"; import { VaultPopupItemsService } from "./vault-popup-items.service"; import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; @@ -36,6 +36,7 @@ describe("VaultPopupItemsService", () => { const vaultPopupListFiltersServiceMock = mock(); const searchService = mock(); const collectionService = mock(); + const vaultAutofillServiceMock = mock(); beforeEach(() => { allCiphers = cipherFactory(10); @@ -70,10 +71,10 @@ describe("VaultPopupItemsService", () => { vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject( (ciphers: CipherView[]) => ciphers, ); - jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false); - jest - .spyOn(BrowserApi, "getTabFromCurrentWindow") - .mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab); + + vaultAutofillServiceMock.currentAutofillTab$ = new BehaviorSubject({ + url: "https://example.com", + } as chrome.tabs.Tab); mockOrg = { id: "org1", @@ -97,6 +98,7 @@ describe("VaultPopupItemsService", () => { { provide: OrganizationService, useValue: organizationServiceMock }, { provide: VaultPopupListFiltersService, useValue: vaultPopupListFiltersServiceMock }, { provide: CollectionService, useValue: collectionService }, + { provide: VaultPopupAutofillService, useValue: vaultAutofillServiceMock }, ], }); @@ -155,15 +157,7 @@ describe("VaultPopupItemsService", () => { 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); + (vaultAutofillServiceMock.currentAutofillTab$ as BehaviorSubject).next(null); service.autoFillCiphers$.subscribe((ciphers) => { expect(ciphers).toEqual([]); done(); @@ -319,31 +313,6 @@ describe("VaultPopupItemsService", () => { }); }); - 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(); - }); - }); - }); - describe("noFilteredResults$", () => { it("should return false when filteredResults has values", (done) => { service.noFilteredResults$.subscribe((noResults) => { 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 c6d155c521..1c26f9cc71 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 @@ -28,11 +28,10 @@ import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault 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 { runInsideAngular } from "../../../platform/browser/run-inside-angular.operator"; -import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; import { PopupCipherView } from "../views/popup-cipher.view"; +import { VaultPopupAutofillService } from "./vault-popup-autofill.service"; import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; /** @@ -42,7 +41,6 @@ import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-fi providedIn: "root", }) export class VaultPopupItemsService { - private _refreshCurrentTab$ = new Subject(); private _searchText$ = new BehaviorSubject(""); /** @@ -70,22 +68,6 @@ export class VaultPopupItemsService { }), ); - /** - * 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 @@ -145,7 +127,7 @@ export class VaultPopupItemsService { autoFillCiphers$: Observable = combineLatest([ this._filteredCipherList$, this._otherAutoFillTypes$, - this._currentAutofillTab$, + this.vaultPopupAutofillService.currentAutofillTab$, ]).pipe( switchMap(([ciphers, otherTypes, tab]) => { if (!tab) { @@ -217,12 +199,6 @@ export class VaultPopupItemsService { }), ); - /** - * 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. */ @@ -257,15 +233,9 @@ export class VaultPopupItemsService { private organizationService: OrganizationService, private searchService: SearchService, private collectionService: CollectionService, + private vaultPopupAutofillService: VaultPopupAutofillService, ) {} - /** - * Re-fetch the current tab to trigger a re-evaluation of the autofill ciphers. - */ - refreshCurrentTab() { - this._refreshCurrentTab$.next(null); - } - applyFilter(newSearchText: string) { this._searchText$.next(newSearchText); }