diff --git a/apps/browser/src/autofill/background/abstractions/overlay.background.ts b/apps/browser/src/autofill/background/abstractions/overlay.background.ts index aa62194af5c..462acb818b8 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay.background.ts @@ -2,17 +2,43 @@ import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import AutofillPageDetails from "../../models/autofill-page-details"; +import { PageDetail } from "../../services/abstractions/autofill.service"; import { LockedVaultPendingNotificationsData } from "./notification.background"; -type WebsiteIconData = { +export type PageDetailsForTab = Record< + chrome.runtime.MessageSender["tab"]["id"], + Map +>; + +export type SubFrameOffsetData = { + top: number; + left: number; + url?: string; + frameId?: number; + parentFrameIds?: number[]; +} | null; + +export type SubFrameOffsetsForTab = Record< + chrome.runtime.MessageSender["tab"]["id"], + Map +>; + +export type WebsiteIconData = { imageEnabled: boolean; image: string; fallbackImage: string; icon: string; }; -type OverlayAddNewItemMessage = { +export type FocusedFieldData = { + focusedFieldStyles: Partial; + focusedFieldRects: Partial; + tabId?: number; + frameId?: number; +}; + +export type OverlayAddNewItemMessage = { login?: { uri?: string; hostname: string; @@ -21,112 +47,132 @@ type OverlayAddNewItemMessage = { }; }; -type OverlayBackgroundExtensionMessage = { - [key: string]: any; +export type CloseInlineMenuMessage = { + forceCloseInlineMenu?: boolean; + overlayElement?: string; +}; + +export type ToggleInlineMenuHiddenMessage = { + isInlineMenuHidden?: boolean; + setTransparentInlineMenu?: boolean; +}; + +export type OverlayBackgroundExtensionMessage = { command: string; + portKey?: string; tab?: chrome.tabs.Tab; sender?: string; details?: AutofillPageDetails; - overlayElement?: string; - display?: string; + isFieldCurrentlyFocused?: boolean; + isFieldCurrentlyFilling?: boolean; + isVisible?: boolean; + subFrameData?: SubFrameOffsetData; + focusedFieldData?: FocusedFieldData; + styles?: Partial; data?: LockedVaultPendingNotificationsData; -} & OverlayAddNewItemMessage; +} & OverlayAddNewItemMessage & + CloseInlineMenuMessage & + ToggleInlineMenuHiddenMessage; -type OverlayPortMessage = { +export type OverlayPortMessage = { [key: string]: any; command: string; direction?: string; - overlayCipherId?: string; + inlineMenuCipherId?: string; }; -type FocusedFieldData = { - focusedFieldStyles: Partial; - focusedFieldRects: Partial; - tabId?: number; -}; - -type OverlayCipherData = { +export type InlineMenuCipherData = { id: string; name: string; type: CipherType; reprompt: CipherRepromptType; favorite: boolean; - icon: { imageEnabled: boolean; image: string; fallbackImage: string; icon: string }; + icon: WebsiteIconData; login?: { username: string }; card?: string; }; -type BackgroundMessageParam = { +export type BackgroundMessageParam = { message: OverlayBackgroundExtensionMessage; }; -type BackgroundSenderParam = { +export type BackgroundSenderParam = { sender: chrome.runtime.MessageSender; }; -type BackgroundOnMessageHandlerParams = BackgroundMessageParam & BackgroundSenderParam; +export type BackgroundOnMessageHandlerParams = BackgroundMessageParam & BackgroundSenderParam; -type OverlayBackgroundExtensionMessageHandlers = { +export type OverlayBackgroundExtensionMessageHandlers = { [key: string]: CallableFunction; - openAutofillOverlay: () => void; autofillOverlayElementClosed: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; autofillOverlayAddNewVaultItem: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; - getAutofillOverlayVisibility: () => void; - checkAutofillOverlayFocused: () => void; - focusAutofillOverlayList: () => void; - updateAutofillOverlayPosition: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; - updateAutofillOverlayHidden: ({ message }: BackgroundMessageParam) => void; + triggerAutofillOverlayReposition: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + checkIsInlineMenuCiphersPopulated: ({ sender }: BackgroundSenderParam) => void; updateFocusedFieldData: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + updateIsFieldCurrentlyFocused: ({ message }: BackgroundMessageParam) => void; + checkIsFieldCurrentlyFocused: () => boolean; + updateIsFieldCurrentlyFilling: ({ message }: BackgroundMessageParam) => void; + checkIsFieldCurrentlyFilling: () => boolean; + getAutofillInlineMenuVisibility: () => void; + openAutofillInlineMenu: () => void; + closeAutofillInlineMenu: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + checkAutofillInlineMenuFocused: ({ sender }: BackgroundSenderParam) => void; + focusAutofillInlineMenuList: () => void; + updateAutofillInlineMenuPosition: ({ + message, + sender, + }: BackgroundOnMessageHandlerParams) => Promise; + updateAutofillInlineMenuElementIsVisibleStatus: ({ + message, + sender, + }: BackgroundOnMessageHandlerParams) => void; + checkIsAutofillInlineMenuButtonVisible: () => void; + checkIsAutofillInlineMenuListVisible: () => void; + getCurrentTabFrameId: ({ sender }: BackgroundSenderParam) => number; + updateSubFrameData: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + triggerSubFrameFocusInRebuild: ({ sender }: BackgroundSenderParam) => void; + destroyAutofillInlineMenuListeners: ({ + message, + sender, + }: BackgroundOnMessageHandlerParams) => void; collectPageDetailsResponse: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; unlockCompleted: ({ message }: BackgroundMessageParam) => void; + doFullSync: () => void; addedCipher: () => void; addEditCipherSubmitted: () => void; editedCipher: () => void; deletedCipher: () => void; }; -type PortMessageParam = { +export type PortMessageParam = { message: OverlayPortMessage; }; -type PortConnectionParam = { +export type PortConnectionParam = { port: chrome.runtime.Port; }; -type PortOnMessageHandlerParams = PortMessageParam & PortConnectionParam; +export type PortOnMessageHandlerParams = PortMessageParam & PortConnectionParam; -type OverlayButtonPortMessageHandlers = { +export type InlineMenuButtonPortMessageHandlers = { [key: string]: CallableFunction; - overlayButtonClicked: ({ port }: PortConnectionParam) => void; - closeAutofillOverlay: ({ port }: PortConnectionParam) => void; - forceCloseAutofillOverlay: ({ port }: PortConnectionParam) => void; - overlayPageBlurred: () => void; - redirectOverlayFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void; + triggerDelayedAutofillInlineMenuClosure: ({ port }: PortConnectionParam) => void; + autofillInlineMenuButtonClicked: ({ port }: PortConnectionParam) => void; + autofillInlineMenuBlurred: () => void; + redirectAutofillInlineMenuFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void; + updateAutofillInlineMenuColorScheme: () => void; }; -type OverlayListPortMessageHandlers = { +export type InlineMenuListPortMessageHandlers = { [key: string]: CallableFunction; - checkAutofillOverlayButtonFocused: () => void; - forceCloseAutofillOverlay: ({ port }: PortConnectionParam) => void; - overlayPageBlurred: () => void; + checkAutofillInlineMenuButtonFocused: () => void; + autofillInlineMenuBlurred: () => void; unlockVault: ({ port }: PortConnectionParam) => void; - fillSelectedListItem: ({ message, port }: PortOnMessageHandlerParams) => void; + fillAutofillInlineMenuCipher: ({ message, port }: PortOnMessageHandlerParams) => void; addNewVaultItem: ({ port }: PortConnectionParam) => void; viewSelectedCipher: ({ message, port }: PortOnMessageHandlerParams) => void; - redirectOverlayFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void; + redirectAutofillInlineMenuFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void; + updateAutofillInlineMenuListHeight: ({ message, port }: PortOnMessageHandlerParams) => void; }; -interface OverlayBackground { +export interface OverlayBackground { init(): Promise; removePageDetails(tabId: number): void; - updateOverlayCiphers(): void; + updateOverlayCiphers(): Promise; } - -export { - WebsiteIconData, - OverlayBackgroundExtensionMessage, - OverlayPortMessage, - FocusedFieldData, - OverlayCipherData, - OverlayAddNewItemMessage, - OverlayBackgroundExtensionMessageHandlers, - OverlayButtonPortMessageHandlers, - OverlayListPortMessageHandlers, - OverlayBackground, -}; diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 179598a8823..9e989b73e62 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -770,12 +770,12 @@ export default class NotificationBackground { ) => { const handler: CallableFunction | undefined = this.extensionMessageHandlers[message?.command]; if (!handler) { - return; + return null; } const messageResponse = handler({ message, sender }); - if (!messageResponse) { - return; + if (typeof messageResponse === "undefined") { + return null; } Promise.resolve(messageResponse) diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 7be93b11e6b..81a7754f84b 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -1,102 +1,161 @@ import { mock, MockProxy, mockReset } from "jest-mock-extended"; -import { BehaviorSubject, of } from "rxjs"; +import { BehaviorSubject } from "rxjs"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { - SHOW_AUTOFILL_BUTTON, AutofillOverlayVisibility, + SHOW_AUTOFILL_BUTTON, } from "@bitwarden/common/autofill/constants"; -import { AutofillSettingsService } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { AutofillSettingsServiceAbstraction as AutofillSettingsService } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { DefaultDomainSettingsService, DomainSettingsService, } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; import { EnvironmentService, Region, } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CloudEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; -import { I18nService } from "@bitwarden/common/platform/services/i18n.service"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { - FakeStateProvider, FakeAccountService, + FakeStateProvider, mockAccountServiceWith, } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; -import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +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 { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserPlatformUtilsService } from "../../platform/services/platform-utils/browser-platform-utils.service"; -import { AutofillService } from "../services/abstractions/autofill.service"; -import { - createAutofillPageDetailsMock, - createChromeTabMock, - createFocusedFieldDataMock, - createPageDetailMock, - createPortSpyMock, -} from "../spec/autofill-mocks"; -import { flushPromises, sendMockExtensionMessage, sendPortMessage } from "../spec/testing-utils"; import { AutofillOverlayElement, AutofillOverlayPort, + MAX_SUB_FRAME_DEPTH, RedirectFocusDirection, -} from "../utils/autofill-overlay.enum"; +} from "../enums/autofill-overlay.enum"; +import { AutofillService } from "../services/abstractions/autofill.service"; +import { + createChromeTabMock, + createAutofillPageDetailsMock, + createPortSpyMock, + createFocusedFieldDataMock, + createPageDetailMock, +} from "../spec/autofill-mocks"; +import { + flushPromises, + sendMockExtensionMessage, + sendPortMessage, + triggerPortOnConnectEvent, + triggerPortOnDisconnectEvent, + triggerPortOnMessageEvent, + triggerWebNavigationOnCommittedEvent, +} from "../spec/testing-utils"; -import OverlayBackground from "./overlay.background"; +import { + FocusedFieldData, + PageDetailsForTab, + SubFrameOffsetData, + SubFrameOffsetsForTab, +} from "./abstractions/overlay.background"; +import { OverlayBackground } from "./overlay.background"; describe("OverlayBackground", () => { const mockUserId = Utils.newGuid() as UserId; - const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); - const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); + const sendResponse = jest.fn(); + let accountService: FakeAccountService; + let fakeStateProvider: FakeStateProvider; + let showFaviconsMock$: BehaviorSubject; let domainSettingsService: DomainSettingsService; - let buttonPortSpy: chrome.runtime.Port; - let listPortSpy: chrome.runtime.Port; - let overlayBackground: OverlayBackground; - const cipherService = mock(); - const autofillService = mock(); + let logService: MockProxy; + let cipherService: MockProxy; + let autofillService: MockProxy; let activeAccountStatusMock$: BehaviorSubject; let authService: MockProxy; + let environmentMock$: BehaviorSubject; + let environmentService: MockProxy; + let inlineMenuVisibilityMock$: BehaviorSubject; + let autofillSettingsService: MockProxy; + let i18nService: MockProxy; + let platformUtilsService: MockProxy; + let selectedThemeMock$: BehaviorSubject; + let themeStateService: MockProxy; + let overlayBackground: OverlayBackground; + let portKeyForTabSpy: Record; + let pageDetailsForTabSpy: PageDetailsForTab; + let subFrameOffsetsSpy: SubFrameOffsetsForTab; + let getFrameDetailsSpy: jest.SpyInstance; + let tabsSendMessageSpy: jest.SpyInstance; + let tabSendMessageDataSpy: jest.SpyInstance; + let sendMessageSpy: jest.SpyInstance; + let getTabFromCurrentWindowIdSpy: jest.SpyInstance; + let getTabSpy: jest.SpyInstance; + let openUnlockPopoutSpy: jest.SpyInstance; + let buttonPortSpy: chrome.runtime.Port; + let buttonMessageConnectorSpy: chrome.runtime.Port; + let listPortSpy: chrome.runtime.Port; + let listMessageConnectorSpy: chrome.runtime.Port; - const environmentService = mock(); - environmentService.environment$ = new BehaviorSubject( - new CloudEnvironment({ - key: Region.US, - domain: "bitwarden.com", - urls: { icons: "https://icons.bitwarden.com/" }, - }), - ); - const autofillSettingsService = mock(); - const i18nService = mock(); - const platformUtilsService = mock(); - const themeStateService = mock(); - const initOverlayElementPorts = async (options = { initList: true, initButton: true }) => { + let getFrameCounter: number = 2; + async function initOverlayElementPorts(options = { initList: true, initButton: true }) { const { initList, initButton } = options; if (initButton) { - await overlayBackground["handlePortOnConnect"](createPortSpyMock(AutofillOverlayPort.Button)); - buttonPortSpy = overlayBackground["overlayButtonPort"]; + triggerPortOnConnectEvent(createPortSpyMock(AutofillOverlayPort.Button)); + buttonPortSpy = overlayBackground["inlineMenuButtonPort"]; + + buttonMessageConnectorSpy = createPortSpyMock(AutofillOverlayPort.ButtonMessageConnector); + triggerPortOnConnectEvent(buttonMessageConnectorSpy); } if (initList) { - await overlayBackground["handlePortOnConnect"](createPortSpyMock(AutofillOverlayPort.List)); - listPortSpy = overlayBackground["overlayListPort"]; + triggerPortOnConnectEvent(createPortSpyMock(AutofillOverlayPort.List)); + listPortSpy = overlayBackground["inlineMenuListPort"]; + + listMessageConnectorSpy = createPortSpyMock(AutofillOverlayPort.ListMessageConnector); + triggerPortOnConnectEvent(listMessageConnectorSpy); } return { buttonPortSpy, listPortSpy }; - }; + } beforeEach(() => { + accountService = mockAccountServiceWith(mockUserId); + fakeStateProvider = new FakeStateProvider(accountService); + showFaviconsMock$ = new BehaviorSubject(true); domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); + domainSettingsService.showFavicons$ = showFaviconsMock$; + logService = mock(); + cipherService = mock(); + autofillService = mock(); activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked); authService = mock(); authService.activeAccountStatus$ = activeAccountStatusMock$; + environmentMock$ = new BehaviorSubject( + new CloudEnvironment({ + key: Region.US, + domain: "bitwarden.com", + urls: { icons: "https://icons.bitwarden.com/" }, + }), + ); + environmentService = mock(); + environmentService.environment$ = environmentMock$; + inlineMenuVisibilityMock$ = new BehaviorSubject(AutofillOverlayVisibility.OnFieldFocus); + autofillSettingsService = mock(); + autofillSettingsService.inlineMenuVisibility$ = inlineMenuVisibilityMock$; + i18nService = mock(); + platformUtilsService = mock(); + selectedThemeMock$ = new BehaviorSubject(ThemeType.Light); + themeStateService = mock(); + themeStateService.selectedTheme$ = selectedThemeMock$; overlayBackground = new OverlayBackground( + logService, cipherService, autofillService, authService, @@ -107,48 +166,528 @@ describe("OverlayBackground", () => { platformUtilsService, themeStateService, ); - - jest - .spyOn(overlayBackground as any, "getOverlayVisibility") - .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); - - themeStateService.selectedTheme$ = of(ThemeType.Light); - domainSettingsService.showFavicons$ = of(true); + portKeyForTabSpy = overlayBackground["portKeyForTab"]; + pageDetailsForTabSpy = overlayBackground["pageDetailsForTab"]; + subFrameOffsetsSpy = overlayBackground["subFrameOffsetsForTab"]; + getFrameDetailsSpy = jest.spyOn(BrowserApi, "getFrameDetails"); + getFrameDetailsSpy.mockImplementation((_details: chrome.webNavigation.GetFrameDetails) => { + getFrameCounter--; + return mock({ + parentFrameId: getFrameCounter, + }); + }); + tabsSendMessageSpy = jest.spyOn(BrowserApi, "tabSendMessage"); + tabSendMessageDataSpy = jest.spyOn(BrowserApi, "tabSendMessageData"); + sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage"); + getTabFromCurrentWindowIdSpy = jest.spyOn(BrowserApi, "getTabFromCurrentWindowId"); + getTabSpy = jest.spyOn(BrowserApi, "getTab"); + openUnlockPopoutSpy = jest.spyOn(overlayBackground as any, "openUnlockPopout"); void overlayBackground.init(); }); afterEach(() => { + getFrameCounter = 2; jest.clearAllMocks(); + jest.useRealTimers(); mockReset(cipherService); }); - describe("removePageDetails", () => { - it("removes the page details for a specific tab from the pageDetailsForTab object", () => { + describe("storing pageDetails", () => { + const tabId = 1; + + beforeEach(() => { + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: createAutofillPageDetailsMock() }, + mock({ tab: createChromeTabMock({ id: tabId }), frameId: 0 }), + ); + }); + + it("stores the page details for the tab", () => { + expect(pageDetailsForTabSpy[tabId]).toBeDefined(); + }); + + describe("building sub frame offsets", () => { + beforeEach(() => { + tabsSendMessageSpy.mockResolvedValue( + mock({ + left: getFrameCounter, + top: getFrameCounter, + url: "url", + }), + ); + }); + + it("triggers a destruction of the inline menu listeners if the max frame depth is exceeded ", async () => { + getFrameCounter = MAX_SUB_FRAME_DEPTH + 1; + const tab = createChromeTabMock({ id: tabId }); + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: createAutofillPageDetailsMock() }, + mock({ + tab, + frameId: 1, + }), + ); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + tab, + { command: "destroyAutofillInlineMenuListeners" }, + { frameId: 1 }, + ); + }); + + it("builds the offset values for a sub frame within the tab", async () => { + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: createAutofillPageDetailsMock() }, + mock({ + tab: createChromeTabMock({ id: tabId }), + frameId: 1, + }), + ); + await flushPromises(); + + expect(subFrameOffsetsSpy[tabId]).toStrictEqual( + new Map([[1, { left: 4, top: 4, url: "url", parentFrameIds: [0, 1] }]]), + ); + expect(pageDetailsForTabSpy[tabId].size).toBe(2); + }); + + it("skips building offset values for a previously calculated sub frame", async () => { + getFrameCounter = 0; + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: createAutofillPageDetailsMock() }, + mock({ + tab: createChromeTabMock({ id: tabId }), + frameId: 1, + }), + ); + await flushPromises(); + + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: createAutofillPageDetailsMock() }, + mock({ + tab: createChromeTabMock({ id: tabId }), + frameId: 1, + }), + ); + await flushPromises(); + + expect(getFrameDetailsSpy).toHaveBeenCalledTimes(1); + expect(subFrameOffsetsSpy[tabId]).toStrictEqual( + new Map([[1, { left: 0, top: 0, url: "url", parentFrameIds: [0] }]]), + ); + }); + + it("will attempt to build the sub frame offsets by posting window messages if a set of offsets is not returned", async () => { + const tab = createChromeTabMock({ id: tabId }); + const frameId = 1; + tabsSendMessageSpy.mockResolvedValue(null); + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: createAutofillPageDetailsMock() }, + mock({ tab, frameId }), + ); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + tab, + { + command: "getSubFrameOffsetsFromWindowMessage", + subFrameId: frameId, + }, + { frameId }, + ); + expect(subFrameOffsetsSpy[tabId]).toStrictEqual(new Map([[frameId, null]])); + }); + + it("updates sub frame data that has been calculated using window messages", async () => { + const tab = createChromeTabMock({ id: tabId }); + const frameId = 1; + const subFrameData = mock({ frameId, left: 10, top: 10, url: "url" }); + tabsSendMessageSpy.mockResolvedValueOnce(null); + subFrameOffsetsSpy[tabId] = new Map([[frameId, null]]); + + sendMockExtensionMessage( + { command: "updateSubFrameData", subFrameData }, + mock({ tab, frameId }), + ); + await flushPromises(); + + expect(subFrameOffsetsSpy[tabId]).toStrictEqual(new Map([[frameId, subFrameData]])); + }); + }); + }); + + describe("removing pageDetails", () => { + it("removes the page details and port key for a specific tab from the pageDetailsForTab object", () => { const tabId = 1; - const frameId = 2; - overlayBackground["pageDetailsForTab"][tabId] = new Map([[frameId, createPageDetailMock()]]); + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: createAutofillPageDetailsMock() }, + mock({ tab: createChromeTabMock({ id: tabId }), frameId: 1 }), + ); + overlayBackground.removePageDetails(tabId); - expect(overlayBackground["pageDetailsForTab"][tabId]).toBeUndefined(); + expect(pageDetailsForTabSpy[tabId]).toBeUndefined(); + expect(portKeyForTabSpy[tabId]).toBeUndefined(); }); }); - describe("init", () => { - it("sets up the extension message listeners, get the overlay's visibility settings, and get the user's auth status", async () => { - overlayBackground["setupExtensionMessageListeners"] = jest.fn(); - overlayBackground["getOverlayVisibility"] = jest.fn(); - overlayBackground["getAuthStatus"] = jest.fn(); + describe("re-positioning the inline menu within sub frames", () => { + const tabId = 1; + const topFrameId = 0; + const middleFrameId = 10; + const middleAdjacentFrameId = 11; + const bottomFrameId = 20; + let tab: chrome.tabs.Tab; + let sender: MockProxy; - await overlayBackground.init(); + async function flushOverlayRepositionPromises() { + await flushPromises(); + jest.advanceTimersByTime(1150); + await flushPromises(); + } - expect(overlayBackground["setupExtensionMessageListeners"]).toHaveBeenCalled(); - expect(overlayBackground["getOverlayVisibility"]).toHaveBeenCalled(); - expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled(); + beforeEach(() => { + jest.useFakeTimers(); + tab = createChromeTabMock({ id: tabId }); + sender = mock({ tab, frameId: middleFrameId }); + overlayBackground["focusedFieldData"] = mock({ + tabId, + frameId: bottomFrameId, + }); + subFrameOffsetsSpy[tabId] = new Map([ + [topFrameId, { left: 1, top: 1, url: "https://top-frame.com", parentFrameIds: [] }], + [ + middleFrameId, + { left: 2, top: 2, url: "https://middle-frame.com", parentFrameIds: [topFrameId] }, + ], + [ + middleAdjacentFrameId, + { + left: 3, + top: 3, + url: "https://middle-adjacent-frame.com", + parentFrameIds: [topFrameId], + }, + ], + [ + bottomFrameId, + { + left: 4, + top: 4, + url: "https://bottom-frame.com", + parentFrameIds: [topFrameId, middleFrameId], + }, + ], + ]); + tabsSendMessageSpy.mockResolvedValue( + mock({ + left: getFrameCounter, + top: getFrameCounter, + url: "url", + }), + ); + }); + + describe("triggerAutofillOverlayReposition", () => { + describe("checkShouldRepositionInlineMenu", () => { + let focusedFieldData: FocusedFieldData; + let repositionInlineMenuSpy: jest.SpyInstance; + + beforeEach(() => { + focusedFieldData = createFocusedFieldDataMock({ tabId }); + repositionInlineMenuSpy = jest.spyOn(overlayBackground as any, "repositionInlineMenu"); + }); + + describe("blocking a reposition of the overlay", () => { + it("blocks repositioning when the focused field data is not set", async () => { + overlayBackground["focusedFieldData"] = undefined; + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(repositionInlineMenuSpy).not.toHaveBeenCalled(); + }); + + it("blocks repositioning when the sender is from a different tab than the focused field", async () => { + const otherSender = mock({ frameId: 1, tab: { id: 2 } }); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + otherSender, + ); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(repositionInlineMenuSpy).not.toHaveBeenCalled(); + }); + + it("blocks repositioning when the sender frame is not a parent frame of the focused field", async () => { + focusedFieldData = createFocusedFieldDataMock({ tabId }); + const otherFrameSender = mock({ + tab, + frameId: middleAdjacentFrameId, + }); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + otherFrameSender, + ); + sender.frameId = bottomFrameId; + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(repositionInlineMenuSpy).not.toHaveBeenCalled(); + }); + }); + + describe("allowing a reposition of the overlay", () => { + it("allows repositioning when the sender frame is for the focused field and the inline menu is visible, ", async () => { + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + sender, + ); + tabsSendMessageSpy.mockImplementation((_tab, message) => { + if (message.command === "checkIsAutofillInlineMenuButtonVisible") { + return Promise.resolve(true); + } + }); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(repositionInlineMenuSpy).toHaveBeenCalled(); + }); + }); + }); + + describe("repositionInlineMenu", () => { + beforeEach(() => { + overlayBackground["isFieldCurrentlyFocused"] = true; + }); + + it("closes the inline menu if the field is not focused", async () => { + overlayBackground["isFieldCurrentlyFocused"] = false; + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + tab, + { command: "closeAutofillInlineMenu" }, + { frameId: 0 }, + ); + }); + + it("closes the inline menu if the focused field is not within the viewport", async () => { + tabsSendMessageSpy.mockImplementation((_tab, message) => { + if (message.command === "checkIsMostRecentlyFocusedFieldWithinViewport") { + return Promise.resolve(false); + } + }); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + tab, + { command: "closeAutofillInlineMenu" }, + { frameId: 0 }, + ); + }); + + it("rebuilds the sub frame offsets when the focused field's frame id indicates that it is within a sub frame", async () => { + const focusedFieldData = createFocusedFieldDataMock({ tabId, frameId: middleFrameId }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(getFrameDetailsSpy).toHaveBeenCalledWith({ tabId, frameId: middleFrameId }); + }); + + describe("updating the inline menu position", () => { + let sender: chrome.runtime.MessageSender; + + async function flushUpdateInlineMenuPromises() { + await flushOverlayRepositionPromises(); + await flushPromises(); + jest.advanceTimersByTime(250); + await flushPromises(); + } + + beforeEach(async () => { + sender = mock({ tab, frameId: middleFrameId }); + jest.useFakeTimers(); + await initOverlayElementPorts(); + }); + + it("skips updating the position of either inline menu element if a field is not currently focused", async () => { + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFocused", + isFieldCurrentlyFocused: false, + }); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushUpdateInlineMenuPromises(); + + expect(tabsSendMessageSpy).not.toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.Button, + }, + { frameId: 0 }, + ); + expect(tabsSendMessageSpy).not.toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.List, + }, + { frameId: 0 }, + ); + }); + + it("sets the inline menu invisible and updates its position", async () => { + overlayBackground["checkIsInlineMenuButtonVisible"] = jest + .fn() + .mockResolvedValue(false); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushUpdateInlineMenuPromises(); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "toggleAutofillInlineMenuHidden", + styles: { display: "none" }, + }); + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.Button, + }, + { frameId: 0 }, + ); + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.List, + }, + { frameId: 0 }, + ); + }); + + it("skips updating the inline menu list if the user has the inline menu set to open on button click", async () => { + inlineMenuVisibilityMock$.next(AutofillOverlayVisibility.OnButtonClick); + tabsSendMessageSpy.mockImplementation((_tab, message, _options) => { + if (message.command === "checkMostRecentlyFocusedFieldHasValue") { + return Promise.resolve(true); + } + + return Promise.resolve({}); + }); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushUpdateInlineMenuPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.Button, + }, + { frameId: 0 }, + ); + expect(tabsSendMessageSpy).not.toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.List, + }, + { frameId: 0 }, + ); + }); + + it("skips updating the inline menu list if the focused field has a value and the user status is not unlocked", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + tabsSendMessageSpy.mockImplementation((_tab, message, _options) => { + if (message.command === "checkMostRecentlyFocusedFieldHasValue") { + return Promise.resolve(true); + } + + return Promise.resolve({}); + }); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushUpdateInlineMenuPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.Button, + }, + { frameId: 0 }, + ); + expect(tabsSendMessageSpy).not.toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.List, + }, + { frameId: 0 }, + ); + }); + }); + }); + + describe("triggerSubFrameFocusInRebuild", () => { + it("triggers a rebuild of the sub frame and updates the inline menu position", async () => { + const rebuildSubFrameOffsetsSpy = jest.spyOn( + overlayBackground as any, + "rebuildSubFrameOffsets", + ); + const repositionInlineMenuSpy = jest.spyOn( + overlayBackground as any, + "repositionInlineMenu", + ); + + sendMockExtensionMessage({ command: "triggerSubFrameFocusInRebuild" }, sender); + await flushOverlayRepositionPromises(); + + expect(rebuildSubFrameOffsetsSpy).toHaveBeenCalled(); + expect(repositionInlineMenuSpy).toHaveBeenCalled(); + }); + }); + + describe("toggleInlineMenuHidden", () => { + beforeEach(async () => { + await initOverlayElementPorts(); + }); + + it("skips adjusting the hidden status of the inline menu if the sender tab does not contain the focused field", async () => { + const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); + const otherSender = mock({ tab: { id: 2 } }); + + await overlayBackground["toggleInlineMenuHidden"]( + { isInlineMenuHidden: true }, + otherSender, + ); + + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "toggleAutofillInlineMenuHidden", + styles: { display: "none" }, + }); + }); + }); }); }); - describe("updateOverlayCiphers", () => { + describe("updating the overlay ciphers", () => { const url = "https://jest-testing-website.com"; const tab = createChromeTabMock({ url }); const cipher1 = mock({ @@ -160,86 +699,100 @@ describe("OverlayBackground", () => { }); const cipher2 = mock({ id: "id-2", - localData: { lastUsedDate: 111 }, + localData: { lastUsedDate: 222 }, name: "name-2", - type: CipherType.Login, - login: { username: "username-2", uri: url }, + type: CipherType.Card, + card: { subTitle: "subtitle-2" }, }); beforeEach(() => { activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); }); - it("ignores updating the overlay ciphers if the user's auth status is not unlocked", async () => { + it("skips updating the overlay ciphers if the user's auth status is not unlocked", async () => { activeAccountStatusMock$.next(AuthenticationStatus.Locked); - jest.spyOn(BrowserApi, "getTabFromCurrentWindowId"); - jest.spyOn(cipherService, "getAllDecryptedForUrl"); await overlayBackground.updateOverlayCiphers(); - expect(BrowserApi.getTabFromCurrentWindowId).not.toHaveBeenCalled(); + expect(getTabFromCurrentWindowIdSpy).not.toHaveBeenCalled(); expect(cipherService.getAllDecryptedForUrl).not.toHaveBeenCalled(); }); - it("ignores updating the overlay ciphers if the tab is undefined", async () => { - jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(undefined); - jest.spyOn(cipherService, "getAllDecryptedForUrl"); + it("closes the inline menu on the focused field's tab if the user's auth status is not unlocked", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + const previousTab = mock({ id: 1 }); + overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: 1 }); + getTabSpy.mockResolvedValueOnce(previousTab); await overlayBackground.updateOverlayCiphers(); - expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); - expect(cipherService.getAllDecryptedForUrl).not.toHaveBeenCalled(); + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + previousTab, + { command: "closeAutofillInlineMenu", overlayElement: undefined }, + { frameId: 0 }, + ); + }); + + it("closes the inline menu on the focused field's tab if current tab is different", async () => { + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); + cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); + cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); + const previousTab = mock({ id: 15 }); + overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: 15 }); + getTabSpy.mockResolvedValueOnce(previousTab); + + await overlayBackground.updateOverlayCiphers(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + previousTab, + { command: "closeAutofillInlineMenu", overlayElement: undefined }, + { frameId: 0 }, + ); }); it("queries all ciphers for the given url, sort them by last used, and format them for usage in the overlay", async () => { - jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(tab); + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); - jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); - jest.spyOn(overlayBackground as any, "getOverlayCipherData"); await overlayBackground.updateOverlayCiphers(); expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url); - expect(overlayBackground["cipherService"].sortCiphersByLastUsedThenName).toHaveBeenCalled(); - expect(overlayBackground["overlayLoginCiphers"]).toStrictEqual( + expect(cipherService.sortCiphersByLastUsedThenName).toHaveBeenCalled(); + expect(overlayBackground["inlineMenuCiphers"]).toStrictEqual( new Map([ - ["overlay-cipher-0", cipher2], - ["overlay-cipher-1", cipher1], + ["inline-menu-cipher-0", cipher2], + ["inline-menu-cipher-1", cipher1], ]), ); - expect(overlayBackground["getOverlayCipherData"]).toHaveBeenCalled(); }); - it("posts an `updateOverlayListCiphers` message to the overlay list port, and send a `updateIsOverlayCiphersPopulated` message to the tab indicating that the list of ciphers is populated", async () => { - overlayBackground["overlayListPort"] = mock(); + it("posts an `updateOverlayListCiphers` message to the overlay list port, and send a `updateAutofillInlineMenuListCiphers` message to the tab indicating that the list of ciphers is populated", async () => { + overlayBackground["inlineMenuListPort"] = mock(); cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); - jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(tab); - jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); await overlayBackground.updateOverlayCiphers(); - expect(overlayBackground["overlayListPort"].postMessage).toHaveBeenCalledWith({ - command: "updateOverlayListCiphers", + expect(overlayBackground["inlineMenuListPort"].postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuListCiphers", ciphers: [ { - card: null, + card: cipher2.card.subTitle, favorite: cipher2.favorite, icon: { - fallbackImage: "images/bwi-globe.png", - icon: "bwi-globe", - image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + fallbackImage: "", + icon: "bwi-credit-card", + image: undefined, imageEnabled: true, }, - id: "overlay-cipher-0", - login: { - username: "username-2", - }, + id: "inline-menu-cipher-0", + login: null, name: "name-2", reprompt: cipher2.reprompt, - type: 1, + type: 3, }, { card: null, @@ -250,7 +803,7 @@ describe("OverlayBackground", () => { image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", imageEnabled: true, }, - id: "overlay-cipher-1", + id: "inline-menu-cipher-1", login: { username: "username-1", }, @@ -260,227 +813,822 @@ describe("OverlayBackground", () => { }, ], }); - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - tab, - "updateIsOverlayCiphersPopulated", - { isOverlayCiphersPopulated: true }, - ); }); }); - describe("getOverlayCipherData", () => { - const url = "https://jest-testing-website.com"; - const cipher1 = mock({ - id: "id-1", - localData: { lastUsedDate: 222 }, - name: "name-1", - type: CipherType.Login, - login: { username: "username-1", uri: url }, - }); - const cipher2 = mock({ - id: "id-2", - localData: { lastUsedDate: 111 }, - name: "name-2", - type: CipherType.Login, - login: { username: "username-2", uri: url }, - }); - const cipher3 = mock({ - id: "id-3", - localData: { lastUsedDate: 333 }, - name: "name-3", - type: CipherType.Card, - card: { subTitle: "Visa, *6789" }, - }); - const cipher4 = mock({ - id: "id-4", - localData: { lastUsedDate: 444 }, - name: "name-4", - type: CipherType.Card, - card: { subTitle: "Mastercard, *1234" }, - }); + describe("extension message handlers", () => { + describe("autofillOverlayElementClosed message handler", () => { + beforeEach(async () => { + await initOverlayElementPorts(); + }); - it("formats and returns the cipher data", async () => { - overlayBackground["overlayLoginCiphers"] = new Map([ - ["overlay-cipher-0", cipher2], - ["overlay-cipher-1", cipher1], - ["overlay-cipher-2", cipher3], - ["overlay-cipher-3", cipher4], - ]); + it("disconnects any expired ports if the sender is not from the same page as the most recently focused field", () => { + const port1 = mock(); + const port2 = mock(); + overlayBackground["expiredPorts"] = [port1, port2]; + const sender = mock({ tab: { id: 1 } }); + const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - const overlayCipherData = await overlayBackground["getOverlayCipherData"](); - - expect(overlayCipherData).toStrictEqual([ - { - card: null, - favorite: cipher2.favorite, - icon: { - fallbackImage: "images/bwi-globe.png", - icon: "bwi-globe", - image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", - imageEnabled: true, + sendMockExtensionMessage( + { + command: "autofillOverlayElementClosed", + overlayElement: AutofillOverlayElement.Button, }, - id: "overlay-cipher-0", - login: { - username: "username-2", - }, - name: "name-2", - reprompt: cipher2.reprompt, - type: 1, - }, - { - card: null, - favorite: cipher1.favorite, - icon: { - fallbackImage: "images/bwi-globe.png", - icon: "bwi-globe", - image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", - imageEnabled: true, - }, - id: "overlay-cipher-1", - login: { - username: "username-1", - }, - name: "name-1", - reprompt: cipher1.reprompt, - type: 1, - }, - { - card: "Visa, *6789", - favorite: cipher3.favorite, - icon: { - fallbackImage: "", - icon: "bwi-credit-card", - image: undefined, - imageEnabled: true, - }, - id: "overlay-cipher-2", - login: null, - name: "name-3", - reprompt: cipher3.reprompt, - type: 3, - }, - { - card: "Mastercard, *1234", - favorite: cipher4.favorite, - icon: { - fallbackImage: "", - icon: "bwi-credit-card", - image: undefined, - imageEnabled: true, - }, - id: "overlay-cipher-3", - login: null, - name: "name-4", - reprompt: cipher4.reprompt, - type: 3, - }, - ]); - }); - }); + sender, + ); - describe("getAuthStatus", () => { - it("will update the user's auth status but will not update the overlay ciphers", async () => { - const authStatus = AuthenticationStatus.Unlocked; - overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; - jest.spyOn(overlayBackground["authService"], "getAuthStatus").mockResolvedValue(authStatus); - jest.spyOn(overlayBackground as any, "updateOverlayButtonAuthStatus").mockImplementation(); - jest.spyOn(overlayBackground as any, "updateOverlayCiphers").mockImplementation(); + expect(port1.disconnect).toHaveBeenCalled(); + expect(port2.disconnect).toHaveBeenCalled(); + }); - const status = await overlayBackground["getAuthStatus"](); + it("disconnects the button element port", () => { + sendMockExtensionMessage({ + command: "autofillOverlayElementClosed", + overlayElement: AutofillOverlayElement.Button, + }); - expect(overlayBackground["authService"].getAuthStatus).toHaveBeenCalled(); - expect(overlayBackground["updateOverlayButtonAuthStatus"]).not.toHaveBeenCalled(); - expect(overlayBackground["updateOverlayCiphers"]).not.toHaveBeenCalled(); - expect(overlayBackground["userAuthStatus"]).toBe(authStatus); - expect(status).toBe(authStatus); - }); + expect(buttonPortSpy.disconnect).toHaveBeenCalled(); + }); - it("will update the user's auth status and update the overlay ciphers if the status has been modified", async () => { - const authStatus = AuthenticationStatus.Unlocked; - overlayBackground["userAuthStatus"] = AuthenticationStatus.LoggedOut; - jest.spyOn(overlayBackground["authService"], "getAuthStatus").mockResolvedValue(authStatus); - jest.spyOn(overlayBackground as any, "updateOverlayButtonAuthStatus").mockImplementation(); - jest.spyOn(overlayBackground as any, "updateOverlayCiphers").mockImplementation(); + it("disconnects the list element port", () => { + sendMockExtensionMessage({ + command: "autofillOverlayElementClosed", + overlayElement: AutofillOverlayElement.List, + }); - await overlayBackground["getAuthStatus"](); - - expect(overlayBackground["authService"].getAuthStatus).toHaveBeenCalled(); - expect(overlayBackground["updateOverlayButtonAuthStatus"]).toHaveBeenCalled(); - expect(overlayBackground["updateOverlayCiphers"]).toHaveBeenCalled(); - expect(overlayBackground["userAuthStatus"]).toBe(authStatus); - }); - }); - - describe("updateOverlayButtonAuthStatus", () => { - it("will send a message to the button port with the user's auth status", () => { - overlayBackground["overlayButtonPort"] = mock(); - jest.spyOn(overlayBackground["overlayButtonPort"], "postMessage"); - - overlayBackground["updateOverlayButtonAuthStatus"](); - - expect(overlayBackground["overlayButtonPort"].postMessage).toHaveBeenCalledWith({ - command: "updateOverlayButtonAuthStatus", - authStatus: overlayBackground["userAuthStatus"], + expect(listPortSpy.disconnect).toHaveBeenCalled(); }); }); - }); - describe("getTranslations", () => { - it("will query the overlay page translations if they have not been queried", () => { - overlayBackground["overlayPageTranslations"] = undefined; - jest.spyOn(overlayBackground as any, "getTranslations"); - jest.spyOn(overlayBackground["i18nService"], "translate").mockImplementation((key) => key); - jest.spyOn(BrowserApi, "getUILanguage").mockReturnValue("en"); + describe("autofillOverlayAddNewVaultItem message handler", () => { + let sender: chrome.runtime.MessageSender; + let openAddEditVaultItemPopoutSpy: jest.SpyInstance; - const translations = overlayBackground["getTranslations"](); + beforeEach(() => { + sender = mock({ tab: { id: 1 } }); + openAddEditVaultItemPopoutSpy = jest + .spyOn(overlayBackground as any, "openAddEditVaultItemPopout") + .mockImplementation(); + }); - expect(overlayBackground["getTranslations"]).toHaveBeenCalled(); - const translationKeys = [ - "opensInANewWindow", - "bitwardenOverlayButton", - "toggleBitwardenVaultOverlay", - "bitwardenVault", - "unlockYourAccountToViewMatchingLogins", - "unlockAccount", - "fillCredentialsFor", - "partialUsername", - "view", - "noItemsToShow", - "newItem", - "addNewVaultItem", + it("will not open the add edit popout window if the message does not have a login cipher provided", () => { + sendMockExtensionMessage({ command: "autofillOverlayAddNewVaultItem" }, sender); + + expect(cipherService.setAddEditCipherInfo).not.toHaveBeenCalled(); + expect(openAddEditVaultItemPopoutSpy).not.toHaveBeenCalled(); + }); + + it("will open the add edit popout window after creating a new cipher", async () => { + sendMockExtensionMessage( + { + command: "autofillOverlayAddNewVaultItem", + login: { + uri: "https://tacos.com", + hostname: "", + username: "username", + password: "password", + }, + }, + sender, + ); + await flushPromises(); + + expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledWith("inlineAutofillMenuRefreshAddEditCipher"); + expect(openAddEditVaultItemPopoutSpy).toHaveBeenCalled(); + }); + }); + + describe("checkIsInlineMenuCiphersPopulated message handler", () => { + let focusedFieldData: FocusedFieldData; + + beforeEach(() => { + focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + mock({ tab: { id: 2 }, frameId: 0 }), + ); + }); + + it("returns false if the sender's tab id is not equal to the focused field's tab id", async () => { + const sender = mock({ tab: { id: 1 } }); + + sendMockExtensionMessage( + { command: "checkIsInlineMenuCiphersPopulated" }, + sender, + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(false); + }); + + it("returns false if the overlay login cipher are not populated", () => {}); + + it("returns true if the overlay login ciphers are populated", async () => { + overlayBackground["inlineMenuCiphers"] = new Map([ + ["inline-menu-cipher-0", mock()], + ]); + + sendMockExtensionMessage( + { command: "checkIsInlineMenuCiphersPopulated" }, + mock({ tab: { id: 2 } }), + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(true); + }); + }); + + describe("updateFocusedFieldData message handler", () => { + it("sends a message to the sender frame to unset the most recently focused field data when the currently focused field does not belong to the sender", async () => { + const tab = createChromeTabMock({ id: 2 }); + const firstSender = mock({ tab, frameId: 100 }); + const focusedFieldData = createFocusedFieldDataMock({ + tabId: tab.id, + frameId: firstSender.frameId, + }); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + firstSender, + ); + await flushPromises(); + + const secondSender = mock({ tab, frameId: 10 }); + const otherFocusedFieldData = createFocusedFieldDataMock({ + tabId: tab.id, + frameId: secondSender.frameId, + }); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData: otherFocusedFieldData }, + secondSender, + ); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + tab, + { command: "unsetMostRecentlyFocusedField" }, + { frameId: firstSender.frameId }, + ); + }); + }); + + describe("checkIsFieldCurrentlyFocused message handler", () => { + it("returns true when a form field is currently focused", async () => { + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFocused", + isFieldCurrentlyFocused: true, + }); + + sendMockExtensionMessage( + { command: "checkIsFieldCurrentlyFocused" }, + mock(), + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(true); + }); + }); + + describe("checkIsFieldCurrentlyFilling message handler", () => { + it("returns true if autofill is currently running", async () => { + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFilling", + isFieldCurrentlyFilling: true, + }); + + sendMockExtensionMessage( + { command: "checkIsFieldCurrentlyFilling" }, + mock(), + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(true); + }); + }); + + describe("getAutofillInlineMenuVisibility message handler", () => { + it("returns the current inline menu visibility setting", async () => { + sendMockExtensionMessage( + { command: "getAutofillInlineMenuVisibility" }, + mock(), + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(AutofillOverlayVisibility.OnFieldFocus); + }); + }); + + describe("openAutofillInlineMenu message handler", () => { + let sender: chrome.runtime.MessageSender; + + beforeEach(() => { + sender = mock({ tab: { id: 1 } }); + getTabFromCurrentWindowIdSpy.mockResolvedValue(sender.tab); + tabsSendMessageSpy.mockImplementation(); + }); + + it("opens the autofill inline menu by sending a message to the current tab", async () => { + sendMockExtensionMessage({ command: "openAutofillInlineMenu" }, sender); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "openAutofillInlineMenu", + isFocusingFieldElement: false, + isOpeningFullInlineMenu: false, + authStatus: AuthenticationStatus.Unlocked, + }, + { frameId: 0 }, + ); + }); + + it("sends the open menu message to the focused field's frameId", async () => { + sender.frameId = 10; + sendMockExtensionMessage({ command: "updateFocusedFieldData" }, sender); + await flushPromises(); + + sendMockExtensionMessage({ command: "openAutofillInlineMenu" }, sender); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "openAutofillInlineMenu", + isFocusingFieldElement: false, + isOpeningFullInlineMenu: false, + authStatus: AuthenticationStatus.Unlocked, + }, + { frameId: 10 }, + ); + }); + }); + + describe("closeAutofillInlineMenu", () => { + let sender: chrome.runtime.MessageSender; + + beforeEach(() => { + sender = mock({ tab: { id: 1 } }); + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFilling", + isFieldCurrentlyFilling: false, + }); + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFocused", + isFieldCurrentlyFocused: false, + }); + }); + + it("sends a message to close the inline menu without checking field focus state if forcing the closure", async () => { + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFocused", + isFieldCurrentlyFocused: true, + }); + await flushPromises(); + + sendMockExtensionMessage( + { + command: "closeAutofillInlineMenu", + forceCloseInlineMenu: true, + overlayElement: AutofillOverlayElement.Button, + }, + sender, + ); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "closeAutofillInlineMenu", + overlayElement: AutofillOverlayElement.Button, + }, + { frameId: 0 }, + ); + }); + + it("skips sending a message to close the inline menu if a form field is currently focused", async () => { + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFocused", + isFieldCurrentlyFocused: true, + }); + await flushPromises(); + + sendMockExtensionMessage( + { + command: "closeAutofillInlineMenu", + forceCloseInlineMenu: false, + overlayElement: AutofillOverlayElement.Button, + }, + sender, + ); + await flushPromises(); + + expect(tabsSendMessageSpy).not.toHaveBeenCalled(); + }); + + it("sends a message to close the inline menu list only if the field is currently filling", async () => { + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFilling", + isFieldCurrentlyFilling: true, + }); + await flushPromises(); + + sendMockExtensionMessage({ command: "closeAutofillInlineMenu" }, sender); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "closeAutofillInlineMenu", + overlayElement: AutofillOverlayElement.List, + }, + { frameId: 0 }, + ); + expect(tabsSendMessageSpy).not.toHaveBeenCalledWith( + sender.tab, + { + command: "closeAutofillInlineMenu", + overlayElement: AutofillOverlayElement.Button, + }, + { frameId: 0 }, + ); + }); + + it("sends a message to close the inline menu if the form field is not focused and not filling", async () => { + overlayBackground["isInlineMenuButtonVisible"] = true; + overlayBackground["isInlineMenuListVisible"] = true; + + sendMockExtensionMessage({ command: "closeAutofillInlineMenu" }, sender); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "closeAutofillInlineMenu", + overlayElement: undefined, + }, + { frameId: 0 }, + ); + expect(overlayBackground["isInlineMenuButtonVisible"]).toBe(false); + expect(overlayBackground["isInlineMenuListVisible"]).toBe(false); + }); + + it("sets a property indicating that the inline menu button is not visible", async () => { + overlayBackground["isInlineMenuButtonVisible"] = true; + + sendMockExtensionMessage( + { command: "closeAutofillInlineMenu", overlayElement: AutofillOverlayElement.Button }, + sender, + ); + await flushPromises(); + + expect(overlayBackground["isInlineMenuButtonVisible"]).toBe(false); + }); + + it("sets a property indicating that the inline menu list is not visible", async () => { + overlayBackground["isInlineMenuListVisible"] = true; + + sendMockExtensionMessage( + { command: "closeAutofillInlineMenu", overlayElement: AutofillOverlayElement.List }, + sender, + ); + await flushPromises(); + + expect(overlayBackground["isInlineMenuListVisible"]).toBe(false); + }); + }); + + describe("checkAutofillInlineMenuFocused message handler", () => { + beforeEach(async () => { + await initOverlayElementPorts(); + }); + + it("skips checking if the inline menu is focused if the sender does not contain the focused field", async () => { + const sender = mock({ tab: { id: 1 } }); + const focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ command: "checkAutofillInlineMenuFocused" }, sender); + + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuListFocused", + }); + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuButtonFocused", + }); + }); + + it("will check if the inline menu list is focused if the list port is open", () => { + sendMockExtensionMessage({ command: "checkAutofillInlineMenuFocused" }); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuListFocused", + }); + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuButtonFocused", + }); + }); + + it("will check if the overlay button is focused if the list port is not open", () => { + overlayBackground["inlineMenuListPort"] = undefined; + + sendMockExtensionMessage({ command: "checkAutofillInlineMenuFocused" }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuButtonFocused", + }); + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuListFocused", + }); + }); + }); + + describe("focusAutofillInlineMenuList message handler", () => { + it("will send a `focusInlineMenuList` message to the overlay list port", async () => { + await initOverlayElementPorts({ initList: true, initButton: false }); + + sendMockExtensionMessage({ command: "focusAutofillInlineMenuList" }); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "focusAutofillInlineMenuList", + }); + }); + }); + + describe("updateAutofillInlineMenuPosition message handler", () => { + beforeEach(async () => { + await initOverlayElementPorts(); + }); + + it("ignores updating the position if the overlay element type is not provided", () => { + sendMockExtensionMessage({ command: "updateAutofillInlineMenuPosition" }); + + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: expect.anything(), + }); + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: expect.anything(), + }); + }); + + it("skips updating the position if the most recently focused field is different than the message sender", () => { + const sender = mock({ tab: { id: 1 } }); + const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ command: "updateAutofillInlineMenuPosition" }, sender); + + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: expect.anything(), + }); + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: expect.anything(), + }); + }); + + it("updates the inline menu button's position", async () => { + const focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillInlineMenuPosition", + overlayElement: AutofillOverlayElement.Button, + }); + await flushPromises(); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuPosition", + styles: { height: "2px", left: "4px", top: "2px", width: "2px" }, + }); + }); + + it("modifies the inline menu button's height for medium sized input elements", async () => { + const focusedFieldData = createFocusedFieldDataMock({ + focusedFieldRects: { top: 1, left: 2, height: 35, width: 4 }, + }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillInlineMenuPosition", + overlayElement: AutofillOverlayElement.Button, + }); + await flushPromises(); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuPosition", + styles: { height: "20px", left: "-22px", top: "8px", width: "20px" }, + }); + }); + + it("modifies the inline menu button's height for large sized input elements", async () => { + const focusedFieldData = createFocusedFieldDataMock({ + focusedFieldRects: { top: 1, left: 2, height: 50, width: 4 }, + }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillInlineMenuPosition", + overlayElement: AutofillOverlayElement.Button, + }); + await flushPromises(); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuPosition", + styles: { height: "27px", left: "-32px", top: "13px", width: "27px" }, + }); + }); + + it("takes into account the right padding of the focused field in positioning the button if the right padding of the field is larger than the left padding", async () => { + const focusedFieldData = createFocusedFieldDataMock({ + focusedFieldStyles: { paddingRight: "20px", paddingLeft: "6px" }, + }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillInlineMenuPosition", + overlayElement: AutofillOverlayElement.Button, + }); + await flushPromises(); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuPosition", + styles: { height: "2px", left: "-18px", top: "2px", width: "2px" }, + }); + }); + + it("updates the inline menu list's position", async () => { + const focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillInlineMenuPosition", + overlayElement: AutofillOverlayElement.List, + }); + await flushPromises(); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuPosition", + styles: { left: "2px", top: "4px", width: "4px" }, + }); + }); + + it("sends a message that triggers a simultaneous fade in for both inline menu elements", async () => { + jest.useFakeTimers(); + const focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillInlineMenuPosition", + overlayElement: AutofillOverlayElement.List, + }); + await flushPromises(); + jest.advanceTimersByTime(150); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "fadeInAutofillInlineMenuIframe", + }); + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "fadeInAutofillInlineMenuIframe", + }); + }); + + it("triggers a debounced reposition of the inline menu if the sender frame has a `null` sub frame offsets value", async () => { + jest.useFakeTimers(); + const focusedFieldData = createFocusedFieldDataMock(); + const sender = mock({ + tab: { id: focusedFieldData.tabId }, + frameId: focusedFieldData.frameId, + }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); + overlayBackground["subFrameOffsetsForTab"][focusedFieldData.tabId] = new Map([ + [focusedFieldData.frameId, null], + ]); + tabsSendMessageSpy.mockImplementation(); + jest.spyOn(overlayBackground as any, "updateInlineMenuPositionAfterRepositionEvent"); + + sendMockExtensionMessage( + { + command: "updateAutofillInlineMenuPosition", + overlayElement: AutofillOverlayElement.List, + }, + sender, + ); + await flushPromises(); + jest.advanceTimersByTime(150); + + expect( + overlayBackground["updateInlineMenuPositionAfterRepositionEvent"], + ).toHaveBeenCalled(); + }); + }); + + describe("updateAutofillInlineMenuElementIsVisibleStatus message handler", () => { + let sender: chrome.runtime.MessageSender; + let focusedFieldData: FocusedFieldData; + + beforeEach(() => { + sender = mock({ tab: { id: 1 } }); + focusedFieldData = createFocusedFieldDataMock(); + overlayBackground["isInlineMenuButtonVisible"] = true; + overlayBackground["isInlineMenuListVisible"] = false; + }); + + it("skips updating the inline menu visibility status if the sender tab does not contain the focused field", async () => { + const otherSender = mock({ tab: { id: 2 } }); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + otherSender, + ); + + sendMockExtensionMessage( + { + command: "updateAutofillInlineMenuElementIsVisibleStatus", + overlayElement: AutofillOverlayElement.Button, + isVisible: false, + }, + sender, + ); + + expect(overlayBackground["isInlineMenuButtonVisible"]).toBe(true); + expect(overlayBackground["isInlineMenuListVisible"]).toBe(false); + }); + + it("updates the visibility status of the inline menu button", async () => { + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); + + sendMockExtensionMessage( + { + command: "updateAutofillInlineMenuElementIsVisibleStatus", + overlayElement: AutofillOverlayElement.Button, + isVisible: false, + }, + sender, + ); + + expect(overlayBackground["isInlineMenuButtonVisible"]).toBe(false); + expect(overlayBackground["isInlineMenuListVisible"]).toBe(false); + }); + + it("updates the visibility status of the inline menu list", async () => { + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); + + sendMockExtensionMessage( + { + command: "updateAutofillInlineMenuElementIsVisibleStatus", + overlayElement: AutofillOverlayElement.List, + isVisible: true, + }, + sender, + ); + + expect(overlayBackground["isInlineMenuButtonVisible"]).toBe(true); + expect(overlayBackground["isInlineMenuListVisible"]).toBe(true); + }); + }); + + describe("checkIsAutofillInlineMenuButtonVisible message handler", () => { + it("returns true when the inline menu button is visible", async () => { + overlayBackground["isInlineMenuButtonVisible"] = true; + const sender = mock({ tab: { id: 1 } }); + + sendMockExtensionMessage( + { command: "checkIsAutofillInlineMenuButtonVisible" }, + sender, + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(true); + }); + }); + + describe("checkIsAutofillInlineMenuListVisible message handler", () => { + it("returns true when the inline menu list is visible", async () => { + overlayBackground["isInlineMenuListVisible"] = true; + const sender = mock({ tab: { id: 1 } }); + + sendMockExtensionMessage( + { command: "checkIsAutofillInlineMenuListVisible" }, + sender, + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(true); + }); + }); + + describe("getCurrentTabFrameId message handler", () => { + it("returns the sender's frame id", async () => { + const sender = mock({ frameId: 1 }); + + sendMockExtensionMessage({ command: "getCurrentTabFrameId" }, sender, sendResponse); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(1); + }); + }); + + describe("destroyAutofillInlineMenuListeners", () => { + it("sends a message to the passed frameId that triggers a destruction of the inline menu listeners on that frame", () => { + const sender = mock({ tab: { id: 1 }, frameId: 0 }); + + sendMockExtensionMessage( + { command: "destroyAutofillInlineMenuListeners", subFrameData: { frameId: 10 } }, + sender, + ); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { command: "destroyAutofillInlineMenuListeners" }, + { frameId: 10 }, + ); + }); + }); + + describe("unlockCompleted", () => { + let updateInlineMenuCiphersSpy: jest.SpyInstance; + + beforeEach(async () => { + updateInlineMenuCiphersSpy = jest.spyOn(overlayBackground, "updateOverlayCiphers"); + await initOverlayElementPorts(); + }); + + it("updates the inline menu button auth status", async () => { + sendMockExtensionMessage({ command: "unlockCompleted" }); + await flushPromises(); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateInlineMenuButtonAuthStatus", + authStatus: AuthenticationStatus.Unlocked, + }); + }); + + it("updates the overlay ciphers", async () => { + const updateInlineMenuCiphersSpy = jest.spyOn(overlayBackground, "updateOverlayCiphers"); + sendMockExtensionMessage({ command: "unlockCompleted" }); + await flushPromises(); + + expect(updateInlineMenuCiphersSpy).toHaveBeenCalled(); + }); + + it("opens the inline menu if a retry command is present in the message", async () => { + updateInlineMenuCiphersSpy.mockImplementation(); + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(createChromeTabMock({ id: 1 })); + sendMockExtensionMessage({ + command: "unlockCompleted", + data: { + commandToRetry: { message: { command: "openAutofillInlineMenu" } }, + }, + }); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + expect.any(Object), + { + command: "openAutofillInlineMenu", + isFocusingFieldElement: true, + isOpeningFullInlineMenu: false, + authStatus: AuthenticationStatus.Unlocked, + }, + { frameId: 0 }, + ); + }); + }); + + describe("extension messages that trigger an update of the inline menu ciphers", () => { + const extensionMessages = [ + "doFullSync", + "addedCipher", + "addEditCipherSubmitted", + "editedCipher", + "deletedCipher", ]; - translationKeys.forEach((key) => { - expect(overlayBackground["i18nService"].translate).toHaveBeenCalledWith(key); + + beforeEach(() => { + jest.spyOn(overlayBackground, "updateOverlayCiphers").mockImplementation(); }); - expect(translations).toStrictEqual({ - locale: "en", - opensInANewWindow: "opensInANewWindow", - buttonPageTitle: "bitwardenOverlayButton", - toggleBitwardenVaultOverlay: "toggleBitwardenVaultOverlay", - listPageTitle: "bitwardenVault", - unlockYourAccount: "unlockYourAccountToViewMatchingLogins", - unlockAccount: "unlockAccount", - fillCredentialsFor: "fillCredentialsFor", - partialUsername: "partialUsername", - view: "view", - noItemsToShow: "noItemsToShow", - newItem: "newItem", - addNewVaultItem: "addNewVaultItem", + + extensionMessages.forEach((message) => { + it(`triggers an update of the overlay ciphers when the ${message} message is received`, () => { + sendMockExtensionMessage({ command: message }); + expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled(); + }); }); }); }); - describe("setupExtensionMessageListeners", () => { - it("will set up onMessage and onConnect listeners", () => { - overlayBackground["setupExtensionMessageListeners"](); - - // eslint-disable-next-line - expect(chrome.runtime.onMessage.addListener).toHaveBeenCalled(); - expect(chrome.runtime.onConnect.addListener).toHaveBeenCalled(); - }); - }); - - describe("handleExtensionMessage", () => { + describe("handle extension onMessage", () => { it("will return early if the message command is not present within the extensionMessageHandlers", () => { const message = { command: "not-a-command", @@ -494,970 +1642,591 @@ describe("OverlayBackground", () => { sendResponse, ); - expect(returnValue).toBe(undefined); + expect(returnValue).toBe(null); expect(sendResponse).not.toHaveBeenCalled(); }); + }); - it("will trigger the message handler and return undefined if the message does not have a response", () => { - const message = { - command: "autofillOverlayElementClosed", - }; - const sender = mock({ tab: { id: 1 } }); - const sendResponse = jest.fn(); - jest.spyOn(overlayBackground as any, "overlayElementClosed"); + describe("inline menu button message handlers", () => { + let sender: chrome.runtime.MessageSender; + const portKey = "inlineMenuButtonPort"; - const returnValue = overlayBackground["handleExtensionMessage"]( - message, - sender, - sendResponse, - ); - - expect(returnValue).toBe(undefined); - expect(sendResponse).not.toHaveBeenCalled(); - expect(overlayBackground["overlayElementClosed"]).toHaveBeenCalledWith(message, sender); + beforeEach(async () => { + sender = mock({ tab: { id: 1 } }); + portKeyForTabSpy[sender.tab.id] = portKey; + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + await initOverlayElementPorts(); + buttonMessageConnectorSpy.sender = sender; + openUnlockPopoutSpy.mockImplementation(); }); - it("will return a response if the message handler returns a response", async () => { - const message = { - command: "openAutofillOverlay", - }; - const sender = mock({ tab: { id: 1 } }); - const sendResponse = jest.fn(); - jest.spyOn(overlayBackground as any, "getTranslations").mockReturnValue("translations"); + describe("autofillInlineMenuButtonClicked message handler", () => { + it("opens the unlock vault popout if the user auth status is not unlocked", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + tabsSendMessageSpy.mockImplementation(); - const returnValue = overlayBackground["handleExtensionMessage"]( - message, - sender, - sendResponse, - ); + sendPortMessage(buttonMessageConnectorSpy, { + command: "autofillInlineMenuButtonClicked", + portKey, + }); + await flushPromises(); - expect(returnValue).toBe(true); + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { command: "closeAutofillInlineMenu", overlayElement: undefined }, + { frameId: 0 }, + ); + expect(tabSendMessageDataSpy).toBeCalledWith( + sender.tab, + "addToLockedVaultPendingNotifications", + { + commandToRetry: { message: { command: "openAutofillInlineMenu" }, sender }, + target: "overlay.background", + }, + ); + expect(openUnlockPopoutSpy).toHaveBeenCalled(); + }); + + it("opens the inline menu if the user auth status is unlocked", async () => { + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(sender.tab); + sendPortMessage(buttonMessageConnectorSpy, { + command: "autofillInlineMenuButtonClicked", + portKey, + }); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "openAutofillInlineMenu", + isFocusingFieldElement: false, + isOpeningFullInlineMenu: true, + authStatus: AuthenticationStatus.Unlocked, + }, + { frameId: 0 }, + ); + }); }); - describe("extension message handlers", () => { - beforeEach(() => { - jest - .spyOn(overlayBackground as any, "getAuthStatus") - .mockResolvedValue(AuthenticationStatus.Unlocked); + describe("triggerDelayedAutofillInlineMenuClosure message handler", () => { + it("skips triggering the delayed closure of the inline menu if a field is currently focused", async () => { + jest.useFakeTimers(); + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFocused", + isFieldCurrentlyFocused: true, + }); + await flushPromises(); + + sendPortMessage(buttonMessageConnectorSpy, { + command: "triggerDelayedAutofillInlineMenuClosure", + portKey, + }); + await flushPromises(); + jest.advanceTimersByTime(100); + + const message = { command: "triggerDelayedAutofillInlineMenuClosure" }; + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith(message); + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith(message); }); - describe("openAutofillOverlay message handler", () => { - it("opens the autofill overlay by sending a message to the current tab", async () => { - const sender = mock({ tab: { id: 1 } }); - jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(sender.tab); - jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); - - sendMockExtensionMessage({ command: "openAutofillOverlay" }); - await flushPromises(); - - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - sender.tab, - "openAutofillOverlay", - { - isFocusingFieldElement: false, - isOpeningFullOverlay: false, - authStatus: AuthenticationStatus.Unlocked, - }, - ); + it("sends a message to the button and list ports that triggers a delayed closure of the inline menu", async () => { + jest.useFakeTimers(); + sendPortMessage(buttonMessageConnectorSpy, { + command: "triggerDelayedAutofillInlineMenuClosure", + portKey, }); + await flushPromises(); + jest.advanceTimersByTime(100); + + const message = { command: "triggerDelayedAutofillInlineMenuClosure" }; + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith(message); + expect(listPortSpy.postMessage).toHaveBeenCalledWith(message); }); - describe("autofillOverlayElementClosed message handler", () => { - beforeEach(async () => { - await initOverlayElementPorts(); + it("triggers a single delayed closure if called again within a 100ms threshold", async () => { + jest.useFakeTimers(); + sendPortMessage(buttonMessageConnectorSpy, { + command: "triggerDelayedAutofillInlineMenuClosure", + portKey, }); - - it("disconnects any expired ports if the sender is not from the same page as the most recently focused field", () => { - const port1 = mock(); - const port2 = mock(); - overlayBackground["expiredPorts"] = [port1, port2]; - const sender = mock({ tab: { id: 1 } }); - const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - sendMockExtensionMessage( - { - command: "autofillOverlayElementClosed", - overlayElement: AutofillOverlayElement.Button, - }, - sender, - ); - - expect(port1.disconnect).toHaveBeenCalled(); - expect(port2.disconnect).toHaveBeenCalled(); + await flushPromises(); + jest.advanceTimersByTime(50); + sendPortMessage(buttonMessageConnectorSpy, { + command: "triggerDelayedAutofillInlineMenuClosure", + portKey, }); + await flushPromises(); + jest.advanceTimersByTime(100); - it("disconnects the button element port", () => { - sendMockExtensionMessage({ - command: "autofillOverlayElementClosed", - overlayElement: AutofillOverlayElement.Button, - }); + const message = { command: "triggerDelayedAutofillInlineMenuClosure" }; + expect(buttonPortSpy.postMessage).toHaveBeenCalledTimes(2); + expect(buttonPortSpy.postMessage).not.toHaveBeenNthCalledWith(1, message); + expect(buttonPortSpy.postMessage).toHaveBeenNthCalledWith(2, message); + expect(listPortSpy.postMessage).toHaveBeenCalledTimes(2); + expect(listPortSpy.postMessage).not.toHaveBeenNthCalledWith(1, message); + expect(listPortSpy.postMessage).toHaveBeenNthCalledWith(2, message); + }); + }); - expect(buttonPortSpy.disconnect).toHaveBeenCalled(); - expect(overlayBackground["overlayButtonPort"]).toBeNull(); + describe("autofillInlineMenuBlurred message handler", () => { + it("sends a message to the inline menu list to check if the element is focused", async () => { + sendPortMessage(buttonMessageConnectorSpy, { + command: "autofillInlineMenuBlurred", + portKey, }); + await flushPromises(); - it("disconnects the list element port", () => { - sendMockExtensionMessage({ - command: "autofillOverlayElementClosed", - overlayElement: AutofillOverlayElement.List, - }); - - expect(listPortSpy.disconnect).toHaveBeenCalled(); - expect(overlayBackground["overlayListPort"]).toBeNull(); + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuListFocused", }); }); + }); - describe("autofillOverlayAddNewVaultItem message handler", () => { - let sender: chrome.runtime.MessageSender; - beforeEach(() => { - sender = mock({ tab: { id: 1 } }); - jest - .spyOn(overlayBackground["cipherService"], "setAddEditCipherInfo") - .mockImplementation(); - jest.spyOn(overlayBackground as any, "openAddEditVaultItemPopout").mockImplementation(); + describe("redirectAutofillInlineMenuFocusOut message handler", () => { + it("ignores the redirect message if the direction is not provided", () => { + sendPortMessage(buttonMessageConnectorSpy, { + command: "redirectAutofillInlineMenuFocusOut", + portKey, }); - it("will not open the add edit popout window if the message does not have a login cipher provided", () => { - sendMockExtensionMessage({ command: "autofillOverlayAddNewVaultItem" }, sender); - - expect(overlayBackground["cipherService"].setAddEditCipherInfo).not.toHaveBeenCalled(); - expect(overlayBackground["openAddEditVaultItemPopout"]).not.toHaveBeenCalled(); - }); - - it("will open the add edit popout window after creating a new cipher", async () => { - jest.spyOn(BrowserApi, "sendMessage"); - - sendMockExtensionMessage( - { - command: "autofillOverlayAddNewVaultItem", - login: { - uri: "https://tacos.com", - hostname: "", - username: "username", - password: "password", - }, - }, - sender, - ); - await flushPromises(); - - expect(overlayBackground["cipherService"].setAddEditCipherInfo).toHaveBeenCalled(); - expect(BrowserApi.sendMessage).toHaveBeenCalledWith( - "inlineAutofillMenuRefreshAddEditCipher", - ); - expect(overlayBackground["openAddEditVaultItemPopout"]).toHaveBeenCalled(); - }); + expect(tabSendMessageDataSpy).not.toHaveBeenCalled(); }); - describe("getAutofillOverlayVisibility message handler", () => { - beforeEach(() => { - jest - .spyOn(overlayBackground as any, "getOverlayVisibility") - .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); + it("sends the redirect message if the direction is provided", () => { + sendPortMessage(buttonMessageConnectorSpy, { + command: "redirectAutofillInlineMenuFocusOut", + direction: RedirectFocusDirection.Next, + portKey, }); - it("will set the overlayVisibility property", async () => { - sendMockExtensionMessage({ command: "getAutofillOverlayVisibility" }); - await flushPromises(); - - expect(await overlayBackground["getOverlayVisibility"]()).toBe( - AutofillOverlayVisibility.OnFieldFocus, - ); - }); - - it("returns the overlayVisibility property", async () => { - const sendMessageSpy = jest.fn(); - - sendMockExtensionMessage( - { command: "getAutofillOverlayVisibility" }, - undefined, - sendMessageSpy, - ); - await flushPromises(); - - expect(sendMessageSpy).toHaveBeenCalledWith(AutofillOverlayVisibility.OnFieldFocus); - }); + expect(tabSendMessageDataSpy).toHaveBeenCalledWith( + sender.tab, + "redirectAutofillInlineMenuFocusOut", + { direction: RedirectFocusDirection.Next }, + ); }); + }); - describe("checkAutofillOverlayFocused message handler", () => { - beforeEach(async () => { - await initOverlayElementPorts(); + describe("updateAutofillInlineMenuColorScheme message handler", () => { + it("sends a message to the button port to update the inline menu color scheme", async () => { + sendPortMessage(buttonMessageConnectorSpy, { + command: "updateAutofillInlineMenuColorScheme", + portKey, }); + await flushPromises(); - it("will check if the overlay list is focused if the list port is open", () => { - sendMockExtensionMessage({ command: "checkAutofillOverlayFocused" }); - - expect(listPortSpy.postMessage).toHaveBeenCalledWith({ - command: "checkAutofillOverlayListFocused", - }); - expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ - command: "checkAutofillOverlayButtonFocused", - }); - }); - - it("will check if the overlay button is focused if the list port is not open", () => { - overlayBackground["overlayListPort"] = undefined; - - sendMockExtensionMessage({ command: "checkAutofillOverlayFocused" }); - - expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ - command: "checkAutofillOverlayButtonFocused", - }); - expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ - command: "checkAutofillOverlayListFocused", - }); - }); - }); - - describe("focusAutofillOverlayList message handler", () => { - it("will send a `focusOverlayList` message to the overlay list port", async () => { - await initOverlayElementPorts({ initList: true, initButton: false }); - - sendMockExtensionMessage({ command: "focusAutofillOverlayList" }); - - expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "focusOverlayList" }); - }); - }); - - describe("updateAutofillOverlayPosition message handler", () => { - beforeEach(async () => { - await overlayBackground["handlePortOnConnect"]( - createPortSpyMock(AutofillOverlayPort.List), - ); - listPortSpy = overlayBackground["overlayListPort"]; - - await overlayBackground["handlePortOnConnect"]( - createPortSpyMock(AutofillOverlayPort.Button), - ); - buttonPortSpy = overlayBackground["overlayButtonPort"]; - }); - - it("ignores updating the position if the overlay element type is not provided", () => { - sendMockExtensionMessage({ command: "updateAutofillOverlayPosition" }); - - expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: expect.anything(), - }); - expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: expect.anything(), - }); - }); - - it("skips updating the position if the most recently focused field is different than the message sender", () => { - const sender = mock({ tab: { id: 1 } }); - const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - sendMockExtensionMessage({ command: "updateAutofillOverlayPosition" }, sender); - - expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: expect.anything(), - }); - expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: expect.anything(), - }); - }); - - it("updates the overlay button's position", () => { - const focusedFieldData = createFocusedFieldDataMock(); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - sendMockExtensionMessage({ - command: "updateAutofillOverlayPosition", - overlayElement: AutofillOverlayElement.Button, - }); - - expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: { height: "2px", left: "4px", top: "2px", width: "2px" }, - }); - }); - - it("modifies the overlay button's height for medium sized input elements", () => { - const focusedFieldData = createFocusedFieldDataMock({ - focusedFieldRects: { top: 1, left: 2, height: 35, width: 4 }, - }); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - sendMockExtensionMessage({ - command: "updateAutofillOverlayPosition", - overlayElement: AutofillOverlayElement.Button, - }); - - expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: { height: "20px", left: "-22px", top: "8px", width: "20px" }, - }); - }); - - it("modifies the overlay button's height for large sized input elements", () => { - const focusedFieldData = createFocusedFieldDataMock({ - focusedFieldRects: { top: 1, left: 2, height: 50, width: 4 }, - }); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - sendMockExtensionMessage({ - command: "updateAutofillOverlayPosition", - overlayElement: AutofillOverlayElement.Button, - }); - - expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: { height: "27px", left: "-32px", top: "13px", width: "27px" }, - }); - }); - - it("takes into account the right padding of the focused field in positioning the button if the right padding of the field is larger than the left padding", () => { - const focusedFieldData = createFocusedFieldDataMock({ - focusedFieldStyles: { paddingRight: "20px", paddingLeft: "6px" }, - }); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - sendMockExtensionMessage({ - command: "updateAutofillOverlayPosition", - overlayElement: AutofillOverlayElement.Button, - }); - - expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: { height: "2px", left: "-18px", top: "2px", width: "2px" }, - }); - }); - - it("will post a message to the overlay list facilitating an update of the list's position", () => { - const sender = mock({ tab: { id: 1 } }); - const focusedFieldData = createFocusedFieldDataMock(); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - overlayBackground["updateOverlayPosition"]( - { overlayElement: AutofillOverlayElement.List }, - sender, - ); - sendMockExtensionMessage({ - command: "updateAutofillOverlayPosition", - overlayElement: AutofillOverlayElement.List, - }); - - expect(listPortSpy.postMessage).toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: { left: "2px", top: "4px", width: "4px" }, - }); - }); - }); - - describe("updateOverlayHidden", () => { - beforeEach(async () => { - await initOverlayElementPorts(); - }); - - it("returns early if the display value is not provided", () => { - const message = { - command: "updateAutofillOverlayHidden", - }; - - sendMockExtensionMessage(message); - - expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith(message); - expect(listPortSpy.postMessage).not.toHaveBeenCalledWith(message); - }); - - it("posts a message to the overlay button and list with the display value", () => { - const message = { command: "updateAutofillOverlayHidden", display: "none" }; - - sendMockExtensionMessage(message); - - expect(overlayBackground["overlayButtonPort"].postMessage).toHaveBeenCalledWith({ - command: "updateOverlayHidden", - styles: { - display: message.display, - }, - }); - expect(overlayBackground["overlayListPort"].postMessage).toHaveBeenCalledWith({ - command: "updateOverlayHidden", - styles: { - display: message.display, - }, - }); - }); - }); - - describe("collectPageDetailsResponse message handler", () => { - let sender: chrome.runtime.MessageSender; - const pageDetails1 = createAutofillPageDetailsMock({ - login: { username: "username1", password: "password1" }, - }); - const pageDetails2 = createAutofillPageDetailsMock({ - login: { username: "username2", password: "password2" }, - }); - - beforeEach(() => { - sender = mock({ tab: { id: 1 } }); - }); - - it("stores the page details provided by the message by the tab id of the sender", () => { - sendMockExtensionMessage( - { command: "collectPageDetailsResponse", details: pageDetails1 }, - sender, - ); - - expect(overlayBackground["pageDetailsForTab"][sender.tab.id]).toStrictEqual( - new Map([ - [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }], - ]), - ); - }); - - it("updates the page details for a tab that already has a set of page details stored ", () => { - const secondFrameSender = mock({ - tab: { id: 1 }, - frameId: 3, - }); - overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ - [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }], - ]); - - sendMockExtensionMessage( - { command: "collectPageDetailsResponse", details: pageDetails2 }, - secondFrameSender, - ); - - expect(overlayBackground["pageDetailsForTab"][sender.tab.id]).toStrictEqual( - new Map([ - [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }], - [ - secondFrameSender.frameId, - { - frameId: secondFrameSender.frameId, - tab: secondFrameSender.tab, - details: pageDetails2, - }, - ], - ]), - ); - }); - }); - - describe("unlockCompleted message handler", () => { - let getAuthStatusSpy: jest.SpyInstance; - - beforeEach(() => { - overlayBackground["userAuthStatus"] = AuthenticationStatus.LoggedOut; - jest.spyOn(BrowserApi, "tabSendMessageData"); - getAuthStatusSpy = jest - .spyOn(overlayBackground as any, "getAuthStatus") - .mockImplementation(() => { - overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; - return Promise.resolve(AuthenticationStatus.Unlocked); - }); - }); - - it("updates the user's auth status but does not open the overlay", async () => { - const message = { - command: "unlockCompleted", - data: { - commandToRetry: { message: { command: "" } }, - }, - }; - - sendMockExtensionMessage(message); - await flushPromises(); - - expect(getAuthStatusSpy).toHaveBeenCalled(); - expect(BrowserApi.tabSendMessageData).not.toHaveBeenCalled(); - }); - - it("updates user's auth status and opens the overlay if a follow up command is provided", async () => { - const sender = mock({ tab: { id: 1 } }); - const message = { - command: "unlockCompleted", - data: { - commandToRetry: { message: { command: "openAutofillOverlay" } }, - }, - }; - jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(sender.tab); - - sendMockExtensionMessage(message); - await flushPromises(); - - expect(getAuthStatusSpy).toHaveBeenCalled(); - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - sender.tab, - "openAutofillOverlay", - { - isFocusingFieldElement: true, - isOpeningFullOverlay: false, - authStatus: AuthenticationStatus.Unlocked, - }, - ); - }); - }); - - describe("extension messages that trigger an update of the inline menu ciphers", () => { - const extensionMessages = [ - "addedCipher", - "addEditCipherSubmitted", - "editedCipher", - "deletedCipher", - ]; - - beforeEach(() => { - jest.spyOn(overlayBackground, "updateOverlayCiphers").mockImplementation(); - }); - - extensionMessages.forEach((message) => { - it(`triggers an update of the overlay ciphers when the ${message} message is received`, () => { - sendMockExtensionMessage({ command: message }); - expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled(); - }); + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuColorScheme", }); }); }); }); - describe("handlePortOnConnect", () => { - beforeEach(() => { - jest.spyOn(overlayBackground as any, "updateOverlayPosition").mockImplementation(); - jest.spyOn(overlayBackground as any, "getAuthStatus").mockImplementation(); - jest.spyOn(overlayBackground as any, "getTranslations").mockImplementation(); - jest.spyOn(overlayBackground as any, "getOverlayCipherData").mockImplementation(); + describe("inline menu list message handlers", () => { + let sender: chrome.runtime.MessageSender; + const portKey = "inlineMenuListPort"; + + beforeEach(async () => { + sender = mock({ tab: { id: 1 } }); + portKeyForTabSpy[sender.tab.id] = portKey; + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + await initOverlayElementPorts(); + listMessageConnectorSpy.sender = sender; + openUnlockPopoutSpy.mockImplementation(); }); + describe("checkAutofillInlineMenuButtonFocused message handler", () => { + it("sends a message to the inline menu button to check if the element is focused", async () => { + sendPortMessage(listMessageConnectorSpy, { + command: "checkAutofillInlineMenuButtonFocused", + portKey, + }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuButtonFocused", + }); + }); + }); + + describe("autofillInlineMenuBlurred message handler", () => { + it("sends a message to the inline menu button to check if the element is focused", async () => { + sendPortMessage(listMessageConnectorSpy, { + command: "autofillInlineMenuBlurred", + portKey, + }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuButtonFocused", + }); + }); + }); + + describe("unlockVault message handler", () => { + it("opens the unlock vault popout", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + tabsSendMessageSpy.mockImplementation(); + + sendPortMessage(listMessageConnectorSpy, { command: "unlockVault", portKey }); + await flushPromises(); + + expect(openUnlockPopoutSpy).toHaveBeenCalled(); + }); + }); + + describe("fillAutofillInlineMenuCipher message handler", () => { + const pageDetails = createAutofillPageDetailsMock({ + login: { username: "username1", password: "password1" }, + }); + + it("ignores the fill request if the overlay cipher id is not provided", async () => { + sendPortMessage(listMessageConnectorSpy, { + command: "fillAutofillInlineMenuCipher", + portKey, + }); + await flushPromises(); + + expect(autofillService.isPasswordRepromptRequired).not.toHaveBeenCalled(); + expect(autofillService.doAutoFill).not.toHaveBeenCalled(); + }); + + it("ignores the fill request if the tab does not contain any identified page details", async () => { + sendPortMessage(listMessageConnectorSpy, { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "inline-menu-cipher-1", + portKey, + }); + await flushPromises(); + + expect(autofillService.isPasswordRepromptRequired).not.toHaveBeenCalled(); + expect(autofillService.doAutoFill).not.toHaveBeenCalled(); + }); + + it("ignores the fill request if a master password reprompt is required", async () => { + const cipher = mock({ + reprompt: CipherRepromptType.Password, + type: CipherType.Login, + }); + overlayBackground["inlineMenuCiphers"] = new Map([["inline-menu-cipher-1", cipher]]); + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails }], + ]); + autofillService.isPasswordRepromptRequired.mockResolvedValue(true); + + sendPortMessage(listMessageConnectorSpy, { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "inline-menu-cipher-1", + portKey, + }); + await flushPromises(); + + expect(autofillService.isPasswordRepromptRequired).toHaveBeenCalledWith(cipher, sender.tab); + expect(autofillService.doAutoFill).not.toHaveBeenCalled(); + }); + + it("auto-fills the selected cipher and move it to the top of the front of the ciphers map", async () => { + const cipher1 = mock({ id: "inline-menu-cipher-1" }); + const cipher2 = mock({ id: "inline-menu-cipher-2" }); + const cipher3 = mock({ id: "inline-menu-cipher-3" }); + overlayBackground["inlineMenuCiphers"] = new Map([ + ["inline-menu-cipher-1", cipher1], + ["inline-menu-cipher-2", cipher2], + ["inline-menu-cipher-3", cipher3], + ]); + const pageDetailsForTab = { + frameId: sender.frameId, + tab: sender.tab, + details: pageDetails, + }; + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, pageDetailsForTab], + ]); + autofillService.isPasswordRepromptRequired.mockResolvedValue(false); + + sendPortMessage(listMessageConnectorSpy, { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "inline-menu-cipher-2", + portKey, + }); + await flushPromises(); + + expect(autofillService.isPasswordRepromptRequired).toHaveBeenCalledWith( + cipher2, + sender.tab, + ); + expect(autofillService.doAutoFill).toHaveBeenCalledWith({ + tab: sender.tab, + cipher: cipher2, + pageDetails: [pageDetailsForTab], + fillNewPassword: true, + allowTotpAutofill: true, + }); + expect(overlayBackground["inlineMenuCiphers"].entries()).toStrictEqual( + new Map([ + ["inline-menu-cipher-2", cipher2], + ["inline-menu-cipher-1", cipher1], + ["inline-menu-cipher-3", cipher3], + ]).entries(), + ); + }); + + it("copies the cipher's totp code to the clipboard after filling", async () => { + const cipher1 = mock({ id: "inline-menu-cipher-1" }); + overlayBackground["inlineMenuCiphers"] = new Map([["inline-menu-cipher-1", cipher1]]); + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails }], + ]); + autofillService.isPasswordRepromptRequired.mockResolvedValue(false); + const copyToClipboardSpy = jest + .spyOn(overlayBackground["platformUtilsService"], "copyToClipboard") + .mockImplementation(); + autofillService.doAutoFill.mockResolvedValue("totp-code"); + + sendPortMessage(listMessageConnectorSpy, { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "inline-menu-cipher-2", + portKey, + }); + await flushPromises(); + + expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code"); + }); + }); + + describe("addNewVaultItem message handler", () => { + it("skips sending the `addNewVaultItemFromOverlay` message if the sender tab does not contain the focused field", async () => { + const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + await flushPromises(); + + sendPortMessage(listMessageConnectorSpy, { command: "addNewVaultItem", portKey }); + await flushPromises(); + + expect(tabsSendMessageSpy).not.toHaveBeenCalled(); + }); + + it("sends a message to the tab to add a new vault item", async () => { + const focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); + await flushPromises(); + + sendPortMessage(listMessageConnectorSpy, { command: "addNewVaultItem", portKey }); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { command: "addNewVaultItemFromOverlay" }, + { frameId: sender.frameId }, + ); + }); + }); + + describe("viewSelectedCipher message handler", () => { + let openViewVaultItemPopoutSpy: jest.SpyInstance; + + beforeEach(() => { + openViewVaultItemPopoutSpy = jest + .spyOn(overlayBackground as any, "openViewVaultItemPopout") + .mockImplementation(); + }); + + it("returns early if the passed cipher ID does not match one of the inline menu ciphers", async () => { + overlayBackground["inlineMenuCiphers"] = new Map([ + ["inline-menu-cipher-0", mock({ id: "inline-menu-cipher-0" })], + ]); + + sendPortMessage(listMessageConnectorSpy, { + command: "viewSelectedCipher", + inlineMenuCipherId: "inline-menu-cipher-1", + portKey, + }); + await flushPromises(); + + expect(openViewVaultItemPopoutSpy).not.toHaveBeenCalled(); + }); + + it("will open the view vault item popout with the selected cipher", async () => { + const cipher = mock({ id: "inline-menu-cipher-1" }); + overlayBackground["inlineMenuCiphers"] = new Map([ + ["inline-menu-cipher-0", mock({ id: "inline-menu-cipher-0" })], + ["inline-menu-cipher-1", cipher], + ]); + + sendPortMessage(listMessageConnectorSpy, { + command: "viewSelectedCipher", + inlineMenuCipherId: "inline-menu-cipher-1", + portKey, + }); + await flushPromises(); + + expect(openViewVaultItemPopoutSpy).toHaveBeenCalledWith(sender.tab, { + cipherId: cipher.id, + action: SHOW_AUTOFILL_BUTTON, + }); + }); + }); + + describe("redirectAutofillInlineMenuFocusOut message handler", () => { + it("redirects focus out of the inline menu list", async () => { + sendPortMessage(listMessageConnectorSpy, { + command: "redirectAutofillInlineMenuFocusOut", + direction: RedirectFocusDirection.Next, + portKey, + }); + await flushPromises(); + + expect(tabSendMessageDataSpy).toHaveBeenCalledWith( + sender.tab, + "redirectAutofillInlineMenuFocusOut", + { direction: RedirectFocusDirection.Next }, + ); + }); + }); + + describe("updateAutofillInlineMenuListHeight message handler", () => { + it("sends a message to the list port to update the menu iframe position", () => { + sendPortMessage(listMessageConnectorSpy, { + command: "updateAutofillInlineMenuListHeight", + styles: { height: "100px" }, + portKey, + }); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuPosition", + styles: { height: "100px" }, + }); + }); + }); + }); + + describe("handle web navigation on committed events", () => { + describe("navigation event occurs in the top frame of the tab", () => { + it("removes the collected page details", async () => { + const sender = mock({ + tabId: 1, + frameId: 0, + }); + overlayBackground["pageDetailsForTab"][sender.tabId] = new Map([ + [sender.frameId, createPageDetailMock()], + ]); + + triggerWebNavigationOnCommittedEvent(sender); + await flushPromises(); + + expect(overlayBackground["pageDetailsForTab"][sender.tabId]).toBe(undefined); + }); + + it("clears the sub frames associated with the tab", () => { + const sender = mock({ + tabId: 1, + frameId: 0, + }); + const subFrameId = 10; + overlayBackground["subFrameOffsetsForTab"][sender.tabId] = new Map([ + [subFrameId, mock()], + ]); + + triggerWebNavigationOnCommittedEvent(sender); + + expect(overlayBackground["subFrameOffsetsForTab"][sender.tabId]).toBe(undefined); + }); + }); + + describe("navigation event occurs within sub frame", () => { + it("clears the sub frame offsets for the current frame", () => { + const sender = mock({ + tabId: 1, + frameId: 1, + }); + overlayBackground["subFrameOffsetsForTab"][sender.tabId] = new Map([ + [sender.frameId, mock()], + ]); + + triggerWebNavigationOnCommittedEvent(sender); + + expect(overlayBackground["subFrameOffsetsForTab"][sender.tabId].get(sender.frameId)).toBe( + undefined, + ); + }); + }); + }); + + describe("handle port onConnect", () => { it("skips setting up the overlay port if the port connection is not for an overlay element", async () => { const port = createPortSpyMock("not-an-overlay-element"); - await overlayBackground["handlePortOnConnect"](port); + triggerPortOnConnectEvent(port); + await flushPromises(); expect(port.onMessage.addListener).not.toHaveBeenCalled(); expect(port.postMessage).not.toHaveBeenCalled(); }); - it("sets up the overlay list port if the port connection is for the overlay list", async () => { - await initOverlayElementPorts({ initList: true, initButton: false }); + it("generates a random 12 character string used to validate port messages from the tab", async () => { + const port = createPortSpyMock(AutofillOverlayPort.Button); + overlayBackground["inlineMenuButtonPort"] = port; + + triggerPortOnConnectEvent(port); await flushPromises(); - expect(overlayBackground["overlayButtonPort"]).toBeUndefined(); - expect(listPortSpy.onMessage.addListener).toHaveBeenCalled(); - expect(listPortSpy.postMessage).toHaveBeenCalled(); - expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled(); - expect(chrome.runtime.getURL).toHaveBeenCalledWith("overlay/list.css"); - expect(overlayBackground["getTranslations"]).toHaveBeenCalled(); - expect(overlayBackground["getOverlayCipherData"]).toHaveBeenCalled(); - expect(overlayBackground["updateOverlayPosition"]).toHaveBeenCalledWith( - { overlayElement: AutofillOverlayElement.List }, - listPortSpy.sender, - ); - }); - - it("sets up the overlay button port if the port connection is for the overlay button", async () => { - await initOverlayElementPorts({ initList: false, initButton: true }); - await flushPromises(); - - expect(overlayBackground["overlayListPort"]).toBeUndefined(); - expect(buttonPortSpy.onMessage.addListener).toHaveBeenCalled(); - expect(buttonPortSpy.postMessage).toHaveBeenCalled(); - expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled(); - expect(chrome.runtime.getURL).toHaveBeenCalledWith("overlay/button.css"); - expect(overlayBackground["getTranslations"]).toHaveBeenCalled(); - expect(overlayBackground["updateOverlayPosition"]).toHaveBeenCalledWith( - { overlayElement: AutofillOverlayElement.Button }, - buttonPortSpy.sender, - ); + expect(portKeyForTabSpy[port.sender.tab.id]).toHaveLength(12); }); it("stores an existing overlay port so that it can be disconnected at a later time", async () => { - overlayBackground["overlayButtonPort"] = mock(); + overlayBackground["inlineMenuButtonPort"] = mock(); await initOverlayElementPorts({ initList: false, initButton: true }); await flushPromises(); expect(overlayBackground["expiredPorts"].length).toBe(1); }); + }); - it("gets the system theme", async () => { - themeStateService.selectedTheme$ = of(ThemeType.System); + describe("handle overlay element port onMessage", () => { + let sender: chrome.runtime.MessageSender; + const portKey = "inlineMenuListPort"; - await initOverlayElementPorts({ initList: true, initButton: false }); + beforeEach(async () => { + sender = mock({ tab: { id: 1 } }); + portKeyForTabSpy[sender.tab.id] = portKey; + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + await initOverlayElementPorts(); + listMessageConnectorSpy.sender = sender; + openUnlockPopoutSpy.mockImplementation(); + }); + + it("ignores messages that do not contain a valid portKey", async () => { + triggerPortOnMessageEvent(buttonMessageConnectorSpy, { + command: "autofillInlineMenuBlurred", + }); await flushPromises(); - expect(listPortSpy.postMessage).toHaveBeenCalledWith( - expect.objectContaining({ theme: ThemeType.System }), - ); + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuListFocused", + }); + }); + + it("ignores messages from ports that are not listened to", () => { + triggerPortOnMessageEvent(buttonPortSpy, { + command: "autofillInlineMenuBlurred", + portKey, + }); + + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuListFocused", + }); }); }); - describe("handleOverlayElementPortMessage", () => { - beforeEach(async () => { + describe("handle port onDisconnect", () => { + it("sets the disconnected port to a `null` value", async () => { await initOverlayElementPorts(); - overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; - }); - it("ignores port messages that do not contain a handler", () => { - jest.spyOn(overlayBackground as any, "checkOverlayButtonFocused").mockImplementation(); + triggerPortOnDisconnectEvent(buttonPortSpy); + triggerPortOnDisconnectEvent(listPortSpy); + await flushPromises(); - sendPortMessage(buttonPortSpy, { command: "checkAutofillOverlayButtonFocused" }); - - expect(overlayBackground["checkOverlayButtonFocused"]).not.toHaveBeenCalled(); - }); - - describe("overlay button message handlers", () => { - it("unlocks the vault if the user auth status is not unlocked", () => { - overlayBackground["userAuthStatus"] = AuthenticationStatus.LoggedOut; - jest.spyOn(overlayBackground as any, "unlockVault").mockImplementation(); - - sendPortMessage(buttonPortSpy, { command: "overlayButtonClicked" }); - - expect(overlayBackground["unlockVault"]).toHaveBeenCalled(); - }); - - it("opens the autofill overlay if the auth status is unlocked", () => { - jest.spyOn(overlayBackground as any, "openOverlay").mockImplementation(); - - sendPortMessage(buttonPortSpy, { command: "overlayButtonClicked" }); - - expect(overlayBackground["openOverlay"]).toHaveBeenCalled(); - }); - - describe("closeAutofillOverlay", () => { - it("sends a `closeOverlay` message to the sender tab", () => { - jest.spyOn(BrowserApi, "tabSendMessageData"); - - sendPortMessage(buttonPortSpy, { command: "closeAutofillOverlay" }); - - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - buttonPortSpy.sender.tab, - "closeAutofillOverlay", - { forceCloseOverlay: false }, - ); - }); - }); - - describe("forceCloseAutofillOverlay", () => { - it("sends a `closeOverlay` message to the sender tab with a `forceCloseOverlay` flag of `true` set", () => { - jest.spyOn(BrowserApi, "tabSendMessageData"); - - sendPortMessage(buttonPortSpy, { command: "forceCloseAutofillOverlay" }); - - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - buttonPortSpy.sender.tab, - "closeAutofillOverlay", - { forceCloseOverlay: true }, - ); - }); - }); - - describe("overlayPageBlurred", () => { - it("checks if the overlay list is focused", () => { - jest.spyOn(overlayBackground as any, "checkOverlayListFocused"); - - sendPortMessage(buttonPortSpy, { command: "overlayPageBlurred" }); - - expect(overlayBackground["checkOverlayListFocused"]).toHaveBeenCalled(); - }); - }); - - describe("redirectOverlayFocusOut", () => { - beforeEach(() => { - jest.spyOn(BrowserApi, "tabSendMessageData"); - }); - - it("ignores the redirect message if the direction is not provided", () => { - sendPortMessage(buttonPortSpy, { command: "redirectOverlayFocusOut" }); - - expect(BrowserApi.tabSendMessageData).not.toHaveBeenCalled(); - }); - - it("sends the redirect message if the direction is provided", () => { - sendPortMessage(buttonPortSpy, { - command: "redirectOverlayFocusOut", - direction: RedirectFocusDirection.Next, - }); - - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - buttonPortSpy.sender.tab, - "redirectOverlayFocusOut", - { direction: RedirectFocusDirection.Next }, - ); - }); - }); - }); - - describe("overlay list message handlers", () => { - describe("checkAutofillOverlayButtonFocused", () => { - it("checks on the focus state of the overlay button", () => { - jest.spyOn(overlayBackground as any, "checkOverlayButtonFocused").mockImplementation(); - - sendPortMessage(listPortSpy, { command: "checkAutofillOverlayButtonFocused" }); - - expect(overlayBackground["checkOverlayButtonFocused"]).toHaveBeenCalled(); - }); - }); - - describe("forceCloseAutofillOverlay", () => { - it("sends a `closeOverlay` message to the sender tab with a `forceCloseOverlay` flag of `true` set", () => { - jest.spyOn(BrowserApi, "tabSendMessageData"); - - sendPortMessage(listPortSpy, { command: "forceCloseAutofillOverlay" }); - - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - listPortSpy.sender.tab, - "closeAutofillOverlay", - { forceCloseOverlay: true }, - ); - }); - }); - - describe("overlayPageBlurred", () => { - it("checks on the focus state of the overlay button", () => { - jest.spyOn(overlayBackground as any, "checkOverlayButtonFocused").mockImplementation(); - - sendPortMessage(listPortSpy, { command: "overlayPageBlurred" }); - - expect(overlayBackground["checkOverlayButtonFocused"]).toHaveBeenCalled(); - }); - }); - - describe("unlockVault", () => { - it("closes the autofill overlay and opens the unlock popout", async () => { - jest.spyOn(overlayBackground as any, "closeOverlay").mockImplementation(); - jest.spyOn(overlayBackground as any, "openUnlockPopout").mockImplementation(); - jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); - - sendPortMessage(listPortSpy, { command: "unlockVault" }); - await flushPromises(); - - expect(overlayBackground["closeOverlay"]).toHaveBeenCalledWith(listPortSpy); - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - listPortSpy.sender.tab, - "addToLockedVaultPendingNotifications", - { - commandToRetry: { - message: { command: "openAutofillOverlay" }, - sender: listPortSpy.sender, - }, - target: "overlay.background", - }, - ); - expect(overlayBackground["openUnlockPopout"]).toHaveBeenCalledWith( - listPortSpy.sender.tab, - true, - ); - }); - }); - - describe("fillSelectedListItem", () => { - let getLoginCiphersSpy: jest.SpyInstance; - let isPasswordRepromptRequiredSpy: jest.SpyInstance; - let doAutoFillSpy: jest.SpyInstance; - let sender: chrome.runtime.MessageSender; - const pageDetails = createAutofillPageDetailsMock({ - login: { username: "username1", password: "password1" }, - }); - - beforeEach(() => { - getLoginCiphersSpy = jest.spyOn(overlayBackground["overlayLoginCiphers"], "get"); - isPasswordRepromptRequiredSpy = jest.spyOn( - overlayBackground["autofillService"], - "isPasswordRepromptRequired", - ); - doAutoFillSpy = jest.spyOn(overlayBackground["autofillService"], "doAutoFill"); - sender = mock({ tab: { id: 1 } }); - }); - - it("ignores the fill request if the overlay cipher id is not provided", async () => { - sendPortMessage(listPortSpy, { command: "fillSelectedListItem" }); - await flushPromises(); - - expect(getLoginCiphersSpy).not.toHaveBeenCalled(); - expect(isPasswordRepromptRequiredSpy).not.toHaveBeenCalled(); - expect(doAutoFillSpy).not.toHaveBeenCalled(); - }); - - it("ignores the fill request if the tab does not contain any identified page details", async () => { - sendPortMessage(listPortSpy, { - command: "fillSelectedListItem", - overlayCipherId: "overlay-cipher-1", - }); - await flushPromises(); - - expect(getLoginCiphersSpy).not.toHaveBeenCalled(); - expect(isPasswordRepromptRequiredSpy).not.toHaveBeenCalled(); - expect(doAutoFillSpy).not.toHaveBeenCalled(); - }); - - it("ignores the fill request if a master password reprompt is required", async () => { - const cipher = mock({ - reprompt: CipherRepromptType.Password, - type: CipherType.Login, - }); - overlayBackground["overlayLoginCiphers"] = new Map([["overlay-cipher-1", cipher]]); - overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ - [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails }], - ]); - getLoginCiphersSpy = jest.spyOn(overlayBackground["overlayLoginCiphers"], "get"); - isPasswordRepromptRequiredSpy.mockResolvedValue(true); - - sendPortMessage(listPortSpy, { - command: "fillSelectedListItem", - overlayCipherId: "overlay-cipher-1", - }); - await flushPromises(); - - expect(getLoginCiphersSpy).toHaveBeenCalled(); - expect(isPasswordRepromptRequiredSpy).toHaveBeenCalledWith( - cipher, - listPortSpy.sender.tab, - ); - expect(doAutoFillSpy).not.toHaveBeenCalled(); - }); - - it("auto-fills the selected cipher and move it to the top of the front of the ciphers map", async () => { - const cipher1 = mock({ id: "overlay-cipher-1" }); - const cipher2 = mock({ id: "overlay-cipher-2" }); - const cipher3 = mock({ id: "overlay-cipher-3" }); - overlayBackground["overlayLoginCiphers"] = new Map([ - ["overlay-cipher-1", cipher1], - ["overlay-cipher-2", cipher2], - ["overlay-cipher-3", cipher3], - ]); - const pageDetailsForTab = { - frameId: sender.frameId, - tab: sender.tab, - details: pageDetails, - }; - overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ - [sender.frameId, pageDetailsForTab], - ]); - isPasswordRepromptRequiredSpy.mockResolvedValue(false); - - sendPortMessage(listPortSpy, { - command: "fillSelectedListItem", - overlayCipherId: "overlay-cipher-2", - }); - await flushPromises(); - - expect(isPasswordRepromptRequiredSpy).toHaveBeenCalledWith( - cipher2, - listPortSpy.sender.tab, - ); - expect(doAutoFillSpy).toHaveBeenCalledWith({ - tab: listPortSpy.sender.tab, - cipher: cipher2, - pageDetails: [pageDetailsForTab], - fillNewPassword: true, - allowTotpAutofill: true, - }); - expect(overlayBackground["overlayLoginCiphers"].entries()).toStrictEqual( - new Map([ - ["overlay-cipher-2", cipher2], - ["overlay-cipher-1", cipher1], - ["overlay-cipher-3", cipher3], - ]).entries(), - ); - }); - - it("copies the cipher's totp code to the clipboard after filling", async () => { - const cipher1 = mock({ id: "overlay-cipher-1" }); - overlayBackground["overlayLoginCiphers"] = new Map([["overlay-cipher-1", cipher1]]); - overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ - [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails }], - ]); - isPasswordRepromptRequiredSpy.mockResolvedValue(false); - const copyToClipboardSpy = jest - .spyOn(overlayBackground["platformUtilsService"], "copyToClipboard") - .mockImplementation(); - doAutoFillSpy.mockReturnValueOnce("totp-code"); - - sendPortMessage(listPortSpy, { - command: "fillSelectedListItem", - overlayCipherId: "overlay-cipher-2", - }); - await flushPromises(); - - expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code"); - }); - }); - - describe("getNewVaultItemDetails", () => { - it("will send an addNewVaultItemFromOverlay message", async () => { - jest.spyOn(BrowserApi, "tabSendMessage"); - - sendPortMessage(listPortSpy, { command: "addNewVaultItem" }); - await flushPromises(); - - expect(BrowserApi.tabSendMessage).toHaveBeenCalledWith(listPortSpy.sender.tab, { - command: "addNewVaultItemFromOverlay", - }); - }); - }); - - describe("viewSelectedCipher", () => { - let openViewVaultItemPopoutSpy: jest.SpyInstance; - - beforeEach(() => { - openViewVaultItemPopoutSpy = jest - .spyOn(overlayBackground as any, "openViewVaultItemPopout") - .mockImplementation(); - }); - - it("returns early if the passed cipher ID does not match one of the overlay login ciphers", async () => { - overlayBackground["overlayLoginCiphers"] = new Map([ - ["overlay-cipher-0", mock({ id: "overlay-cipher-0" })], - ]); - - sendPortMessage(listPortSpy, { - command: "viewSelectedCipher", - overlayCipherId: "overlay-cipher-1", - }); - await flushPromises(); - - expect(openViewVaultItemPopoutSpy).not.toHaveBeenCalled(); - }); - - it("will open the view vault item popout with the selected cipher", async () => { - const cipher = mock({ id: "overlay-cipher-1" }); - overlayBackground["overlayLoginCiphers"] = new Map([ - ["overlay-cipher-0", mock({ id: "overlay-cipher-0" })], - ["overlay-cipher-1", cipher], - ]); - - sendPortMessage(listPortSpy, { - command: "viewSelectedCipher", - overlayCipherId: "overlay-cipher-1", - }); - await flushPromises(); - - expect(overlayBackground["openViewVaultItemPopout"]).toHaveBeenCalledWith( - listPortSpy.sender.tab, - { - cipherId: cipher.id, - action: SHOW_AUTOFILL_BUTTON, - }, - ); - }); - }); - - describe("redirectOverlayFocusOut", () => { - it("redirects focus out of the overlay list", async () => { - const message = { - command: "redirectOverlayFocusOut", - direction: RedirectFocusDirection.Next, - }; - const redirectOverlayFocusOutSpy = jest.spyOn( - overlayBackground as any, - "redirectOverlayFocusOut", - ); - - sendPortMessage(listPortSpy, message); - await flushPromises(); - - expect(redirectOverlayFocusOutSpy).toHaveBeenCalledWith(message, listPortSpy); - }); - }); + expect(overlayBackground["inlineMenuListPort"]).toBeNull(); + expect(overlayBackground["inlineMenuButtonPort"]).toBeNull(); }); }); }); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 2f80790134e..3b770af2004 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -1,13 +1,18 @@ -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, merge, Subject, throttleTime } from "rxjs"; +import { debounceTime, switchMap } from "rxjs/operators"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { SHOW_AUTOFILL_BUTTON } from "@bitwarden/common/autofill/constants"; +import { + AutofillOverlayVisibility, + SHOW_AUTOFILL_BUTTON, +} from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; @@ -21,80 +26,118 @@ import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { openUnlockPopout } from "../../auth/popup/utils/auth-popout-window"; import { BrowserApi } from "../../platform/browser/browser-api"; import { - openViewVaultItemPopout, openAddEditVaultItemPopout, + openViewVaultItemPopout, } from "../../vault/popup/utils/vault-popout-window"; -import { AutofillService, PageDetail } from "../services/abstractions/autofill.service"; -import { AutofillOverlayElement, AutofillOverlayPort } from "../utils/autofill-overlay.enum"; +import { + AutofillOverlayElement, + AutofillOverlayPort, + MAX_SUB_FRAME_DEPTH, +} from "../enums/autofill-overlay.enum"; +import { AutofillService } from "../services/abstractions/autofill.service"; +import { generateRandomChars } from "../utils"; import { LockedVaultPendingNotificationsData } from "./abstractions/notification.background"; import { FocusedFieldData, - OverlayBackgroundExtensionMessageHandlers, - OverlayButtonPortMessageHandlers, - OverlayCipherData, - OverlayListPortMessageHandlers, + OverlayAddNewItemMessage, OverlayBackground as OverlayBackgroundInterface, OverlayBackgroundExtensionMessage, - OverlayAddNewItemMessage, + OverlayBackgroundExtensionMessageHandlers, + InlineMenuButtonPortMessageHandlers, + InlineMenuCipherData, + InlineMenuListPortMessageHandlers, OverlayPortMessage, - WebsiteIconData, + PageDetailsForTab, + SubFrameOffsetData, + SubFrameOffsetsForTab, + CloseInlineMenuMessage, + ToggleInlineMenuHiddenMessage, } from "./abstractions/overlay.background"; -class OverlayBackground implements OverlayBackgroundInterface { +export class OverlayBackground implements OverlayBackgroundInterface { private readonly openUnlockPopout = openUnlockPopout; private readonly openViewVaultItemPopout = openViewVaultItemPopout; private readonly openAddEditVaultItemPopout = openAddEditVaultItemPopout; - private overlayLoginCiphers: Map = new Map(); - private pageDetailsForTab: Record< - chrome.runtime.MessageSender["tab"]["id"], - Map - > = {}; - private userAuthStatus: AuthenticationStatus = AuthenticationStatus.LoggedOut; - private overlayButtonPort: chrome.runtime.Port; - private overlayListPort: chrome.runtime.Port; + private pageDetailsForTab: PageDetailsForTab = {}; + private subFrameOffsetsForTab: SubFrameOffsetsForTab = {}; + private portKeyForTab: Record = {}; private expiredPorts: chrome.runtime.Port[] = []; + private inlineMenuButtonPort: chrome.runtime.Port; + private inlineMenuListPort: chrome.runtime.Port; + private inlineMenuCiphers: Map = new Map(); + private inlineMenuPageTranslations: Record; + private delayedCloseTimeout: number | NodeJS.Timeout; + private startInlineMenuFadeInSubject = new Subject(); + private cancelInlineMenuFadeInSubject = new Subject(); + private startUpdateInlineMenuPositionSubject = new Subject(); + private cancelUpdateInlineMenuPositionSubject = new Subject(); + private repositionInlineMenuSubject = new Subject(); + private rebuildSubFrameOffsetsSubject = new Subject(); private focusedFieldData: FocusedFieldData; - private overlayPageTranslations: Record; + private isFieldCurrentlyFocused: boolean = false; + private isFieldCurrentlyFilling: boolean = false; + private isInlineMenuButtonVisible: boolean = false; + private isInlineMenuListVisible: boolean = false; private iconsServerUrl: string; private readonly extensionMessageHandlers: OverlayBackgroundExtensionMessageHandlers = { - openAutofillOverlay: () => this.openOverlay(false), autofillOverlayElementClosed: ({ message, sender }) => this.overlayElementClosed(message, sender), autofillOverlayAddNewVaultItem: ({ message, sender }) => this.addNewVaultItem(message, sender), - getAutofillOverlayVisibility: () => this.getOverlayVisibility(), - checkAutofillOverlayFocused: () => this.checkOverlayFocused(), - focusAutofillOverlayList: () => this.focusOverlayList(), - updateAutofillOverlayPosition: ({ message, sender }) => - this.updateOverlayPosition(message, sender), - updateAutofillOverlayHidden: ({ message }) => this.updateOverlayHidden(message), + triggerAutofillOverlayReposition: ({ sender }) => this.triggerOverlayReposition(sender), + checkIsInlineMenuCiphersPopulated: ({ sender }) => + this.checkIsInlineMenuCiphersPopulated(sender), updateFocusedFieldData: ({ message, sender }) => this.setFocusedFieldData(message, sender), + updateIsFieldCurrentlyFocused: ({ message }) => this.updateIsFieldCurrentlyFocused(message), + checkIsFieldCurrentlyFocused: () => this.checkIsFieldCurrentlyFocused(), + updateIsFieldCurrentlyFilling: ({ message }) => this.updateIsFieldCurrentlyFilling(message), + checkIsFieldCurrentlyFilling: () => this.checkIsFieldCurrentlyFilling(), + getAutofillInlineMenuVisibility: () => this.getInlineMenuVisibility(), + openAutofillInlineMenu: () => this.openInlineMenu(false), + closeAutofillInlineMenu: ({ message, sender }) => this.closeInlineMenu(sender, message), + checkAutofillInlineMenuFocused: ({ sender }) => this.checkInlineMenuFocused(sender), + focusAutofillInlineMenuList: () => this.focusInlineMenuList(), + updateAutofillInlineMenuPosition: ({ message, sender }) => + this.updateInlineMenuPosition(message, sender), + updateAutofillInlineMenuElementIsVisibleStatus: ({ message, sender }) => + this.updateInlineMenuElementIsVisibleStatus(message, sender), + checkIsAutofillInlineMenuButtonVisible: () => this.checkIsInlineMenuButtonVisible(), + checkIsAutofillInlineMenuListVisible: () => this.checkIsInlineMenuListVisible(), + getCurrentTabFrameId: ({ sender }) => this.getSenderFrameId(sender), + updateSubFrameData: ({ message, sender }) => this.updateSubFrameData(message, sender), + triggerSubFrameFocusInRebuild: ({ sender }) => this.triggerSubFrameFocusInRebuild(sender), + destroyAutofillInlineMenuListeners: ({ message, sender }) => + this.triggerDestroyInlineMenuListeners(sender.tab, message.subFrameData.frameId), collectPageDetailsResponse: ({ message, sender }) => this.storePageDetails(message, sender), unlockCompleted: ({ message }) => this.unlockCompleted(message), + doFullSync: () => this.updateOverlayCiphers(), addedCipher: () => this.updateOverlayCiphers(), addEditCipherSubmitted: () => this.updateOverlayCiphers(), editedCipher: () => this.updateOverlayCiphers(), deletedCipher: () => this.updateOverlayCiphers(), }; - private readonly overlayButtonPortMessageHandlers: OverlayButtonPortMessageHandlers = { - overlayButtonClicked: ({ port }) => this.handleOverlayButtonClicked(port), - closeAutofillOverlay: ({ port }) => this.closeOverlay(port), - forceCloseAutofillOverlay: ({ port }) => this.closeOverlay(port, true), - overlayPageBlurred: () => this.checkOverlayListFocused(), - redirectOverlayFocusOut: ({ message, port }) => this.redirectOverlayFocusOut(message, port), + private readonly inlineMenuButtonPortMessageHandlers: InlineMenuButtonPortMessageHandlers = { + triggerDelayedAutofillInlineMenuClosure: ({ port }) => this.triggerDelayedInlineMenuClosure(), + autofillInlineMenuButtonClicked: ({ port }) => this.handleInlineMenuButtonClicked(port), + autofillInlineMenuBlurred: () => this.checkInlineMenuListFocused(), + redirectAutofillInlineMenuFocusOut: ({ message, port }) => + this.redirectInlineMenuFocusOut(message, port), + updateAutofillInlineMenuColorScheme: () => this.updateInlineMenuButtonColorScheme(), }; - private readonly overlayListPortMessageHandlers: OverlayListPortMessageHandlers = { - checkAutofillOverlayButtonFocused: () => this.checkOverlayButtonFocused(), - forceCloseAutofillOverlay: ({ port }) => this.closeOverlay(port, true), - overlayPageBlurred: () => this.checkOverlayButtonFocused(), + private readonly inlineMenuListPortMessageHandlers: InlineMenuListPortMessageHandlers = { + checkAutofillInlineMenuButtonFocused: () => this.checkInlineMenuButtonFocused(), + autofillInlineMenuBlurred: () => this.checkInlineMenuButtonFocused(), unlockVault: ({ port }) => this.unlockVault(port), - fillSelectedListItem: ({ message, port }) => this.fillSelectedOverlayListItem(message, port), + fillAutofillInlineMenuCipher: ({ message, port }) => this.fillInlineMenuCipher(message, port), addNewVaultItem: ({ port }) => this.getNewVaultItemDetails(port), viewSelectedCipher: ({ message, port }) => this.viewSelectedCipher(message, port), - redirectOverlayFocusOut: ({ message, port }) => this.redirectOverlayFocusOut(message, port), + redirectAutofillInlineMenuFocusOut: ({ message, port }) => + this.redirectInlineMenuFocusOut(message, port), + updateAutofillInlineMenuListHeight: ({ message }) => this.updateInlineMenuListHeight(message), }; constructor( + private logService: LogService, private cipherService: CipherService, private autofillService: AutofillService, private authService: AuthService, @@ -104,7 +147,53 @@ class OverlayBackground implements OverlayBackgroundInterface { private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private themeStateService: ThemeStateService, - ) {} + ) { + this.initOverlayEventObservables(); + } + + /** + * Sets up the extension message listeners and gets the settings for the + * overlay's visibility and the user's authentication status. + */ + async init() { + this.setupExtensionListeners(); + const env = await firstValueFrom(this.environmentService.environment$); + this.iconsServerUrl = env.getIconsUrl(); + } + + /** + * Initializes event observables that handle events which affect the overlay's behavior. + */ + private initOverlayEventObservables() { + this.repositionInlineMenuSubject + .pipe( + debounceTime(1000), + switchMap((sender) => this.repositionInlineMenu(sender)), + ) + .subscribe(); + this.rebuildSubFrameOffsetsSubject + .pipe( + throttleTime(100), + switchMap((sender) => this.rebuildSubFrameOffsets(sender)), + ) + .subscribe(); + + // Debounce used to update inline menu position + merge( + this.startUpdateInlineMenuPositionSubject.pipe(debounceTime(150)), + this.cancelUpdateInlineMenuPositionSubject, + ) + .pipe(switchMap((sender) => this.updateInlineMenuPositionAfterRepositionEvent(sender))) + .subscribe(); + + // FadeIn Observable behavior + merge( + this.startInlineMenuFadeInSubject.pipe(debounceTime(150)), + this.cancelInlineMenuFadeInSubject, + ) + .pipe(switchMap((cancelSignal) => this.triggerInlineMenuFadeIn(!!cancelSignal))) + .subscribe(); + } /** * Removes cached page details for a tab @@ -113,89 +202,83 @@ class OverlayBackground implements OverlayBackgroundInterface { * @param tabId - Used to reference the page details of a specific tab */ removePageDetails(tabId: number) { - if (!this.pageDetailsForTab[tabId]) { - return; + if (this.pageDetailsForTab[tabId]) { + this.pageDetailsForTab[tabId].clear(); + delete this.pageDetailsForTab[tabId]; } - this.pageDetailsForTab[tabId].clear(); - delete this.pageDetailsForTab[tabId]; + if (this.portKeyForTab[tabId]) { + delete this.portKeyForTab[tabId]; + } } /** - * Sets up the extension message listeners and gets the settings for the - * overlay's visibility and the user's authentication status. - */ - async init() { - this.setupExtensionMessageListeners(); - const env = await firstValueFrom(this.environmentService.environment$); - this.iconsServerUrl = env.getIconsUrl(); - await this.getOverlayVisibility(); - await this.getAuthStatus(); - } - - /** - * Updates the overlay list's ciphers and sends the updated list to the overlay list iframe. + * Updates the inline menu list's ciphers and sends the updated list to the inline menu list iframe. * Queries all ciphers for the given url, and sorts them by last used. Will not update the * list of ciphers if the extension is not unlocked. */ async updateOverlayCiphers() { const authStatus = await firstValueFrom(this.authService.activeAccountStatus$); if (authStatus !== AuthenticationStatus.Unlocked) { + if (this.focusedFieldData) { + void this.closeInlineMenuAfterCiphersUpdate(); + } return; } const currentTab = await BrowserApi.getTabFromCurrentWindowId(); - if (!currentTab?.url) { - return; + if (this.focusedFieldData && currentTab?.id !== this.focusedFieldData.tabId) { + void this.closeInlineMenuAfterCiphersUpdate(); } - this.overlayLoginCiphers = new Map(); - const ciphersViews = (await this.cipherService.getAllDecryptedForUrl(currentTab.url)).sort( - (a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b), - ); + this.inlineMenuCiphers = new Map(); + const ciphersViews = ( + await this.cipherService.getAllDecryptedForUrl(currentTab?.url || "") + ).sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b)); for (let cipherIndex = 0; cipherIndex < ciphersViews.length; cipherIndex++) { - this.overlayLoginCiphers.set(`overlay-cipher-${cipherIndex}`, ciphersViews[cipherIndex]); + this.inlineMenuCiphers.set(`inline-menu-cipher-${cipherIndex}`, ciphersViews[cipherIndex]); } - const ciphers = await this.getOverlayCipherData(); - this.overlayListPort?.postMessage({ command: "updateOverlayListCiphers", ciphers }); - await BrowserApi.tabSendMessageData(currentTab, "updateIsOverlayCiphersPopulated", { - isOverlayCiphersPopulated: Boolean(ciphers.length), + const ciphers = await this.getInlineMenuCipherData(); + this.inlineMenuListPort?.postMessage({ + command: "updateAutofillInlineMenuListCiphers", + ciphers, }); } /** * Strips out unnecessary data from the ciphers and returns an array of - * objects that contain the cipher data needed for the overlay list. + * objects that contain the cipher data needed for the inline menu list. */ - private async getOverlayCipherData(): Promise { + private async getInlineMenuCipherData(): Promise { const showFavicons = await firstValueFrom(this.domainSettingsService.showFavicons$); - const overlayCiphersArray = Array.from(this.overlayLoginCiphers); - const overlayCipherData: OverlayCipherData[] = []; - let loginCipherIcon: WebsiteIconData; + const inlineMenuCiphersArray = Array.from(this.inlineMenuCiphers); + const inlineMenuCipherData: InlineMenuCipherData[] = []; - for (let cipherIndex = 0; cipherIndex < overlayCiphersArray.length; cipherIndex++) { - const [overlayCipherId, cipher] = overlayCiphersArray[cipherIndex]; - if (!loginCipherIcon && cipher.type === CipherType.Login) { - loginCipherIcon = buildCipherIcon(this.iconsServerUrl, cipher, showFavicons); - } + for (let cipherIndex = 0; cipherIndex < inlineMenuCiphersArray.length; cipherIndex++) { + const [inlineMenuCipherId, cipher] = inlineMenuCiphersArray[cipherIndex]; - overlayCipherData.push({ - id: overlayCipherId, + inlineMenuCipherData.push({ + id: inlineMenuCipherId, name: cipher.name, type: cipher.type, reprompt: cipher.reprompt, favorite: cipher.favorite, - icon: - cipher.type === CipherType.Login - ? loginCipherIcon - : buildCipherIcon(this.iconsServerUrl, cipher, showFavicons), + icon: buildCipherIcon(this.iconsServerUrl, cipher, showFavicons), login: cipher.type === CipherType.Login ? { username: cipher.login.username } : null, card: cipher.type === CipherType.Card ? cipher.card.subTitle : null, }); } - return overlayCipherData; + return inlineMenuCipherData; + } + + /** + * Gets the currently focused field and closes the inline menu on that tab. + */ + private async closeInlineMenuAfterCiphersUpdate() { + const focusedFieldTab = await BrowserApi.getTab(this.focusedFieldData.tabId); + this.closeInlineMenu({ tab: focusedFieldTab }, { forceCloseInlineMenu: true }); } /** @@ -215,6 +298,13 @@ class OverlayBackground implements OverlayBackgroundInterface { details: message.details, }; + if (pageDetails.frameId !== 0 && pageDetails.details.fields.length) { + void this.buildSubFrameOffsets(pageDetails.tab, pageDetails.frameId, pageDetails.details.url); + void BrowserApi.tabSendMessage(pageDetails.tab, { + command: "setupRebuildSubFrameOffsetsListeners", + }); + } + const pageDetailsMap = this.pageDetailsForTab[sender.tab.id]; if (!pageDetailsMap) { this.pageDetailsForTab[sender.tab.id] = new Map([[sender.frameId, pageDetails]]); @@ -225,22 +315,205 @@ class OverlayBackground implements OverlayBackgroundInterface { } /** - * Triggers autofill for the selected cipher in the overlay list. Also places - * the selected cipher at the top of the list of ciphers. + * Returns the frameId, called when calculating sub frame offsets within the tab. + * Is used to determine if we should reposition the inline menu when a resize event + * occurs within a frame. * - * @param overlayCipherId - Cipher ID corresponding to the overlayLoginCiphers map. Does not correspond to the actual cipher's ID. - * @param sender - The sender of the port message + * @param sender - The sender of the message */ - private async fillSelectedOverlayListItem( - { overlayCipherId }: OverlayPortMessage, - { sender }: chrome.runtime.Port, + private getSenderFrameId(sender: chrome.runtime.MessageSender) { + return sender.frameId; + } + + /** + * Handles sub frame offset calculations for the given tab and frame id. + * Is used in setting the position of the inline menu list and button. + * + * @param message - The message received from the `updateSubFrameData` command + * @param sender - The sender of the message + */ + private updateSubFrameData( + message: OverlayBackgroundExtensionMessage, + sender: chrome.runtime.MessageSender, ) { - const pageDetails = this.pageDetailsForTab[sender.tab.id]; - if (!overlayCipherId || !pageDetails?.size) { + const subFrameOffsetsForTab = this.subFrameOffsetsForTab[sender.tab.id]; + if (subFrameOffsetsForTab) { + subFrameOffsetsForTab.set(message.subFrameData.frameId, message.subFrameData); + } + } + + /** + * Builds the offset data for a sub frame of a tab. The offset data is used + * to calculate the position of the inline menu list and button. + * + * @param tab - The tab that the sub frame is associated with + * @param frameId - The frame ID of the sub frame + * @param url - The URL of the sub frame + * @param forceRebuild - Identifies whether the sub frame offsets should be rebuilt + */ + private async buildSubFrameOffsets( + tab: chrome.tabs.Tab, + frameId: number, + url: string, + forceRebuild: boolean = false, + ) { + let subFrameDepth = 0; + const tabId = tab.id; + let subFrameOffsetsForTab = this.subFrameOffsetsForTab[tabId]; + if (!subFrameOffsetsForTab) { + this.subFrameOffsetsForTab[tabId] = new Map(); + subFrameOffsetsForTab = this.subFrameOffsetsForTab[tabId]; + } + + if (!forceRebuild && subFrameOffsetsForTab.get(frameId)) { return; } - const cipher = this.overlayLoginCiphers.get(overlayCipherId); + const subFrameData: SubFrameOffsetData = { url, top: 0, left: 0, parentFrameIds: [0] }; + let frameDetails = await BrowserApi.getFrameDetails({ tabId, frameId }); + + while (frameDetails && frameDetails.parentFrameId > -1) { + subFrameDepth++; + if (subFrameDepth >= MAX_SUB_FRAME_DEPTH) { + subFrameOffsetsForTab.set(frameId, null); + this.triggerDestroyInlineMenuListeners(tab, frameId); + return; + } + + const subFrameOffset: SubFrameOffsetData = await BrowserApi.tabSendMessage( + tab, + { + command: "getSubFrameOffsets", + subFrameUrl: frameDetails.url, + subFrameId: frameDetails.documentId, + }, + { frameId: frameDetails.parentFrameId }, + ); + + if (!subFrameOffset) { + subFrameOffsetsForTab.set(frameId, null); + void BrowserApi.tabSendMessage( + tab, + { command: "getSubFrameOffsetsFromWindowMessage", subFrameId: frameId }, + { frameId }, + ); + return; + } + + subFrameData.top += subFrameOffset.top; + subFrameData.left += subFrameOffset.left; + if (!subFrameData.parentFrameIds.includes(frameDetails.parentFrameId)) { + subFrameData.parentFrameIds.push(frameDetails.parentFrameId); + } + + frameDetails = await BrowserApi.getFrameDetails({ + tabId, + frameId: frameDetails.parentFrameId, + }); + } + + subFrameOffsetsForTab.set(frameId, subFrameData); + } + + /** + * Triggers a removal and destruction of all + * + * @param tab - The tab that the sub frame is associated with + * @param frameId - The frame ID of the sub frame + */ + private triggerDestroyInlineMenuListeners(tab: chrome.tabs.Tab, frameId: number) { + this.logService.error( + "Excessive frame depth encountered, destroying inline menu on field within frame", + tab, + frameId, + ); + + void BrowserApi.tabSendMessage( + tab, + { command: "destroyAutofillInlineMenuListeners" }, + { frameId }, + ); + } + + /** + * Rebuilds the sub frame offsets for the tab associated with the sender. + * + * @param sender - The sender of the message + */ + private async rebuildSubFrameOffsets(sender: chrome.runtime.MessageSender) { + this.cancelUpdateInlineMenuPositionSubject.next(); + this.clearDelayedInlineMenuClosure(); + + const subFrameOffsetsForTab = this.subFrameOffsetsForTab[sender.tab.id]; + if (subFrameOffsetsForTab) { + const tabFrameIds = Array.from(subFrameOffsetsForTab.keys()); + for (const frameId of tabFrameIds) { + await this.buildSubFrameOffsets(sender.tab, frameId, sender.url, true); + } + } + } + + /** + * Handles updating the inline menu's position after rebuilding the sub frames + * for the provided tab. Will skip repositioning the inline menu if the field + * is not currently focused, or if the focused field has a value. + * + * @param sender - The sender of the message + */ + private async updateInlineMenuPositionAfterRepositionEvent( + sender: chrome.runtime.MessageSender | void, + ) { + if (!sender || !this.isFieldCurrentlyFocused) { + return; + } + + if (!this.checkIsInlineMenuButtonVisible()) { + void this.toggleInlineMenuHidden( + { isInlineMenuHidden: false, setTransparentInlineMenu: true }, + sender, + ); + } + + void this.updateInlineMenuPosition({ overlayElement: AutofillOverlayElement.Button }, sender); + + const mostRecentlyFocusedFieldHasValue = await BrowserApi.tabSendMessage( + sender.tab, + { command: "checkMostRecentlyFocusedFieldHasValue" }, + { frameId: this.focusedFieldData?.frameId }, + ); + + if ((await this.getInlineMenuVisibility()) === AutofillOverlayVisibility.OnButtonClick) { + return; + } + + if ( + mostRecentlyFocusedFieldHasValue && + (this.checkIsInlineMenuCiphersPopulated(sender) || + (await this.getAuthStatus()) !== AuthenticationStatus.Unlocked) + ) { + return; + } + + void this.updateInlineMenuPosition({ overlayElement: AutofillOverlayElement.List }, sender); + } + + /** + * Triggers autofill for the selected cipher in the inline menu list. Also places + * the selected cipher at the top of the list of ciphers. + * + * @param inlineMenuCipherId - Cipher ID corresponding to the inlineMenuCiphers map. Does not correspond to the actual cipher's ID. + * @param sender - The sender of the port message + */ + private async fillInlineMenuCipher( + { inlineMenuCipherId }: OverlayPortMessage, + { sender }: chrome.runtime.Port, + ) { + const pageDetails = this.pageDetailsForTab[sender.tab.id]; + if (!inlineMenuCipherId || !pageDetails?.size) { + return; + } + + const cipher = this.inlineMenuCiphers.get(inlineMenuCipherId); if (await this.autofillService.isPasswordRepromptRequired(cipher, sender.tab)) { return; @@ -257,47 +530,117 @@ class OverlayBackground implements OverlayBackgroundInterface { this.platformUtilsService.copyToClipboard(totpCode); } - this.overlayLoginCiphers = new Map([[overlayCipherId, cipher], ...this.overlayLoginCiphers]); + this.inlineMenuCiphers = new Map([[inlineMenuCipherId, cipher], ...this.inlineMenuCiphers]); } /** - * Checks if the overlay is focused. Will check the overlay list - * if it is open, otherwise it will check the overlay button. + * Checks if the inline menu is focused. Will check the inline menu list + * if it is open, otherwise it will check the inline menu button. */ - private checkOverlayFocused() { - if (this.overlayListPort) { - this.checkOverlayListFocused(); + private checkInlineMenuFocused(sender: chrome.runtime.MessageSender) { + if (!this.senderTabHasFocusedField(sender)) { + return; + } + + if (this.inlineMenuListPort) { + this.checkInlineMenuListFocused(); return; } - this.checkOverlayButtonFocused(); + this.checkInlineMenuButtonFocused(); } /** - * Posts a message to the overlay button iframe to check if it is focused. + * Posts a message to the inline menu button iframe to check if it is focused. */ - private checkOverlayButtonFocused() { - this.overlayButtonPort?.postMessage({ command: "checkAutofillOverlayButtonFocused" }); + private checkInlineMenuButtonFocused() { + this.inlineMenuButtonPort?.postMessage({ command: "checkAutofillInlineMenuButtonFocused" }); } /** - * Posts a message to the overlay list iframe to check if it is focused. + * Posts a message to the inline menu list iframe to check if it is focused. */ - private checkOverlayListFocused() { - this.overlayListPort?.postMessage({ command: "checkAutofillOverlayListFocused" }); + private checkInlineMenuListFocused() { + this.inlineMenuListPort?.postMessage({ command: "checkAutofillInlineMenuListFocused" }); } /** - * Sends a message to the sender tab to close the autofill overlay. + * Sends a message to the sender tab to close the autofill inline menu. * * @param sender - The sender of the port message - * @param forceCloseOverlay - Identifies whether the overlay should be force closed + * @param forceCloseInlineMenu - Identifies whether the inline menu should be forced closed + * @param overlayElement - The overlay element to close, either the list or button */ - private closeOverlay({ sender }: chrome.runtime.Port, forceCloseOverlay = false) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - BrowserApi.tabSendMessageData(sender.tab, "closeAutofillOverlay", { forceCloseOverlay }); + private closeInlineMenu( + sender: chrome.runtime.MessageSender, + { forceCloseInlineMenu, overlayElement }: CloseInlineMenuMessage = {}, + ) { + const command = "closeAutofillInlineMenu"; + const sendOptions = { frameId: 0 }; + if (forceCloseInlineMenu) { + void BrowserApi.tabSendMessage(sender.tab, { command, overlayElement }, sendOptions); + this.isInlineMenuButtonVisible = false; + this.isInlineMenuListVisible = false; + return; + } + + if (this.isFieldCurrentlyFocused) { + return; + } + + if (this.isFieldCurrentlyFilling) { + void BrowserApi.tabSendMessage( + sender.tab, + { command, overlayElement: AutofillOverlayElement.List }, + sendOptions, + ); + this.isInlineMenuListVisible = false; + return; + } + + if (overlayElement === AutofillOverlayElement.Button) { + this.isInlineMenuButtonVisible = false; + } + + if (overlayElement === AutofillOverlayElement.List) { + this.isInlineMenuListVisible = false; + } + + if (!overlayElement) { + this.isInlineMenuButtonVisible = false; + this.isInlineMenuListVisible = false; + } + + void BrowserApi.tabSendMessage(sender.tab, { command, overlayElement }, sendOptions); + } + + /** + * Sends a message to the sender tab to trigger a delayed closure of the inline menu. + * This is used to ensure that we capture click events on the inline menu in the case + * that some on page programmatic method attempts to force focus redirection. + */ + private triggerDelayedInlineMenuClosure() { + if (this.isFieldCurrentlyFocused) { + return; + } + + this.clearDelayedInlineMenuClosure(); + this.delayedCloseTimeout = globalThis.setTimeout(() => { + const message = { command: "triggerDelayedAutofillInlineMenuClosure" }; + this.inlineMenuButtonPort?.postMessage(message); + this.inlineMenuListPort?.postMessage(message); + }, 100); + } + + /** + * Clears the delayed closure timeout for the inline menu, effectively + * cancelling the event from occurring. + */ + private clearDelayedInlineMenuClosure() { + if (this.delayedCloseTimeout) { + clearTimeout(this.delayedCloseTimeout); + } } /** @@ -311,61 +654,141 @@ class OverlayBackground implements OverlayBackgroundInterface { { overlayElement }: OverlayBackgroundExtensionMessage, sender: chrome.runtime.MessageSender, ) { - if (sender.tab.id !== this.focusedFieldData?.tabId) { + if (!this.senderTabHasFocusedField(sender)) { this.expiredPorts.forEach((port) => port.disconnect()); this.expiredPorts = []; + return; } if (overlayElement === AutofillOverlayElement.Button) { - this.overlayButtonPort?.disconnect(); - this.overlayButtonPort = null; + this.inlineMenuButtonPort?.disconnect(); + this.inlineMenuButtonPort = null; + this.isInlineMenuButtonVisible = false; return; } - this.overlayListPort?.disconnect(); - this.overlayListPort = null; + this.inlineMenuListPort?.disconnect(); + this.inlineMenuListPort = null; + this.isInlineMenuListVisible = false; } /** - * Updates the position of either the overlay list or button. The position + * Updates the position of either the inline menu list or button. The position * is based on the focused field's position and dimensions. * * @param overlayElement - The overlay element to update, either the list or button * @param sender - The sender of the port message */ - private updateOverlayPosition( + private async updateInlineMenuPosition( { overlayElement }: { overlayElement?: string }, sender: chrome.runtime.MessageSender, ) { - if (!overlayElement || sender.tab.id !== this.focusedFieldData?.tabId) { + if (!overlayElement || !this.senderTabHasFocusedField(sender)) { return; } + this.cancelInlineMenuFadeInAndPositionUpdate(); + + await BrowserApi.tabSendMessage( + sender.tab, + { command: "appendAutofillInlineMenuToDom", overlayElement }, + { frameId: 0 }, + ); + + const subFrameOffsetsForTab = this.subFrameOffsetsForTab[this.focusedFieldData.tabId]; + let subFrameOffsets: SubFrameOffsetData; + if (subFrameOffsetsForTab) { + subFrameOffsets = subFrameOffsetsForTab.get(this.focusedFieldData.frameId); + if (subFrameOffsets === null) { + this.rebuildSubFrameOffsetsSubject.next(sender); + this.startUpdateInlineMenuPositionSubject.next(sender); + return; + } + } + if (overlayElement === AutofillOverlayElement.Button) { - this.overlayButtonPort?.postMessage({ - command: "updateIframePosition", - styles: this.getOverlayButtonPosition(), + this.inlineMenuButtonPort?.postMessage({ + command: "updateAutofillInlineMenuPosition", + styles: this.getInlineMenuButtonPosition(subFrameOffsets), }); + this.startInlineMenuFadeIn(); return; } - this.overlayListPort?.postMessage({ - command: "updateIframePosition", - styles: this.getOverlayListPosition(), + this.inlineMenuListPort?.postMessage({ + command: "updateAutofillInlineMenuPosition", + styles: this.getInlineMenuListPosition(subFrameOffsets), }); + this.startInlineMenuFadeIn(); + } + + /** + * Triggers an update of the inline menu's visibility after the top level frame + * appends the element to the DOM. + * + * @param message - The message received from the content script + * @param sender - The sender of the port message + */ + private updateInlineMenuElementIsVisibleStatus( + message: OverlayBackgroundExtensionMessage, + sender: chrome.runtime.MessageSender, + ) { + if (!this.senderTabHasFocusedField(sender)) { + return; + } + + const { overlayElement, isVisible } = message; + if (overlayElement === AutofillOverlayElement.Button) { + this.isInlineMenuButtonVisible = isVisible; + return; + } + + if (overlayElement === AutofillOverlayElement.List) { + this.isInlineMenuListVisible = isVisible; + } + } + + /** + * Handles updating the opacity of both the inline menu button and list. + * This is used to simultaneously fade in the inline menu elements. + */ + private startInlineMenuFadeIn() { + this.cancelInlineMenuFadeIn(); + this.startInlineMenuFadeInSubject.next(); + } + + /** + * Clears the timeout used to fade in the inline menu elements. + */ + private cancelInlineMenuFadeIn() { + this.cancelInlineMenuFadeInSubject.next(true); + } + + /** + * Posts a message to the inline menu elements to trigger a fade in of the inline menu. + * + * @param cancelFadeIn - Signal passed to debounced observable to cancel the fade in + */ + private async triggerInlineMenuFadeIn(cancelFadeIn: boolean = false) { + if (cancelFadeIn) { + return; + } + + const message = { command: "fadeInAutofillInlineMenuIframe" }; + this.inlineMenuButtonPort?.postMessage(message); + this.inlineMenuListPort?.postMessage(message); } /** * Gets the position of the focused field and calculates the position - * of the overlay button based on the focused field's position and dimensions. + * of the inline menu button based on the focused field's position and dimensions. */ - private getOverlayButtonPosition() { - if (!this.focusedFieldData) { - return; - } + private getInlineMenuButtonPosition(subFrameOffsets: SubFrameOffsetData) { + const subFrameTopOffset = subFrameOffsets?.top || 0; + const subFrameLeftOffset = subFrameOffsets?.left || 0; const { top, left, width, height } = this.focusedFieldData.focusedFieldRects; const { paddingRight, paddingLeft } = this.focusedFieldData.focusedFieldStyles; @@ -374,15 +797,15 @@ class OverlayBackground implements OverlayBackgroundInterface { elementOffset = height >= 50 ? height * 0.47 : height * 0.42; } - const elementHeight = height - elementOffset; - const elementTopPosition = top + elementOffset / 2; - let elementLeftPosition = left + width - height + elementOffset / 2; - const fieldPaddingRight = parseInt(paddingRight, 10); const fieldPaddingLeft = parseInt(paddingLeft, 10); - if (fieldPaddingRight > fieldPaddingLeft) { - elementLeftPosition = left + width - height - (fieldPaddingRight - elementOffset + 2); - } + const elementHeight = height - elementOffset; + + const elementTopPosition = subFrameTopOffset + top + elementOffset / 2; + const elementLeftPosition = + fieldPaddingRight > fieldPaddingLeft + ? subFrameLeftOffset + left + width - height - (fieldPaddingRight - elementOffset + 2) + : subFrameLeftOffset + left + width - height + elementOffset / 2; return { top: `${Math.round(elementTopPosition)}px`, @@ -394,18 +817,17 @@ class OverlayBackground implements OverlayBackgroundInterface { /** * Gets the position of the focused field and calculates the position - * of the overlay list based on the focused field's position and dimensions. + * of the inline menu list based on the focused field's position and dimensions. */ - private getOverlayListPosition() { - if (!this.focusedFieldData) { - return; - } + private getInlineMenuListPosition(subFrameOffsets: SubFrameOffsetData) { + const subFrameTopOffset = subFrameOffsets?.top || 0; + const subFrameLeftOffset = subFrameOffsets?.left || 0; const { top, left, width, height } = this.focusedFieldData.focusedFieldRects; return { width: `${Math.round(width)}px`, - top: `${Math.round(top + height)}px`, - left: `${Math.round(left)}px`, + top: `${Math.round(top + height + subFrameTopOffset)}px`, + left: `${Math.round(left + subFrameLeftOffset)}px`, }; } @@ -419,109 +841,137 @@ class OverlayBackground implements OverlayBackgroundInterface { { focusedFieldData }: OverlayBackgroundExtensionMessage, sender: chrome.runtime.MessageSender, ) { - this.focusedFieldData = { ...focusedFieldData, tabId: sender.tab.id }; + if (this.focusedFieldData?.frameId && this.focusedFieldData.frameId !== sender.frameId) { + void BrowserApi.tabSendMessage( + sender.tab, + { command: "unsetMostRecentlyFocusedField" }, + { frameId: this.focusedFieldData.frameId }, + ); + } + + this.focusedFieldData = { ...focusedFieldData, tabId: sender.tab.id, frameId: sender.frameId }; } /** - * Updates the overlay's visibility based on the display property passed in the extension message. + * Updates the inline menu's visibility based on the display property passed in the extension message. * - * @param display - The display property of the overlay, either "block" or "none" + * @param display - The display property of the inline menu, either "block" or "none" + * @param sender - The sender of the extension message */ - private updateOverlayHidden({ display }: OverlayBackgroundExtensionMessage) { - if (!display) { + private async toggleInlineMenuHidden( + { isInlineMenuHidden, setTransparentInlineMenu }: ToggleInlineMenuHiddenMessage, + sender: chrome.runtime.MessageSender, + ) { + if (!this.senderTabHasFocusedField(sender)) { return; } - const portMessage = { command: "updateOverlayHidden", styles: { display } }; + this.cancelInlineMenuFadeIn(); + const display = isInlineMenuHidden ? "none" : "block"; + let styles: { display: string; opacity?: string } = { display }; - this.overlayButtonPort?.postMessage(portMessage); - this.overlayListPort?.postMessage(portMessage); + if (typeof setTransparentInlineMenu !== "undefined") { + const opacity = setTransparentInlineMenu ? "0" : "1"; + styles = { ...styles, opacity }; + } + + const portMessage = { command: "toggleAutofillInlineMenuHidden", styles }; + if (this.inlineMenuButtonPort) { + this.isInlineMenuButtonVisible = !isInlineMenuHidden; + this.inlineMenuButtonPort.postMessage(portMessage); + } + + if (this.inlineMenuListPort) { + this.isInlineMenuListVisible = !isInlineMenuHidden; + this.inlineMenuListPort.postMessage(portMessage); + } + + if (setTransparentInlineMenu) { + this.startInlineMenuFadeIn(); + } } /** - * Sends a message to the currently active tab to open the autofill overlay. + * Sends a message to the currently active tab to open the autofill inline menu. * - * @param isFocusingFieldElement - Identifies whether the field element should be focused when the overlay is opened - * @param isOpeningFullOverlay - Identifies whether the full overlay should be forced open regardless of other states + * @param isFocusingFieldElement - Identifies whether the field element should be focused when the inline menu is opened + * @param isOpeningFullInlineMenu - Identifies whether the full inline menu should be forced open regardless of other states */ - private async openOverlay(isFocusingFieldElement = false, isOpeningFullOverlay = false) { + private async openInlineMenu(isFocusingFieldElement = false, isOpeningFullInlineMenu = false) { + this.clearDelayedInlineMenuClosure(); const currentTab = await BrowserApi.getTabFromCurrentWindowId(); - await BrowserApi.tabSendMessageData(currentTab, "openAutofillOverlay", { - isFocusingFieldElement, - isOpeningFullOverlay, + await BrowserApi.tabSendMessage( + currentTab, + { + command: "openAutofillInlineMenu", + isFocusingFieldElement, + isOpeningFullInlineMenu, + authStatus: await this.getAuthStatus(), + }, + { + frameId: + this.focusedFieldData?.tabId === currentTab?.id ? this.focusedFieldData.frameId : 0, + }, + ); + } + + /** + * Gets the inline menu's visibility setting from the settings service. + */ + private async getInlineMenuVisibility(): Promise { + return await firstValueFrom(this.autofillSettingsService.inlineMenuVisibility$); + } + + /** + * Gets the user's authentication status from the auth service. If the user's authentication + * status has changed, the inline menu button's authentication status will be updated + * and the inline menu list's ciphers will be updated. + */ + private async getAuthStatus() { + return await firstValueFrom(this.authService.activeAccountStatus$); + } + + /** + * Sends a message to the inline menu button to update its authentication status. + */ + private async updateInlineMenuButtonAuthStatus() { + this.inlineMenuButtonPort?.postMessage({ + command: "updateInlineMenuButtonAuthStatus", authStatus: await this.getAuthStatus(), }); } /** - * Gets the overlay's visibility setting from the settings service. - */ - private async getOverlayVisibility(): Promise { - return await firstValueFrom(this.autofillSettingsService.inlineMenuVisibility$); - } - - /** - * Gets the user's authentication status from the auth service. If the user's - * authentication status has changed, the overlay button's authentication status - * will be updated and the overlay list's ciphers will be updated. - */ - private async getAuthStatus() { - const formerAuthStatus = this.userAuthStatus; - this.userAuthStatus = await this.authService.getAuthStatus(); - - if ( - this.userAuthStatus !== formerAuthStatus && - this.userAuthStatus === AuthenticationStatus.Unlocked - ) { - this.updateOverlayButtonAuthStatus(); - await this.updateOverlayCiphers(); - } - - return this.userAuthStatus; - } - - /** - * Sends a message to the overlay button to update its authentication status. - */ - private updateOverlayButtonAuthStatus() { - this.overlayButtonPort?.postMessage({ - command: "updateOverlayButtonAuthStatus", - authStatus: this.userAuthStatus, - }); - } - - /** - * Handles the overlay button being clicked. If the user is not authenticated, - * the vault will be unlocked. If the user is authenticated, the overlay will + * Handles the inline menu button being clicked. If the user is not authenticated, + * the vault will be unlocked. If the user is authenticated, the inline menu will * be opened. * - * @param port - The port of the overlay button + * @param port - The port of the inline menu button */ - private handleOverlayButtonClicked(port: chrome.runtime.Port) { - if (this.userAuthStatus !== AuthenticationStatus.Unlocked) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.unlockVault(port); + private async handleInlineMenuButtonClicked(port: chrome.runtime.Port) { + this.clearDelayedInlineMenuClosure(); + this.cancelInlineMenuFadeInAndPositionUpdate(); + + if ((await this.getAuthStatus()) !== AuthenticationStatus.Unlocked) { + await this.unlockVault(port); return; } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.openOverlay(false, true); + await this.openInlineMenu(false, true); } /** * Facilitates opening the unlock popout window. * - * @param port - The port of the overlay list + * @param port - The port of the inline menu list */ private async unlockVault(port: chrome.runtime.Port) { const { sender } = port; - this.closeOverlay(port); + this.closeInlineMenu(port.sender); const retryMessage: LockedVaultPendingNotificationsData = { - commandToRetry: { message: { command: "openAutofillOverlay" }, sender }, + commandToRetry: { message: { command: "openAutofillInlineMenu" }, sender }, target: "overlay.background", }; await BrowserApi.tabSendMessageData( @@ -535,18 +985,19 @@ class OverlayBackground implements OverlayBackgroundInterface { /** * Triggers the opening of a vault item popout window associated * with the passed cipher ID. - * @param overlayCipherId - Cipher ID corresponding to the overlayLoginCiphers map. Does not correspond to the actual cipher's ID. + * @param inlineMenuCipherId - Cipher ID corresponding to the inlineMenuCiphers map. Does not correspond to the actual cipher's ID. * @param sender - The sender of the port message */ private async viewSelectedCipher( - { overlayCipherId }: OverlayPortMessage, + { inlineMenuCipherId }: OverlayPortMessage, { sender }: chrome.runtime.Port, ) { - const cipher = this.overlayLoginCiphers.get(overlayCipherId); + const cipher = this.inlineMenuCiphers.get(inlineMenuCipherId); if (!cipher) { return; } + this.closeInlineMenu(sender); await this.openViewVaultItemPopout(sender.tab, { cipherId: cipher.id, action: SHOW_AUTOFILL_BUTTON, @@ -554,32 +1005,33 @@ class OverlayBackground implements OverlayBackgroundInterface { } /** - * Facilitates redirecting focus to the overlay list. + * Facilitates redirecting focus to the inline menu list. */ - private focusOverlayList() { - this.overlayListPort?.postMessage({ command: "focusOverlayList" }); + private focusInlineMenuList() { + this.inlineMenuListPort?.postMessage({ command: "focusAutofillInlineMenuList" }); } /** - * Updates the authentication status for the user and opens the overlay if + * Updates the authentication status for the user and opens the inline menu if * a followup command is present in the message. * * @param message - Extension message received from the `unlockCompleted` command */ private async unlockCompleted(message: OverlayBackgroundExtensionMessage) { - await this.getAuthStatus(); + await this.updateInlineMenuButtonAuthStatus(); + await this.updateOverlayCiphers(); - if (message.data?.commandToRetry?.message?.command === "openAutofillOverlay") { - await this.openOverlay(true); + if (message.data?.commandToRetry?.message?.command === "openAutofillInlineMenu") { + await this.openInlineMenu(true); } } /** - * Gets the translations for the overlay page. + * Gets the translations for the inline menu page. */ - private getTranslations() { - if (!this.overlayPageTranslations) { - this.overlayPageTranslations = { + private getInlineMenuTranslations() { + if (!this.inlineMenuPageTranslations) { + this.inlineMenuPageTranslations = { locale: BrowserApi.getUILanguage(), opensInANewWindow: this.i18nService.translate("opensInANewWindow"), buttonPageTitle: this.i18nService.translate("bitwardenOverlayButton"), @@ -588,7 +1040,7 @@ class OverlayBackground implements OverlayBackgroundInterface { unlockYourAccount: this.i18nService.translate("unlockYourAccountToViewMatchingLogins"), unlockAccount: this.i18nService.translate("unlockAccount"), fillCredentialsFor: this.i18nService.translate("fillCredentialsFor"), - partialUsername: this.i18nService.translate("partialUsername"), + username: this.i18nService.translate("username")?.toLowerCase(), view: this.i18nService.translate("view"), noItemsToShow: this.i18nService.translate("noItemsToShow"), newItem: this.i18nService.translate("newItem"), @@ -596,17 +1048,17 @@ class OverlayBackground implements OverlayBackgroundInterface { }; } - return this.overlayPageTranslations; + return this.inlineMenuPageTranslations; } /** * Facilitates redirecting focus out of one of the - * overlay elements to elements on the page. + * inline menu elements to elements on the page. * * @param direction - The direction to redirect focus to (either "next", "previous" or "current) * @param sender - The sender of the port message */ - private redirectOverlayFocusOut( + private redirectInlineMenuFocusOut( { direction }: OverlayPortMessage, { sender }: chrome.runtime.Port, ) { @@ -614,9 +1066,9 @@ class OverlayBackground implements OverlayBackgroundInterface { return; } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - BrowserApi.tabSendMessageData(sender.tab, "redirectOverlayFocusOut", { direction }); + void BrowserApi.tabSendMessageData(sender.tab, "redirectAutofillInlineMenuFocusOut", { + direction, + }); } /** @@ -626,7 +1078,17 @@ class OverlayBackground implements OverlayBackgroundInterface { * @param sender - The sender of the port message */ private getNewVaultItemDetails({ sender }: chrome.runtime.Port) { - void BrowserApi.tabSendMessage(sender.tab, { command: "addNewVaultItemFromOverlay" }); + if (!this.senderTabHasFocusedField(sender)) { + return; + } + + void BrowserApi.tabSendMessage( + sender.tab, + { command: "addNewVaultItemFromOverlay" }, + { + frameId: this.focusedFieldData.frameId || 0, + }, + ); } /** @@ -644,6 +1106,7 @@ class OverlayBackground implements OverlayBackgroundInterface { return; } + this.closeInlineMenu(sender); const uriView = new LoginUriView(); uriView.uri = login.uri; @@ -667,11 +1130,222 @@ class OverlayBackground implements OverlayBackgroundInterface { await BrowserApi.sendMessage("inlineAutofillMenuRefreshAddEditCipher"); } + /** + * Updates the property that identifies if a form field set up for the inline menu is currently focused. + * + * @param message - The message received from the web page + */ + private updateIsFieldCurrentlyFocused(message: OverlayBackgroundExtensionMessage) { + this.isFieldCurrentlyFocused = message.isFieldCurrentlyFocused; + } + + /** + * Allows a content script to check if a form field setup for the inline menu is currently focused. + */ + private checkIsFieldCurrentlyFocused() { + return this.isFieldCurrentlyFocused; + } + + /** + * Updates the property that identifies if a form field is currently being autofilled. + * + * @param message - The message received from the web page + */ + private updateIsFieldCurrentlyFilling(message: OverlayBackgroundExtensionMessage) { + this.isFieldCurrentlyFilling = message.isFieldCurrentlyFilling; + } + + /** + * Allows a content script to check if a form field is currently being autofilled. + */ + private checkIsFieldCurrentlyFilling() { + return this.isFieldCurrentlyFilling; + } + + /** + * Returns the visibility status of the inline menu button. + */ + private checkIsInlineMenuButtonVisible(): boolean { + return this.isInlineMenuButtonVisible; + } + + /** + * Returns the visibility status of the inline menu list. + */ + private checkIsInlineMenuListVisible(): boolean { + return this.isInlineMenuListVisible; + } + + /** + * Responds to the content script's request to check if the inline menu ciphers are populated. + * This will return true only if the sender is the focused field's tab and the inline menu + * ciphers are populated. + * + * @param sender - The sender of the message + */ + private checkIsInlineMenuCiphersPopulated(sender: chrome.runtime.MessageSender) { + return this.senderTabHasFocusedField(sender) && this.inlineMenuCiphers.size > 0; + } + + /** + * Triggers an update in the meta "color-scheme" value within the inline menu button. + * This is done to ensure that the button element has a transparent background, which + * is accomplished by setting the "color-scheme" meta value of the button iframe to + * the same value as the page's meta "color-scheme" value. + */ + private updateInlineMenuButtonColorScheme() { + this.inlineMenuButtonPort?.postMessage({ + command: "updateAutofillInlineMenuColorScheme", + }); + } + + /** + * Triggers an update in the inline menu list's height. + * + * @param message - Contains the dimensions of the inline menu list + */ + private updateInlineMenuListHeight(message: OverlayBackgroundExtensionMessage) { + this.inlineMenuListPort?.postMessage({ + command: "updateAutofillInlineMenuPosition", + styles: message.styles, + }); + } + + /** + * Handles verifying whether the inline menu should be repositioned. This is used to + * guard against removing the inline menu when other frames trigger a resize event. + * + * @param sender - The sender of the message + */ + private checkShouldRepositionInlineMenu(sender: chrome.runtime.MessageSender): boolean { + if (!this.focusedFieldData || !this.senderTabHasFocusedField(sender)) { + return false; + } + + if (this.focusedFieldData?.frameId === sender.frameId) { + return true; + } + + const subFrameOffsetsForTab = this.subFrameOffsetsForTab[sender.tab.id]; + if (subFrameOffsetsForTab) { + for (const value of subFrameOffsetsForTab.values()) { + if (value?.parentFrameIds.includes(sender.frameId)) { + return true; + } + } + } + + return false; + } + + /** + * Identifies if the sender tab is the same as the focused field's tab. + * + * @param sender - The sender of the message + */ + private senderTabHasFocusedField(sender: chrome.runtime.MessageSender) { + return sender.tab.id === this.focusedFieldData?.tabId; + } + + /** + * Triggers when a scroll or resize event occurs within a tab. Will reposition the inline menu + * if the focused field is within the viewport. + * + * @param sender - The sender of the message + */ + private async triggerOverlayReposition(sender: chrome.runtime.MessageSender) { + if (!this.checkShouldRepositionInlineMenu(sender)) { + return; + } + + this.resetFocusedFieldSubFrameOffsets(sender); + this.cancelInlineMenuFadeInAndPositionUpdate(); + void this.toggleInlineMenuHidden({ isInlineMenuHidden: true }, sender); + this.repositionInlineMenuSubject.next(sender); + } + + /** + * Sets the sub frame offsets for the currently focused field's frame to a null value . + * This ensures that we can delay presentation of the inline menu after a reposition + * event if the user clicks on a field before the sub frames can be rebuilt. + * + * @param sender + */ + private resetFocusedFieldSubFrameOffsets(sender: chrome.runtime.MessageSender) { + if (this.focusedFieldData.frameId > 0 && this.subFrameOffsetsForTab[sender.tab.id]) { + this.subFrameOffsetsForTab[sender.tab.id].set(this.focusedFieldData.frameId, null); + } + } + + /** + * Triggers when a focus event occurs within a tab. Will reposition the inline menu + * if the focused field is within the viewport. + * + * @param sender - The sender of the message + */ + private async triggerSubFrameFocusInRebuild(sender: chrome.runtime.MessageSender) { + this.cancelInlineMenuFadeInAndPositionUpdate(); + this.rebuildSubFrameOffsetsSubject.next(sender); + this.repositionInlineMenuSubject.next(sender); + } + + /** + * Handles determining if the inline menu should be repositioned or closed, and initiates + * the process of calculating the new position of the inline menu. + * + * @param sender - The sender of the message + */ + private repositionInlineMenu = async (sender: chrome.runtime.MessageSender) => { + this.cancelInlineMenuFadeInAndPositionUpdate(); + if (!this.isFieldCurrentlyFocused && !this.isInlineMenuButtonVisible) { + await this.closeInlineMenuAfterReposition(sender); + return; + } + + const isFieldWithinViewport = await BrowserApi.tabSendMessage( + sender.tab, + { command: "checkIsMostRecentlyFocusedFieldWithinViewport" }, + { frameId: this.focusedFieldData.frameId }, + ); + if (!isFieldWithinViewport) { + await this.closeInlineMenuAfterReposition(sender); + return; + } + + if (this.focusedFieldData.frameId > 0) { + this.rebuildSubFrameOffsetsSubject.next(sender); + } + + this.startUpdateInlineMenuPositionSubject.next(sender); + }; + + /** + * Triggers a closure of the inline menu during a reposition event. + * + * @param sender - The sender of the message +| */ + private async closeInlineMenuAfterReposition(sender: chrome.runtime.MessageSender) { + await this.toggleInlineMenuHidden( + { isInlineMenuHidden: false, setTransparentInlineMenu: true }, + sender, + ); + this.closeInlineMenu(sender, { forceCloseInlineMenu: true }); + } + + /** + * Cancels the observables that update the position and fade in of the inline menu. + */ + private cancelInlineMenuFadeInAndPositionUpdate() { + this.cancelInlineMenuFadeIn(); + this.cancelUpdateInlineMenuPositionSubject.next(); + } + /** * Sets up the extension message listeners for the overlay. */ - private setupExtensionMessageListeners() { + private setupExtensionListeners() { BrowserApi.messageListener("overlay.background", this.handleExtensionMessage); + BrowserApi.addListener(chrome.webNavigation.onCommitted, this.handleWebNavigationOnCommitted); BrowserApi.addListener(chrome.runtime.onConnect, this.handlePortOnConnect); } @@ -689,18 +1363,42 @@ class OverlayBackground implements OverlayBackgroundInterface { ) => { const handler: CallableFunction | undefined = this.extensionMessageHandlers[message?.command]; if (!handler) { - return; + return null; } const messageResponse = handler({ message, sender }); - if (!messageResponse) { + if (typeof messageResponse === "undefined") { + return null; + } + + Promise.resolve(messageResponse) + .then((response) => sendResponse(response)) + .catch(this.logService.error); + return true; + }; + + /** + * Handles clearing page details and sub frame offsets when a frame or tab navigation event occurs. + * + * @param details - The details of the web navigation event + */ + private handleWebNavigationOnCommitted = ( + details: chrome.webNavigation.WebNavigationTransitionCallbackDetails, + ) => { + const { frameId, tabId } = details; + const subFrames = this.subFrameOffsetsForTab[tabId]; + if (frameId === 0) { + this.removePageDetails(tabId); + if (subFrames) { + subFrames.clear(); + delete this.subFrameOffsetsForTab[tabId]; + } return; } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - Promise.resolve(messageResponse).then((response) => sendResponse(response)); - return true; + if (subFrames && subFrames.has(frameId)) { + subFrames.delete(frameId); + } }; /** @@ -709,25 +1407,50 @@ class OverlayBackground implements OverlayBackgroundInterface { * @param port - The port that connected to the extension background */ private handlePortOnConnect = async (port: chrome.runtime.Port) => { - const isOverlayListPort = port.name === AutofillOverlayPort.List; - const isOverlayButtonPort = port.name === AutofillOverlayPort.Button; - if (!isOverlayListPort && !isOverlayButtonPort) { + const isInlineMenuListMessageConnector = port.name === AutofillOverlayPort.ListMessageConnector; + const isInlineMenuButtonMessageConnector = + port.name === AutofillOverlayPort.ButtonMessageConnector; + if (isInlineMenuListMessageConnector || isInlineMenuButtonMessageConnector) { + port.onMessage.addListener(this.handleOverlayElementPortMessage); return; } + const isInlineMenuListPort = port.name === AutofillOverlayPort.List; + const isInlineMenuButtonPort = port.name === AutofillOverlayPort.Button; + if (!isInlineMenuListPort && !isInlineMenuButtonPort) { + return; + } + + if (!this.portKeyForTab[port.sender.tab.id]) { + this.portKeyForTab[port.sender.tab.id] = generateRandomChars(12); + } + this.storeOverlayPort(port); + port.onDisconnect.addListener(this.handlePortOnDisconnect); port.onMessage.addListener(this.handleOverlayElementPortMessage); port.postMessage({ - command: `initAutofillOverlay${isOverlayListPort ? "List" : "Button"}`, + command: `initAutofillInlineMenu${isInlineMenuListPort ? "List" : "Button"}`, + iframeUrl: chrome.runtime.getURL( + `overlay/menu-${isInlineMenuListPort ? "list" : "button"}.html`, + ), + pageTitle: chrome.i18n.getMessage( + isInlineMenuListPort ? "bitwardenVault" : "bitwardenOverlayButton", + ), authStatus: await this.getAuthStatus(), - styleSheetUrl: chrome.runtime.getURL(`overlay/${isOverlayListPort ? "list" : "button"}.css`), + styleSheetUrl: chrome.runtime.getURL( + `overlay/menu-${isInlineMenuListPort ? "list" : "button"}.css`, + ), theme: await firstValueFrom(this.themeStateService.selectedTheme$), - translations: this.getTranslations(), - ciphers: isOverlayListPort ? await this.getOverlayCipherData() : null, + translations: this.getInlineMenuTranslations(), + ciphers: isInlineMenuListPort ? await this.getInlineMenuCipherData() : null, + portKey: this.portKeyForTab[port.sender.tab.id], + portName: isInlineMenuListPort + ? AutofillOverlayPort.ListMessageConnector + : AutofillOverlayPort.ButtonMessageConnector, }); - this.updateOverlayPosition( + void this.updateInlineMenuPosition( { - overlayElement: isOverlayListPort + overlayElement: isInlineMenuListPort ? AutofillOverlayElement.List : AutofillOverlayElement.Button, }, @@ -742,14 +1465,14 @@ class OverlayBackground implements OverlayBackgroundInterface { | */ private storeOverlayPort(port: chrome.runtime.Port) { if (port.name === AutofillOverlayPort.List) { - this.storeExpiredOverlayPort(this.overlayListPort); - this.overlayListPort = port; + this.storeExpiredOverlayPort(this.inlineMenuListPort); + this.inlineMenuListPort = port; return; } if (port.name === AutofillOverlayPort.Button) { - this.storeExpiredOverlayPort(this.overlayButtonPort); - this.overlayButtonPort = port; + this.storeExpiredOverlayPort(this.inlineMenuButtonPort); + this.inlineMenuButtonPort = port; } } @@ -776,15 +1499,20 @@ class OverlayBackground implements OverlayBackgroundInterface { message: OverlayBackgroundExtensionMessage, port: chrome.runtime.Port, ) => { - const command = message?.command; - let handler: CallableFunction | undefined; - - if (port.name === AutofillOverlayPort.Button) { - handler = this.overlayButtonPortMessageHandlers[command]; + const tabPortKey = this.portKeyForTab[port.sender.tab.id]; + if (!tabPortKey || tabPortKey !== message?.portKey) { + return; } - if (port.name === AutofillOverlayPort.List) { - handler = this.overlayListPortMessageHandlers[command]; + const command = message.command; + let handler: CallableFunction | undefined; + + if (port.name === AutofillOverlayPort.ButtonMessageConnector) { + handler = this.inlineMenuButtonPortMessageHandlers[command]; + } + + if (port.name === AutofillOverlayPort.ListMessageConnector) { + handler = this.inlineMenuListPortMessageHandlers[command]; } if (!handler) { @@ -793,6 +1521,22 @@ class OverlayBackground implements OverlayBackgroundInterface { handler({ message, port }); }; -} -export default OverlayBackground; + /** + * Ensures that the inline menu list and button port + * references are reset when they are disconnected. + * + * @param port - The port that was disconnected + */ + private handlePortOnDisconnect = (port: chrome.runtime.Port) => { + if (port.name === AutofillOverlayPort.List) { + this.inlineMenuListPort = null; + this.isInlineMenuListVisible = false; + } + + if (port.name === AutofillOverlayPort.Button) { + this.inlineMenuButtonPort = null; + this.isInlineMenuButtonVisible = false; + } + }; +} diff --git a/apps/browser/src/autofill/background/tabs.background.spec.ts b/apps/browser/src/autofill/background/tabs.background.spec.ts index b95e303f17e..4473eb452f3 100644 --- a/apps/browser/src/autofill/background/tabs.background.spec.ts +++ b/apps/browser/src/autofill/background/tabs.background.spec.ts @@ -11,7 +11,7 @@ import { } from "../spec/testing-utils"; import NotificationBackground from "./notification.background"; -import OverlayBackground from "./overlay.background"; +import { OverlayBackground } from "./overlay.background"; import TabsBackground from "./tabs.background"; describe("TabsBackground", () => { @@ -146,6 +146,7 @@ describe("TabsBackground", () => { beforeEach(() => { mainBackground.onUpdatedRan = false; + mainBackground.configService.getFeatureFlag = jest.fn().mockResolvedValue(true); tabsBackground["focusedWindowId"] = focusedWindowId; tab = mock({ windowId: focusedWindowId, @@ -154,18 +155,6 @@ describe("TabsBackground", () => { }); }); - it("removes the cached page details from the overlay background if the tab status is `loading`", () => { - triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab); - - expect(overlayBackground.removePageDetails).toHaveBeenCalledWith(focusedWindowId); - }); - - it("removes the cached page details from the overlay background if the tab status is `unloaded`", () => { - triggerTabOnUpdatedEvent(focusedWindowId, { status: "unloaded" }, tab); - - expect(overlayBackground.removePageDetails).toHaveBeenCalledWith(focusedWindowId); - }); - it("skips updating the current tab data the focusedWindowId is set to a value less than zero", async () => { tab.windowId = -1; triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab); diff --git a/apps/browser/src/autofill/background/tabs.background.ts b/apps/browser/src/autofill/background/tabs.background.ts index 53c801ff7bc..f68ae6c6edc 100644 --- a/apps/browser/src/autofill/background/tabs.background.ts +++ b/apps/browser/src/autofill/background/tabs.background.ts @@ -1,7 +1,9 @@ +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; + import MainBackground from "../../background/main.background"; +import { OverlayBackground } from "./abstractions/overlay.background"; import NotificationBackground from "./notification.background"; -import OverlayBackground from "./overlay.background"; export default class TabsBackground { constructor( @@ -86,8 +88,11 @@ export default class TabsBackground { changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab, ) => { + const overlayImprovementsFlag = await this.main.configService.getFeatureFlag( + FeatureFlag.InlineMenuPositioningImprovements, + ); const removePageDetailsStatus = new Set(["loading", "unloaded"]); - if (removePageDetailsStatus.has(changeInfo.status)) { + if (!!overlayImprovementsFlag && removePageDetailsStatus.has(changeInfo.status)) { this.overlayBackground.removePageDetails(tabId); } diff --git a/apps/browser/src/autofill/content/abstractions/autofill-init.ts b/apps/browser/src/autofill/content/abstractions/autofill-init.ts index 91866ffa0bb..8b00b4ecc9e 100644 --- a/apps/browser/src/autofill/content/abstractions/autofill-init.ts +++ b/apps/browser/src/autofill/content/abstractions/autofill-init.ts @@ -1,46 +1,40 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { AutofillOverlayElementType } from "../../enums/autofill-overlay.enum"; import AutofillScript from "../../models/autofill-script"; -type AutofillExtensionMessage = { +export type AutofillExtensionMessage = { command: string; tab?: chrome.tabs.Tab; sender?: string; fillScript?: AutofillScript; url?: string; + subFrameUrl?: string; + subFrameId?: string; pageDetailsUrl?: string; ciphers?: any; + isInlineMenuHidden?: boolean; + overlayElement?: AutofillOverlayElementType; + isFocusingFieldElement?: boolean; + authStatus?: AuthenticationStatus; + isOpeningFullInlineMenu?: boolean; data?: { - authStatus?: AuthenticationStatus; - isFocusingFieldElement?: boolean; - isOverlayCiphersPopulated?: boolean; - direction?: "previous" | "next"; - isOpeningFullOverlay?: boolean; - forceCloseOverlay?: boolean; - autofillOverlayVisibility?: number; + direction?: "previous" | "next" | "current"; + forceCloseInlineMenu?: boolean; + inlineMenuVisibility?: number; }; }; -type AutofillExtensionMessageParam = { message: AutofillExtensionMessage }; +export type AutofillExtensionMessageParam = { message: AutofillExtensionMessage }; -type AutofillExtensionMessageHandlers = { +export type AutofillExtensionMessageHandlers = { [key: string]: CallableFunction; collectPageDetails: ({ message }: AutofillExtensionMessageParam) => void; collectPageDetailsImmediately: ({ message }: AutofillExtensionMessageParam) => void; fillForm: ({ message }: AutofillExtensionMessageParam) => void; - openAutofillOverlay: ({ message }: AutofillExtensionMessageParam) => void; - closeAutofillOverlay: ({ message }: AutofillExtensionMessageParam) => void; - addNewVaultItemFromOverlay: () => void; - redirectOverlayFocusOut: ({ message }: AutofillExtensionMessageParam) => void; - updateIsOverlayCiphersPopulated: ({ message }: AutofillExtensionMessageParam) => void; - bgUnlockPopoutOpened: () => void; - bgVaultItemRepromptPopoutOpened: () => void; - updateAutofillOverlayVisibility: ({ message }: AutofillExtensionMessageParam) => void; }; -interface AutofillInit { +export interface AutofillInit { init(): void; destroy(): void; } - -export { AutofillExtensionMessage, AutofillExtensionMessageHandlers, AutofillInit }; diff --git a/apps/browser/src/autofill/content/autofill-init.spec.ts b/apps/browser/src/autofill/content/autofill-init.spec.ts index 302b520e336..e27e8ef73d0 100644 --- a/apps/browser/src/autofill/content/autofill-init.spec.ts +++ b/apps/browser/src/autofill/content/autofill-init.spec.ts @@ -1,26 +1,25 @@ -import { mock } from "jest-mock-extended"; - -import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; +import { mock, MockProxy } from "jest-mock-extended"; import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript from "../models/autofill-script"; -import AutofillOverlayContentService from "../services/autofill-overlay-content.service"; +import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service"; +import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service"; import { flushPromises, mockQuerySelectorAllDefinedCall, sendMockExtensionMessage, } from "../spec/testing-utils"; -import { RedirectFocusDirection } from "../utils/autofill-overlay.enum"; import { AutofillExtensionMessage } from "./abstractions/autofill-init"; import AutofillInit from "./autofill-init"; describe("AutofillInit", () => { + let inlineMenuElements: MockProxy; + let autofillOverlayContentService: MockProxy; let autofillInit: AutofillInit; - const autofillOverlayContentService = mock(); const originalDocumentReadyState = document.readyState; const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); + let sendExtensionMessageSpy: jest.SpyInstance; beforeEach(() => { chrome.runtime.connect = jest.fn().mockReturnValue({ @@ -28,7 +27,12 @@ describe("AutofillInit", () => { addListener: jest.fn(), }, }); - autofillInit = new AutofillInit(autofillOverlayContentService); + inlineMenuElements = mock(); + autofillOverlayContentService = mock(); + autofillInit = new AutofillInit(autofillOverlayContentService, inlineMenuElements); + sendExtensionMessageSpy = jest + .spyOn(autofillInit as any, "sendExtensionMessage") + .mockImplementation(); window.IntersectionObserver = jest.fn(() => mock()); }); @@ -61,13 +65,9 @@ describe("AutofillInit", () => { autofillInit.init(); jest.advanceTimersByTime(250); - expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( - { - command: "bgCollectPageDetails", - sender: "autofillInit", - }, - expect.any(Function), - ); + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("bgCollectPageDetails", { + sender: "autofillInit", + }); }); it("registers a window load listener to collect the page details if the document is not in a `complete` ready state", () => { @@ -106,15 +106,15 @@ describe("AutofillInit", () => { sender = mock(); }); - it("returns a undefined value if a extension message handler is not found with the given message command", () => { + it("returns a null value if a extension message handler is not found with the given message command", () => { message.command = "unknownCommand"; const response = autofillInit["handleExtensionMessage"](message, sender, sendResponse); - expect(response).toBe(undefined); + expect(response).toBe(null); }); - it("returns a undefined value if the message handler does not return a response", async () => { + it("returns a null value if the message handler does not return a response", async () => { const response1 = await autofillInit["handleExtensionMessage"](message, sender, sendResponse); await flushPromises(); @@ -126,7 +126,7 @@ describe("AutofillInit", () => { const response2 = autofillInit["handleExtensionMessage"](message, sender, sendResponse); await flushPromises(); - expect(response2).toBe(undefined); + expect(response2).toBe(null); }); it("returns a true value and calls sendResponse if the message handler returns a response", async () => { @@ -155,6 +155,22 @@ describe("AutofillInit", () => { autofillInit.init(); }); + it("triggers extension message handlers from the AutofillOverlayContentService", () => { + autofillOverlayContentService.messageHandlers.messageHandler = jest.fn(); + + sendMockExtensionMessage({ command: "messageHandler" }, sender, sendResponse); + + expect(autofillOverlayContentService.messageHandlers.messageHandler).toHaveBeenCalled(); + }); + + it("triggers extension message handlers from the AutofillInlineMenuContentService", () => { + inlineMenuElements.messageHandlers.messageHandler = jest.fn(); + + sendMockExtensionMessage({ command: "messageHandler" }, sender, sendResponse); + + expect(inlineMenuElements.messageHandlers.messageHandler).toHaveBeenCalled(); + }); + describe("collectPageDetails", () => { it("sends the collected page details for autofill using a background script message", async () => { const pageDetails: AutofillPageDetails = { @@ -177,8 +193,7 @@ describe("AutofillInit", () => { sendMockExtensionMessage(message, sender, sendResponse); await flushPromises(); - expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ - command: "collectPageDetailsResponse", + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("collectPageDetailsResponse", { tab: message.tab, details: pageDetails, sender: message.sender, @@ -226,14 +241,11 @@ describe("AutofillInit", () => { }); it("skips calling the InsertAutofillContentService and does not fill the form if the url to fill is not equal to the current tab url", async () => { - const fillScript = mock(); - const message = { + sendMockExtensionMessage({ command: "fillForm", fillScript, pageDetailsUrl: "https://a-different-url.com", - }; - - sendMockExtensionMessage(message); + }); await flushPromises(); expect(autofillInit["insertAutofillContentService"].fillForm).not.toHaveBeenCalledWith( @@ -255,7 +267,10 @@ describe("AutofillInit", () => { }); it("removes the overlay when filling the form", async () => { - const blurAndRemoveOverlaySpy = jest.spyOn(autofillInit as any, "blurAndRemoveOverlay"); + const blurAndRemoveOverlaySpy = jest.spyOn( + autofillInit as any, + "blurFocusedFieldAndCloseInlineMenu", + ); sendMockExtensionMessage({ command: "fillForm", fillScript, @@ -268,10 +283,6 @@ describe("AutofillInit", () => { it("updates the isCurrentlyFilling property of the overlay to true after filling", async () => { jest.useFakeTimers(); - jest.spyOn(autofillInit as any, "updateOverlayIsCurrentlyFilling"); - jest - .spyOn(autofillInit["autofillOverlayContentService"], "focusMostRecentOverlayField") - .mockImplementation(); sendMockExtensionMessage({ command: "fillForm", @@ -281,292 +292,18 @@ describe("AutofillInit", () => { await flushPromises(); jest.advanceTimersByTime(300); - expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(1, true); + expect(sendExtensionMessageSpy).toHaveBeenNthCalledWith( + 1, + "updateIsFieldCurrentlyFilling", + { isFieldCurrentlyFilling: true }, + ); expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( fillScript, ); - expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(2, false); - }); - - it("skips attempting to focus the most recent field if the autofillOverlayContentService is not present", async () => { - jest.useFakeTimers(); - const newAutofillInit = new AutofillInit(undefined); - newAutofillInit.init(); - jest.spyOn(newAutofillInit as any, "updateOverlayIsCurrentlyFilling"); - jest - .spyOn(newAutofillInit["insertAutofillContentService"], "fillForm") - .mockImplementation(); - - sendMockExtensionMessage({ - command: "fillForm", - fillScript, - pageDetailsUrl: window.location.href, - }); - await flushPromises(); - jest.advanceTimersByTime(300); - - expect(newAutofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith( - 1, - true, - ); - expect(newAutofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( - fillScript, - ); - expect(newAutofillInit["updateOverlayIsCurrentlyFilling"]).not.toHaveBeenNthCalledWith( + expect(sendExtensionMessageSpy).toHaveBeenNthCalledWith( 2, - false, - ); - }); - }); - - describe("openAutofillOverlay", () => { - const message = { - command: "openAutofillOverlay", - data: { - isFocusingFieldElement: true, - isOpeningFullOverlay: true, - authStatus: AuthenticationStatus.Unlocked, - }, - }; - - it("skips attempting to open the autofill overlay if the autofillOverlayContentService is not present", () => { - const newAutofillInit = new AutofillInit(undefined); - newAutofillInit.init(); - - sendMockExtensionMessage(message); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - }); - - it("opens the autofill overlay", () => { - sendMockExtensionMessage(message); - - expect( - autofillInit["autofillOverlayContentService"].openAutofillOverlay, - ).toHaveBeenCalledWith({ - isFocusingFieldElement: message.data.isFocusingFieldElement, - isOpeningFullOverlay: message.data.isOpeningFullOverlay, - authStatus: message.data.authStatus, - }); - }); - }); - - describe("closeAutofillOverlay", () => { - beforeEach(() => { - autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = false; - autofillInit["autofillOverlayContentService"].isCurrentlyFilling = false; - }); - - it("skips attempting to remove the overlay if the autofillOverlayContentService is not present", () => { - const newAutofillInit = new AutofillInit(undefined); - newAutofillInit.init(); - jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); - - sendMockExtensionMessage({ - command: "closeAutofillOverlay", - data: { forceCloseOverlay: false }, - }); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - }); - - it("removes the autofill overlay if the message flags a forced closure", () => { - sendMockExtensionMessage({ - command: "closeAutofillOverlay", - data: { forceCloseOverlay: true }, - }); - - expect( - autofillInit["autofillOverlayContentService"].removeAutofillOverlay, - ).toHaveBeenCalled(); - }); - - it("ignores the message if a field is currently focused", () => { - autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = true; - - sendMockExtensionMessage({ command: "closeAutofillOverlay" }); - - expect( - autofillInit["autofillOverlayContentService"].removeAutofillOverlayList, - ).not.toHaveBeenCalled(); - expect( - autofillInit["autofillOverlayContentService"].removeAutofillOverlay, - ).not.toHaveBeenCalled(); - }); - - it("removes the autofill overlay list if the overlay is currently filling", () => { - autofillInit["autofillOverlayContentService"].isCurrentlyFilling = true; - - sendMockExtensionMessage({ command: "closeAutofillOverlay" }); - - expect( - autofillInit["autofillOverlayContentService"].removeAutofillOverlayList, - ).toHaveBeenCalled(); - expect( - autofillInit["autofillOverlayContentService"].removeAutofillOverlay, - ).not.toHaveBeenCalled(); - }); - - it("removes the entire overlay if the overlay is not currently filling", () => { - sendMockExtensionMessage({ command: "closeAutofillOverlay" }); - - expect( - autofillInit["autofillOverlayContentService"].removeAutofillOverlayList, - ).not.toHaveBeenCalled(); - expect( - autofillInit["autofillOverlayContentService"].removeAutofillOverlay, - ).toHaveBeenCalled(); - }); - }); - - describe("addNewVaultItemFromOverlay", () => { - it("will not add a new vault item if the autofillOverlayContentService is not present", () => { - const newAutofillInit = new AutofillInit(undefined); - newAutofillInit.init(); - - sendMockExtensionMessage({ command: "addNewVaultItemFromOverlay" }); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - }); - - it("will add a new vault item", () => { - sendMockExtensionMessage({ command: "addNewVaultItemFromOverlay" }); - - expect(autofillInit["autofillOverlayContentService"].addNewVaultItem).toHaveBeenCalled(); - }); - }); - - describe("redirectOverlayFocusOut", () => { - const message = { - command: "redirectOverlayFocusOut", - data: { - direction: RedirectFocusDirection.Next, - }, - }; - - it("ignores the message to redirect focus if the autofillOverlayContentService does not exist", () => { - const newAutofillInit = new AutofillInit(undefined); - newAutofillInit.init(); - - sendMockExtensionMessage(message); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - }); - - it("redirects the overlay focus", () => { - sendMockExtensionMessage(message); - - expect( - autofillInit["autofillOverlayContentService"].redirectOverlayFocusOut, - ).toHaveBeenCalledWith(message.data.direction); - }); - }); - - describe("updateIsOverlayCiphersPopulated", () => { - const message = { - command: "updateIsOverlayCiphersPopulated", - data: { - isOverlayCiphersPopulated: true, - }, - }; - - it("skips updating whether the ciphers are populated if the autofillOverlayContentService does note exist", () => { - const newAutofillInit = new AutofillInit(undefined); - newAutofillInit.init(); - - sendMockExtensionMessage(message); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - }); - - it("updates whether the overlay ciphers are populated", () => { - sendMockExtensionMessage(message); - - expect(autofillInit["autofillOverlayContentService"].isOverlayCiphersPopulated).toEqual( - message.data.isOverlayCiphersPopulated, - ); - }); - }); - - describe("bgUnlockPopoutOpened", () => { - it("skips attempting to blur and remove the overlay if the autofillOverlayContentService is not present", () => { - const newAutofillInit = new AutofillInit(undefined); - newAutofillInit.init(); - jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); - - sendMockExtensionMessage({ command: "bgUnlockPopoutOpened" }); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - expect(newAutofillInit["removeAutofillOverlay"]).not.toHaveBeenCalled(); - }); - - it("blurs the most recently focused feel and remove the autofill overlay", () => { - jest.spyOn(autofillInit["autofillOverlayContentService"], "blurMostRecentOverlayField"); - jest.spyOn(autofillInit as any, "removeAutofillOverlay"); - - sendMockExtensionMessage({ command: "bgUnlockPopoutOpened" }); - - expect( - autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField, - ).toHaveBeenCalled(); - expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled(); - }); - }); - - describe("bgVaultItemRepromptPopoutOpened", () => { - it("skips attempting to blur and remove the overlay if the autofillOverlayContentService is not present", () => { - const newAutofillInit = new AutofillInit(undefined); - newAutofillInit.init(); - jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); - - sendMockExtensionMessage({ command: "bgVaultItemRepromptPopoutOpened" }); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - expect(newAutofillInit["removeAutofillOverlay"]).not.toHaveBeenCalled(); - }); - - it("blurs the most recently focused feel and remove the autofill overlay", () => { - jest.spyOn(autofillInit["autofillOverlayContentService"], "blurMostRecentOverlayField"); - jest.spyOn(autofillInit as any, "removeAutofillOverlay"); - - sendMockExtensionMessage({ command: "bgVaultItemRepromptPopoutOpened" }); - - expect( - autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField, - ).toHaveBeenCalled(); - expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled(); - }); - }); - - describe("updateAutofillOverlayVisibility", () => { - beforeEach(() => { - autofillInit["autofillOverlayContentService"].autofillOverlayVisibility = - AutofillOverlayVisibility.OnButtonClick; - }); - - it("skips attempting to update the overlay visibility if the autofillOverlayVisibility data value is not present", () => { - sendMockExtensionMessage({ - command: "updateAutofillOverlayVisibility", - data: {}, - }); - - expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual( - AutofillOverlayVisibility.OnButtonClick, - ); - }); - - it("updates the overlay visibility value", () => { - const message = { - command: "updateAutofillOverlayVisibility", - data: { - autofillOverlayVisibility: AutofillOverlayVisibility.Off, - }, - }; - - sendMockExtensionMessage(message); - - expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual( - message.data.autofillOverlayVisibility, + "updateIsFieldCurrentlyFilling", + { isFieldCurrentlyFilling: false }, ); }); }); diff --git a/apps/browser/src/autofill/content/autofill-init.ts b/apps/browser/src/autofill/content/autofill-init.ts index e78a1fb5ee1..70f815d2234 100644 --- a/apps/browser/src/autofill/content/autofill-init.ts +++ b/apps/browser/src/autofill/content/autofill-init.ts @@ -1,4 +1,7 @@ +import { EVENTS } from "@bitwarden/common/autofill/constants"; + import AutofillPageDetails from "../models/autofill-page-details"; +import { AutofillInlineMenuContentService } from "../overlay/inline-menu/abstractions/autofill-inline-menu-content.service"; import { AutofillOverlayContentService } from "../services/abstractions/autofill-overlay-content.service"; import CollectAutofillContentService from "../services/collect-autofill-content.service"; import DomElementVisibilityService from "../services/dom-element-visibility.service"; @@ -12,7 +15,9 @@ import { } from "./abstractions/autofill-init"; class AutofillInit implements AutofillInitInterface { + private readonly sendExtensionMessage = sendExtensionMessage; private readonly autofillOverlayContentService: AutofillOverlayContentService | undefined; + private readonly autofillInlineMenuContentService: AutofillInlineMenuContentService | undefined; private readonly domElementVisibilityService: DomElementVisibilityService; private readonly collectAutofillContentService: CollectAutofillContentService; private readonly insertAutofillContentService: InsertAutofillContentService; @@ -21,14 +26,6 @@ class AutofillInit implements AutofillInitInterface { collectPageDetails: ({ message }) => this.collectPageDetails(message), collectPageDetailsImmediately: ({ message }) => this.collectPageDetails(message, true), fillForm: ({ message }) => this.fillForm(message), - openAutofillOverlay: ({ message }) => this.openAutofillOverlay(message), - closeAutofillOverlay: ({ message }) => this.removeAutofillOverlay(message), - addNewVaultItemFromOverlay: () => this.addNewVaultItemFromOverlay(), - redirectOverlayFocusOut: ({ message }) => this.redirectOverlayFocusOut(message), - updateIsOverlayCiphersPopulated: ({ message }) => this.updateIsOverlayCiphersPopulated(message), - bgUnlockPopoutOpened: () => this.blurAndRemoveOverlay(), - bgVaultItemRepromptPopoutOpened: () => this.blurAndRemoveOverlay(), - updateAutofillOverlayVisibility: ({ message }) => this.updateAutofillOverlayVisibility(message), }; /** @@ -36,10 +33,17 @@ class AutofillInit implements AutofillInitInterface { * CollectAutofillContentService and InsertAutofillContentService classes. * * @param autofillOverlayContentService - The autofill overlay content service, potentially undefined. + * @param inlineMenuElements - The inline menu elements, potentially undefined. */ - constructor(autofillOverlayContentService?: AutofillOverlayContentService) { + constructor( + autofillOverlayContentService?: AutofillOverlayContentService, + inlineMenuElements?: AutofillInlineMenuContentService, + ) { this.autofillOverlayContentService = autofillOverlayContentService; - this.domElementVisibilityService = new DomElementVisibilityService(); + this.autofillInlineMenuContentService = inlineMenuElements; + this.domElementVisibilityService = new DomElementVisibilityService( + this.autofillInlineMenuContentService, + ); this.collectAutofillContentService = new CollectAutofillContentService( this.domElementVisibilityService, this.autofillOverlayContentService, @@ -70,7 +74,7 @@ class AutofillInit implements AutofillInitInterface { const sendCollectDetailsMessage = () => { this.clearCollectPageDetailsOnLoadTimeout(); this.collectPageDetailsOnLoadTimeout = setTimeout( - () => sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }), + () => this.sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }), 250, ); }; @@ -79,7 +83,7 @@ class AutofillInit implements AutofillInitInterface { sendCollectDetailsMessage(); } - globalThis.addEventListener("load", sendCollectDetailsMessage); + globalThis.addEventListener(EVENTS.LOAD, sendCollectDetailsMessage); } /** @@ -102,8 +106,7 @@ class AutofillInit implements AutofillInitInterface { return pageDetails; } - void chrome.runtime.sendMessage({ - command: "collectPageDetailsResponse", + void this.sendExtensionMessage("collectPageDetailsResponse", { tab: message.tab, details: pageDetails, sender: message.sender, @@ -120,134 +123,28 @@ class AutofillInit implements AutofillInitInterface { return; } - this.blurAndRemoveOverlay(); - this.updateOverlayIsCurrentlyFilling(true); + this.blurFocusedFieldAndCloseInlineMenu(); + await this.sendExtensionMessage("updateIsFieldCurrentlyFilling", { + isFieldCurrentlyFilling: true, + }); await this.insertAutofillContentService.fillForm(fillScript); - if (!this.autofillOverlayContentService) { - return; - } - - setTimeout(() => this.updateOverlayIsCurrentlyFilling(false), 250); - } - - /** - * Handles updating the overlay is currently filling value. - * - * @param isCurrentlyFilling - Indicates if the overlay is currently filling - */ - private updateOverlayIsCurrentlyFilling(isCurrentlyFilling: boolean) { - if (!this.autofillOverlayContentService) { - return; - } - - this.autofillOverlayContentService.isCurrentlyFilling = isCurrentlyFilling; - } - - /** - * Opens the autofill overlay. - * - * @param data - The extension message data. - */ - private openAutofillOverlay({ data }: AutofillExtensionMessage) { - if (!this.autofillOverlayContentService) { - return; - } - - this.autofillOverlayContentService.openAutofillOverlay(data); - } - - /** - * Blurs the most recent overlay field and removes the overlay. Used - * in cases where the background unlock or vault item reprompt popout - * is opened. - */ - private blurAndRemoveOverlay() { - if (!this.autofillOverlayContentService) { - return; - } - - this.autofillOverlayContentService.blurMostRecentOverlayField(); - this.removeAutofillOverlay(); - } - - /** - * Removes the autofill overlay if the field is not currently focused. - * If the autofill is currently filling, only the overlay list will be - * removed. - */ - private removeAutofillOverlay(message?: AutofillExtensionMessage) { - if (message?.data?.forceCloseOverlay) { - this.autofillOverlayContentService?.removeAutofillOverlay(); - return; - } - - if ( - !this.autofillOverlayContentService || - this.autofillOverlayContentService.isFieldCurrentlyFocused - ) { - return; - } - - if (this.autofillOverlayContentService.isCurrentlyFilling) { - this.autofillOverlayContentService.removeAutofillOverlayList(); - return; - } - - this.autofillOverlayContentService.removeAutofillOverlay(); - } - - /** - * Adds a new vault item from the overlay. - */ - private addNewVaultItemFromOverlay() { - if (!this.autofillOverlayContentService) { - return; - } - - this.autofillOverlayContentService.addNewVaultItem(); - } - - /** - * Redirects the overlay focus out of an overlay iframe. - * - * @param data - Contains the direction to redirect the focus. - */ - private redirectOverlayFocusOut({ data }: AutofillExtensionMessage) { - if (!this.autofillOverlayContentService) { - return; - } - - this.autofillOverlayContentService.redirectOverlayFocusOut(data?.direction); - } - - /** - * Updates whether the current tab has ciphers that can populate the overlay list - * - * @param data - Contains the isOverlayCiphersPopulated value - * - */ - private updateIsOverlayCiphersPopulated({ data }: AutofillExtensionMessage) { - if (!this.autofillOverlayContentService) { - return; - } - - this.autofillOverlayContentService.isOverlayCiphersPopulated = Boolean( - data?.isOverlayCiphersPopulated, + setTimeout( + () => + this.sendExtensionMessage("updateIsFieldCurrentlyFilling", { + isFieldCurrentlyFilling: false, + }), + 250, ); } /** - * Updates the autofill overlay visibility. - * - * @param data - Contains the autoFillOverlayVisibility value + * Blurs the most recently focused field and removes the inline menu. Used + * in cases where the background unlock or vault item reprompt popout + * is opened. */ - private updateAutofillOverlayVisibility({ data }: AutofillExtensionMessage) { - if (!this.autofillOverlayContentService || isNaN(data?.autofillOverlayVisibility)) { - return; - } - - this.autofillOverlayContentService.autofillOverlayVisibility = data?.autofillOverlayVisibility; + private blurFocusedFieldAndCloseInlineMenu() { + this.autofillOverlayContentService?.blurMostRecentlyFocusedField(true); } /** @@ -279,22 +176,37 @@ class AutofillInit implements AutofillInitInterface { sendResponse: (response?: any) => void, ): boolean => { const command: string = message.command; - const handler: CallableFunction | undefined = this.extensionMessageHandlers[command]; + const handler: CallableFunction | undefined = this.getExtensionMessageHandler(command); if (!handler) { - return; + return null; } const messageResponse = handler({ message, sender }); - if (!messageResponse) { - return; + if (typeof messageResponse === "undefined") { + return null; } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - Promise.resolve(messageResponse).then((response) => sendResponse(response)); + void Promise.resolve(messageResponse).then((response) => sendResponse(response)); return true; }; + /** + * Gets the extension message handler for the given command. + * + * @param command - The extension message command. + */ + private getExtensionMessageHandler(command: string): CallableFunction | undefined { + if (this.autofillOverlayContentService?.messageHandlers?.[command]) { + return this.autofillOverlayContentService.messageHandlers[command]; + } + + if (this.autofillInlineMenuContentService?.messageHandlers?.[command]) { + return this.autofillInlineMenuContentService.messageHandlers[command]; + } + + return this.extensionMessageHandlers[command]; + } + /** * Handles destroying the autofill init content script. Removes all * listeners, timeouts, and object instances to prevent memory leaks. @@ -304,6 +216,7 @@ class AutofillInit implements AutofillInitInterface { chrome.runtime.onMessage.removeListener(this.handleExtensionMessage); this.collectAutofillContentService.destroy(); this.autofillOverlayContentService?.destroy(); + this.autofillInlineMenuContentService?.destroy(); } } diff --git a/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts b/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts index ab21e367c29..22430227660 100644 --- a/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts +++ b/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts @@ -1,12 +1,24 @@ -import AutofillOverlayContentService from "../services/autofill-overlay-content.service"; +import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service"; +import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service"; +import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service"; import { setupAutofillInitDisconnectAction } from "../utils"; import AutofillInit from "./autofill-init"; (function (windowContext) { if (!windowContext.bitwardenAutofillInit) { - const autofillOverlayContentService = new AutofillOverlayContentService(); - windowContext.bitwardenAutofillInit = new AutofillInit(autofillOverlayContentService); + const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); + const autofillOverlayContentService = new AutofillOverlayContentService( + inlineMenuFieldQualificationService, + ); + let inlineMenuElements: AutofillInlineMenuContentService; + if (globalThis.self === globalThis.top) { + inlineMenuElements = new AutofillInlineMenuContentService(); + } + windowContext.bitwardenAutofillInit = new AutofillInit( + autofillOverlayContentService, + inlineMenuElements, + ); setupAutofillInitDisconnectAction(windowContext); windowContext.bitwardenAutofillInit.init(); diff --git a/apps/browser/src/autofill/deprecated/background/abstractions/overlay.background.deprecated.ts b/apps/browser/src/autofill/deprecated/background/abstractions/overlay.background.deprecated.ts new file mode 100644 index 00000000000..88b78dc2495 --- /dev/null +++ b/apps/browser/src/autofill/deprecated/background/abstractions/overlay.background.deprecated.ts @@ -0,0 +1,124 @@ +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; + +import { LockedVaultPendingNotificationsData } from "../../../background/abstractions/notification.background"; +import AutofillPageDetails from "../../../models/autofill-page-details"; + +type WebsiteIconData = { + imageEnabled: boolean; + image: string; + fallbackImage: string; + icon: string; +}; + +type OverlayAddNewItemMessage = { + login?: { + uri?: string; + hostname: string; + username: string; + password: string; + }; +}; + +type OverlayBackgroundExtensionMessage = { + [key: string]: any; + command: string; + tab?: chrome.tabs.Tab; + sender?: string; + details?: AutofillPageDetails; + overlayElement?: string; + display?: string; + data?: LockedVaultPendingNotificationsData; +} & OverlayAddNewItemMessage; + +type OverlayPortMessage = { + [key: string]: any; + command: string; + direction?: string; + overlayCipherId?: string; +}; + +type FocusedFieldData = { + focusedFieldStyles: Partial; + focusedFieldRects: Partial; + tabId?: number; +}; + +type OverlayCipherData = { + id: string; + name: string; + type: CipherType; + reprompt: CipherRepromptType; + favorite: boolean; + icon: { imageEnabled: boolean; image: string; fallbackImage: string; icon: string }; + login?: { username: string }; + card?: string; +}; + +type BackgroundMessageParam = { + message: OverlayBackgroundExtensionMessage; +}; +type BackgroundSenderParam = { + sender: chrome.runtime.MessageSender; +}; +type BackgroundOnMessageHandlerParams = BackgroundMessageParam & BackgroundSenderParam; + +type OverlayBackgroundExtensionMessageHandlers = { + [key: string]: CallableFunction; + openAutofillOverlay: () => void; + autofillOverlayElementClosed: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + autofillOverlayAddNewVaultItem: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + getAutofillOverlayVisibility: () => void; + checkAutofillOverlayFocused: () => void; + focusAutofillOverlayList: () => void; + updateAutofillOverlayPosition: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + updateAutofillOverlayHidden: ({ message }: BackgroundMessageParam) => void; + updateFocusedFieldData: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + collectPageDetailsResponse: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + unlockCompleted: ({ message }: BackgroundMessageParam) => void; + addedCipher: () => void; + addEditCipherSubmitted: () => void; + editedCipher: () => void; + deletedCipher: () => void; +}; + +type PortMessageParam = { + message: OverlayPortMessage; +}; +type PortConnectionParam = { + port: chrome.runtime.Port; +}; +type PortOnMessageHandlerParams = PortMessageParam & PortConnectionParam; + +type OverlayButtonPortMessageHandlers = { + [key: string]: CallableFunction; + overlayButtonClicked: ({ port }: PortConnectionParam) => void; + closeAutofillOverlay: ({ port }: PortConnectionParam) => void; + forceCloseAutofillOverlay: ({ port }: PortConnectionParam) => void; + overlayPageBlurred: () => void; + redirectOverlayFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void; +}; + +type OverlayListPortMessageHandlers = { + [key: string]: CallableFunction; + checkAutofillOverlayButtonFocused: () => void; + forceCloseAutofillOverlay: ({ port }: PortConnectionParam) => void; + overlayPageBlurred: () => void; + unlockVault: ({ port }: PortConnectionParam) => void; + fillSelectedListItem: ({ message, port }: PortOnMessageHandlerParams) => void; + addNewVaultItem: ({ port }: PortConnectionParam) => void; + viewSelectedCipher: ({ message, port }: PortOnMessageHandlerParams) => void; + redirectOverlayFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void; +}; + +export { + WebsiteIconData, + OverlayBackgroundExtensionMessage, + OverlayPortMessage, + FocusedFieldData, + OverlayCipherData, + OverlayAddNewItemMessage, + OverlayBackgroundExtensionMessageHandlers, + OverlayButtonPortMessageHandlers, + OverlayListPortMessageHandlers, +}; diff --git a/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts b/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts new file mode 100644 index 00000000000..c3285059c7e --- /dev/null +++ b/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts @@ -0,0 +1,1463 @@ +import { mock, MockProxy, mockReset } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { AuthService } from "@bitwarden/common/auth/services/auth.service"; +import { + SHOW_AUTOFILL_BUTTON, + AutofillOverlayVisibility, +} from "@bitwarden/common/autofill/constants"; +import { AutofillSettingsService } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { + DefaultDomainSettingsService, + DomainSettingsService, +} from "@bitwarden/common/autofill/services/domain-settings.service"; +import { + EnvironmentService, + Region, +} from "@bitwarden/common/platform/abstractions/environment.service"; +import { ThemeType } from "@bitwarden/common/platform/enums"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { CloudEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; +import { I18nService } from "@bitwarden/common/platform/services/i18n.service"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; +import { + FakeStateProvider, + FakeAccountService, + mockAccountServiceWith, +} from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; + +import { BrowserApi } from "../../../platform/browser/browser-api"; +import { BrowserPlatformUtilsService } from "../../../platform/services/platform-utils/browser-platform-utils.service"; +import { + AutofillOverlayElement, + AutofillOverlayPort, + RedirectFocusDirection, +} from "../../enums/autofill-overlay.enum"; +import { AutofillService } from "../../services/abstractions/autofill.service"; +import { + createAutofillPageDetailsMock, + createChromeTabMock, + createFocusedFieldDataMock, + createPageDetailMock, + createPortSpyMock, +} from "../../spec/autofill-mocks"; +import { flushPromises, sendMockExtensionMessage, sendPortMessage } from "../../spec/testing-utils"; + +import LegacyOverlayBackground from "./overlay.background.deprecated"; + +describe("OverlayBackground", () => { + const mockUserId = Utils.newGuid() as UserId; + const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); + const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); + let domainSettingsService: DomainSettingsService; + let buttonPortSpy: chrome.runtime.Port; + let listPortSpy: chrome.runtime.Port; + let overlayBackground: LegacyOverlayBackground; + const cipherService = mock(); + const autofillService = mock(); + let activeAccountStatusMock$: BehaviorSubject; + let authService: MockProxy; + + const environmentService = mock(); + environmentService.environment$ = new BehaviorSubject( + new CloudEnvironment({ + key: Region.US, + domain: "bitwarden.com", + urls: { icons: "https://icons.bitwarden.com/" }, + }), + ); + const autofillSettingsService = mock(); + const i18nService = mock(); + const platformUtilsService = mock(); + const themeStateService = mock(); + const initOverlayElementPorts = async (options = { initList: true, initButton: true }) => { + const { initList, initButton } = options; + if (initButton) { + await overlayBackground["handlePortOnConnect"](createPortSpyMock(AutofillOverlayPort.Button)); + buttonPortSpy = overlayBackground["overlayButtonPort"]; + } + + if (initList) { + await overlayBackground["handlePortOnConnect"](createPortSpyMock(AutofillOverlayPort.List)); + listPortSpy = overlayBackground["overlayListPort"]; + } + + return { buttonPortSpy, listPortSpy }; + }; + + beforeEach(() => { + domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); + activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked); + authService = mock(); + authService.activeAccountStatus$ = activeAccountStatusMock$; + overlayBackground = new LegacyOverlayBackground( + cipherService, + autofillService, + authService, + environmentService, + domainSettingsService, + autofillSettingsService, + i18nService, + platformUtilsService, + themeStateService, + ); + + jest + .spyOn(overlayBackground as any, "getOverlayVisibility") + .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); + + themeStateService.selectedTheme$ = of(ThemeType.Light); + domainSettingsService.showFavicons$ = of(true); + + void overlayBackground.init(); + }); + + afterEach(() => { + jest.clearAllMocks(); + mockReset(cipherService); + }); + + describe("removePageDetails", () => { + it("removes the page details for a specific tab from the pageDetailsForTab object", () => { + const tabId = 1; + const frameId = 2; + overlayBackground["pageDetailsForTab"][tabId] = new Map([[frameId, createPageDetailMock()]]); + overlayBackground.removePageDetails(tabId); + + expect(overlayBackground["pageDetailsForTab"][tabId]).toBeUndefined(); + }); + }); + + describe("init", () => { + it("sets up the extension message listeners, get the overlay's visibility settings, and get the user's auth status", async () => { + overlayBackground["setupExtensionMessageListeners"] = jest.fn(); + overlayBackground["getOverlayVisibility"] = jest.fn(); + overlayBackground["getAuthStatus"] = jest.fn(); + + await overlayBackground.init(); + + expect(overlayBackground["setupExtensionMessageListeners"]).toHaveBeenCalled(); + expect(overlayBackground["getOverlayVisibility"]).toHaveBeenCalled(); + expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled(); + }); + }); + + describe("updateOverlayCiphers", () => { + const url = "https://jest-testing-website.com"; + const tab = createChromeTabMock({ url }); + const cipher1 = mock({ + id: "id-1", + localData: { lastUsedDate: 222 }, + name: "name-1", + type: CipherType.Login, + login: { username: "username-1", uri: url }, + }); + const cipher2 = mock({ + id: "id-2", + localData: { lastUsedDate: 111 }, + name: "name-2", + type: CipherType.Login, + login: { username: "username-2", uri: url }, + }); + + beforeEach(() => { + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + }); + + it("ignores updating the overlay ciphers if the user's auth status is not unlocked", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + jest.spyOn(BrowserApi, "getTabFromCurrentWindowId"); + jest.spyOn(cipherService, "getAllDecryptedForUrl"); + + await overlayBackground.updateOverlayCiphers(); + + expect(BrowserApi.getTabFromCurrentWindowId).not.toHaveBeenCalled(); + expect(cipherService.getAllDecryptedForUrl).not.toHaveBeenCalled(); + }); + + it("ignores updating the overlay ciphers if the tab is undefined", async () => { + jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(undefined); + jest.spyOn(cipherService, "getAllDecryptedForUrl"); + + await overlayBackground.updateOverlayCiphers(); + + expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); + expect(cipherService.getAllDecryptedForUrl).not.toHaveBeenCalled(); + }); + + it("queries all ciphers for the given url, sort them by last used, and format them for usage in the overlay", async () => { + jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(tab); + cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); + cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); + jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); + jest.spyOn(overlayBackground as any, "getOverlayCipherData"); + + await overlayBackground.updateOverlayCiphers(); + + expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); + expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url); + expect(overlayBackground["cipherService"].sortCiphersByLastUsedThenName).toHaveBeenCalled(); + expect(overlayBackground["overlayLoginCiphers"]).toStrictEqual( + new Map([ + ["overlay-cipher-0", cipher2], + ["overlay-cipher-1", cipher1], + ]), + ); + expect(overlayBackground["getOverlayCipherData"]).toHaveBeenCalled(); + }); + + it("posts an `updateOverlayListCiphers` message to the overlay list port, and send a `updateIsOverlayCiphersPopulated` message to the tab indicating that the list of ciphers is populated", async () => { + overlayBackground["overlayListPort"] = mock(); + cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); + cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); + jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(tab); + jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); + + await overlayBackground.updateOverlayCiphers(); + + expect(overlayBackground["overlayListPort"].postMessage).toHaveBeenCalledWith({ + command: "updateOverlayListCiphers", + ciphers: [ + { + card: null, + favorite: cipher2.favorite, + icon: { + fallbackImage: "images/bwi-globe.png", + icon: "bwi-globe", + image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + imageEnabled: true, + }, + id: "overlay-cipher-0", + login: { + username: "username-2", + }, + name: "name-2", + reprompt: cipher2.reprompt, + type: 1, + }, + { + card: null, + favorite: cipher1.favorite, + icon: { + fallbackImage: "images/bwi-globe.png", + icon: "bwi-globe", + image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + imageEnabled: true, + }, + id: "overlay-cipher-1", + login: { + username: "username-1", + }, + name: "name-1", + reprompt: cipher1.reprompt, + type: 1, + }, + ], + }); + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + tab, + "updateIsOverlayCiphersPopulated", + { isOverlayCiphersPopulated: true }, + ); + }); + }); + + describe("getOverlayCipherData", () => { + const url = "https://jest-testing-website.com"; + const cipher1 = mock({ + id: "id-1", + localData: { lastUsedDate: 222 }, + name: "name-1", + type: CipherType.Login, + login: { username: "username-1", uri: url }, + }); + const cipher2 = mock({ + id: "id-2", + localData: { lastUsedDate: 111 }, + name: "name-2", + type: CipherType.Login, + login: { username: "username-2", uri: url }, + }); + const cipher3 = mock({ + id: "id-3", + localData: { lastUsedDate: 333 }, + name: "name-3", + type: CipherType.Card, + card: { subTitle: "Visa, *6789" }, + }); + const cipher4 = mock({ + id: "id-4", + localData: { lastUsedDate: 444 }, + name: "name-4", + type: CipherType.Card, + card: { subTitle: "Mastercard, *1234" }, + }); + + it("formats and returns the cipher data", async () => { + overlayBackground["overlayLoginCiphers"] = new Map([ + ["overlay-cipher-0", cipher2], + ["overlay-cipher-1", cipher1], + ["overlay-cipher-2", cipher3], + ["overlay-cipher-3", cipher4], + ]); + + const overlayCipherData = await overlayBackground["getOverlayCipherData"](); + + expect(overlayCipherData).toStrictEqual([ + { + card: null, + favorite: cipher2.favorite, + icon: { + fallbackImage: "images/bwi-globe.png", + icon: "bwi-globe", + image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + imageEnabled: true, + }, + id: "overlay-cipher-0", + login: { + username: "username-2", + }, + name: "name-2", + reprompt: cipher2.reprompt, + type: 1, + }, + { + card: null, + favorite: cipher1.favorite, + icon: { + fallbackImage: "images/bwi-globe.png", + icon: "bwi-globe", + image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + imageEnabled: true, + }, + id: "overlay-cipher-1", + login: { + username: "username-1", + }, + name: "name-1", + reprompt: cipher1.reprompt, + type: 1, + }, + { + card: "Visa, *6789", + favorite: cipher3.favorite, + icon: { + fallbackImage: "", + icon: "bwi-credit-card", + image: undefined, + imageEnabled: true, + }, + id: "overlay-cipher-2", + login: null, + name: "name-3", + reprompt: cipher3.reprompt, + type: 3, + }, + { + card: "Mastercard, *1234", + favorite: cipher4.favorite, + icon: { + fallbackImage: "", + icon: "bwi-credit-card", + image: undefined, + imageEnabled: true, + }, + id: "overlay-cipher-3", + login: null, + name: "name-4", + reprompt: cipher4.reprompt, + type: 3, + }, + ]); + }); + }); + + describe("getAuthStatus", () => { + it("will update the user's auth status but will not update the overlay ciphers", async () => { + const authStatus = AuthenticationStatus.Unlocked; + overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; + jest.spyOn(overlayBackground["authService"], "getAuthStatus").mockResolvedValue(authStatus); + jest.spyOn(overlayBackground as any, "updateOverlayButtonAuthStatus").mockImplementation(); + jest.spyOn(overlayBackground as any, "updateOverlayCiphers").mockImplementation(); + + const status = await overlayBackground["getAuthStatus"](); + + expect(overlayBackground["authService"].getAuthStatus).toHaveBeenCalled(); + expect(overlayBackground["updateOverlayButtonAuthStatus"]).not.toHaveBeenCalled(); + expect(overlayBackground["updateOverlayCiphers"]).not.toHaveBeenCalled(); + expect(overlayBackground["userAuthStatus"]).toBe(authStatus); + expect(status).toBe(authStatus); + }); + + it("will update the user's auth status and update the overlay ciphers if the status has been modified", async () => { + const authStatus = AuthenticationStatus.Unlocked; + overlayBackground["userAuthStatus"] = AuthenticationStatus.LoggedOut; + jest.spyOn(overlayBackground["authService"], "getAuthStatus").mockResolvedValue(authStatus); + jest.spyOn(overlayBackground as any, "updateOverlayButtonAuthStatus").mockImplementation(); + jest.spyOn(overlayBackground as any, "updateOverlayCiphers").mockImplementation(); + + await overlayBackground["getAuthStatus"](); + + expect(overlayBackground["authService"].getAuthStatus).toHaveBeenCalled(); + expect(overlayBackground["updateOverlayButtonAuthStatus"]).toHaveBeenCalled(); + expect(overlayBackground["updateOverlayCiphers"]).toHaveBeenCalled(); + expect(overlayBackground["userAuthStatus"]).toBe(authStatus); + }); + }); + + describe("updateOverlayButtonAuthStatus", () => { + it("will send a message to the button port with the user's auth status", () => { + overlayBackground["overlayButtonPort"] = mock(); + jest.spyOn(overlayBackground["overlayButtonPort"], "postMessage"); + + overlayBackground["updateOverlayButtonAuthStatus"](); + + expect(overlayBackground["overlayButtonPort"].postMessage).toHaveBeenCalledWith({ + command: "updateOverlayButtonAuthStatus", + authStatus: overlayBackground["userAuthStatus"], + }); + }); + }); + + describe("getTranslations", () => { + it("will query the overlay page translations if they have not been queried", () => { + overlayBackground["overlayPageTranslations"] = undefined; + jest.spyOn(overlayBackground as any, "getTranslations"); + jest.spyOn(overlayBackground["i18nService"], "translate").mockImplementation((key) => key); + jest.spyOn(BrowserApi, "getUILanguage").mockReturnValue("en"); + + const translations = overlayBackground["getTranslations"](); + + expect(overlayBackground["getTranslations"]).toHaveBeenCalled(); + const translationKeys = [ + "opensInANewWindow", + "bitwardenOverlayButton", + "toggleBitwardenVaultOverlay", + "bitwardenVault", + "unlockYourAccountToViewMatchingLogins", + "unlockAccount", + "fillCredentialsFor", + "partialUsername", + "view", + "noItemsToShow", + "newItem", + "addNewVaultItem", + ]; + translationKeys.forEach((key) => { + expect(overlayBackground["i18nService"].translate).toHaveBeenCalledWith(key); + }); + expect(translations).toStrictEqual({ + locale: "en", + opensInANewWindow: "opensInANewWindow", + buttonPageTitle: "bitwardenOverlayButton", + toggleBitwardenVaultOverlay: "toggleBitwardenVaultOverlay", + listPageTitle: "bitwardenVault", + unlockYourAccount: "unlockYourAccountToViewMatchingLogins", + unlockAccount: "unlockAccount", + fillCredentialsFor: "fillCredentialsFor", + partialUsername: "partialUsername", + view: "view", + noItemsToShow: "noItemsToShow", + newItem: "newItem", + addNewVaultItem: "addNewVaultItem", + }); + }); + }); + + describe("setupExtensionMessageListeners", () => { + it("will set up onMessage and onConnect listeners", () => { + overlayBackground["setupExtensionMessageListeners"](); + + // eslint-disable-next-line + expect(chrome.runtime.onMessage.addListener).toHaveBeenCalled(); + expect(chrome.runtime.onConnect.addListener).toHaveBeenCalled(); + }); + }); + + describe("handleExtensionMessage", () => { + it("will return early if the message command is not present within the extensionMessageHandlers", () => { + const message = { + command: "not-a-command", + }; + const sender = mock({ tab: { id: 1 } }); + const sendResponse = jest.fn(); + + const returnValue = overlayBackground["handleExtensionMessage"]( + message, + sender, + sendResponse, + ); + + expect(returnValue).toBe(null); + expect(sendResponse).not.toHaveBeenCalled(); + }); + + it("will trigger the message handler and return undefined if the message does not have a response", () => { + const message = { + command: "autofillOverlayElementClosed", + }; + const sender = mock({ tab: { id: 1 } }); + const sendResponse = jest.fn(); + jest.spyOn(overlayBackground as any, "overlayElementClosed"); + + const returnValue = overlayBackground["handleExtensionMessage"]( + message, + sender, + sendResponse, + ); + + expect(returnValue).toBe(null); + expect(sendResponse).not.toHaveBeenCalled(); + expect(overlayBackground["overlayElementClosed"]).toHaveBeenCalledWith(message, sender); + }); + + it("will return a response if the message handler returns a response", async () => { + const message = { + command: "openAutofillOverlay", + }; + const sender = mock({ tab: { id: 1 } }); + const sendResponse = jest.fn(); + jest.spyOn(overlayBackground as any, "getTranslations").mockReturnValue("translations"); + + const returnValue = overlayBackground["handleExtensionMessage"]( + message, + sender, + sendResponse, + ); + + expect(returnValue).toBe(true); + }); + + describe("extension message handlers", () => { + beforeEach(() => { + jest + .spyOn(overlayBackground as any, "getAuthStatus") + .mockResolvedValue(AuthenticationStatus.Unlocked); + }); + + describe("openAutofillOverlay message handler", () => { + it("opens the autofill overlay by sending a message to the current tab", async () => { + const sender = mock({ tab: { id: 1 } }); + jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(sender.tab); + jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); + + sendMockExtensionMessage({ command: "openAutofillOverlay" }); + await flushPromises(); + + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + sender.tab, + "openAutofillOverlay", + { + isFocusingFieldElement: false, + isOpeningFullOverlay: false, + authStatus: AuthenticationStatus.Unlocked, + }, + ); + }); + }); + + describe("autofillOverlayElementClosed message handler", () => { + beforeEach(async () => { + await initOverlayElementPorts(); + }); + + it("disconnects any expired ports if the sender is not from the same page as the most recently focused field", () => { + const port1 = mock(); + const port2 = mock(); + overlayBackground["expiredPorts"] = [port1, port2]; + const sender = mock({ tab: { id: 1 } }); + const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage( + { + command: "autofillOverlayElementClosed", + overlayElement: AutofillOverlayElement.Button, + }, + sender, + ); + + expect(port1.disconnect).toHaveBeenCalled(); + expect(port2.disconnect).toHaveBeenCalled(); + }); + + it("disconnects the button element port", () => { + sendMockExtensionMessage({ + command: "autofillOverlayElementClosed", + overlayElement: AutofillOverlayElement.Button, + }); + + expect(buttonPortSpy.disconnect).toHaveBeenCalled(); + expect(overlayBackground["overlayButtonPort"]).toBeNull(); + }); + + it("disconnects the list element port", () => { + sendMockExtensionMessage({ + command: "autofillOverlayElementClosed", + overlayElement: AutofillOverlayElement.List, + }); + + expect(listPortSpy.disconnect).toHaveBeenCalled(); + expect(overlayBackground["overlayListPort"]).toBeNull(); + }); + }); + + describe("autofillOverlayAddNewVaultItem message handler", () => { + let sender: chrome.runtime.MessageSender; + beforeEach(() => { + sender = mock({ tab: { id: 1 } }); + jest + .spyOn(overlayBackground["cipherService"], "setAddEditCipherInfo") + .mockImplementation(); + jest.spyOn(overlayBackground as any, "openAddEditVaultItemPopout").mockImplementation(); + }); + + it("will not open the add edit popout window if the message does not have a login cipher provided", () => { + sendMockExtensionMessage({ command: "autofillOverlayAddNewVaultItem" }, sender); + + expect(overlayBackground["cipherService"].setAddEditCipherInfo).not.toHaveBeenCalled(); + expect(overlayBackground["openAddEditVaultItemPopout"]).not.toHaveBeenCalled(); + }); + + it("will open the add edit popout window after creating a new cipher", async () => { + jest.spyOn(BrowserApi, "sendMessage"); + + sendMockExtensionMessage( + { + command: "autofillOverlayAddNewVaultItem", + login: { + uri: "https://tacos.com", + hostname: "", + username: "username", + password: "password", + }, + }, + sender, + ); + await flushPromises(); + + expect(overlayBackground["cipherService"].setAddEditCipherInfo).toHaveBeenCalled(); + expect(BrowserApi.sendMessage).toHaveBeenCalledWith( + "inlineAutofillMenuRefreshAddEditCipher", + ); + expect(overlayBackground["openAddEditVaultItemPopout"]).toHaveBeenCalled(); + }); + }); + + describe("getAutofillOverlayVisibility message handler", () => { + beforeEach(() => { + jest + .spyOn(overlayBackground as any, "getOverlayVisibility") + .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); + }); + + it("will set the overlayVisibility property", async () => { + sendMockExtensionMessage({ command: "getAutofillOverlayVisibility" }); + await flushPromises(); + + expect(await overlayBackground["getOverlayVisibility"]()).toBe( + AutofillOverlayVisibility.OnFieldFocus, + ); + }); + + it("returns the overlayVisibility property", async () => { + const sendMessageSpy = jest.fn(); + + sendMockExtensionMessage( + { command: "getAutofillOverlayVisibility" }, + undefined, + sendMessageSpy, + ); + await flushPromises(); + + expect(sendMessageSpy).toHaveBeenCalledWith(AutofillOverlayVisibility.OnFieldFocus); + }); + }); + + describe("checkAutofillOverlayFocused message handler", () => { + beforeEach(async () => { + await initOverlayElementPorts(); + }); + + it("will check if the overlay list is focused if the list port is open", () => { + sendMockExtensionMessage({ command: "checkAutofillOverlayFocused" }); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "checkAutofillOverlayListFocused", + }); + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillOverlayButtonFocused", + }); + }); + + it("will check if the overlay button is focused if the list port is not open", () => { + overlayBackground["overlayListPort"] = undefined; + + sendMockExtensionMessage({ command: "checkAutofillOverlayFocused" }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "checkAutofillOverlayButtonFocused", + }); + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillOverlayListFocused", + }); + }); + }); + + describe("focusAutofillOverlayList message handler", () => { + it("will send a `focusOverlayList` message to the overlay list port", async () => { + await initOverlayElementPorts({ initList: true, initButton: false }); + + sendMockExtensionMessage({ command: "focusAutofillOverlayList" }); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "focusOverlayList" }); + }); + }); + + describe("updateAutofillOverlayPosition message handler", () => { + beforeEach(async () => { + await overlayBackground["handlePortOnConnect"]( + createPortSpyMock(AutofillOverlayPort.List), + ); + listPortSpy = overlayBackground["overlayListPort"]; + + await overlayBackground["handlePortOnConnect"]( + createPortSpyMock(AutofillOverlayPort.Button), + ); + buttonPortSpy = overlayBackground["overlayButtonPort"]; + }); + + it("ignores updating the position if the overlay element type is not provided", () => { + sendMockExtensionMessage({ command: "updateAutofillOverlayPosition" }); + + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: expect.anything(), + }); + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: expect.anything(), + }); + }); + + it("skips updating the position if the most recently focused field is different than the message sender", () => { + const sender = mock({ tab: { id: 1 } }); + const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ command: "updateAutofillOverlayPosition" }, sender); + + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: expect.anything(), + }); + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: expect.anything(), + }); + }); + + it("updates the overlay button's position", () => { + const focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillOverlayPosition", + overlayElement: AutofillOverlayElement.Button, + }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: { height: "2px", left: "4px", top: "2px", width: "2px" }, + }); + }); + + it("modifies the overlay button's height for medium sized input elements", () => { + const focusedFieldData = createFocusedFieldDataMock({ + focusedFieldRects: { top: 1, left: 2, height: 35, width: 4 }, + }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillOverlayPosition", + overlayElement: AutofillOverlayElement.Button, + }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: { height: "20px", left: "-22px", top: "8px", width: "20px" }, + }); + }); + + it("modifies the overlay button's height for large sized input elements", () => { + const focusedFieldData = createFocusedFieldDataMock({ + focusedFieldRects: { top: 1, left: 2, height: 50, width: 4 }, + }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillOverlayPosition", + overlayElement: AutofillOverlayElement.Button, + }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: { height: "27px", left: "-32px", top: "13px", width: "27px" }, + }); + }); + + it("takes into account the right padding of the focused field in positioning the button if the right padding of the field is larger than the left padding", () => { + const focusedFieldData = createFocusedFieldDataMock({ + focusedFieldStyles: { paddingRight: "20px", paddingLeft: "6px" }, + }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage({ + command: "updateAutofillOverlayPosition", + overlayElement: AutofillOverlayElement.Button, + }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: { height: "2px", left: "-18px", top: "2px", width: "2px" }, + }); + }); + + it("will post a message to the overlay list facilitating an update of the list's position", () => { + const sender = mock({ tab: { id: 1 } }); + const focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + overlayBackground["updateOverlayPosition"]( + { overlayElement: AutofillOverlayElement.List }, + sender, + ); + sendMockExtensionMessage({ + command: "updateAutofillOverlayPosition", + overlayElement: AutofillOverlayElement.List, + }); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: { left: "2px", top: "4px", width: "4px" }, + }); + }); + }); + + describe("updateOverlayHidden", () => { + beforeEach(async () => { + await initOverlayElementPorts(); + }); + + it("returns early if the display value is not provided", () => { + const message = { + command: "updateAutofillOverlayHidden", + }; + + sendMockExtensionMessage(message); + + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith(message); + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith(message); + }); + + it("posts a message to the overlay button and list with the display value", () => { + const message = { command: "updateAutofillOverlayHidden", display: "none" }; + + sendMockExtensionMessage(message); + + expect(overlayBackground["overlayButtonPort"].postMessage).toHaveBeenCalledWith({ + command: "updateOverlayHidden", + styles: { + display: message.display, + }, + }); + expect(overlayBackground["overlayListPort"].postMessage).toHaveBeenCalledWith({ + command: "updateOverlayHidden", + styles: { + display: message.display, + }, + }); + }); + }); + + describe("collectPageDetailsResponse message handler", () => { + let sender: chrome.runtime.MessageSender; + const pageDetails1 = createAutofillPageDetailsMock({ + login: { username: "username1", password: "password1" }, + }); + const pageDetails2 = createAutofillPageDetailsMock({ + login: { username: "username2", password: "password2" }, + }); + + beforeEach(() => { + sender = mock({ tab: { id: 1 } }); + }); + + it("stores the page details provided by the message by the tab id of the sender", () => { + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: pageDetails1 }, + sender, + ); + + expect(overlayBackground["pageDetailsForTab"][sender.tab.id]).toStrictEqual( + new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }], + ]), + ); + }); + + it("updates the page details for a tab that already has a set of page details stored ", () => { + const secondFrameSender = mock({ + tab: { id: 1 }, + frameId: 3, + }); + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }], + ]); + + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: pageDetails2 }, + secondFrameSender, + ); + + expect(overlayBackground["pageDetailsForTab"][sender.tab.id]).toStrictEqual( + new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }], + [ + secondFrameSender.frameId, + { + frameId: secondFrameSender.frameId, + tab: secondFrameSender.tab, + details: pageDetails2, + }, + ], + ]), + ); + }); + }); + + describe("unlockCompleted message handler", () => { + let getAuthStatusSpy: jest.SpyInstance; + + beforeEach(() => { + overlayBackground["userAuthStatus"] = AuthenticationStatus.LoggedOut; + jest.spyOn(BrowserApi, "tabSendMessageData"); + getAuthStatusSpy = jest + .spyOn(overlayBackground as any, "getAuthStatus") + .mockImplementation(() => { + overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; + return Promise.resolve(AuthenticationStatus.Unlocked); + }); + }); + + it("updates the user's auth status but does not open the overlay", async () => { + const message = { + command: "unlockCompleted", + data: { + commandToRetry: { message: { command: "" } }, + }, + }; + + sendMockExtensionMessage(message); + await flushPromises(); + + expect(getAuthStatusSpy).toHaveBeenCalled(); + expect(BrowserApi.tabSendMessageData).not.toHaveBeenCalled(); + }); + + it("updates user's auth status and opens the overlay if a follow up command is provided", async () => { + const sender = mock({ tab: { id: 1 } }); + const message = { + command: "unlockCompleted", + data: { + commandToRetry: { message: { command: "openAutofillOverlay" } }, + }, + }; + jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(sender.tab); + + sendMockExtensionMessage(message); + await flushPromises(); + + expect(getAuthStatusSpy).toHaveBeenCalled(); + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + sender.tab, + "openAutofillOverlay", + { + isFocusingFieldElement: true, + isOpeningFullOverlay: false, + authStatus: AuthenticationStatus.Unlocked, + }, + ); + }); + }); + + describe("extension messages that trigger an update of the inline menu ciphers", () => { + const extensionMessages = [ + "addedCipher", + "addEditCipherSubmitted", + "editedCipher", + "deletedCipher", + ]; + + beforeEach(() => { + jest.spyOn(overlayBackground, "updateOverlayCiphers").mockImplementation(); + }); + + extensionMessages.forEach((message) => { + it(`triggers an update of the overlay ciphers when the ${message} message is received`, () => { + sendMockExtensionMessage({ command: message }); + expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled(); + }); + }); + }); + }); + }); + + describe("handlePortOnConnect", () => { + beforeEach(() => { + jest.spyOn(overlayBackground as any, "updateOverlayPosition").mockImplementation(); + jest.spyOn(overlayBackground as any, "getAuthStatus").mockImplementation(); + jest.spyOn(overlayBackground as any, "getTranslations").mockImplementation(); + jest.spyOn(overlayBackground as any, "getOverlayCipherData").mockImplementation(); + }); + + it("skips setting up the overlay port if the port connection is not for an overlay element", async () => { + const port = createPortSpyMock("not-an-overlay-element"); + + await overlayBackground["handlePortOnConnect"](port); + + expect(port.onMessage.addListener).not.toHaveBeenCalled(); + expect(port.postMessage).not.toHaveBeenCalled(); + }); + + it("sets up the overlay list port if the port connection is for the overlay list", async () => { + await initOverlayElementPorts({ initList: true, initButton: false }); + await flushPromises(); + + expect(overlayBackground["overlayButtonPort"]).toBeUndefined(); + expect(listPortSpy.onMessage.addListener).toHaveBeenCalled(); + expect(listPortSpy.postMessage).toHaveBeenCalled(); + expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled(); + expect(chrome.runtime.getURL).toHaveBeenCalledWith("overlay/list.css"); + expect(overlayBackground["getTranslations"]).toHaveBeenCalled(); + expect(overlayBackground["getOverlayCipherData"]).toHaveBeenCalled(); + expect(overlayBackground["updateOverlayPosition"]).toHaveBeenCalledWith( + { overlayElement: AutofillOverlayElement.List }, + listPortSpy.sender, + ); + }); + + it("sets up the overlay button port if the port connection is for the overlay button", async () => { + await initOverlayElementPorts({ initList: false, initButton: true }); + await flushPromises(); + + expect(overlayBackground["overlayListPort"]).toBeUndefined(); + expect(buttonPortSpy.onMessage.addListener).toHaveBeenCalled(); + expect(buttonPortSpy.postMessage).toHaveBeenCalled(); + expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled(); + expect(chrome.runtime.getURL).toHaveBeenCalledWith("overlay/button.css"); + expect(overlayBackground["getTranslations"]).toHaveBeenCalled(); + expect(overlayBackground["updateOverlayPosition"]).toHaveBeenCalledWith( + { overlayElement: AutofillOverlayElement.Button }, + buttonPortSpy.sender, + ); + }); + + it("stores an existing overlay port so that it can be disconnected at a later time", async () => { + overlayBackground["overlayButtonPort"] = mock(); + + await initOverlayElementPorts({ initList: false, initButton: true }); + await flushPromises(); + + expect(overlayBackground["expiredPorts"].length).toBe(1); + }); + + it("gets the system theme", async () => { + themeStateService.selectedTheme$ = of(ThemeType.System); + + await initOverlayElementPorts({ initList: true, initButton: false }); + await flushPromises(); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ theme: ThemeType.System }), + ); + }); + }); + + describe("handleOverlayElementPortMessage", () => { + beforeEach(async () => { + await initOverlayElementPorts(); + overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; + }); + + it("ignores port messages that do not contain a handler", () => { + jest.spyOn(overlayBackground as any, "checkOverlayButtonFocused").mockImplementation(); + + sendPortMessage(buttonPortSpy, { command: "checkAutofillOverlayButtonFocused" }); + + expect(overlayBackground["checkOverlayButtonFocused"]).not.toHaveBeenCalled(); + }); + + describe("overlay button message handlers", () => { + it("unlocks the vault if the user auth status is not unlocked", () => { + overlayBackground["userAuthStatus"] = AuthenticationStatus.LoggedOut; + jest.spyOn(overlayBackground as any, "unlockVault").mockImplementation(); + + sendPortMessage(buttonPortSpy, { command: "overlayButtonClicked" }); + + expect(overlayBackground["unlockVault"]).toHaveBeenCalled(); + }); + + it("opens the autofill overlay if the auth status is unlocked", () => { + jest.spyOn(overlayBackground as any, "openOverlay").mockImplementation(); + + sendPortMessage(buttonPortSpy, { command: "overlayButtonClicked" }); + + expect(overlayBackground["openOverlay"]).toHaveBeenCalled(); + }); + + describe("closeAutofillOverlay", () => { + it("sends a `closeOverlay` message to the sender tab", () => { + jest.spyOn(BrowserApi, "tabSendMessageData"); + + sendPortMessage(buttonPortSpy, { command: "closeAutofillOverlay" }); + + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + buttonPortSpy.sender.tab, + "closeAutofillOverlay", + { forceCloseOverlay: false }, + ); + }); + }); + + describe("forceCloseAutofillOverlay", () => { + it("sends a `closeOverlay` message to the sender tab with a `forceCloseOverlay` flag of `true` set", () => { + jest.spyOn(BrowserApi, "tabSendMessageData"); + + sendPortMessage(buttonPortSpy, { command: "forceCloseAutofillOverlay" }); + + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + buttonPortSpy.sender.tab, + "closeAutofillOverlay", + { forceCloseOverlay: true }, + ); + }); + }); + + describe("overlayPageBlurred", () => { + it("checks if the overlay list is focused", () => { + jest.spyOn(overlayBackground as any, "checkOverlayListFocused"); + + sendPortMessage(buttonPortSpy, { command: "overlayPageBlurred" }); + + expect(overlayBackground["checkOverlayListFocused"]).toHaveBeenCalled(); + }); + }); + + describe("redirectOverlayFocusOut", () => { + beforeEach(() => { + jest.spyOn(BrowserApi, "tabSendMessageData"); + }); + + it("ignores the redirect message if the direction is not provided", () => { + sendPortMessage(buttonPortSpy, { command: "redirectOverlayFocusOut" }); + + expect(BrowserApi.tabSendMessageData).not.toHaveBeenCalled(); + }); + + it("sends the redirect message if the direction is provided", () => { + sendPortMessage(buttonPortSpy, { + command: "redirectOverlayFocusOut", + direction: RedirectFocusDirection.Next, + }); + + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + buttonPortSpy.sender.tab, + "redirectOverlayFocusOut", + { direction: RedirectFocusDirection.Next }, + ); + }); + }); + }); + + describe("overlay list message handlers", () => { + describe("checkAutofillOverlayButtonFocused", () => { + it("checks on the focus state of the overlay button", () => { + jest.spyOn(overlayBackground as any, "checkOverlayButtonFocused").mockImplementation(); + + sendPortMessage(listPortSpy, { command: "checkAutofillOverlayButtonFocused" }); + + expect(overlayBackground["checkOverlayButtonFocused"]).toHaveBeenCalled(); + }); + }); + + describe("forceCloseAutofillOverlay", () => { + it("sends a `closeOverlay` message to the sender tab with a `forceCloseOverlay` flag of `true` set", () => { + jest.spyOn(BrowserApi, "tabSendMessageData"); + + sendPortMessage(listPortSpy, { command: "forceCloseAutofillOverlay" }); + + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + listPortSpy.sender.tab, + "closeAutofillOverlay", + { forceCloseOverlay: true }, + ); + }); + }); + + describe("overlayPageBlurred", () => { + it("checks on the focus state of the overlay button", () => { + jest.spyOn(overlayBackground as any, "checkOverlayButtonFocused").mockImplementation(); + + sendPortMessage(listPortSpy, { command: "overlayPageBlurred" }); + + expect(overlayBackground["checkOverlayButtonFocused"]).toHaveBeenCalled(); + }); + }); + + describe("unlockVault", () => { + it("closes the autofill overlay and opens the unlock popout", async () => { + jest.spyOn(overlayBackground as any, "closeOverlay").mockImplementation(); + jest.spyOn(overlayBackground as any, "openUnlockPopout").mockImplementation(); + jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); + + sendPortMessage(listPortSpy, { command: "unlockVault" }); + await flushPromises(); + + expect(overlayBackground["closeOverlay"]).toHaveBeenCalledWith(listPortSpy); + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + listPortSpy.sender.tab, + "addToLockedVaultPendingNotifications", + { + commandToRetry: { + message: { command: "openAutofillOverlay" }, + sender: listPortSpy.sender, + }, + target: "overlay.background", + }, + ); + expect(overlayBackground["openUnlockPopout"]).toHaveBeenCalledWith( + listPortSpy.sender.tab, + true, + ); + }); + }); + + describe("fillSelectedListItem", () => { + let getLoginCiphersSpy: jest.SpyInstance; + let isPasswordRepromptRequiredSpy: jest.SpyInstance; + let doAutoFillSpy: jest.SpyInstance; + let sender: chrome.runtime.MessageSender; + const pageDetails = createAutofillPageDetailsMock({ + login: { username: "username1", password: "password1" }, + }); + + beforeEach(() => { + getLoginCiphersSpy = jest.spyOn(overlayBackground["overlayLoginCiphers"], "get"); + isPasswordRepromptRequiredSpy = jest.spyOn( + overlayBackground["autofillService"], + "isPasswordRepromptRequired", + ); + doAutoFillSpy = jest.spyOn(overlayBackground["autofillService"], "doAutoFill"); + sender = mock({ tab: { id: 1 } }); + }); + + it("ignores the fill request if the overlay cipher id is not provided", async () => { + sendPortMessage(listPortSpy, { command: "fillSelectedListItem" }); + await flushPromises(); + + expect(getLoginCiphersSpy).not.toHaveBeenCalled(); + expect(isPasswordRepromptRequiredSpy).not.toHaveBeenCalled(); + expect(doAutoFillSpy).not.toHaveBeenCalled(); + }); + + it("ignores the fill request if the tab does not contain any identified page details", async () => { + sendPortMessage(listPortSpy, { + command: "fillSelectedListItem", + overlayCipherId: "overlay-cipher-1", + }); + await flushPromises(); + + expect(getLoginCiphersSpy).not.toHaveBeenCalled(); + expect(isPasswordRepromptRequiredSpy).not.toHaveBeenCalled(); + expect(doAutoFillSpy).not.toHaveBeenCalled(); + }); + + it("ignores the fill request if a master password reprompt is required", async () => { + const cipher = mock({ + reprompt: CipherRepromptType.Password, + type: CipherType.Login, + }); + overlayBackground["overlayLoginCiphers"] = new Map([["overlay-cipher-1", cipher]]); + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails }], + ]); + getLoginCiphersSpy = jest.spyOn(overlayBackground["overlayLoginCiphers"], "get"); + isPasswordRepromptRequiredSpy.mockResolvedValue(true); + + sendPortMessage(listPortSpy, { + command: "fillSelectedListItem", + overlayCipherId: "overlay-cipher-1", + }); + await flushPromises(); + + expect(getLoginCiphersSpy).toHaveBeenCalled(); + expect(isPasswordRepromptRequiredSpy).toHaveBeenCalledWith( + cipher, + listPortSpy.sender.tab, + ); + expect(doAutoFillSpy).not.toHaveBeenCalled(); + }); + + it("auto-fills the selected cipher and move it to the top of the front of the ciphers map", async () => { + const cipher1 = mock({ id: "overlay-cipher-1" }); + const cipher2 = mock({ id: "overlay-cipher-2" }); + const cipher3 = mock({ id: "overlay-cipher-3" }); + overlayBackground["overlayLoginCiphers"] = new Map([ + ["overlay-cipher-1", cipher1], + ["overlay-cipher-2", cipher2], + ["overlay-cipher-3", cipher3], + ]); + const pageDetailsForTab = { + frameId: sender.frameId, + tab: sender.tab, + details: pageDetails, + }; + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, pageDetailsForTab], + ]); + isPasswordRepromptRequiredSpy.mockResolvedValue(false); + + sendPortMessage(listPortSpy, { + command: "fillSelectedListItem", + overlayCipherId: "overlay-cipher-2", + }); + await flushPromises(); + + expect(isPasswordRepromptRequiredSpy).toHaveBeenCalledWith( + cipher2, + listPortSpy.sender.tab, + ); + expect(doAutoFillSpy).toHaveBeenCalledWith({ + tab: listPortSpy.sender.tab, + cipher: cipher2, + pageDetails: [pageDetailsForTab], + fillNewPassword: true, + allowTotpAutofill: true, + }); + expect(overlayBackground["overlayLoginCiphers"].entries()).toStrictEqual( + new Map([ + ["overlay-cipher-2", cipher2], + ["overlay-cipher-1", cipher1], + ["overlay-cipher-3", cipher3], + ]).entries(), + ); + }); + + it("copies the cipher's totp code to the clipboard after filling", async () => { + const cipher1 = mock({ id: "overlay-cipher-1" }); + overlayBackground["overlayLoginCiphers"] = new Map([["overlay-cipher-1", cipher1]]); + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails }], + ]); + isPasswordRepromptRequiredSpy.mockResolvedValue(false); + const copyToClipboardSpy = jest + .spyOn(overlayBackground["platformUtilsService"], "copyToClipboard") + .mockImplementation(); + doAutoFillSpy.mockReturnValueOnce("totp-code"); + + sendPortMessage(listPortSpy, { + command: "fillSelectedListItem", + overlayCipherId: "overlay-cipher-2", + }); + await flushPromises(); + + expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code"); + }); + }); + + describe("getNewVaultItemDetails", () => { + it("will send an addNewVaultItemFromOverlay message", async () => { + jest.spyOn(BrowserApi, "tabSendMessage"); + + sendPortMessage(listPortSpy, { command: "addNewVaultItem" }); + await flushPromises(); + + expect(BrowserApi.tabSendMessage).toHaveBeenCalledWith(listPortSpy.sender.tab, { + command: "addNewVaultItemFromOverlay", + }); + }); + }); + + describe("viewSelectedCipher", () => { + let openViewVaultItemPopoutSpy: jest.SpyInstance; + + beforeEach(() => { + openViewVaultItemPopoutSpy = jest + .spyOn(overlayBackground as any, "openViewVaultItemPopout") + .mockImplementation(); + }); + + it("returns early if the passed cipher ID does not match one of the overlay login ciphers", async () => { + overlayBackground["overlayLoginCiphers"] = new Map([ + ["overlay-cipher-0", mock({ id: "overlay-cipher-0" })], + ]); + + sendPortMessage(listPortSpy, { + command: "viewSelectedCipher", + overlayCipherId: "overlay-cipher-1", + }); + await flushPromises(); + + expect(openViewVaultItemPopoutSpy).not.toHaveBeenCalled(); + }); + + it("will open the view vault item popout with the selected cipher", async () => { + const cipher = mock({ id: "overlay-cipher-1" }); + overlayBackground["overlayLoginCiphers"] = new Map([ + ["overlay-cipher-0", mock({ id: "overlay-cipher-0" })], + ["overlay-cipher-1", cipher], + ]); + + sendPortMessage(listPortSpy, { + command: "viewSelectedCipher", + overlayCipherId: "overlay-cipher-1", + }); + await flushPromises(); + + expect(overlayBackground["openViewVaultItemPopout"]).toHaveBeenCalledWith( + listPortSpy.sender.tab, + { + cipherId: cipher.id, + action: SHOW_AUTOFILL_BUTTON, + }, + ); + }); + }); + + describe("redirectOverlayFocusOut", () => { + it("redirects focus out of the overlay list", async () => { + const message = { + command: "redirectOverlayFocusOut", + direction: RedirectFocusDirection.Next, + }; + const redirectOverlayFocusOutSpy = jest.spyOn( + overlayBackground as any, + "redirectOverlayFocusOut", + ); + + sendPortMessage(listPortSpy, message); + await flushPromises(); + + expect(redirectOverlayFocusOutSpy).toHaveBeenCalledWith(message, listPortSpy); + }); + }); + }); + }); +}); diff --git a/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.ts b/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.ts new file mode 100644 index 00000000000..1a5d49e9e1f --- /dev/null +++ b/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.ts @@ -0,0 +1,798 @@ +import { firstValueFrom } from "rxjs"; + +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { SHOW_AUTOFILL_BUTTON } from "@bitwarden/common/autofill/constants"; +import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon"; +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 { openUnlockPopout } from "../../../auth/popup/utils/auth-popout-window"; +import { BrowserApi } from "../../../platform/browser/browser-api"; +import { + openViewVaultItemPopout, + openAddEditVaultItemPopout, +} from "../../../vault/popup/utils/vault-popout-window"; +import { LockedVaultPendingNotificationsData } from "../../background/abstractions/notification.background"; +import { OverlayBackground as OverlayBackgroundInterface } from "../../background/abstractions/overlay.background"; +import { AutofillOverlayElement, AutofillOverlayPort } from "../../enums/autofill-overlay.enum"; +import { AutofillService, PageDetail } from "../../services/abstractions/autofill.service"; + +import { + FocusedFieldData, + OverlayBackgroundExtensionMessageHandlers, + OverlayButtonPortMessageHandlers, + OverlayCipherData, + OverlayListPortMessageHandlers, + OverlayBackgroundExtensionMessage, + OverlayAddNewItemMessage, + OverlayPortMessage, + WebsiteIconData, +} from "./abstractions/overlay.background.deprecated"; + +class LegacyOverlayBackground implements OverlayBackgroundInterface { + private readonly openUnlockPopout = openUnlockPopout; + private readonly openViewVaultItemPopout = openViewVaultItemPopout; + private readonly openAddEditVaultItemPopout = openAddEditVaultItemPopout; + private overlayLoginCiphers: Map = new Map(); + private pageDetailsForTab: Record< + chrome.runtime.MessageSender["tab"]["id"], + Map + > = {}; + private userAuthStatus: AuthenticationStatus = AuthenticationStatus.LoggedOut; + private overlayButtonPort: chrome.runtime.Port; + private overlayListPort: chrome.runtime.Port; + private expiredPorts: chrome.runtime.Port[] = []; + private focusedFieldData: FocusedFieldData; + private overlayPageTranslations: Record; + private iconsServerUrl: string; + private readonly extensionMessageHandlers: OverlayBackgroundExtensionMessageHandlers = { + openAutofillOverlay: () => this.openOverlay(false), + autofillOverlayElementClosed: ({ message, sender }) => + this.overlayElementClosed(message, sender), + autofillOverlayAddNewVaultItem: ({ message, sender }) => this.addNewVaultItem(message, sender), + getAutofillOverlayVisibility: () => this.getOverlayVisibility(), + checkAutofillOverlayFocused: () => this.checkOverlayFocused(), + focusAutofillOverlayList: () => this.focusOverlayList(), + updateAutofillOverlayPosition: ({ message, sender }) => + this.updateOverlayPosition(message, sender), + updateAutofillOverlayHidden: ({ message }) => this.updateOverlayHidden(message), + updateFocusedFieldData: ({ message, sender }) => this.setFocusedFieldData(message, sender), + collectPageDetailsResponse: ({ message, sender }) => this.storePageDetails(message, sender), + unlockCompleted: ({ message }) => this.unlockCompleted(message), + addedCipher: () => this.updateOverlayCiphers(), + addEditCipherSubmitted: () => this.updateOverlayCiphers(), + editedCipher: () => this.updateOverlayCiphers(), + deletedCipher: () => this.updateOverlayCiphers(), + }; + private readonly overlayButtonPortMessageHandlers: OverlayButtonPortMessageHandlers = { + overlayButtonClicked: ({ port }) => this.handleOverlayButtonClicked(port), + closeAutofillOverlay: ({ port }) => this.closeOverlay(port), + forceCloseAutofillOverlay: ({ port }) => this.closeOverlay(port, true), + overlayPageBlurred: () => this.checkOverlayListFocused(), + redirectOverlayFocusOut: ({ message, port }) => this.redirectOverlayFocusOut(message, port), + }; + private readonly overlayListPortMessageHandlers: OverlayListPortMessageHandlers = { + checkAutofillOverlayButtonFocused: () => this.checkOverlayButtonFocused(), + forceCloseAutofillOverlay: ({ port }) => this.closeOverlay(port, true), + overlayPageBlurred: () => this.checkOverlayButtonFocused(), + unlockVault: ({ port }) => this.unlockVault(port), + fillSelectedListItem: ({ message, port }) => this.fillSelectedOverlayListItem(message, port), + addNewVaultItem: ({ port }) => this.getNewVaultItemDetails(port), + viewSelectedCipher: ({ message, port }) => this.viewSelectedCipher(message, port), + redirectOverlayFocusOut: ({ message, port }) => this.redirectOverlayFocusOut(message, port), + }; + + constructor( + private cipherService: CipherService, + private autofillService: AutofillService, + private authService: AuthService, + private environmentService: EnvironmentService, + private domainSettingsService: DomainSettingsService, + private autofillSettingsService: AutofillSettingsServiceAbstraction, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + private themeStateService: ThemeStateService, + ) {} + + /** + * Removes cached page details for a tab + * based on the passed tabId. + * + * @param tabId - Used to reference the page details of a specific tab + */ + removePageDetails(tabId: number) { + if (!this.pageDetailsForTab[tabId]) { + return; + } + + this.pageDetailsForTab[tabId].clear(); + delete this.pageDetailsForTab[tabId]; + } + + /** + * Sets up the extension message listeners and gets the settings for the + * overlay's visibility and the user's authentication status. + */ + async init() { + this.setupExtensionMessageListeners(); + const env = await firstValueFrom(this.environmentService.environment$); + this.iconsServerUrl = env.getIconsUrl(); + await this.getOverlayVisibility(); + await this.getAuthStatus(); + } + + /** + * Updates the overlay list's ciphers and sends the updated list to the overlay list iframe. + * Queries all ciphers for the given url, and sorts them by last used. Will not update the + * list of ciphers if the extension is not unlocked. + */ + async updateOverlayCiphers() { + const authStatus = await firstValueFrom(this.authService.activeAccountStatus$); + if (authStatus !== AuthenticationStatus.Unlocked) { + return; + } + + const currentTab = await BrowserApi.getTabFromCurrentWindowId(); + if (!currentTab?.url) { + return; + } + + this.overlayLoginCiphers = new Map(); + const ciphersViews = (await this.cipherService.getAllDecryptedForUrl(currentTab.url)).sort( + (a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b), + ); + for (let cipherIndex = 0; cipherIndex < ciphersViews.length; cipherIndex++) { + this.overlayLoginCiphers.set(`overlay-cipher-${cipherIndex}`, ciphersViews[cipherIndex]); + } + + const ciphers = await this.getOverlayCipherData(); + this.overlayListPort?.postMessage({ command: "updateOverlayListCiphers", ciphers }); + await BrowserApi.tabSendMessageData(currentTab, "updateIsOverlayCiphersPopulated", { + isOverlayCiphersPopulated: Boolean(ciphers.length), + }); + } + + /** + * Strips out unnecessary data from the ciphers and returns an array of + * objects that contain the cipher data needed for the overlay list. + */ + private async getOverlayCipherData(): Promise { + const showFavicons = await firstValueFrom(this.domainSettingsService.showFavicons$); + const overlayCiphersArray = Array.from(this.overlayLoginCiphers); + const overlayCipherData: OverlayCipherData[] = []; + let loginCipherIcon: WebsiteIconData; + + for (let cipherIndex = 0; cipherIndex < overlayCiphersArray.length; cipherIndex++) { + const [overlayCipherId, cipher] = overlayCiphersArray[cipherIndex]; + if (!loginCipherIcon && cipher.type === CipherType.Login) { + loginCipherIcon = buildCipherIcon(this.iconsServerUrl, cipher, showFavicons); + } + + overlayCipherData.push({ + id: overlayCipherId, + name: cipher.name, + type: cipher.type, + reprompt: cipher.reprompt, + favorite: cipher.favorite, + icon: + cipher.type === CipherType.Login + ? loginCipherIcon + : buildCipherIcon(this.iconsServerUrl, cipher, showFavicons), + login: cipher.type === CipherType.Login ? { username: cipher.login.username } : null, + card: cipher.type === CipherType.Card ? cipher.card.subTitle : null, + }); + } + + return overlayCipherData; + } + + /** + * Handles aggregation of page details for a tab. Stores the page details + * in association with the tabId of the tab that sent the message. + * + * @param message - Message received from the `collectPageDetailsResponse` command + * @param sender - The sender of the message + */ + private storePageDetails( + message: OverlayBackgroundExtensionMessage, + sender: chrome.runtime.MessageSender, + ) { + const pageDetails = { + frameId: sender.frameId, + tab: sender.tab, + details: message.details, + }; + + const pageDetailsMap = this.pageDetailsForTab[sender.tab.id]; + if (!pageDetailsMap) { + this.pageDetailsForTab[sender.tab.id] = new Map([[sender.frameId, pageDetails]]); + return; + } + + pageDetailsMap.set(sender.frameId, pageDetails); + } + + /** + * Triggers autofill for the selected cipher in the overlay list. Also places + * the selected cipher at the top of the list of ciphers. + * + * @param overlayCipherId - Cipher ID corresponding to the overlayLoginCiphers map. Does not correspond to the actual cipher's ID. + * @param sender - The sender of the port message + */ + private async fillSelectedOverlayListItem( + { overlayCipherId }: OverlayPortMessage, + { sender }: chrome.runtime.Port, + ) { + const pageDetails = this.pageDetailsForTab[sender.tab.id]; + if (!overlayCipherId || !pageDetails?.size) { + return; + } + + const cipher = this.overlayLoginCiphers.get(overlayCipherId); + + if (await this.autofillService.isPasswordRepromptRequired(cipher, sender.tab)) { + return; + } + const totpCode = await this.autofillService.doAutoFill({ + tab: sender.tab, + cipher: cipher, + pageDetails: Array.from(pageDetails.values()), + fillNewPassword: true, + allowTotpAutofill: true, + }); + + if (totpCode) { + this.platformUtilsService.copyToClipboard(totpCode); + } + + this.overlayLoginCiphers = new Map([[overlayCipherId, cipher], ...this.overlayLoginCiphers]); + } + + /** + * Checks if the overlay is focused. Will check the overlay list + * if it is open, otherwise it will check the overlay button. + */ + private checkOverlayFocused() { + if (this.overlayListPort) { + this.checkOverlayListFocused(); + + return; + } + + this.checkOverlayButtonFocused(); + } + + /** + * Posts a message to the overlay button iframe to check if it is focused. + */ + private checkOverlayButtonFocused() { + this.overlayButtonPort?.postMessage({ command: "checkAutofillOverlayButtonFocused" }); + } + + /** + * Posts a message to the overlay list iframe to check if it is focused. + */ + private checkOverlayListFocused() { + this.overlayListPort?.postMessage({ command: "checkAutofillOverlayListFocused" }); + } + + /** + * Sends a message to the sender tab to close the autofill overlay. + * + * @param sender - The sender of the port message + * @param forceCloseOverlay - Identifies whether the overlay should be force closed + */ + private closeOverlay({ sender }: chrome.runtime.Port, forceCloseOverlay = false) { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + BrowserApi.tabSendMessageData(sender.tab, "closeAutofillOverlay", { forceCloseOverlay }); + } + + /** + * Handles cleanup when an overlay element is closed. Disconnects + * the list and button ports and sets them to null. + * + * @param overlayElement - The overlay element that was closed, either the list or button + * @param sender - The sender of the port message + */ + private overlayElementClosed( + { overlayElement }: OverlayBackgroundExtensionMessage, + sender: chrome.runtime.MessageSender, + ) { + if (sender.tab.id !== this.focusedFieldData?.tabId) { + this.expiredPorts.forEach((port) => port.disconnect()); + this.expiredPorts = []; + return; + } + + if (overlayElement === AutofillOverlayElement.Button) { + this.overlayButtonPort?.disconnect(); + this.overlayButtonPort = null; + + return; + } + + this.overlayListPort?.disconnect(); + this.overlayListPort = null; + } + + /** + * Updates the position of either the overlay list or button. The position + * is based on the focused field's position and dimensions. + * + * @param overlayElement - The overlay element to update, either the list or button + * @param sender - The sender of the port message + */ + private updateOverlayPosition( + { overlayElement }: { overlayElement?: string }, + sender: chrome.runtime.MessageSender, + ) { + if (!overlayElement || sender.tab.id !== this.focusedFieldData?.tabId) { + return; + } + + if (overlayElement === AutofillOverlayElement.Button) { + this.overlayButtonPort?.postMessage({ + command: "updateIframePosition", + styles: this.getOverlayButtonPosition(), + }); + + return; + } + + this.overlayListPort?.postMessage({ + command: "updateIframePosition", + styles: this.getOverlayListPosition(), + }); + } + + /** + * Gets the position of the focused field and calculates the position + * of the overlay button based on the focused field's position and dimensions. + */ + private getOverlayButtonPosition() { + if (!this.focusedFieldData) { + return; + } + + const { top, left, width, height } = this.focusedFieldData.focusedFieldRects; + const { paddingRight, paddingLeft } = this.focusedFieldData.focusedFieldStyles; + let elementOffset = height * 0.37; + if (height >= 35) { + elementOffset = height >= 50 ? height * 0.47 : height * 0.42; + } + + const elementHeight = height - elementOffset; + const elementTopPosition = top + elementOffset / 2; + let elementLeftPosition = left + width - height + elementOffset / 2; + + const fieldPaddingRight = parseInt(paddingRight, 10); + const fieldPaddingLeft = parseInt(paddingLeft, 10); + if (fieldPaddingRight > fieldPaddingLeft) { + elementLeftPosition = left + width - height - (fieldPaddingRight - elementOffset + 2); + } + + return { + top: `${Math.round(elementTopPosition)}px`, + left: `${Math.round(elementLeftPosition)}px`, + height: `${Math.round(elementHeight)}px`, + width: `${Math.round(elementHeight)}px`, + }; + } + + /** + * Gets the position of the focused field and calculates the position + * of the overlay list based on the focused field's position and dimensions. + */ + private getOverlayListPosition() { + if (!this.focusedFieldData) { + return; + } + + const { top, left, width, height } = this.focusedFieldData.focusedFieldRects; + return { + width: `${Math.round(width)}px`, + top: `${Math.round(top + height)}px`, + left: `${Math.round(left)}px`, + }; + } + + /** + * Sets the focused field data to the data passed in the extension message. + * + * @param focusedFieldData - Contains the rects and styles of the focused field. + * @param sender - The sender of the extension message + */ + private setFocusedFieldData( + { focusedFieldData }: OverlayBackgroundExtensionMessage, + sender: chrome.runtime.MessageSender, + ) { + this.focusedFieldData = { ...focusedFieldData, tabId: sender.tab.id }; + } + + /** + * Updates the overlay's visibility based on the display property passed in the extension message. + * + * @param display - The display property of the overlay, either "block" or "none" + */ + private updateOverlayHidden({ display }: OverlayBackgroundExtensionMessage) { + if (!display) { + return; + } + + const portMessage = { command: "updateOverlayHidden", styles: { display } }; + + this.overlayButtonPort?.postMessage(portMessage); + this.overlayListPort?.postMessage(portMessage); + } + + /** + * Sends a message to the currently active tab to open the autofill overlay. + * + * @param isFocusingFieldElement - Identifies whether the field element should be focused when the overlay is opened + * @param isOpeningFullOverlay - Identifies whether the full overlay should be forced open regardless of other states + */ + private async openOverlay(isFocusingFieldElement = false, isOpeningFullOverlay = false) { + const currentTab = await BrowserApi.getTabFromCurrentWindowId(); + + await BrowserApi.tabSendMessageData(currentTab, "openAutofillOverlay", { + isFocusingFieldElement, + isOpeningFullOverlay, + authStatus: await this.getAuthStatus(), + }); + } + + /** + * Gets the overlay's visibility setting from the settings service. + */ + private async getOverlayVisibility(): Promise { + return await firstValueFrom(this.autofillSettingsService.inlineMenuVisibility$); + } + + /** + * Gets the user's authentication status from the auth service. If the user's + * authentication status has changed, the overlay button's authentication status + * will be updated and the overlay list's ciphers will be updated. + */ + private async getAuthStatus() { + const formerAuthStatus = this.userAuthStatus; + this.userAuthStatus = await this.authService.getAuthStatus(); + + if ( + this.userAuthStatus !== formerAuthStatus && + this.userAuthStatus === AuthenticationStatus.Unlocked + ) { + this.updateOverlayButtonAuthStatus(); + await this.updateOverlayCiphers(); + } + + return this.userAuthStatus; + } + + /** + * Sends a message to the overlay button to update its authentication status. + */ + private updateOverlayButtonAuthStatus() { + this.overlayButtonPort?.postMessage({ + command: "updateOverlayButtonAuthStatus", + authStatus: this.userAuthStatus, + }); + } + + /** + * Handles the overlay button being clicked. If the user is not authenticated, + * the vault will be unlocked. If the user is authenticated, the overlay will + * be opened. + * + * @param port - The port of the overlay button + */ + private handleOverlayButtonClicked(port: chrome.runtime.Port) { + if (this.userAuthStatus !== AuthenticationStatus.Unlocked) { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.unlockVault(port); + return; + } + + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.openOverlay(false, true); + } + + /** + * Facilitates opening the unlock popout window. + * + * @param port - The port of the overlay list + */ + private async unlockVault(port: chrome.runtime.Port) { + const { sender } = port; + + this.closeOverlay(port); + const retryMessage: LockedVaultPendingNotificationsData = { + commandToRetry: { message: { command: "openAutofillOverlay" }, sender }, + target: "overlay.background", + }; + await BrowserApi.tabSendMessageData( + sender.tab, + "addToLockedVaultPendingNotifications", + retryMessage, + ); + await this.openUnlockPopout(sender.tab, true); + } + + /** + * Triggers the opening of a vault item popout window associated + * with the passed cipher ID. + * @param overlayCipherId - Cipher ID corresponding to the overlayLoginCiphers map. Does not correspond to the actual cipher's ID. + * @param sender - The sender of the port message + */ + private async viewSelectedCipher( + { overlayCipherId }: OverlayPortMessage, + { sender }: chrome.runtime.Port, + ) { + const cipher = this.overlayLoginCiphers.get(overlayCipherId); + if (!cipher) { + return; + } + + await this.openViewVaultItemPopout(sender.tab, { + cipherId: cipher.id, + action: SHOW_AUTOFILL_BUTTON, + }); + } + + /** + * Facilitates redirecting focus to the overlay list. + */ + private focusOverlayList() { + this.overlayListPort?.postMessage({ command: "focusOverlayList" }); + } + + /** + * Updates the authentication status for the user and opens the overlay if + * a followup command is present in the message. + * + * @param message - Extension message received from the `unlockCompleted` command + */ + private async unlockCompleted(message: OverlayBackgroundExtensionMessage) { + await this.getAuthStatus(); + + if (message.data?.commandToRetry?.message?.command === "openAutofillOverlay") { + await this.openOverlay(true); + } + } + + /** + * Gets the translations for the overlay page. + */ + private getTranslations() { + if (!this.overlayPageTranslations) { + this.overlayPageTranslations = { + locale: BrowserApi.getUILanguage(), + opensInANewWindow: this.i18nService.translate("opensInANewWindow"), + buttonPageTitle: this.i18nService.translate("bitwardenOverlayButton"), + toggleBitwardenVaultOverlay: this.i18nService.translate("toggleBitwardenVaultOverlay"), + listPageTitle: this.i18nService.translate("bitwardenVault"), + unlockYourAccount: this.i18nService.translate("unlockYourAccountToViewMatchingLogins"), + unlockAccount: this.i18nService.translate("unlockAccount"), + fillCredentialsFor: this.i18nService.translate("fillCredentialsFor"), + partialUsername: this.i18nService.translate("partialUsername"), + view: this.i18nService.translate("view"), + noItemsToShow: this.i18nService.translate("noItemsToShow"), + newItem: this.i18nService.translate("newItem"), + addNewVaultItem: this.i18nService.translate("addNewVaultItem"), + }; + } + + return this.overlayPageTranslations; + } + + /** + * Facilitates redirecting focus out of one of the + * overlay elements to elements on the page. + * + * @param direction - The direction to redirect focus to (either "next", "previous" or "current) + * @param sender - The sender of the port message + */ + private redirectOverlayFocusOut( + { direction }: OverlayPortMessage, + { sender }: chrome.runtime.Port, + ) { + if (!direction) { + return; + } + + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + BrowserApi.tabSendMessageData(sender.tab, "redirectOverlayFocusOut", { direction }); + } + + /** + * Triggers adding a new vault item from the overlay. Gathers data + * input by the user before calling to open the add/edit window. + * + * @param sender - The sender of the port message + */ + private getNewVaultItemDetails({ sender }: chrome.runtime.Port) { + void BrowserApi.tabSendMessage(sender.tab, { command: "addNewVaultItemFromOverlay" }); + } + + /** + * Handles adding a new vault item from the overlay. Gathers data login + * data captured in the extension message. + * + * @param login - The login data captured from the extension message + * @param sender - The sender of the extension message + */ + private async addNewVaultItem( + { login }: OverlayAddNewItemMessage, + sender: chrome.runtime.MessageSender, + ) { + if (!login) { + return; + } + + const uriView = new LoginUriView(); + uriView.uri = login.uri; + + const loginView = new LoginView(); + loginView.uris = [uriView]; + loginView.username = login.username || ""; + loginView.password = login.password || ""; + + const cipherView = new CipherView(); + cipherView.name = (Utils.getHostname(login.uri) || login.hostname).replace(/^www\./, ""); + cipherView.folderId = null; + cipherView.type = CipherType.Login; + cipherView.login = loginView; + + await this.cipherService.setAddEditCipherInfo({ + cipher: cipherView, + collectionIds: cipherView.collectionIds, + }); + + await this.openAddEditVaultItemPopout(sender.tab, { cipherId: cipherView.id }); + await BrowserApi.sendMessage("inlineAutofillMenuRefreshAddEditCipher"); + } + + /** + * Sets up the extension message listeners for the overlay. + */ + private setupExtensionMessageListeners() { + BrowserApi.messageListener("overlay.background", this.handleExtensionMessage); + BrowserApi.addListener(chrome.runtime.onConnect, this.handlePortOnConnect); + } + + /** + * Handles extension messages sent to the extension background. + * + * @param message - The message received from the extension + * @param sender - The sender of the message + * @param sendResponse - The response to send back to the sender + */ + private handleExtensionMessage = ( + message: OverlayBackgroundExtensionMessage, + sender: chrome.runtime.MessageSender, + sendResponse: (response?: any) => void, + ) => { + const handler: CallableFunction | undefined = this.extensionMessageHandlers[message?.command]; + if (!handler) { + return null; + } + + const messageResponse = handler({ message, sender }); + if (typeof messageResponse === "undefined") { + return null; + } + + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Promise.resolve(messageResponse).then((response) => sendResponse(response)); + return true; + }; + + /** + * Handles the connection of a port to the extension background. + * + * @param port - The port that connected to the extension background + */ + private handlePortOnConnect = async (port: chrome.runtime.Port) => { + const isOverlayListPort = port.name === AutofillOverlayPort.List; + const isOverlayButtonPort = port.name === AutofillOverlayPort.Button; + if (!isOverlayListPort && !isOverlayButtonPort) { + return; + } + + this.storeOverlayPort(port); + port.onMessage.addListener(this.handleOverlayElementPortMessage); + port.postMessage({ + command: `initAutofillOverlay${isOverlayListPort ? "List" : "Button"}`, + authStatus: await this.getAuthStatus(), + styleSheetUrl: chrome.runtime.getURL(`overlay/${isOverlayListPort ? "list" : "button"}.css`), + theme: await firstValueFrom(this.themeStateService.selectedTheme$), + translations: this.getTranslations(), + ciphers: isOverlayListPort ? await this.getOverlayCipherData() : null, + }); + this.updateOverlayPosition( + { + overlayElement: isOverlayListPort + ? AutofillOverlayElement.List + : AutofillOverlayElement.Button, + }, + port.sender, + ); + }; + + /** + * Stores the connected overlay port and sets up any existing ports to be disconnected. + * + * @param port - The port to store +| */ + private storeOverlayPort(port: chrome.runtime.Port) { + if (port.name === AutofillOverlayPort.List) { + this.storeExpiredOverlayPort(this.overlayListPort); + this.overlayListPort = port; + return; + } + + if (port.name === AutofillOverlayPort.Button) { + this.storeExpiredOverlayPort(this.overlayButtonPort); + this.overlayButtonPort = port; + } + } + + /** + * When registering a new connection, we want to ensure that the port is disconnected. + * This method places an existing port in the expiredPorts array to be disconnected + * at a later time. + * + * @param port - The port to store in the expiredPorts array + */ + private storeExpiredOverlayPort(port: chrome.runtime.Port | null) { + if (port) { + this.expiredPorts.push(port); + } + } + + /** + * Handles messages sent to the overlay list or button ports. + * + * @param message - The message received from the port + * @param port - The port that sent the message + */ + private handleOverlayElementPortMessage = ( + message: OverlayBackgroundExtensionMessage, + port: chrome.runtime.Port, + ) => { + const command = message?.command; + let handler: CallableFunction | undefined; + + if (port.name === AutofillOverlayPort.Button) { + handler = this.overlayButtonPortMessageHandlers[command]; + } + + if (port.name === AutofillOverlayPort.List) { + handler = this.overlayListPortMessageHandlers[command]; + } + + if (!handler) { + return; + } + + handler({ message, port }); + }; +} + +export default LegacyOverlayBackground; diff --git a/apps/browser/src/autofill/deprecated/content/abstractions/autofill-init.deprecated.ts b/apps/browser/src/autofill/deprecated/content/abstractions/autofill-init.deprecated.ts new file mode 100644 index 00000000000..ed422822b36 --- /dev/null +++ b/apps/browser/src/autofill/deprecated/content/abstractions/autofill-init.deprecated.ts @@ -0,0 +1,41 @@ +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; + +import AutofillScript from "../../../models/autofill-script"; + +type AutofillExtensionMessage = { + command: string; + tab?: chrome.tabs.Tab; + sender?: string; + fillScript?: AutofillScript; + url?: string; + pageDetailsUrl?: string; + ciphers?: any; + data?: { + authStatus?: AuthenticationStatus; + isFocusingFieldElement?: boolean; + isOverlayCiphersPopulated?: boolean; + direction?: "previous" | "next"; + isOpeningFullOverlay?: boolean; + forceCloseOverlay?: boolean; + autofillOverlayVisibility?: number; + }; +}; + +type AutofillExtensionMessageParam = { message: AutofillExtensionMessage }; + +type AutofillExtensionMessageHandlers = { + [key: string]: CallableFunction; + collectPageDetails: ({ message }: AutofillExtensionMessageParam) => void; + collectPageDetailsImmediately: ({ message }: AutofillExtensionMessageParam) => void; + fillForm: ({ message }: AutofillExtensionMessageParam) => void; + openAutofillOverlay: ({ message }: AutofillExtensionMessageParam) => void; + closeAutofillOverlay: ({ message }: AutofillExtensionMessageParam) => void; + addNewVaultItemFromOverlay: () => void; + redirectOverlayFocusOut: ({ message }: AutofillExtensionMessageParam) => void; + updateIsOverlayCiphersPopulated: ({ message }: AutofillExtensionMessageParam) => void; + bgUnlockPopoutOpened: () => void; + bgVaultItemRepromptPopoutOpened: () => void; + updateAutofillOverlayVisibility: ({ message }: AutofillExtensionMessageParam) => void; +}; + +export { AutofillExtensionMessage, AutofillExtensionMessageHandlers }; diff --git a/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts b/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts new file mode 100644 index 00000000000..96d5e85ca34 --- /dev/null +++ b/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts @@ -0,0 +1,604 @@ +import { mock } from "jest-mock-extended"; + +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; + +import { RedirectFocusDirection } from "../../enums/autofill-overlay.enum"; +import AutofillPageDetails from "../../models/autofill-page-details"; +import AutofillScript from "../../models/autofill-script"; +import { + flushPromises, + mockQuerySelectorAllDefinedCall, + sendMockExtensionMessage, +} from "../../spec/testing-utils"; +import AutofillOverlayContentServiceDeprecated from "../services/autofill-overlay-content.service.deprecated"; + +import { AutofillExtensionMessage } from "./abstractions/autofill-init.deprecated"; +import AutofillInitDeprecated from "./autofill-init.deprecated"; + +describe("AutofillInit", () => { + let autofillInit: AutofillInitDeprecated; + const autofillOverlayContentService = mock(); + const originalDocumentReadyState = document.readyState; + const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); + + beforeEach(() => { + chrome.runtime.connect = jest.fn().mockReturnValue({ + onDisconnect: { + addListener: jest.fn(), + }, + }); + autofillInit = new AutofillInitDeprecated(autofillOverlayContentService); + window.IntersectionObserver = jest.fn(() => mock()); + }); + + afterEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + Object.defineProperty(document, "readyState", { + value: originalDocumentReadyState, + writable: true, + }); + }); + + afterAll(() => { + mockQuerySelectorAll.mockRestore(); + }); + + describe("init", () => { + it("sets up the extension message listeners", () => { + jest.spyOn(autofillInit as any, "setupExtensionMessageListeners"); + + autofillInit.init(); + + expect(autofillInit["setupExtensionMessageListeners"]).toHaveBeenCalled(); + }); + + it("triggers a collection of page details if the document is in a `complete` ready state", () => { + jest.useFakeTimers(); + Object.defineProperty(document, "readyState", { value: "complete", writable: true }); + + autofillInit.init(); + jest.advanceTimersByTime(250); + + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( + { + command: "bgCollectPageDetails", + sender: "autofillInit", + }, + expect.any(Function), + ); + }); + + it("registers a window load listener to collect the page details if the document is not in a `complete` ready state", () => { + jest.spyOn(window, "addEventListener"); + Object.defineProperty(document, "readyState", { value: "loading", writable: true }); + + autofillInit.init(); + + expect(window.addEventListener).toHaveBeenCalledWith("load", expect.any(Function)); + }); + }); + + describe("setupExtensionMessageListeners", () => { + it("sets up a chrome runtime on message listener", () => { + jest.spyOn(chrome.runtime.onMessage, "addListener"); + + autofillInit["setupExtensionMessageListeners"](); + + expect(chrome.runtime.onMessage.addListener).toHaveBeenCalledWith( + autofillInit["handleExtensionMessage"], + ); + }); + }); + + describe("handleExtensionMessage", () => { + let message: AutofillExtensionMessage; + let sender: chrome.runtime.MessageSender; + const sendResponse = jest.fn(); + + beforeEach(() => { + message = { + command: "collectPageDetails", + tab: mock(), + sender: "sender", + }; + sender = mock(); + }); + + it("returns a undefined value if a extension message handler is not found with the given message command", () => { + message.command = "unknownCommand"; + + const response = autofillInit["handleExtensionMessage"](message, sender, sendResponse); + + expect(response).toBe(null); + }); + + it("returns a undefined value if the message handler does not return a response", async () => { + const response1 = await autofillInit["handleExtensionMessage"](message, sender, sendResponse); + await flushPromises(); + + expect(response1).not.toBe(false); + + message.command = "removeAutofillOverlay"; + message.fillScript = mock(); + + const response2 = autofillInit["handleExtensionMessage"](message, sender, sendResponse); + await flushPromises(); + + expect(response2).toBe(null); + }); + + it("returns a true value and calls sendResponse if the message handler returns a response", async () => { + message.command = "collectPageDetailsImmediately"; + const pageDetails: AutofillPageDetails = { + title: "title", + url: "http://example.com", + documentUrl: "documentUrl", + forms: {}, + fields: [], + collectedTimestamp: 0, + }; + jest + .spyOn(autofillInit["collectAutofillContentService"], "getPageDetails") + .mockResolvedValue(pageDetails); + + const response = await autofillInit["handleExtensionMessage"](message, sender, sendResponse); + await flushPromises(); + + expect(response).toBe(true); + expect(sendResponse).toHaveBeenCalledWith(pageDetails); + }); + + describe("extension message handlers", () => { + beforeEach(() => { + autofillInit.init(); + }); + + describe("collectPageDetails", () => { + it("sends the collected page details for autofill using a background script message", async () => { + const pageDetails: AutofillPageDetails = { + title: "title", + url: "http://example.com", + documentUrl: "documentUrl", + forms: {}, + fields: [], + collectedTimestamp: 0, + }; + const message = { + command: "collectPageDetails", + sender: "sender", + tab: mock(), + }; + jest + .spyOn(autofillInit["collectAutofillContentService"], "getPageDetails") + .mockResolvedValue(pageDetails); + + sendMockExtensionMessage(message, sender, sendResponse); + await flushPromises(); + + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + command: "collectPageDetailsResponse", + tab: message.tab, + details: pageDetails, + sender: message.sender, + }); + }); + }); + + describe("collectPageDetailsImmediately", () => { + it("returns collected page details for autofill if set to send the details in the response", async () => { + const pageDetails: AutofillPageDetails = { + title: "title", + url: "http://example.com", + documentUrl: "documentUrl", + forms: {}, + fields: [], + collectedTimestamp: 0, + }; + jest + .spyOn(autofillInit["collectAutofillContentService"], "getPageDetails") + .mockResolvedValue(pageDetails); + + sendMockExtensionMessage( + { command: "collectPageDetailsImmediately" }, + sender, + sendResponse, + ); + await flushPromises(); + + expect(autofillInit["collectAutofillContentService"].getPageDetails).toHaveBeenCalled(); + expect(sendResponse).toBeCalledWith(pageDetails); + expect(chrome.runtime.sendMessage).not.toHaveBeenCalledWith({ + command: "collectPageDetailsResponse", + tab: message.tab, + details: pageDetails, + sender: message.sender, + }); + }); + }); + + describe("fillForm", () => { + let fillScript: AutofillScript; + beforeEach(() => { + fillScript = mock(); + jest.spyOn(autofillInit["insertAutofillContentService"], "fillForm").mockImplementation(); + }); + + it("skips calling the InsertAutofillContentService and does not fill the form if the url to fill is not equal to the current tab url", async () => { + const fillScript = mock(); + const message = { + command: "fillForm", + fillScript, + pageDetailsUrl: "https://a-different-url.com", + }; + + sendMockExtensionMessage(message); + await flushPromises(); + + expect(autofillInit["insertAutofillContentService"].fillForm).not.toHaveBeenCalledWith( + fillScript, + ); + }); + + it("calls the InsertAutofillContentService to fill the form", async () => { + sendMockExtensionMessage({ + command: "fillForm", + fillScript, + pageDetailsUrl: window.location.href, + }); + await flushPromises(); + + expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( + fillScript, + ); + }); + + it("removes the overlay when filling the form", async () => { + const blurAndRemoveOverlaySpy = jest.spyOn(autofillInit as any, "blurAndRemoveOverlay"); + sendMockExtensionMessage({ + command: "fillForm", + fillScript, + pageDetailsUrl: window.location.href, + }); + await flushPromises(); + + expect(blurAndRemoveOverlaySpy).toHaveBeenCalled(); + }); + + it("updates the isCurrentlyFilling property of the overlay to true after filling", async () => { + jest.useFakeTimers(); + jest.spyOn(autofillInit as any, "updateOverlayIsCurrentlyFilling"); + jest + .spyOn(autofillInit["autofillOverlayContentService"], "focusMostRecentOverlayField") + .mockImplementation(); + + sendMockExtensionMessage({ + command: "fillForm", + fillScript, + pageDetailsUrl: window.location.href, + }); + await flushPromises(); + jest.advanceTimersByTime(300); + + expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(1, true); + expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( + fillScript, + ); + expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(2, false); + }); + + it("skips attempting to focus the most recent field if the autofillOverlayContentService is not present", async () => { + jest.useFakeTimers(); + const newAutofillInit = new AutofillInitDeprecated(undefined); + newAutofillInit.init(); + jest.spyOn(newAutofillInit as any, "updateOverlayIsCurrentlyFilling"); + jest + .spyOn(newAutofillInit["insertAutofillContentService"], "fillForm") + .mockImplementation(); + + sendMockExtensionMessage({ + command: "fillForm", + fillScript, + pageDetailsUrl: window.location.href, + }); + await flushPromises(); + jest.advanceTimersByTime(300); + + expect(newAutofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith( + 1, + true, + ); + expect(newAutofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( + fillScript, + ); + expect(newAutofillInit["updateOverlayIsCurrentlyFilling"]).not.toHaveBeenNthCalledWith( + 2, + false, + ); + }); + }); + + describe("openAutofillOverlay", () => { + const message = { + command: "openAutofillOverlay", + data: { + isFocusingFieldElement: true, + isOpeningFullOverlay: true, + authStatus: AuthenticationStatus.Unlocked, + }, + }; + + it("skips attempting to open the autofill overlay if the autofillOverlayContentService is not present", () => { + const newAutofillInit = new AutofillInitDeprecated(undefined); + newAutofillInit.init(); + + sendMockExtensionMessage(message); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("opens the autofill overlay", () => { + sendMockExtensionMessage(message); + + expect( + autofillInit["autofillOverlayContentService"].openAutofillOverlay, + ).toHaveBeenCalledWith({ + isFocusingFieldElement: message.data.isFocusingFieldElement, + isOpeningFullOverlay: message.data.isOpeningFullOverlay, + authStatus: message.data.authStatus, + }); + }); + }); + + describe("closeAutofillOverlay", () => { + beforeEach(() => { + autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = false; + autofillInit["autofillOverlayContentService"].isCurrentlyFilling = false; + }); + + it("skips attempting to remove the overlay if the autofillOverlayContentService is not present", () => { + const newAutofillInit = new AutofillInitDeprecated(undefined); + newAutofillInit.init(); + jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); + + sendMockExtensionMessage({ + command: "closeAutofillOverlay", + data: { forceCloseOverlay: false }, + }); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("removes the autofill overlay if the message flags a forced closure", () => { + sendMockExtensionMessage({ + command: "closeAutofillOverlay", + data: { forceCloseOverlay: true }, + }); + + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlay, + ).toHaveBeenCalled(); + }); + + it("ignores the message if a field is currently focused", () => { + autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = true; + + sendMockExtensionMessage({ command: "closeAutofillOverlay" }); + + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlayList, + ).not.toHaveBeenCalled(); + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlay, + ).not.toHaveBeenCalled(); + }); + + it("removes the autofill overlay list if the overlay is currently filling", () => { + autofillInit["autofillOverlayContentService"].isCurrentlyFilling = true; + + sendMockExtensionMessage({ command: "closeAutofillOverlay" }); + + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlayList, + ).toHaveBeenCalled(); + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlay, + ).not.toHaveBeenCalled(); + }); + + it("removes the entire overlay if the overlay is not currently filling", () => { + sendMockExtensionMessage({ command: "closeAutofillOverlay" }); + + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlayList, + ).not.toHaveBeenCalled(); + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlay, + ).toHaveBeenCalled(); + }); + }); + + describe("addNewVaultItemFromOverlay", () => { + it("will not add a new vault item if the autofillOverlayContentService is not present", () => { + const newAutofillInit = new AutofillInitDeprecated(undefined); + newAutofillInit.init(); + + sendMockExtensionMessage({ command: "addNewVaultItemFromOverlay" }); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("will add a new vault item", () => { + sendMockExtensionMessage({ command: "addNewVaultItemFromOverlay" }); + + expect(autofillInit["autofillOverlayContentService"].addNewVaultItem).toHaveBeenCalled(); + }); + }); + + describe("redirectOverlayFocusOut", () => { + const message = { + command: "redirectOverlayFocusOut", + data: { + direction: RedirectFocusDirection.Next, + }, + }; + + it("ignores the message to redirect focus if the autofillOverlayContentService does not exist", () => { + const newAutofillInit = new AutofillInitDeprecated(undefined); + newAutofillInit.init(); + + sendMockExtensionMessage(message); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("redirects the overlay focus", () => { + sendMockExtensionMessage(message); + + expect( + autofillInit["autofillOverlayContentService"].redirectOverlayFocusOut, + ).toHaveBeenCalledWith(message.data.direction); + }); + }); + + describe("updateIsOverlayCiphersPopulated", () => { + const message = { + command: "updateIsOverlayCiphersPopulated", + data: { + isOverlayCiphersPopulated: true, + }, + }; + + it("skips updating whether the ciphers are populated if the autofillOverlayContentService does note exist", () => { + const newAutofillInit = new AutofillInitDeprecated(undefined); + newAutofillInit.init(); + + sendMockExtensionMessage(message); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("updates whether the overlay ciphers are populated", () => { + sendMockExtensionMessage(message); + + expect(autofillInit["autofillOverlayContentService"].isOverlayCiphersPopulated).toEqual( + message.data.isOverlayCiphersPopulated, + ); + }); + }); + + describe("bgUnlockPopoutOpened", () => { + it("skips attempting to blur and remove the overlay if the autofillOverlayContentService is not present", () => { + const newAutofillInit = new AutofillInitDeprecated(undefined); + newAutofillInit.init(); + jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); + + sendMockExtensionMessage({ command: "bgUnlockPopoutOpened" }); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + expect(newAutofillInit["removeAutofillOverlay"]).not.toHaveBeenCalled(); + }); + + it("blurs the most recently focused feel and remove the autofill overlay", () => { + jest.spyOn(autofillInit["autofillOverlayContentService"], "blurMostRecentOverlayField"); + jest.spyOn(autofillInit as any, "removeAutofillOverlay"); + + sendMockExtensionMessage({ command: "bgUnlockPopoutOpened" }); + + expect( + autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField, + ).toHaveBeenCalled(); + expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled(); + }); + }); + + describe("bgVaultItemRepromptPopoutOpened", () => { + it("skips attempting to blur and remove the overlay if the autofillOverlayContentService is not present", () => { + const newAutofillInit = new AutofillInitDeprecated(undefined); + newAutofillInit.init(); + jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); + + sendMockExtensionMessage({ command: "bgVaultItemRepromptPopoutOpened" }); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + expect(newAutofillInit["removeAutofillOverlay"]).not.toHaveBeenCalled(); + }); + + it("blurs the most recently focused feel and remove the autofill overlay", () => { + jest.spyOn(autofillInit["autofillOverlayContentService"], "blurMostRecentOverlayField"); + jest.spyOn(autofillInit as any, "removeAutofillOverlay"); + + sendMockExtensionMessage({ command: "bgVaultItemRepromptPopoutOpened" }); + + expect( + autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField, + ).toHaveBeenCalled(); + expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled(); + }); + }); + + describe("updateAutofillOverlayVisibility", () => { + beforeEach(() => { + autofillInit["autofillOverlayContentService"].autofillOverlayVisibility = + AutofillOverlayVisibility.OnButtonClick; + }); + + it("skips attempting to update the overlay visibility if the autofillOverlayVisibility data value is not present", () => { + sendMockExtensionMessage({ + command: "updateAutofillOverlayVisibility", + data: {}, + }); + + expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual( + AutofillOverlayVisibility.OnButtonClick, + ); + }); + + it("updates the overlay visibility value", () => { + const message = { + command: "updateAutofillOverlayVisibility", + data: { + autofillOverlayVisibility: AutofillOverlayVisibility.Off, + }, + }; + + sendMockExtensionMessage(message); + + expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual( + message.data.autofillOverlayVisibility, + ); + }); + }); + }); + }); + + describe("destroy", () => { + it("clears the timeout used to collect page details on load", () => { + jest.spyOn(window, "clearTimeout"); + + autofillInit.init(); + autofillInit.destroy(); + + expect(window.clearTimeout).toHaveBeenCalledWith( + autofillInit["collectPageDetailsOnLoadTimeout"], + ); + }); + + it("removes the extension message listeners", () => { + autofillInit.destroy(); + + expect(chrome.runtime.onMessage.removeListener).toHaveBeenCalledWith( + autofillInit["handleExtensionMessage"], + ); + }); + + it("destroys the collectAutofillContentService", () => { + jest.spyOn(autofillInit["collectAutofillContentService"], "destroy"); + + autofillInit.destroy(); + + expect(autofillInit["collectAutofillContentService"].destroy).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.ts b/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.ts new file mode 100644 index 00000000000..3e36fa43bbd --- /dev/null +++ b/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.ts @@ -0,0 +1,310 @@ +import { AutofillInit } from "../../content/abstractions/autofill-init"; +import AutofillPageDetails from "../../models/autofill-page-details"; +import CollectAutofillContentService from "../../services/collect-autofill-content.service"; +import DomElementVisibilityService from "../../services/dom-element-visibility.service"; +import InsertAutofillContentService from "../../services/insert-autofill-content.service"; +import { sendExtensionMessage } from "../../utils"; +import { LegacyAutofillOverlayContentService } from "../services/abstractions/autofill-overlay-content.service"; + +import { + AutofillExtensionMessage, + AutofillExtensionMessageHandlers, +} from "./abstractions/autofill-init.deprecated"; + +class LegacyAutofillInit implements AutofillInit { + private readonly autofillOverlayContentService: LegacyAutofillOverlayContentService | undefined; + private readonly domElementVisibilityService: DomElementVisibilityService; + private readonly collectAutofillContentService: CollectAutofillContentService; + private readonly insertAutofillContentService: InsertAutofillContentService; + private collectPageDetailsOnLoadTimeout: number | NodeJS.Timeout | undefined; + private readonly extensionMessageHandlers: AutofillExtensionMessageHandlers = { + collectPageDetails: ({ message }) => this.collectPageDetails(message), + collectPageDetailsImmediately: ({ message }) => this.collectPageDetails(message, true), + fillForm: ({ message }) => this.fillForm(message), + openAutofillOverlay: ({ message }) => this.openAutofillOverlay(message), + closeAutofillOverlay: ({ message }) => this.removeAutofillOverlay(message), + addNewVaultItemFromOverlay: () => this.addNewVaultItemFromOverlay(), + redirectOverlayFocusOut: ({ message }) => this.redirectOverlayFocusOut(message), + updateIsOverlayCiphersPopulated: ({ message }) => this.updateIsOverlayCiphersPopulated(message), + bgUnlockPopoutOpened: () => this.blurAndRemoveOverlay(), + bgVaultItemRepromptPopoutOpened: () => this.blurAndRemoveOverlay(), + updateAutofillOverlayVisibility: ({ message }) => this.updateAutofillOverlayVisibility(message), + }; + + /** + * AutofillInit constructor. Initializes the DomElementVisibilityService, + * CollectAutofillContentService and InsertAutofillContentService classes. + * + * @param autofillOverlayContentService - The autofill overlay content service, potentially undefined. + */ + constructor(autofillOverlayContentService?: LegacyAutofillOverlayContentService) { + this.autofillOverlayContentService = autofillOverlayContentService; + this.domElementVisibilityService = new DomElementVisibilityService(); + this.collectAutofillContentService = new CollectAutofillContentService( + this.domElementVisibilityService, + this.autofillOverlayContentService, + ); + this.insertAutofillContentService = new InsertAutofillContentService( + this.domElementVisibilityService, + this.collectAutofillContentService, + ); + } + + /** + * Initializes the autofill content script, setting up + * the extension message listeners. This method should + * be called once when the content script is loaded. + */ + init() { + this.setupExtensionMessageListeners(); + this.autofillOverlayContentService?.init(); + this.collectPageDetailsOnLoad(); + } + + /** + * Triggers a collection of the page details from the + * background script, ensuring that autofill is ready + * to act on the page. + */ + private collectPageDetailsOnLoad() { + const sendCollectDetailsMessage = () => { + this.clearCollectPageDetailsOnLoadTimeout(); + this.collectPageDetailsOnLoadTimeout = setTimeout( + () => sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }), + 250, + ); + }; + + if (globalThis.document.readyState === "complete") { + sendCollectDetailsMessage(); + } + + globalThis.addEventListener("load", sendCollectDetailsMessage); + } + + /** + * Collects the page details and sends them to the + * extension background script. If the `sendDetailsInResponse` + * parameter is set to true, the page details will be + * returned to facilitate sending the details in the + * response to the extension message. + * + * @param message - The extension message. + * @param sendDetailsInResponse - Determines whether to send the details in the response. + */ + private async collectPageDetails( + message: AutofillExtensionMessage, + sendDetailsInResponse = false, + ): Promise { + const pageDetails: AutofillPageDetails = + await this.collectAutofillContentService.getPageDetails(); + if (sendDetailsInResponse) { + return pageDetails; + } + + void chrome.runtime.sendMessage({ + command: "collectPageDetailsResponse", + tab: message.tab, + details: pageDetails, + sender: message.sender, + }); + } + + /** + * Fills the form with the given fill script. + * + * @param {AutofillExtensionMessage} message + */ + private async fillForm({ fillScript, pageDetailsUrl }: AutofillExtensionMessage) { + if ((document.defaultView || window).location.href !== pageDetailsUrl) { + return; + } + + this.blurAndRemoveOverlay(); + this.updateOverlayIsCurrentlyFilling(true); + await this.insertAutofillContentService.fillForm(fillScript); + + if (!this.autofillOverlayContentService) { + return; + } + + setTimeout(() => this.updateOverlayIsCurrentlyFilling(false), 250); + } + + /** + * Handles updating the overlay is currently filling value. + * + * @param isCurrentlyFilling - Indicates if the overlay is currently filling + */ + private updateOverlayIsCurrentlyFilling(isCurrentlyFilling: boolean) { + if (!this.autofillOverlayContentService) { + return; + } + + this.autofillOverlayContentService.isCurrentlyFilling = isCurrentlyFilling; + } + + /** + * Opens the autofill overlay. + * + * @param data - The extension message data. + */ + private openAutofillOverlay({ data }: AutofillExtensionMessage) { + if (!this.autofillOverlayContentService) { + return; + } + + this.autofillOverlayContentService.openAutofillOverlay(data); + } + + /** + * Blurs the most recent overlay field and removes the overlay. Used + * in cases where the background unlock or vault item reprompt popout + * is opened. + */ + private blurAndRemoveOverlay() { + if (!this.autofillOverlayContentService) { + return; + } + + this.autofillOverlayContentService.blurMostRecentOverlayField(); + this.removeAutofillOverlay(); + } + + /** + * Removes the autofill overlay if the field is not currently focused. + * If the autofill is currently filling, only the overlay list will be + * removed. + */ + private removeAutofillOverlay(message?: AutofillExtensionMessage) { + if (message?.data?.forceCloseOverlay) { + this.autofillOverlayContentService?.removeAutofillOverlay(); + return; + } + + if ( + !this.autofillOverlayContentService || + this.autofillOverlayContentService.isFieldCurrentlyFocused + ) { + return; + } + + if (this.autofillOverlayContentService.isCurrentlyFilling) { + this.autofillOverlayContentService.removeAutofillOverlayList(); + return; + } + + this.autofillOverlayContentService.removeAutofillOverlay(); + } + + /** + * Adds a new vault item from the overlay. + */ + private addNewVaultItemFromOverlay() { + if (!this.autofillOverlayContentService) { + return; + } + + this.autofillOverlayContentService.addNewVaultItem(); + } + + /** + * Redirects the overlay focus out of an overlay iframe. + * + * @param data - Contains the direction to redirect the focus. + */ + private redirectOverlayFocusOut({ data }: AutofillExtensionMessage) { + if (!this.autofillOverlayContentService) { + return; + } + + this.autofillOverlayContentService.redirectOverlayFocusOut(data?.direction); + } + + /** + * Updates whether the current tab has ciphers that can populate the overlay list + * + * @param data - Contains the isOverlayCiphersPopulated value + * + */ + private updateIsOverlayCiphersPopulated({ data }: AutofillExtensionMessage) { + if (!this.autofillOverlayContentService) { + return; + } + + this.autofillOverlayContentService.isOverlayCiphersPopulated = Boolean( + data?.isOverlayCiphersPopulated, + ); + } + + /** + * Updates the autofill overlay visibility. + * + * @param data - Contains the autoFillOverlayVisibility value + */ + private updateAutofillOverlayVisibility({ data }: AutofillExtensionMessage) { + if (!this.autofillOverlayContentService || isNaN(data?.autofillOverlayVisibility)) { + return; + } + + this.autofillOverlayContentService.autofillOverlayVisibility = data?.autofillOverlayVisibility; + } + + /** + * Clears the send collect details message timeout. + */ + private clearCollectPageDetailsOnLoadTimeout() { + if (this.collectPageDetailsOnLoadTimeout) { + clearTimeout(this.collectPageDetailsOnLoadTimeout); + } + } + + /** + * Sets up the extension message listeners for the content script. + */ + private setupExtensionMessageListeners() { + chrome.runtime.onMessage.addListener(this.handleExtensionMessage); + } + + /** + * Handles the extension messages sent to the content script. + * + * @param message - The extension message. + * @param sender - The message sender. + * @param sendResponse - The send response callback. + */ + private handleExtensionMessage = ( + message: AutofillExtensionMessage, + sender: chrome.runtime.MessageSender, + sendResponse: (response?: any) => void, + ): boolean => { + const command: string = message.command; + const handler: CallableFunction | undefined = this.extensionMessageHandlers[command]; + if (!handler) { + return null; + } + + const messageResponse = handler({ message, sender }); + if (typeof messageResponse === "undefined") { + return null; + } + + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Promise.resolve(messageResponse).then((response) => sendResponse(response)); + return true; + }; + + /** + * Handles destroying the autofill init content script. Removes all + * listeners, timeouts, and object instances to prevent memory leaks. + */ + destroy() { + this.clearCollectPageDetailsOnLoadTimeout(); + chrome.runtime.onMessage.removeListener(this.handleExtensionMessage); + this.collectAutofillContentService.destroy(); + this.autofillOverlayContentService?.destroy(); + } +} + +export default LegacyAutofillInit; diff --git a/apps/browser/src/autofill/deprecated/content/bootstrap-legacy-autofill-overlay.ts b/apps/browser/src/autofill/deprecated/content/bootstrap-legacy-autofill-overlay.ts new file mode 100644 index 00000000000..66d672172ae --- /dev/null +++ b/apps/browser/src/autofill/deprecated/content/bootstrap-legacy-autofill-overlay.ts @@ -0,0 +1,14 @@ +import { setupAutofillInitDisconnectAction } from "../../utils"; +import LegacyAutofillOverlayContentService from "../services/autofill-overlay-content.service.deprecated"; + +import LegacyAutofillInit from "./autofill-init.deprecated"; + +(function (windowContext) { + if (!windowContext.bitwardenAutofillInit) { + const autofillOverlayContentService = new LegacyAutofillOverlayContentService(); + windowContext.bitwardenAutofillInit = new LegacyAutofillInit(autofillOverlayContentService); + setupAutofillInitDisconnectAction(windowContext); + + windowContext.bitwardenAutofillInit.init(); + } +})(window); diff --git a/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-button.ts b/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-button.deprecated.ts similarity index 100% rename from apps/browser/src/autofill/overlay/abstractions/autofill-overlay-button.ts rename to apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-button.deprecated.ts diff --git a/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-iframe.service.ts b/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-iframe.service.deprecated.ts similarity index 100% rename from apps/browser/src/autofill/overlay/abstractions/autofill-overlay-iframe.service.ts rename to apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-iframe.service.deprecated.ts diff --git a/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-list.ts b/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-list.deprecated.ts similarity index 96% rename from apps/browser/src/autofill/overlay/abstractions/autofill-overlay-list.ts rename to apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-list.deprecated.ts index b656f238dce..83578b13043 100644 --- a/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-list.ts +++ b/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-list.deprecated.ts @@ -1,6 +1,6 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { OverlayCipherData } from "../../background/abstractions/overlay.background"; +import { OverlayCipherData } from "../../background/abstractions/overlay.background.deprecated"; type OverlayListMessage = { command: string }; diff --git a/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-page-element.ts b/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-page-element.deprecated.ts similarity index 89% rename from apps/browser/src/autofill/overlay/abstractions/autofill-overlay-page-element.ts rename to apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-page-element.deprecated.ts index eb3c2fa4a71..368ae4e7303 100644 --- a/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-page-element.ts +++ b/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-page-element.deprecated.ts @@ -1,5 +1,5 @@ -import { OverlayButtonWindowMessageHandlers } from "./autofill-overlay-button"; -import { OverlayListWindowMessageHandlers } from "./autofill-overlay-list"; +import { OverlayButtonWindowMessageHandlers } from "./autofill-overlay-button.deprecated"; +import { OverlayListWindowMessageHandlers } from "./autofill-overlay-list.deprecated"; type WindowMessageHandlers = OverlayButtonWindowMessageHandlers | OverlayListWindowMessageHandlers; diff --git a/apps/browser/src/autofill/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.spec.ts.snap b/apps/browser/src/autofill/deprecated/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.deprecated.spec.ts.snap similarity index 95% rename from apps/browser/src/autofill/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.spec.ts.snap rename to apps/browser/src/autofill/deprecated/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.deprecated.spec.ts.snap index cb8e4a541bb..132bd968899 100644 --- a/apps/browser/src/autofill/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.spec.ts.snap +++ b/apps/browser/src/autofill/deprecated/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.deprecated.spec.ts.snap @@ -15,7 +15,7 @@ exports[`AutofillOverlayIframeService initOverlayIframe sets up the iframe's att `; + }); + + it("returns null if the sub frame URL cannot be parsed correctly", async () => { + delete globalThis.location; + globalThis.location = { href: "invalid-base" } as Location; + sendMockExtensionMessage( + { + command: "getSubFrameOffsets", + subFrameUrl: iframeSource, + }, + mock(), + sendResponseSpy, + ); + await flushPromises(); + + expect(sendResponseSpy).toHaveBeenCalledWith(null); + }); + + it("calculates the sub frame's offsets if a single frame with the referenced url exists", async () => { + sendMockExtensionMessage( + { + command: "getSubFrameOffsets", + subFrameUrl: iframeSource, + }, + mock(), + sendResponseSpy, + ); + await flushPromises(); + + expect(sendResponseSpy).toHaveBeenCalledWith({ + frameId: undefined, + left: 2, + top: 2, + url: iframeSource, + }); + }); + + it("returns null if a matching iframe is not found", async () => { + document.body.innerHTML = ""; + sendMockExtensionMessage( + { + command: "getSubFrameOffsets", + subFrameUrl: iframeSource, + }, + mock(), + sendResponseSpy, + ); + await flushPromises(); + + expect(sendResponseSpy).toHaveBeenCalledWith(null); + }); + + it("returns null if two or more iframes are found with the same src", async () => { + document.body.innerHTML = ` + + + `; + + sendMockExtensionMessage( + { + command: "getSubFrameOffsets", + subFrameUrl: iframeSource, + }, + mock(), + sendResponseSpy, + ); + await flushPromises(); + + expect(sendResponseSpy).toHaveBeenCalledWith(null); + }); + }); + + describe("getSubFrameOffsetsFromWindowMessage", () => { + it("sends a message to the parent to calculate the sub frame positioning", () => { + jest.spyOn(globalThis.parent, "postMessage").mockImplementation(); + const subFrameId = 10; + + sendMockExtensionMessage({ + command: "getSubFrameOffsetsFromWindowMessage", + subFrameId, + }); + + expect(globalThis.parent.postMessage).toHaveBeenCalledWith( + { + command: "calculateSubFramePositioning", + subFrameData: { + url: window.location.href, + frameId: subFrameId, + left: 0, + top: 0, + parentFrameIds: [0], + subFrameDepth: 0, + }, + }, + "*", + ); + }); + + describe("calculateSubFramePositioning", () => { + beforeEach(() => { + autofillOverlayContentService.init(); + jest.spyOn(globalThis.parent, "postMessage"); + document.body.innerHTML = ``; + }); + + it("destroys the inline menu listeners on the origin frame if the depth exceeds the threshold", async () => { + document.body.innerHTML = ``; + const iframe = document.querySelector("iframe") as HTMLIFrameElement; + const subFrameData = { + url: "https://example.com/", + frameId: 10, + left: 0, + top: 0, + parentFrameIds: [1, 2, 3], + subFrameDepth: MAX_SUB_FRAME_DEPTH, + }; + sendExtensionMessageSpy.mockResolvedValue(4); + + postWindowMessage( + { command: "calculateSubFramePositioning", subFrameData }, + "*", + iframe.contentWindow as any, + ); + await flushPromises(); + + expect(globalThis.parent.postMessage).not.toHaveBeenCalled(); + }); + + it("calculates the sub frame offset for the current frame and sends those values to the parent if not in the top frame", async () => { + Object.defineProperty(window, "top", { + value: null, + writable: true, + }); + document.body.innerHTML = ``; + const iframe = document.querySelector("iframe") as HTMLIFrameElement; + const subFrameData = { + url: "https://example.com/", + frameId: 10, + left: 0, + top: 0, + parentFrameIds: [1, 2, 3], + subFrameDepth: 0, + }; + + postWindowMessage( + { command: "calculateSubFramePositioning", subFrameData }, + "*", + iframe.contentWindow as any, + ); + await flushPromises(); + + expect(globalThis.parent.postMessage).toHaveBeenCalledWith( + { + command: "calculateSubFramePositioning", + subFrameData: { + frameId: 10, + left: expect.any(Number), + parentFrameIds: [1, 2, 3], + top: expect.any(Number), + url: "https://example.com/", + subFrameDepth: expect.any(Number), + }, + }, + "*", + ); + }); + + it("posts the calculated sub frame data to the background", async () => { + document.body.innerHTML = ``; + const iframe = document.querySelector("iframe") as HTMLIFrameElement; + const subFrameData = { + url: "https://example.com/", + frameId: 10, + left: 0, + top: 0, + parentFrameIds: [1, 2, 3], + subFrameDepth: expect.any(Number), + }; + + postWindowMessage( + { command: "calculateSubFramePositioning", subFrameData }, + "*", + iframe.contentWindow as any, + ); + await flushPromises(); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateSubFrameData", { + subFrameData: { + frameId: 10, + left: expect.any(Number), + top: expect.any(Number), + url: "https://example.com/", + parentFrameIds: [1, 2, 3, 4], + subFrameDepth: expect.any(Number), + }, + }); + }); + }); + }); + + describe("checkMostRecentlyFocusedFieldHasValue message handler", () => { + it("returns true if the most recently focused field has a truthy value", async () => { + autofillOverlayContentService["mostRecentlyFocusedField"] = mock< + ElementWithOpId + >({ value: "test" }); + + sendMockExtensionMessage( + { + command: "checkMostRecentlyFocusedFieldHasValue", + }, + mock(), + sendResponseSpy, + ); + await flushPromises(); + + expect(sendResponseSpy).toHaveBeenCalledWith(true); + }); + }); + + describe("setupRebuildSubFrameOffsetsListeners message handler", () => { + let autofillFieldElement: ElementWithOpId; + + beforeEach(() => { + Object.defineProperty(window, "top", { + value: null, + writable: true, + }); + jest.spyOn(globalThis, "addEventListener"); + jest.spyOn(globalThis.document.body, "addEventListener"); + document.body.innerHTML = ` +
+ + +
+ `; + autofillFieldElement = document.getElementById( + "username-field", + ) as ElementWithOpId; + }); + + describe("skipping the setup of the sub frame listeners", () => { + it('skips setup when the window is the "top" frame', async () => { + Object.defineProperty(window, "top", { + value: window, + writable: true, + }); + + sendMockExtensionMessage({ command: "setupRebuildSubFrameOffsetsListeners" }); + await flushPromises(); + + expect(globalThis.addEventListener).not.toHaveBeenCalledWith( + EVENTS.FOCUS, + expect.any(Function), + ); + expect(globalThis.document.body.addEventListener).not.toHaveBeenCalledWith( + EVENTS.MOUSEENTER, + expect.any(Function), + ); + }); + + it("skips setup when no form fields exist on the current frame", async () => { + autofillOverlayContentService["formFieldElements"] = new Set(); + + sendMockExtensionMessage({ command: "setupRebuildSubFrameOffsetsListeners" }); + await flushPromises(); + + expect(globalThis.addEventListener).not.toHaveBeenCalledWith( + EVENTS.FOCUS, + expect.any(Function), + ); + expect(globalThis.document.body.addEventListener).not.toHaveBeenCalledWith( + EVENTS.MOUSEENTER, + expect.any(Function), + ); + }); + }); + + it("sets up the sub frame rebuild listeners when the sub frame contains fields", async () => { + autofillOverlayContentService["formFieldElements"].add(autofillFieldElement); + + sendMockExtensionMessage({ command: "setupRebuildSubFrameOffsetsListeners" }); + await flushPromises(); + + expect(globalThis.addEventListener).toHaveBeenCalledWith( + EVENTS.FOCUS, + expect.any(Function), + ); + expect(globalThis.document.body.addEventListener).toHaveBeenCalledWith( + EVENTS.MOUSEENTER, + expect.any(Function), + ); + }); + + describe("triggering the sub frame listener", () => { + beforeEach(async () => { + autofillOverlayContentService["formFieldElements"].add(autofillFieldElement); + await sendMockExtensionMessage({ command: "setupRebuildSubFrameOffsetsListeners" }); + }); + + it("triggers a rebuild of the sub frame listener when a focus event occurs", async () => { + globalThis.dispatchEvent(new Event(EVENTS.FOCUS)); + await flushPromises(); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("triggerSubFrameFocusInRebuild"); + }); + }); + }); + + describe("destroyAutofillInlineMenuListeners message handler", () => { + it("destroys the inline menu listeners", () => { + jest.spyOn(autofillOverlayContentService, "destroy"); + + sendMockExtensionMessage({ command: "destroyAutofillInlineMenuListeners" }); + + expect(autofillOverlayContentService.destroy).toHaveBeenCalled(); + }); }); }); @@ -1670,36 +1679,18 @@ describe("AutofillOverlayContentService", () => { forms: { validFormId: mock() }, fields: [autofillFieldData, passwordFieldData], }); - void autofillOverlayContentService.setupAutofillOverlayListenerOnField( + void autofillOverlayContentService.setupInlineMenu( autofillFieldElement, autofillFieldData, pageDetailsMock, ); autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement; - }); - - it("disconnects all mutation observers", () => { - autofillOverlayContentService["setupMutationObserver"](); - jest.spyOn(autofillOverlayContentService["bodyElementMutationObserver"], "disconnect"); - - autofillOverlayContentService.destroy(); - - expect( - autofillOverlayContentService["bodyElementMutationObserver"].disconnect, - ).toHaveBeenCalled(); - }); - - it("clears the user interaction event timeout", () => { - jest.spyOn(autofillOverlayContentService as any, "clearUserInteractionEventTimeout"); - - autofillOverlayContentService.destroy(); - - expect(autofillOverlayContentService["clearUserInteractionEventTimeout"]).toHaveBeenCalled(); + jest.spyOn(globalThis, "clearTimeout"); + jest.spyOn(globalThis.document, "removeEventListener"); + jest.spyOn(globalThis, "removeEventListener"); }); it("de-registers all global event listeners", () => { - jest.spyOn(globalThis.document, "removeEventListener"); - jest.spyOn(globalThis, "removeEventListener"); jest.spyOn(autofillOverlayContentService as any, "removeOverlayRepositionEventListeners"); autofillOverlayContentService.destroy(); @@ -1739,5 +1730,22 @@ describe("AutofillOverlayContentService", () => { autofillFieldElement, ); }); + + it("clears all existing timeouts", () => { + autofillOverlayContentService["focusInlineMenuListTimeout"] = setTimeout(jest.fn(), 100); + autofillOverlayContentService["closeInlineMenuOnRedirectTimeout"] = setTimeout( + jest.fn(), + 100, + ); + + autofillOverlayContentService.destroy(); + + expect(clearTimeout).toHaveBeenCalledWith( + autofillOverlayContentService["focusInlineMenuListTimeout"], + ); + expect(clearTimeout).toHaveBeenCalledWith( + autofillOverlayContentService["closeInlineMenuOnRedirectTimeout"], + ); + }); }); }); diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index d56a8a80cc6..8148ab98d8a 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -3,71 +3,79 @@ import "lit/polyfill-support.js"; import { FocusableElement, tabbable } from "tabbable"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { EVENTS, AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; +import { + EVENTS, + AutofillOverlayVisibility, + AUTOFILL_OVERLAY_HANDLE_REPOSITION, +} from "@bitwarden/common/autofill/constants"; -import { FocusedFieldData } from "../background/abstractions/overlay.background"; +import { + FocusedFieldData, + SubFrameOffsetData, +} from "../background/abstractions/overlay.background"; +import { AutofillExtensionMessage } from "../content/abstractions/autofill-init"; +import { + AutofillOverlayElement, + MAX_SUB_FRAME_DEPTH, + RedirectFocusDirection, +} from "../enums/autofill-overlay.enum"; import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; -import AutofillOverlayButtonIframe from "../overlay/iframe-content/autofill-overlay-button-iframe"; -import AutofillOverlayListIframe from "../overlay/iframe-content/autofill-overlay-list-iframe"; import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types"; import { elementIsFillableFormField, - generateRandomCustomElementName, + getAttributeBoolean, sendExtensionMessage, - setElementStyles, + throttle, } from "../utils"; -import { AutofillOverlayElement, RedirectFocusDirection } from "../utils/autofill-overlay.enum"; import { + AutofillOverlayContentExtensionMessageHandlers, AutofillOverlayContentService as AutofillOverlayContentServiceInterface, - OpenAutofillOverlayOptions, + OpenAutofillInlineMenuOptions, + SubFrameDataFromWindowMessage, } from "./abstractions/autofill-overlay-content.service"; +import { InlineMenuFieldQualificationService } from "./abstractions/inline-menu-field-qualifications.service"; import { AutoFillConstants } from "./autofill-constants"; -import { InlineMenuFieldQualificationService } from "./inline-menu-field-qualification.service"; -class AutofillOverlayContentService implements AutofillOverlayContentServiceInterface { - private readonly inlineMenuFieldQualificationService: InlineMenuFieldQualificationService; - isFieldCurrentlyFocused = false; - isCurrentlyFilling = false; - isOverlayCiphersPopulated = false; +export class AutofillOverlayContentService implements AutofillOverlayContentServiceInterface { pageDetailsUpdateRequired = false; - autofillOverlayVisibility: number; - private isFirefoxBrowser = - globalThis.navigator.userAgent.indexOf(" Firefox/") !== -1 || - globalThis.navigator.userAgent.indexOf(" Gecko/") !== -1; - private readonly generateRandomCustomElementName = generateRandomCustomElementName; + inlineMenuVisibility: number; private readonly findTabs = tabbable; private readonly sendExtensionMessage = sendExtensionMessage; private formFieldElements: Set> = new Set([]); - private ignoredFieldTypes: Set = new Set(AutoFillConstants.ExcludedOverlayTypes); + private hiddenFormFieldElements: WeakMap, AutofillField> = + new WeakMap(); + private ignoredFieldTypes: Set = new Set(AutoFillConstants.ExcludedInlineMenuTypes); private userFilledFields: Record = {}; private authStatus: AuthenticationStatus; private focusableElements: FocusableElement[] = []; - private isOverlayButtonVisible = false; - private isOverlayListVisible = false; - private overlayButtonElement: HTMLElement; - private overlayListElement: HTMLElement; private mostRecentlyFocusedField: ElementWithOpId; private focusedFieldData: FocusedFieldData; - private userInteractionEventTimeout: number | NodeJS.Timeout; - private overlayElementsMutationObserver: MutationObserver; - private bodyElementMutationObserver: MutationObserver; - private documentElementMutationObserver: MutationObserver; - private mutationObserverIterations = 0; - private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout; - private autofillFieldKeywordsMap: WeakMap = new WeakMap(); + private closeInlineMenuOnRedirectTimeout: number | NodeJS.Timeout; + private focusInlineMenuListTimeout: number | NodeJS.Timeout; private eventHandlersMemo: { [key: string]: EventListener } = {}; - private readonly customElementDefaultStyles: Partial = { - all: "initial", - position: "fixed", - display: "block", - zIndex: "2147483647", + private readonly extensionMessageHandlers: AutofillOverlayContentExtensionMessageHandlers = { + openAutofillInlineMenu: ({ message }) => this.openInlineMenu(message), + addNewVaultItemFromOverlay: () => this.addNewVaultItem(), + blurMostRecentlyFocusedField: () => this.blurMostRecentlyFocusedField(), + unsetMostRecentlyFocusedField: () => this.unsetMostRecentlyFocusedField(), + checkIsMostRecentlyFocusedFieldWithinViewport: () => + this.checkIsMostRecentlyFocusedFieldWithinViewport(), + bgUnlockPopoutOpened: () => this.blurMostRecentlyFocusedField(true), + bgVaultItemRepromptPopoutOpened: () => this.blurMostRecentlyFocusedField(true), + redirectAutofillInlineMenuFocusOut: ({ message }) => + this.redirectInlineMenuFocusOut(message?.data?.direction), + updateAutofillInlineMenuVisibility: ({ message }) => this.updateInlineMenuVisibility(message), + getSubFrameOffsets: ({ message }) => this.getSubFrameOffsets(message), + getSubFrameOffsetsFromWindowMessage: ({ message }) => + this.getSubFrameOffsetsFromWindowMessage(message), + checkMostRecentlyFocusedFieldHasValue: () => this.mostRecentlyFocusedFieldHasValue(), + setupRebuildSubFrameOffsetsListeners: () => this.setupRebuildSubFrameOffsetsListeners(), + destroyAutofillInlineMenuListeners: () => this.destroy(), }; - constructor() { - this.inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); - } + constructor(private inlineMenuFieldQualificationService: InlineMenuFieldQualificationService) {} /** * Initializes the autofill overlay content service by setting up the mutation observers. @@ -83,14 +91,22 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte } /** - * Sets up the autofill overlay listener on the form field element. This method is called + * Getter used to access the extension message handlers associated + * with the autofill overlay content service. + */ + get messageHandlers(): AutofillOverlayContentExtensionMessageHandlers { + return this.extensionMessageHandlers; + } + + /** + * Sets up the autofill inline menu listener on the form field element. This method is called * during the page details collection process. * * @param formFieldElement - Form field elements identified during the page details collection process. * @param autofillFieldData - Autofill field data captured from the form field element. * @param pageDetails - The collected page details from the tab. */ - async setupAutofillOverlayListenerOnField( + async setupInlineMenu( formFieldElement: ElementWithOpId, autofillFieldData: AutofillField, pageDetails: AutofillPageDetails, @@ -102,49 +118,36 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte return; } - this.formFieldElements.add(formFieldElement); - - if (!this.autofillOverlayVisibility) { - await this.getAutofillOverlayVisibility(); - } - - this.setupFormFieldElementEventListeners(formFieldElement); - - if (this.getRootNodeActiveElement(formFieldElement) === formFieldElement) { - await this.triggerFormFieldFocusedAction(formFieldElement); + if (this.isHiddenField(formFieldElement, autofillFieldData)) { return; } - if (!this.mostRecentlyFocusedField) { - await this.updateMostRecentlyFocusedField(formFieldElement); - } + await this.setupInlineMenuOnQualifiedField(formFieldElement); } /** - * Handles opening the autofill overlay. Will conditionally open - * the overlay based on the current autofill overlay visibility setting. - * Allows you to optionally focus the field element when opening the overlay. - * Will also optionally ignore the overlay visibility setting and open the + * Handles opening the autofill inline menu. Will conditionally open + * the inline menu based on the current inline menu visibility setting. + * Allows you to optionally focus the field element when opening the inline menu. + * Will also optionally ignore the inline menu visibility setting and open the * - * @param options - Options for opening the autofill overlay. + * @param options - Options for opening the autofill inline menu. */ - openAutofillOverlay(options: OpenAutofillOverlayOptions = {}) { - const { isFocusingFieldElement, isOpeningFullOverlay, authStatus } = options; + openInlineMenu(options: OpenAutofillInlineMenuOptions = {}) { + const { isFocusingFieldElement, isOpeningFullInlineMenu, authStatus } = options; if (!this.mostRecentlyFocusedField) { return; } if (this.pageDetailsUpdateRequired) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendExtensionMessage("bgCollectPageDetails", { + void this.sendExtensionMessage("bgCollectPageDetails", { sender: "autofillOverlayContentService", }); this.pageDetailsUpdateRequired = false; } if (isFocusingFieldElement && !this.recentlyFocusedFieldIsCurrentlyFocused()) { - this.focusMostRecentOverlayField(); + this.focusMostRecentlyFocusedField(); } if (typeof authStatus !== "undefined") { @@ -152,79 +155,47 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte } if ( - this.autofillOverlayVisibility === AutofillOverlayVisibility.OnButtonClick && - !isOpeningFullOverlay + this.inlineMenuVisibility === AutofillOverlayVisibility.OnButtonClick && + !isOpeningFullInlineMenu ) { - this.updateOverlayButtonPosition(); + this.updateInlineMenuButtonPosition(); return; } - this.updateOverlayElementsPosition(); + this.updateInlineMenuElementsPosition(); } /** * Focuses the most recently focused field element. */ - focusMostRecentOverlayField() { + focusMostRecentlyFocusedField() { this.mostRecentlyFocusedField?.focus(); } /** * Removes focus from the most recently focused field element. */ - blurMostRecentOverlayField() { + blurMostRecentlyFocusedField(isClosingInlineMenu: boolean = false) { this.mostRecentlyFocusedField?.blur(); + + if (isClosingInlineMenu) { + void this.sendExtensionMessage("closeAutofillInlineMenu"); + } } /** - * Removes the autofill overlay from the page. This will initially - * unobserve the body element to ensure the mutation observer no - * longer triggers. + * Sets the most recently focused field within the current frame to a `null` value. */ - removeAutofillOverlay = () => { - this.removeBodyElementObserver(); - this.removeAutofillOverlayButton(); - this.removeAutofillOverlayList(); - }; - - /** - * Removes the overlay button from the DOM if it is currently present. Will - * also remove the overlay reposition event listeners. - */ - removeAutofillOverlayButton() { - if (!this.overlayButtonElement) { - return; - } - - this.overlayButtonElement.remove(); - this.isOverlayButtonVisible = false; - void this.sendExtensionMessage("autofillOverlayElementClosed", { - overlayElement: AutofillOverlayElement.Button, - }); - this.removeOverlayRepositionEventListeners(); - } - - /** - * Removes the overlay list from the DOM if it is currently present. - */ - removeAutofillOverlayList() { - if (!this.overlayListElement) { - return; - } - - this.overlayListElement.remove(); - this.isOverlayListVisible = false; - void this.sendExtensionMessage("autofillOverlayElementClosed", { - overlayElement: AutofillOverlayElement.List, - }); + unsetMostRecentlyFocusedField() { + this.mostRecentlyFocusedField = null; } /** * Formats any found user filled fields for a login cipher and sends a message * to the background script to add a new cipher. */ - addNewVaultItem() { - if (!this.isOverlayListVisible) { + async addNewVaultItem() { + if (!(await this.isInlineMenuListVisible())) { return; } @@ -235,26 +206,27 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte hostname: globalThis.document.location.hostname, }; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendExtensionMessage("autofillOverlayAddNewVaultItem", { login }); + void this.sendExtensionMessage("autofillOverlayAddNewVaultItem", { login }); } /** - * Redirects the keyboard focus out of the overlay, selecting the element that is + * Redirects the keyboard focus out of the inline menu, selecting the element that is * either previous or next in the tab order. If the direction is current, the most * recently focused field will be focused. * - * @param direction - The direction to redirect the focus. + * @param direction - The direction to redirect the focus out. */ - redirectOverlayFocusOut(direction: string) { - if (!this.isOverlayListVisible || !this.mostRecentlyFocusedField) { + private async redirectInlineMenuFocusOut(direction?: string) { + if (!direction || !this.mostRecentlyFocusedField || !(await this.isInlineMenuListVisible())) { return; } if (direction === RedirectFocusDirection.Current) { - this.focusMostRecentOverlayField(); - setTimeout(this.removeAutofillOverlay, 100); + this.focusMostRecentlyFocusedField(); + this.closeInlineMenuOnRedirectTimeout = globalThis.setTimeout( + () => void this.sendExtensionMessage("closeAutofillInlineMenu"), + 100, + ); return; } @@ -274,7 +246,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte /** * Sets up the event listeners that facilitate interaction with the form field elements. * Will clear any cached form field element handlers that are encountered when setting - * up a form field element to the overlay. + * up a form field element. * * @param formFieldElement - The form field element to set up the event listeners for. */ @@ -299,7 +271,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte /** * Removes any cached form field element handlers that are encountered - * when setting up a form field element to present the overlay. + * when setting up a form field element to present the inline menu. * * @param formFieldElement - The form field element to remove the cached handlers for. */ @@ -343,33 +315,35 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte /** * Form Field blur event handler. Updates the value identifying whether - * the field is focused and sends a message to check if the overlay itself + * the field is focused and sends a message to check if the inline menu itself * is currently focused. */ private handleFormFieldBlurEvent = () => { - this.isFieldCurrentlyFocused = false; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendExtensionMessage("checkAutofillOverlayFocused"); + void this.sendExtensionMessage("updateIsFieldCurrentlyFocused", { + isFieldCurrentlyFocused: false, + }); + void this.sendExtensionMessage("checkAutofillInlineMenuFocused"); }; /** * Form field keyup event handler. Facilitates the ability to remove the - * autofill overlay using the escape key, focusing the overlay list using - * the ArrowDown key, and ensuring that the overlay is repositioned when + * autofill inline menu using the escape key, focusing the inline menu list using + * the ArrowDown key, and ensuring that the inline menu is repositioned when * the form is submitted using the Enter key. * * @param event - The keyup event. */ - private handleFormFieldKeyupEvent = (event: KeyboardEvent) => { + private handleFormFieldKeyupEvent = async (event: KeyboardEvent) => { const eventCode = event.code; if (eventCode === "Escape") { - this.removeAutofillOverlay(); + void this.sendExtensionMessage("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); return; } - if (eventCode === "Enter" && !this.isCurrentlyFilling) { - this.handleOverlayRepositionEvent(); + if (eventCode === "Enter" && !(await this.isFieldCurrentlyFilling())) { + void this.handleOverlayRepositionEvent(); return; } @@ -377,28 +351,28 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte event.preventDefault(); event.stopPropagation(); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.focusOverlayList(); + void this.focusInlineMenuList(); } }; /** - * Triggers a focus of the overlay list, if it is visible. If the list is not visible, - * the overlay will be opened and the list will be focused after a short delay. Ensures - * that the overlay list is focused when the user presses the down arrow key. + * Triggers a focus of the inline menu list, if it is visible. If the list is not visible, + * the inline menu will be opened and the list will be focused after a short delay. Ensures + * that the inline menu list is focused when the user presses the down arrow key. */ - private async focusOverlayList() { - if (!this.isOverlayListVisible && this.mostRecentlyFocusedField) { + private async focusInlineMenuList() { + if (this.mostRecentlyFocusedField && !(await this.isInlineMenuListVisible())) { + this.clearFocusInlineMenuListTimeout(); await this.updateMostRecentlyFocusedField(this.mostRecentlyFocusedField); - this.openAutofillOverlay({ isOpeningFullOverlay: true }); - setTimeout(() => this.sendExtensionMessage("focusAutofillOverlayList"), 125); + this.openInlineMenu({ isOpeningFullInlineMenu: true }); + this.focusInlineMenuListTimeout = globalThis.setTimeout( + () => this.sendExtensionMessage("focusAutofillInlineMenuList"), + 125, + ); return; } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendExtensionMessage("focusAutofillOverlayList"); + void this.sendExtensionMessage("focusAutofillInlineMenuList"); } /** @@ -416,23 +390,26 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte /** * Triggers when the form field element receives an input event. This method will * store the modified form element data for use when the user attempts to add a new - * vault item. It also acts to remove the overlay list while the user is typing. + * vault item. It also acts to remove the inline menu list while the user is typing. * * @param formFieldElement - The form field element that triggered the input event. */ - private triggerFormFieldInput(formFieldElement: ElementWithOpId) { + private async triggerFormFieldInput(formFieldElement: ElementWithOpId) { if (!elementIsFillableFormField(formFieldElement)) { return; } this.storeModifiedFormElement(formFieldElement); - if (formFieldElement.value && (this.isOverlayCiphersPopulated || !this.isUserAuthed())) { - this.removeAutofillOverlayList(); + if (await this.hideInlineMenuListOnFilledField(formFieldElement)) { + void this.sendExtensionMessage("closeAutofillInlineMenu", { + overlayElement: AutofillOverlayElement.List, + forceCloseInlineMenu: true, + }); return; } - this.openAutofillOverlay(); + this.openInlineMenu(); } /** @@ -444,8 +421,8 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte * @private */ private storeModifiedFormElement(formFieldElement: ElementWithOpId) { - if (formFieldElement === this.mostRecentlyFocusedField) { - this.mostRecentlyFocusedField = formFieldElement; + if (formFieldElement !== this.mostRecentlyFocusedField) { + void this.updateMostRecentlyFocusedField(formFieldElement); } if (formFieldElement.type === "password") { @@ -470,12 +447,12 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte /** * Triggers when the form field element receives a click event. This method will - * trigger the focused action for the form field element if the overlay is not visible. + * trigger the focused action for the form field element if the inline menu is not visible. * * @param formFieldElement - The form field element that triggered the click event. */ private async triggerFormFieldClickedAction(formFieldElement: ElementWithOpId) { - if (this.isOverlayButtonVisible || this.isOverlayListVisible) { + if ((await this.isInlineMenuButtonVisible()) || (await this.isInlineMenuListVisible())) { return; } @@ -496,37 +473,39 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte /** * Triggers when the form field element receives a focus event. This method will - * update the most recently focused field and open the autofill overlay if the + * update the most recently focused field and open the autofill inline menu if the * autofill process is not currently active. * * @param formFieldElement - The form field element that triggered the focus event. */ private async triggerFormFieldFocusedAction(formFieldElement: ElementWithOpId) { - if (this.isCurrentlyFilling) { + if (await this.isFieldCurrentlyFilling()) { return; } - this.isFieldCurrentlyFocused = true; - this.clearUserInteractionEventTimeout(); + await this.sendExtensionMessage("updateIsFieldCurrentlyFocused", { + isFieldCurrentlyFocused: true, + }); const initiallyFocusedField = this.mostRecentlyFocusedField; await this.updateMostRecentlyFocusedField(formFieldElement); - const formElementHasValue = Boolean((formFieldElement as HTMLInputElement).value); if ( - this.autofillOverlayVisibility === AutofillOverlayVisibility.OnButtonClick || - (formElementHasValue && initiallyFocusedField !== this.mostRecentlyFocusedField) + this.inlineMenuVisibility === AutofillOverlayVisibility.OnButtonClick || + (initiallyFocusedField !== this.mostRecentlyFocusedField && + (await this.hideInlineMenuListOnFilledField(formFieldElement as FillableFormFieldElement))) ) { - this.removeAutofillOverlayList(); + await this.sendExtensionMessage("closeAutofillInlineMenu", { + overlayElement: AutofillOverlayElement.List, + forceCloseInlineMenu: true, + }); } - if (!formElementHasValue || (!this.isOverlayCiphersPopulated && this.isUserAuthed())) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendExtensionMessage("openAutofillOverlay"); + if (await this.hideInlineMenuListOnFilledField(formFieldElement as FillableFormFieldElement)) { + this.updateInlineMenuButtonPosition(); return; } - this.updateOverlayButtonPosition(); + void this.sendExtensionMessage("openAutofillInlineMenu"); } /** @@ -547,82 +526,33 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte } /** - * Updates the position of both the overlay button and overlay list. + * Updates the position of both the inline menu button and list. */ - private updateOverlayElementsPosition() { - this.updateOverlayButtonPosition(); - this.updateOverlayListPosition(); + private updateInlineMenuElementsPosition() { + this.updateInlineMenuButtonPosition(); + this.updateInlineMenuListPosition(); } /** - * Updates the position of the overlay button. + * Updates the position of the inline menu button. */ - private updateOverlayButtonPosition() { - if (!this.overlayButtonElement) { - this.createAutofillOverlayButton(); - this.updateCustomElementDefaultStyles(this.overlayButtonElement); - } - - if (!this.isOverlayButtonVisible) { - this.appendOverlayElementToBody(this.overlayButtonElement); - this.isOverlayButtonVisible = true; - this.setOverlayRepositionEventListeners(); - } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendExtensionMessage("updateAutofillOverlayPosition", { + private updateInlineMenuButtonPosition() { + void this.sendExtensionMessage("updateAutofillInlineMenuPosition", { overlayElement: AutofillOverlayElement.Button, }); } /** - * Updates the position of the overlay list. + * Updates the position of the inline menu list. */ - private updateOverlayListPosition() { - if (!this.overlayListElement) { - this.createAutofillOverlayList(); - this.updateCustomElementDefaultStyles(this.overlayListElement); - } - - if (!this.isOverlayListVisible) { - this.appendOverlayElementToBody(this.overlayListElement); - this.isOverlayListVisible = true; - } - - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendExtensionMessage("updateAutofillOverlayPosition", { + private updateInlineMenuListPosition() { + void this.sendExtensionMessage("updateAutofillInlineMenuPosition", { overlayElement: AutofillOverlayElement.List, }); } /** - * Appends the overlay element to the body element. This method will also - * observe the body element to ensure that the overlay element is not - * interfered with by any DOM changes. - * - * @param element - The overlay element to append to the body element. - */ - private appendOverlayElementToBody(element: HTMLElement) { - this.observeBodyElement(); - globalThis.document.body.appendChild(element); - } - - /** - * Sends a message that facilitates hiding the overlay elements. - * - * @param isHidden - Indicates if the overlay elements should be hidden. - */ - private toggleOverlayHidden(isHidden: boolean) { - const displayValue = isHidden ? "none" : "block"; - void this.sendExtensionMessage("updateAutofillOverlayHidden", { display: displayValue }); - - this.isOverlayButtonVisible = !!this.overlayButtonElement && !isHidden; - this.isOverlayListVisible = !!this.overlayListElement && !isHidden; - } - - /** - * Updates the data used to position the overlay elements in relation + * Updates the data used to position the inline menu elements in relation * to the most recently focused form field. * * @param formFieldElement - The form field element that triggered the focus event. @@ -630,6 +560,10 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte private async updateMostRecentlyFocusedField( formFieldElement: ElementWithOpId, ) { + if (!formFieldElement || !elementIsFillableFormField(formFieldElement)) { + return; + } + this.mostRecentlyFocusedField = formFieldElement; const { paddingRight, paddingLeft } = globalThis.getComputedStyle(formFieldElement); const { width, height, top, left } = @@ -639,9 +573,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte focusedFieldRects: { width, height, top, left }, }; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendExtensionMessage("updateFocusedFieldData", { + await this.sendExtensionMessage("updateFocusedFieldData", { focusedFieldData: this.focusedFieldData, }); } @@ -701,7 +633,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte } /** - * Identifies if the field should have the autofill overlay setup on it. Currently, this is mainly + * Identifies if the field should have the autofill inline menu setup on it. Currently, this is mainly * determined by whether the field correlates with a login cipher. This method will need to be * updated in the future to support other types of forms. * @@ -712,12 +644,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte autofillFieldData: AutofillField, pageDetails: AutofillPageDetails, ): boolean { - if ( - autofillFieldData.readonly || - autofillFieldData.disabled || - !autofillFieldData.viewable || - this.ignoredFieldTypes.has(autofillFieldData.type) - ) { + if (this.ignoredFieldTypes.has(autofillFieldData.type)) { return true; } @@ -728,354 +655,167 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte } /** - * Creates the autofill overlay button element. Will not attempt - * to create the element if it already exists in the DOM. - */ - private createAutofillOverlayButton() { - if (this.overlayButtonElement) { - return; - } - - if (this.isFirefoxBrowser) { - this.overlayButtonElement = globalThis.document.createElement("div"); - new AutofillOverlayButtonIframe(this.overlayButtonElement); - - return; - } - - const customElementName = this.generateRandomCustomElementName(); - globalThis.customElements?.define( - customElementName, - class extends HTMLElement { - constructor() { - super(); - new AutofillOverlayButtonIframe(this); - } - }, - ); - this.overlayButtonElement = globalThis.document.createElement(customElementName); - } - - /** - * Creates the autofill overlay list element. Will not attempt - * to create the element if it already exists in the DOM. - */ - private createAutofillOverlayList() { - if (this.overlayListElement) { - return; - } - - if (this.isFirefoxBrowser) { - this.overlayListElement = globalThis.document.createElement("div"); - new AutofillOverlayListIframe(this.overlayListElement); - - return; - } - - const customElementName = this.generateRandomCustomElementName(); - globalThis.customElements?.define( - customElementName, - class extends HTMLElement { - constructor() { - super(); - new AutofillOverlayListIframe(this); - } - }, - ); - this.overlayListElement = globalThis.document.createElement(customElementName); - } - - /** - * Updates the default styles for the custom element. This method will - * remove any styles that are added to the custom element by other methods. + * Validates whether a field is considered to be "hidden" based on the field's attributes. + * If the field is hidden, a fallback listener will be set up to ensure that the + * field will have the inline menu set up on it when it becomes visible. * - * @param element - The custom element to update the default styles for. + * @param formFieldElement - The form field element that triggered the focus event. + * @param autofillFieldData - Autofill field data captured from the form field element. */ - private updateCustomElementDefaultStyles(element: HTMLElement) { - this.unobserveCustomElements(); + private isHiddenField( + formFieldElement: ElementWithOpId, + autofillFieldData: AutofillField, + ): boolean { + if (!autofillFieldData.readonly && !autofillFieldData.disabled && autofillFieldData.viewable) { + this.removeHiddenFieldFallbackListener(formFieldElement); + return false; + } - setElementStyles(element, this.customElementDefaultStyles, true); - - this.observeCustomElements(); + this.setupHiddenFieldFallbackListener(formFieldElement, autofillFieldData); + return true; } /** - * Queries the background script for the autofill overlay visibility setting. + * Sets up a fallback listener that will facilitate setting up the + * inline menu on the field when it becomes visible and focused. + * + * @param formFieldElement - The form field element that triggered the focus event. + * @param autofillFieldData - Autofill field data captured from the form field element. + */ + private setupHiddenFieldFallbackListener( + formFieldElement: ElementWithOpId, + autofillFieldData: AutofillField, + ) { + this.hiddenFormFieldElements.set(formFieldElement, autofillFieldData); + formFieldElement.addEventListener(EVENTS.FOCUS, this.handleHiddenFieldFocusEvent); + } + + /** + * Removes the fallback listener that facilitates setting up the inline + * menu on the field when it becomes visible and focused. + * + * @param formFieldElement - The form field element that triggered the focus event. + */ + private removeHiddenFieldFallbackListener(formFieldElement: ElementWithOpId) { + formFieldElement.removeEventListener(EVENTS.FOCUS, this.handleHiddenFieldFocusEvent); + this.hiddenFormFieldElements.delete(formFieldElement); + } + + /** + * Handles the focus event on a hidden field. When + * triggered, the inline menu is set up on the field. + * + * @param event - The focus event. + */ + private handleHiddenFieldFocusEvent = (event: FocusEvent) => { + const formFieldElement = event.target as ElementWithOpId; + const autofillFieldData = this.hiddenFormFieldElements.get(formFieldElement); + if (autofillFieldData) { + autofillFieldData.readonly = getAttributeBoolean(formFieldElement, "disabled"); + autofillFieldData.disabled = getAttributeBoolean(formFieldElement, "disabled"); + autofillFieldData.viewable = true; + void this.setupInlineMenuOnQualifiedField(formFieldElement); + } + + this.removeHiddenFieldFallbackListener(formFieldElement); + }; + + /** + * Sets up the inline menu on a qualified form field element. + * + * @param formFieldElement - The form field element to set up the inline menu on. + */ + private async setupInlineMenuOnQualifiedField( + formFieldElement: ElementWithOpId, + ) { + this.formFieldElements.add(formFieldElement); + + if (!this.mostRecentlyFocusedField) { + await this.updateMostRecentlyFocusedField(formFieldElement); + } + + if (!this.inlineMenuVisibility) { + await this.getInlineMenuVisibility(); + } + + this.setupFormFieldElementEventListeners(formFieldElement); + + if ( + globalThis.document.hasFocus() && + this.getRootNodeActiveElement(formFieldElement) === formFieldElement + ) { + await this.triggerFormFieldFocusedAction(formFieldElement); + } + } + + /** + * Queries the background script for the autofill inline menu visibility setting. * If the setting is not found, a default value of OnFieldFocus will be used * @private */ - private async getAutofillOverlayVisibility() { - const overlayVisibility = await this.sendExtensionMessage("getAutofillOverlayVisibility"); - this.autofillOverlayVisibility = overlayVisibility || AutofillOverlayVisibility.OnFieldFocus; + private async getInlineMenuVisibility() { + const inlineMenuVisibility = await this.sendExtensionMessage("getAutofillInlineMenuVisibility"); + this.inlineMenuVisibility = inlineMenuVisibility || AutofillOverlayVisibility.OnFieldFocus; } /** - * Sets up event listeners that facilitate repositioning - * the autofill overlay on scroll or resize. - */ - private setOverlayRepositionEventListeners() { - globalThis.addEventListener(EVENTS.SCROLL, this.handleOverlayRepositionEvent, { - capture: true, - }); - globalThis.addEventListener(EVENTS.RESIZE, this.handleOverlayRepositionEvent); - } - - /** - * Removes the listeners that facilitate repositioning - * the autofill overlay on scroll or resize. - */ - private removeOverlayRepositionEventListeners() { - globalThis.removeEventListener(EVENTS.SCROLL, this.handleOverlayRepositionEvent, { - capture: true, - }); - globalThis.removeEventListener(EVENTS.RESIZE, this.handleOverlayRepositionEvent); - } - - /** - * Handles the resize or scroll events that enact - * repositioning of the overlay. - */ - private handleOverlayRepositionEvent = () => { - if (!this.isOverlayButtonVisible && !this.isOverlayListVisible) { - return; - } - - this.toggleOverlayHidden(true); - this.clearUserInteractionEventTimeout(); - this.userInteractionEventTimeout = setTimeout( - this.triggerOverlayRepositionUpdates, - 750, - ) as unknown as number; - }; - - /** - * Triggers the overlay reposition updates. This method ensures that the overlay elements - * are correctly positioned when the viewport scrolls or repositions. - */ - private triggerOverlayRepositionUpdates = async () => { - if (!this.recentlyFocusedFieldIsCurrentlyFocused()) { - this.toggleOverlayHidden(false); - this.removeAutofillOverlay(); - return; - } - - await this.updateMostRecentlyFocusedField(this.mostRecentlyFocusedField); - this.updateOverlayElementsPosition(); - this.toggleOverlayHidden(false); - this.clearUserInteractionEventTimeout(); - - if ( - this.focusedFieldData.focusedFieldRects?.top > 0 && - this.focusedFieldData.focusedFieldRects?.top < globalThis.innerHeight - ) { - return; - } - - this.removeAutofillOverlay(); - }; - - /** - * Clears the user interaction event timeout. This is used to ensure that - * the overlay is not repositioned while the user is interacting with it. - */ - private clearUserInteractionEventTimeout() { - if (this.userInteractionEventTimeout) { - clearTimeout(this.userInteractionEventTimeout); - } - } - - /** - * Sets up global event listeners and the mutation - * observer to facilitate required changes to the - * overlay elements. - */ - private setupGlobalEventListeners = () => { - globalThis.document.addEventListener(EVENTS.VISIBILITYCHANGE, this.handleVisibilityChangeEvent); - globalThis.addEventListener(EVENTS.FOCUSOUT, this.handleFormFieldBlurEvent); - this.setupMutationObserver(); - }; - - /** - * Handles the visibility change event. This method will remove the - * autofill overlay if the document is not visible. - */ - private handleVisibilityChangeEvent = () => { - if (document.visibilityState === "visible") { - return; - } - - this.mostRecentlyFocusedField = null; - this.removeAutofillOverlay(); - }; - - /** - * Sets up mutation observers for the overlay elements, the body element, and the - * document element. The mutation observers are used to remove any styles that are - * added to the overlay elements by the website. They are also used to ensure that - * the overlay elements are always present at the bottom of the body element. - */ - private setupMutationObserver = () => { - this.overlayElementsMutationObserver = new MutationObserver( - this.handleOverlayElementMutationObserverUpdate, - ); - - this.bodyElementMutationObserver = new MutationObserver( - this.handleBodyElementMutationObserverUpdate, - ); - }; - - /** - * Sets up mutation observers to verify that the overlay - * elements are not modified by the website. - */ - private observeCustomElements() { - if (this.overlayButtonElement) { - this.overlayElementsMutationObserver?.observe(this.overlayButtonElement, { - attributes: true, - }); - } - - if (this.overlayListElement) { - this.overlayElementsMutationObserver?.observe(this.overlayListElement, { attributes: true }); - } - } - - /** - * Disconnects the mutation observers that are used to verify that the overlay - * elements are not modified by the website. - */ - private unobserveCustomElements() { - this.overlayElementsMutationObserver?.disconnect(); - } - - /** - * Sets up a mutation observer for the body element. The mutation observer is used - * to ensure that the overlay elements are always present at the bottom of the body - * element. - */ - private observeBodyElement() { - this.bodyElementMutationObserver?.observe(globalThis.document.body, { childList: true }); - } - - /** - * Disconnects the mutation observer for the body element. - */ - private removeBodyElementObserver() { - this.bodyElementMutationObserver?.disconnect(); - } - - /** - * Handles the mutation observer update for the overlay elements. This method will - * remove any attributes or styles that might be added to the overlay elements by - * a separate process within the website where this script is injected. + * Returns a value that indicates if we should hide the inline menu list due to a filled field. * - * @param mutationRecord - The mutation record that triggered the update. + * @param formFieldElement - The form field element that triggered the focus event. */ - private handleOverlayElementMutationObserverUpdate = (mutationRecord: MutationRecord[]) => { - if (this.isTriggeringExcessiveMutationObserverIterations()) { - return; - } - - for (let recordIndex = 0; recordIndex < mutationRecord.length; recordIndex++) { - const record = mutationRecord[recordIndex]; - if (record.type !== "attributes") { - continue; - } - - const element = record.target as HTMLElement; - if (record.attributeName !== "style") { - this.removeModifiedElementAttributes(element); - - continue; - } - - element.removeAttribute("style"); - this.updateCustomElementDefaultStyles(element); - } - }; + private async hideInlineMenuListOnFilledField( + formFieldElement?: FillableFormFieldElement, + ): Promise { + return ( + formFieldElement?.value && + ((await this.isInlineMenuCiphersPopulated()) || !this.isUserAuthed()) + ); + } /** - * Removes all elements from a passed overlay - * element except for the style attribute. - * - * @param element - The element to remove the attributes from. + * Indicates whether the most recently focused field has a value. */ - private removeModifiedElementAttributes(element: HTMLElement) { - const attributes = Array.from(element.attributes); - for (let attributeIndex = 0; attributeIndex < attributes.length; attributeIndex++) { - const attribute = attributes[attributeIndex]; - if (attribute.name === "style") { - continue; - } + private mostRecentlyFocusedFieldHasValue() { + return Boolean((this.mostRecentlyFocusedField as FillableFormFieldElement)?.value); + } - element.removeAttribute(attribute.name); + /** + * Updates the local reference to the inline menu visibility setting. + * + * @param data - The data object from the extension message. + */ + private updateInlineMenuVisibility({ data }: AutofillExtensionMessage) { + if (!isNaN(data?.inlineMenuVisibility)) { + this.inlineMenuVisibility = data.inlineMenuVisibility; } } /** - * Handles the mutation observer update for the body element. This method will - * ensure that the overlay elements are always present at the bottom of the body - * element. + * Checks if a field is currently filling within an frame in the tab. */ - private handleBodyElementMutationObserverUpdate = () => { - if ( - (!this.overlayButtonElement && !this.overlayListElement) || - this.isTriggeringExcessiveMutationObserverIterations() - ) { - return; - } - - const lastChild = globalThis.document.body.lastElementChild; - const secondToLastChild = lastChild?.previousElementSibling; - const lastChildIsOverlayList = lastChild === this.overlayListElement; - const lastChildIsOverlayButton = lastChild === this.overlayButtonElement; - const secondToLastChildIsOverlayButton = secondToLastChild === this.overlayButtonElement; - - if ( - (lastChildIsOverlayList && secondToLastChildIsOverlayButton) || - (lastChildIsOverlayButton && !this.isOverlayListVisible) - ) { - return; - } - - if ( - (lastChildIsOverlayList && !secondToLastChildIsOverlayButton) || - (lastChildIsOverlayButton && this.isOverlayListVisible) - ) { - globalThis.document.body.insertBefore(this.overlayButtonElement, this.overlayListElement); - return; - } - - globalThis.document.body.insertBefore(lastChild, this.overlayButtonElement); - }; + private async isFieldCurrentlyFilling() { + return (await this.sendExtensionMessage("checkIsFieldCurrentlyFilling")) === true; + } /** - * Identifies if the mutation observer is triggering excessive iterations. - * Will trigger a blur of the most recently focused field and remove the - * autofill overlay if any set mutation observer is triggering - * excessive iterations. + * Checks if the inline menu button is visible at the top frame. */ - private isTriggeringExcessiveMutationObserverIterations() { - if (this.mutationObserverIterationsResetTimeout) { - clearTimeout(this.mutationObserverIterationsResetTimeout); - } + private async isInlineMenuButtonVisible() { + return (await this.sendExtensionMessage("checkIsAutofillInlineMenuButtonVisible")) === true; + } - this.mutationObserverIterations++; - this.mutationObserverIterationsResetTimeout = setTimeout( - () => (this.mutationObserverIterations = 0), - 2000, - ); + /** + * Checks if the inline menu list if visible at the top frame. + */ + private async isInlineMenuListVisible() { + return (await this.sendExtensionMessage("checkIsAutofillInlineMenuListVisible")) === true; + } - if (this.mutationObserverIterations > 100) { - clearTimeout(this.mutationObserverIterationsResetTimeout); - this.mutationObserverIterations = 0; - this.blurMostRecentOverlayField(); - this.removeAutofillOverlay(); - - return true; - } - - return false; + /** + * Checks if the current tab contains ciphers that can be used to populate the inline menu. + */ + private async isInlineMenuCiphersPopulated() { + return (await this.sendExtensionMessage("checkIsInlineMenuCiphersPopulated")) === true; } /** @@ -1084,31 +824,394 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte * @param element - The element to get the root node active element for. */ private getRootNodeActiveElement(element: Element): Element { + if (!element) { + return null; + } + const documentRoot = element.getRootNode() as ShadowRoot | Document; return documentRoot?.activeElement; } + /** + * Queries all iframe elements within the document and returns the + * sub frame offsets for each iframe element. + * + * @param message - The message object from the extension. + */ + private async getSubFrameOffsets( + message: AutofillExtensionMessage, + ): Promise { + const { subFrameUrl } = message; + + const subFrameUrlVariations = this.getSubFrameUrlVariations(subFrameUrl); + if (!subFrameUrlVariations) { + return null; + } + + let iframeElement: HTMLIFrameElement | null = null; + const iframeElements = globalThis.document.getElementsByTagName("iframe"); + + for (let iframeIndex = 0; iframeIndex < iframeElements.length; iframeIndex++) { + const iframe = iframeElements[iframeIndex]; + if (!subFrameUrlVariations.has(iframe.src)) { + continue; + } + + if (iframeElement) { + return null; + } + + iframeElement = iframe; + } + + if (!iframeElement) { + return null; + } + + return this.calculateSubFrameOffsets(iframeElement, subFrameUrl); + } + + /** + * Returns a set of all possible URL variations for the sub frame URL. + * + * @param subFrameUrl - The URL of the sub frame. + */ + private getSubFrameUrlVariations(subFrameUrl: string) { + try { + const url = new URL(subFrameUrl, globalThis.location.href); + const pathAndHash = url.pathname + url.hash; + const pathAndSearch = url.pathname + url.search; + const pathSearchAndHash = pathAndSearch + url.hash; + const pathNameWithoutTrailingSlash = url.pathname.replace(/\/$/, ""); + const pathWithoutTrailingSlashAndHash = pathNameWithoutTrailingSlash + url.hash; + const pathWithoutTrailingSlashAndSearch = pathNameWithoutTrailingSlash + url.search; + const pathWithoutTrailingSlashSearchAndHash = pathWithoutTrailingSlashAndSearch + url.hash; + + return new Set([ + url.href, + url.href.replace(/\/$/, ""), + url.pathname, + pathAndHash, + pathAndSearch, + pathSearchAndHash, + pathNameWithoutTrailingSlash, + pathWithoutTrailingSlashAndHash, + pathWithoutTrailingSlashAndSearch, + pathWithoutTrailingSlashSearchAndHash, + url.hostname + url.pathname, + url.hostname + pathAndHash, + url.hostname + pathAndSearch, + url.hostname + pathSearchAndHash, + url.hostname + pathNameWithoutTrailingSlash, + url.hostname + pathWithoutTrailingSlashAndHash, + url.hostname + pathWithoutTrailingSlashAndSearch, + url.hostname + pathWithoutTrailingSlashSearchAndHash, + url.origin + url.pathname, + url.origin + pathAndHash, + url.origin + pathAndSearch, + url.origin + pathSearchAndHash, + url.origin + pathNameWithoutTrailingSlash, + url.origin + pathWithoutTrailingSlashAndHash, + url.origin + pathWithoutTrailingSlashAndSearch, + url.origin + pathWithoutTrailingSlashSearchAndHash, + ]); + } catch (_error) { + return null; + } + } + + /** + * Posts a message to the parent frame to calculate the sub frame offset of the current frame. + * + * @param message - The message object from the extension. + */ + private getSubFrameOffsetsFromWindowMessage(message: any) { + globalThis.parent.postMessage( + { + command: "calculateSubFramePositioning", + subFrameData: { + url: window.location.href, + frameId: message.subFrameId, + left: 0, + top: 0, + parentFrameIds: [0], + subFrameDepth: 0, + } as SubFrameDataFromWindowMessage, + }, + "*", + ); + } + + /** + * Calculates the bounding rect for the queried frame and returns the + * offset data for the sub frame. + * + * @param iframeElement - The iframe element to calculate the sub frame offsets for. + * @param subFrameUrl - The URL of the sub frame. + * @param frameId - The frame ID of the sub frame. + */ + private calculateSubFrameOffsets( + iframeElement: HTMLIFrameElement, + subFrameUrl?: string, + frameId?: number, + ): SubFrameOffsetData { + const iframeRect = iframeElement.getBoundingClientRect(); + const iframeStyles = globalThis.getComputedStyle(iframeElement); + const paddingLeft = parseInt(iframeStyles.getPropertyValue("padding-left")) || 0; + const paddingTop = parseInt(iframeStyles.getPropertyValue("padding-top")) || 0; + const borderWidthLeft = parseInt(iframeStyles.getPropertyValue("border-left-width")) || 0; + const borderWidthTop = parseInt(iframeStyles.getPropertyValue("border-top-width")) || 0; + + return { + url: subFrameUrl, + frameId, + top: iframeRect.top + paddingTop + borderWidthTop, + left: iframeRect.left + paddingLeft + borderWidthLeft, + }; + } + + /** + * Calculates the sub frame positioning for the current frame + * through all parent frames until the top frame is reached. + * + * @param event - The message event. + */ + private calculateSubFramePositioning = async (event: MessageEvent) => { + const subFrameData: SubFrameDataFromWindowMessage = event.data.subFrameData; + + subFrameData.subFrameDepth++; + if (subFrameData.subFrameDepth >= MAX_SUB_FRAME_DEPTH) { + void this.sendExtensionMessage("destroyAutofillInlineMenuListeners", { subFrameData }); + return; + } + + let subFrameOffsets: SubFrameOffsetData; + const iframes = globalThis.document.querySelectorAll("iframe"); + for (let i = 0; i < iframes.length; i++) { + if (iframes[i].contentWindow === event.source) { + const iframeElement = iframes[i]; + subFrameOffsets = this.calculateSubFrameOffsets( + iframeElement, + subFrameData.url, + subFrameData.frameId, + ); + + subFrameData.top += subFrameOffsets.top; + subFrameData.left += subFrameOffsets.left; + + const parentFrameId = await this.sendExtensionMessage("getCurrentTabFrameId"); + if (typeof parentFrameId !== "undefined") { + subFrameData.parentFrameIds.push(parentFrameId); + } + + break; + } + } + + if (globalThis.window.self !== globalThis.window.top) { + globalThis.parent.postMessage({ command: "calculateSubFramePositioning", subFrameData }, "*"); + return; + } + + void this.sendExtensionMessage("updateSubFrameData", { subFrameData }); + }; + + /** + * Sets up global event listeners and the mutation + * observer to facilitate required changes to the + * overlay elements. + */ + private setupGlobalEventListeners = () => { + globalThis.addEventListener(EVENTS.MESSAGE, this.handleWindowMessageEvent); + globalThis.document.addEventListener(EVENTS.VISIBILITYCHANGE, this.handleVisibilityChangeEvent); + globalThis.addEventListener(EVENTS.FOCUSOUT, this.handleFormFieldBlurEvent); + this.setOverlayRepositionEventListeners(); + }; + + /** + * Handles window messages that are sent to the current frame. Will trigger a + * calculation of the sub frame offsets through the parent frame. + * + * @param event - The message event. + */ + private handleWindowMessageEvent = (event: MessageEvent) => { + if (event.data?.command === "calculateSubFramePositioning") { + void this.calculateSubFramePositioning(event); + } + }; + + /** + * Handles the visibility change event. This method will remove the + * autofill overlay if the document is not visible. + */ + private handleVisibilityChangeEvent = () => { + if (!this.mostRecentlyFocusedField || globalThis.document.visibilityState === "visible") { + return; + } + + this.unsetMostRecentlyFocusedField(); + void this.sendExtensionMessage("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }; + + /** + * Sets up event listeners that facilitate repositioning + * the overlay elements on scroll or resize. + */ + private setOverlayRepositionEventListeners() { + const handler = this.useEventHandlersMemo( + throttle(this.handleOverlayRepositionEvent, 250), + AUTOFILL_OVERLAY_HANDLE_REPOSITION, + ); + globalThis.addEventListener(EVENTS.SCROLL, handler, { + capture: true, + passive: true, + }); + globalThis.addEventListener(EVENTS.RESIZE, handler); + } + + /** + * Removes the listeners that facilitate repositioning + * the overlay elements on scroll or resize. + */ + private removeOverlayRepositionEventListeners() { + const handler = this.eventHandlersMemo[AUTOFILL_OVERLAY_HANDLE_REPOSITION]; + globalThis.removeEventListener(EVENTS.SCROLL, handler, { + capture: true, + }); + globalThis.removeEventListener(EVENTS.RESIZE, handler); + + delete this.eventHandlersMemo[AUTOFILL_OVERLAY_HANDLE_REPOSITION]; + } + + /** + * Handles the resize or scroll events that enact + * repositioning of existing overlay elements. + */ + private handleOverlayRepositionEvent = async () => { + await this.sendExtensionMessage("triggerAutofillOverlayReposition"); + }; + + /** + * Sets up listeners that facilitate a rebuild of the sub frame offsets + * when a user interacts or focuses an element within the frame. + */ + private setupRebuildSubFrameOffsetsListeners = () => { + if (globalThis.window.top === globalThis.window || this.formFieldElements.size < 1) { + return; + } + this.removeSubFrameFocusOutListeners(); + + globalThis.addEventListener(EVENTS.FOCUS, this.handleSubFrameFocusInEvent); + globalThis.document.body.addEventListener(EVENTS.MOUSEENTER, this.handleSubFrameFocusInEvent); + }; + + /** + * Removes the listeners that facilitate a rebuild of the sub frame offsets. + */ + private removeRebuildSubFrameOffsetsListeners = () => { + globalThis.removeEventListener(EVENTS.FOCUS, this.handleSubFrameFocusInEvent); + globalThis.document.body.removeEventListener( + EVENTS.MOUSEENTER, + this.handleSubFrameFocusInEvent, + ); + }; + + /** + * Re-establishes listeners that handle the sub frame offsets rebuild of the frame + * based on user interaction with the sub frame. + */ + private setupSubFrameFocusOutListeners = () => { + globalThis.addEventListener(EVENTS.BLUR, this.setupRebuildSubFrameOffsetsListeners); + globalThis.document.body.addEventListener( + EVENTS.MOUSELEAVE, + this.setupRebuildSubFrameOffsetsListeners, + ); + }; + + /** + * Removes the listeners that trigger when a user focuses away from the sub frame. + */ + private removeSubFrameFocusOutListeners = () => { + globalThis.removeEventListener(EVENTS.BLUR, this.setupRebuildSubFrameOffsetsListeners); + globalThis.document.body.removeEventListener( + EVENTS.MOUSELEAVE, + this.setupRebuildSubFrameOffsetsListeners, + ); + }; + + /** + * Sends a message to the background script to trigger a rebuild of the sub frame + * offsets. Will deregister the listeners to ensure that other focus and mouse + * events do not unnecessarily re-trigger a sub frame rebuild. + */ + private handleSubFrameFocusInEvent = () => { + void this.sendExtensionMessage("triggerSubFrameFocusInRebuild"); + + this.removeRebuildSubFrameOffsetsListeners(); + this.setupSubFrameFocusOutListeners(); + }; + + /** + * Triggers an update in the most recently focused field's data and returns + * whether the field is within the viewport bounds. If not within the bounds + * of the viewport, the inline menu will be closed. + */ + private async checkIsMostRecentlyFocusedFieldWithinViewport() { + await this.updateMostRecentlyFocusedField(this.mostRecentlyFocusedField); + + const focusedFieldRectsTop = this.focusedFieldData?.focusedFieldRects?.top; + const focusedFieldRectsBottom = + focusedFieldRectsTop + this.focusedFieldData?.focusedFieldRects?.height; + const viewportHeight = globalThis.innerHeight + globalThis.scrollY; + return ( + focusedFieldRectsTop && + focusedFieldRectsTop > 0 && + focusedFieldRectsTop < viewportHeight && + focusedFieldRectsBottom < viewportHeight + ); + } + + /** + * Clears the timeout that triggers a debounced focus of the inline menu list. + */ + private clearFocusInlineMenuListTimeout() { + if (this.focusInlineMenuListTimeout) { + globalThis.clearTimeout(this.focusInlineMenuListTimeout); + } + } + + /** + * Clears the timeout that triggers the closing of the inline menu on a focus redirection. + */ + private clearCloseInlineMenuOnRedirectTimeout() { + if (this.closeInlineMenuOnRedirectTimeout) { + globalThis.clearTimeout(this.closeInlineMenuOnRedirectTimeout); + } + } + /** * Destroys the autofill overlay content service. This method will * disconnect the mutation observers and remove all event listeners. */ destroy() { - this.documentElementMutationObserver?.disconnect(); - this.clearUserInteractionEventTimeout(); + this.clearFocusInlineMenuListTimeout(); + this.clearCloseInlineMenuOnRedirectTimeout(); this.formFieldElements.forEach((formFieldElement) => { this.removeCachedFormFieldEventListeners(formFieldElement); formFieldElement.removeEventListener(EVENTS.BLUR, this.handleFormFieldBlurEvent); formFieldElement.removeEventListener(EVENTS.KEYUP, this.handleFormFieldKeyupEvent); this.formFieldElements.delete(formFieldElement); }); + globalThis.removeEventListener(EVENTS.MESSAGE, this.handleWindowMessageEvent); globalThis.document.removeEventListener( EVENTS.VISIBILITYCHANGE, this.handleVisibilityChangeEvent, ); globalThis.removeEventListener(EVENTS.FOCUSOUT, this.handleFormFieldBlurEvent); - this.removeAutofillOverlay(); this.removeOverlayRepositionEventListeners(); + this.removeRebuildSubFrameOffsetsListeners(); + this.removeSubFrameFocusOutListeners(); } } - -export default AutofillOverlayContentService; diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index dc9f3fcdbd4..ce7f4d41d26 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -5,7 +5,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; -import { AutofillSettingsService } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { DefaultDomainSettingsService, DomainSettingsService, @@ -14,6 +14,7 @@ import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; @@ -40,7 +41,7 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service"; import { AutofillMessageCommand, AutofillMessageSender } from "../enums/autofill-message.enums"; -import { AutofillPort } from "../enums/autofill-port.enums"; +import { AutofillPort } from "../enums/autofill-port.enum"; import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript from "../models/autofill-script"; @@ -72,7 +73,7 @@ describe("AutofillService", () => { let autofillService: AutofillService; const cipherService = mock(); let inlineMenuVisibilityMock$!: BehaviorSubject; - let autofillSettingsService: MockProxy; + let autofillSettingsService: MockProxy; const mockUserId = Utils.newGuid() as UserId; const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); @@ -86,16 +87,18 @@ describe("AutofillService", () => { const platformUtilsService = mock(); let activeAccountStatusMock$: BehaviorSubject; let authService: MockProxy; + let configService: MockProxy; let messageListener: MockProxy; beforeEach(() => { scriptInjectorService = new BrowserScriptInjectorService(platformUtilsService, logService); inlineMenuVisibilityMock$ = new BehaviorSubject(AutofillOverlayVisibility.OnFieldFocus); - autofillSettingsService = mock(); - (autofillSettingsService as any).inlineMenuVisibility$ = inlineMenuVisibilityMock$; + autofillSettingsService = mock(); + autofillSettingsService.inlineMenuVisibility$ = inlineMenuVisibilityMock$; activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked); authService = mock(); authService.activeAccountStatus$ = activeAccountStatusMock$; + configService = mock(); messageListener = mock(); autofillService = new AutofillService( cipherService, @@ -109,6 +112,7 @@ describe("AutofillService", () => { scriptInjectorService, accountService, authService, + configService, messageListener, ); domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); @@ -213,7 +217,7 @@ describe("AutofillService", () => { .spyOn(BrowserApi, "getAllFrameDetails") .mockResolvedValue([mock({ frameId: 0 })]); jest - .spyOn(autofillService, "getOverlayVisibility") + .spyOn(autofillService, "getInlineMenuVisibility") .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); jest.spyOn(autofillService, "getAutofillOnPageLoad").mockResolvedValue(true); }); @@ -275,13 +279,13 @@ describe("AutofillService", () => { expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( tab1, - "updateAutofillOverlayVisibility", - { autofillOverlayVisibility: AutofillOverlayVisibility.OnButtonClick }, + "updateAutofillInlineMenuVisibility", + { inlineMenuVisibility: AutofillOverlayVisibility.OnButtonClick }, ); expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( tab2, - "updateAutofillOverlayVisibility", - { autofillOverlayVisibility: AutofillOverlayVisibility.OnButtonClick }, + "updateAutofillInlineMenuVisibility", + { inlineMenuVisibility: AutofillOverlayVisibility.OnButtonClick }, ); }); @@ -292,13 +296,13 @@ describe("AutofillService", () => { expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( tab1, - "updateAutofillOverlayVisibility", - { autofillOverlayVisibility: AutofillOverlayVisibility.OnFieldFocus }, + "updateAutofillInlineMenuVisibility", + { inlineMenuVisibility: AutofillOverlayVisibility.OnFieldFocus }, ); expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( tab2, - "updateAutofillOverlayVisibility", - { autofillOverlayVisibility: AutofillOverlayVisibility.OnFieldFocus }, + "updateAutofillInlineMenuVisibility", + { inlineMenuVisibility: AutofillOverlayVisibility.OnFieldFocus }, ); }); }); @@ -351,11 +355,12 @@ describe("AutofillService", () => { let sender: chrome.runtime.MessageSender; beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(true); tabMock = createChromeTabMock(); sender = { tab: tabMock, frameId: 1 }; jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation(); jest - .spyOn(autofillService, "getOverlayVisibility") + .spyOn(autofillService, "getInlineMenuVisibility") .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); jest.spyOn(autofillService, "getAutofillOnPageLoad").mockResolvedValue(true); }); @@ -413,7 +418,7 @@ describe("AutofillService", () => { it("will inject the bootstrap-autofill script if the user does not have the autofill overlay enabled", async () => { jest - .spyOn(autofillService, "getOverlayVisibility") + .spyOn(autofillService, "getInlineMenuVisibility") .mockResolvedValue(AutofillOverlayVisibility.Off); await autofillService.injectAutofillScripts(sender.tab, sender.frameId); diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 4c37cd1f07f..81a47b2f614 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -12,10 +12,12 @@ import { DomainSettingsService } from "@bitwarden/common/autofill/services/domai import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { UriMatchStrategySetting, UriMatchStrategy, } from "@bitwarden/common/models/domain/domain-service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -29,7 +31,7 @@ import { BrowserApi } from "../../platform/browser/browser-api"; import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service"; import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window"; import { AutofillMessageCommand, AutofillMessageSender } from "../enums/autofill-message.enums"; -import { AutofillPort } from "../enums/autofill-port.enums"; +import { AutofillPort } from "../enums/autofill-port.enum"; import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript from "../models/autofill-script"; @@ -67,6 +69,7 @@ export default class AutofillService implements AutofillServiceInterface { private scriptInjectorService: ScriptInjectorService, private accountService: AccountService, private authService: AuthService, + private configService: ConfigService, private messageListener: MessageListener, ) {} @@ -160,16 +163,23 @@ export default class AutofillService implements AutofillServiceInterface { const activeAccount = await firstValueFrom(this.accountService.activeAccount$); const authStatus = await firstValueFrom(this.authService.activeAccountStatus$); const accountIsUnlocked = authStatus === AuthenticationStatus.Unlocked; - let overlayVisibility: InlineMenuVisibilitySetting = AutofillOverlayVisibility.Off; + let inlineMenuVisibility: InlineMenuVisibilitySetting = AutofillOverlayVisibility.Off; let autoFillOnPageLoadIsEnabled = false; if (activeAccount) { - overlayVisibility = await this.getOverlayVisibility(); + inlineMenuVisibility = await this.getInlineMenuVisibility(); } - const mainAutofillScript = overlayVisibility - ? "bootstrap-autofill-overlay.js" - : "bootstrap-autofill.js"; + let mainAutofillScript = "bootstrap-autofill.js"; + + if (inlineMenuVisibility) { + const inlineMenuPositioningImprovements = await this.configService.getFeatureFlag( + FeatureFlag.InlineMenuPositioningImprovements, + ); + mainAutofillScript = inlineMenuPositioningImprovements + ? "bootstrap-autofill-overlay.js" + : "bootstrap-legacy-autofill-overlay.js"; + } const injectedScripts = [mainAutofillScript]; @@ -274,7 +284,7 @@ export default class AutofillService implements AutofillServiceInterface { /** * Gets the overlay's visibility setting from the autofill settings service. */ - async getOverlayVisibility(): Promise { + async getInlineMenuVisibility(): Promise { return await firstValueFrom(this.autofillSettingsService.inlineMenuVisibility$); } @@ -2162,8 +2172,8 @@ export default class AutofillService implements AutofillServiceInterface { if (!inlineMenuPreviouslyDisabled && !inlineMenuCurrentlyDisabled) { const tabs = await BrowserApi.tabsQuery({}); tabs.forEach((tab) => - BrowserApi.tabSendMessageData(tab, "updateAutofillOverlayVisibility", { - autofillOverlayVisibility: currentSetting, + BrowserApi.tabSendMessageData(tab, "updateAutofillInlineMenuVisibility", { + inlineMenuVisibility: currentSetting, }), ); return; diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts index 9bb0e717a26..f67c0e88aa0 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts @@ -11,7 +11,8 @@ import { FormElementWithAttribute, } from "../types"; -import AutofillOverlayContentService from "./autofill-overlay-content.service"; +import { InlineMenuFieldQualificationService } from "./abstractions/inline-menu-field-qualifications.service"; +import { AutofillOverlayContentService } from "./autofill-overlay-content.service"; import CollectAutofillContentService from "./collect-autofill-content.service"; import DomElementVisibilityService from "./dom-element-visibility.service"; @@ -28,7 +29,10 @@ const waitForIdleCallback = () => new Promise((resolve) => globalThis.requestIdl describe("CollectAutofillContentService", () => { const domElementVisibilityService = new DomElementVisibilityService(); - const autofillOverlayContentService = new AutofillOverlayContentService(); + const inlineMenuFieldQualificationService = mock(); + const autofillOverlayContentService = new AutofillOverlayContentService( + inlineMenuFieldQualificationService, + ); let collectAutofillContentService: CollectAutofillContentService; const mockIntersectionObserver = mock(); const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); @@ -250,7 +254,7 @@ describe("CollectAutofillContentService", () => { .mockResolvedValue(true); const setupAutofillOverlayListenerOnFieldSpy = jest.spyOn( collectAutofillContentService["autofillOverlayContentService"], - "setupAutofillOverlayListenerOnField", + "setupInlineMenu", ); await collectAutofillContentService.getPageDetails(); @@ -2564,7 +2568,7 @@ describe("CollectAutofillContentService", () => { ); setupAutofillOverlayListenerOnFieldSpy = jest.spyOn( collectAutofillContentService["autofillOverlayContentService"], - "setupAutofillOverlayListenerOnField", + "setupInlineMenu", ); }); @@ -2585,9 +2589,11 @@ describe("CollectAutofillContentService", () => { it("skips setting up the overlay listeners on a field that is not viewable", async () => { const formFieldElement = document.createElement("input") as ElementWithOpId; + const autofillField = mock(); const entries = [ { target: formFieldElement, isIntersecting: true }, ] as unknown as IntersectionObserverEntry[]; + collectAutofillContentService["autofillFieldElements"].set(formFieldElement, autofillField); isFormFieldViewableSpy.mockReturnValueOnce(false); await collectAutofillContentService["handleFormElementIntersection"](entries); @@ -2596,7 +2602,21 @@ describe("CollectAutofillContentService", () => { expect(setupAutofillOverlayListenerOnFieldSpy).not.toHaveBeenCalled(); }); - it("sets up the overlay listeners on a viewable field", async () => { + it("skips setting up the inline menu listeners if the observed form field is not present in the cache", async () => { + const formFieldElement = document.createElement("input") as ElementWithOpId; + const entries = [ + { target: formFieldElement, isIntersecting: true }, + ] as unknown as IntersectionObserverEntry[]; + isFormFieldViewableSpy.mockReturnValueOnce(true); + collectAutofillContentService["intersectionObserver"] = mockIntersectionObserver; + + await collectAutofillContentService["handleFormElementIntersection"](entries); + + expect(isFormFieldViewableSpy).not.toHaveBeenCalled(); + expect(setupAutofillOverlayListenerOnFieldSpy).not.toHaveBeenCalled(); + }); + + it("sets up the inline menu listeners on a viewable field", async () => { const formFieldElement = document.createElement("input") as ElementWithOpId; const autofillField = mock(); const entries = [ @@ -2616,4 +2636,17 @@ describe("CollectAutofillContentService", () => { ); }); }); + + describe("destroy", () => { + it("clears the updateAfterMutationIdleCallback", () => { + jest.spyOn(window, "clearTimeout"); + collectAutofillContentService["updateAfterMutationIdleCallback"] = setTimeout(jest.fn, 100); + + collectAutofillContentService.destroy(); + + expect(clearTimeout).toHaveBeenCalledWith( + collectAutofillContentService["updateAfterMutationIdleCallback"], + ); + }); + }); }); diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index 75c564e868e..b5541ba5eb6 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -1,12 +1,7 @@ import AutofillField from "../models/autofill-field"; import AutofillForm from "../models/autofill-form"; import AutofillPageDetails from "../models/autofill-page-details"; -import { - ElementWithOpId, - FillableFormFieldElement, - FormElementWithAttribute, - FormFieldElement, -} from "../types"; +import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types"; import { elementIsDescriptionDetailsElement, elementIsDescriptionTermElement, @@ -21,6 +16,8 @@ import { nodeIsFormElement, nodeIsInputElement, // sendExtensionMessage, + getAttributeBoolean, + getPropertyOrAttribute, requestIdleCallbackPolyfill, cancelIdleCallbackPolyfill, } from "../utils"; @@ -37,6 +34,8 @@ import { DomElementVisibilityService } from "./abstractions/dom-element-visibili class CollectAutofillContentService implements CollectAutofillContentServiceInterface { private readonly domElementVisibilityService: DomElementVisibilityService; private readonly autofillOverlayContentService: AutofillOverlayContentService; + private readonly getAttributeBoolean = getAttributeBoolean; + private readonly getPropertyOrAttribute = getPropertyOrAttribute; private noFieldsFound = false; private domRecentlyMutated = true; private autofillFormElements: AutofillFormElements = new Map(); @@ -286,7 +285,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte autofillField.viewable = await this.domElementVisibilityService.isFormFieldViewable(element); if (!previouslyViewable && autofillField.viewable) { - this.setupInlineMenuListenerOnField(element, autofillField); + this.setupInlineMenu(element, autofillField); } }); } @@ -537,26 +536,6 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte ); } - /** - * Returns a boolean representing the attribute value of an element. - * @param {ElementWithOpId} element - * @param {string} attributeName - * @param {boolean} checkString - * @returns {boolean} - * @private - */ - private getAttributeBoolean( - element: ElementWithOpId, - attributeName: string, - checkString = false, - ): boolean { - if (checkString) { - return this.getPropertyOrAttribute(element, attributeName) === "true"; - } - - return Boolean(this.getPropertyOrAttribute(element, attributeName)); - } - /** * Returns the attribute of an element as a lowercase value. * @param {ElementWithOpId} element @@ -868,21 +847,6 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte return this.recursivelyGetTextFromPreviousSiblings(siblingElement); } - /** - * Get the value of a property or attribute from a FormFieldElement. - * @param {HTMLElement} element - * @param {string} attributeName - * @returns {string | null} - * @private - */ - private getPropertyOrAttribute(element: HTMLElement, attributeName: string): string | null { - if (attributeName in element) { - return (element as FormElementWithAttribute)[attributeName]; - } - - return element.getAttribute(attributeName); - } - /** * Gets the value of the element. If the element is a checkbox, returns a checkmark if the * checkbox is checked, or an empty string if it is not checked. If the element is a hidden @@ -1411,20 +1375,20 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte continue; } + const cachedAutofillFieldElement = this.autofillFieldElements.get(formFieldElement); + if (!cachedAutofillFieldElement) { + this.intersectionObserver.unobserve(entry.target); + continue; + } + const isViewable = await this.domElementVisibilityService.isFormFieldViewable(formFieldElement); if (!isViewable) { continue; } - const cachedAutofillFieldElement = this.autofillFieldElements.get(formFieldElement); - if (!cachedAutofillFieldElement) { - continue; - } - cachedAutofillFieldElement.viewable = true; - - this.setupInlineMenuListenerOnField(formFieldElement, cachedAutofillFieldElement); + this.setupInlineMenu(formFieldElement, cachedAutofillFieldElement); this.intersectionObserver?.unobserve(entry.target); } @@ -1441,7 +1405,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte } this.autofillFieldElements.forEach((autofillField, formFieldElement) => { - this.setupInlineMenuListenerOnField(formFieldElement, autofillField, pageDetails); + this.setupInlineMenu(formFieldElement, autofillField, pageDetails); }); } @@ -1452,7 +1416,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte * @param autofillField - The metadata for the form field * @param pageDetails - The page details to use for the inline menu listeners */ - private setupInlineMenuListenerOnField( + private setupInlineMenu( formFieldElement: ElementWithOpId, autofillField: AutofillField, pageDetails?: AutofillPageDetails, @@ -1468,7 +1432,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte this.getFormattedAutofillFieldsData(), ); - void this.autofillOverlayContentService.setupAutofillOverlayListenerOnField( + void this.autofillOverlayContentService.setupInlineMenu( formFieldElement, autofillField, autofillPageDetails, diff --git a/apps/browser/src/autofill/services/dom-element-visibility.service.ts b/apps/browser/src/autofill/services/dom-element-visibility.service.ts index 127ce84d919..67986eb00f2 100644 --- a/apps/browser/src/autofill/services/dom-element-visibility.service.ts +++ b/apps/browser/src/autofill/services/dom-element-visibility.service.ts @@ -1,3 +1,4 @@ +import { AutofillInlineMenuContentService } from "../overlay/inline-menu/abstractions/autofill-inline-menu-content.service"; import { FillableFormFieldElement, FormFieldElement } from "../types"; import { DomElementVisibilityService as domElementVisibilityServiceInterface } from "./abstractions/dom-element-visibility.service"; @@ -5,6 +6,8 @@ import { DomElementVisibilityService as domElementVisibilityServiceInterface } f class DomElementVisibilityService implements domElementVisibilityServiceInterface { private cachedComputedStyle: CSSStyleDeclaration | null = null; + constructor(private inlineMenuElements?: AutofillInlineMenuContentService) {} + /** * Checks if a form field is viewable. This is done by checking if the element is within the * viewport bounds, not hidden by CSS, and not hidden behind another element. @@ -187,6 +190,10 @@ class DomElementVisibilityService implements domElementVisibilityServiceInterfac return true; } + if (this.inlineMenuElements?.isElementInlineMenu(elementAtCenterPoint as HTMLElement)) { + return true; + } + const targetElementLabelsSet = new Set((targetElement as FillableFormFieldElement).labels); if (targetElementLabelsSet.has(elementAtCenterPoint as HTMLLabelElement)) { return true; diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts index 7bc027b392c..a6253dffac2 100644 --- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts @@ -2,11 +2,11 @@ import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; import { sendExtensionMessage } from "../utils"; -import { InlineMenuFieldQualificationsService as InlineMenuFieldQualificationsServiceInterface } from "./abstractions/inline-menu-field-qualifications.service"; +import { InlineMenuFieldQualificationService as InlineMenuFieldQualificationServiceInterface } from "./abstractions/inline-menu-field-qualifications.service"; import { AutoFillConstants } from "./autofill-constants"; export class InlineMenuFieldQualificationService - implements InlineMenuFieldQualificationsServiceInterface + implements InlineMenuFieldQualificationServiceInterface { private searchFieldNamesSet = new Set(AutoFillConstants.SearchFieldNames); private excludedAutofillLoginTypesSet = new Set(AutoFillConstants.ExcludedAutofillLoginTypes); diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts index 6ee5171e58c..ff0e82d664d 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts @@ -1,10 +1,13 @@ +import { mock } from "jest-mock-extended"; + import { EVENTS } from "@bitwarden/common/autofill/constants"; import AutofillScript, { FillScript, FillScriptActions } from "../models/autofill-script"; import { mockQuerySelectorAllDefinedCall } from "../spec/testing-utils"; import { FillableFormFieldElement, FormElementWithAttribute, FormFieldElement } from "../types"; -import AutofillOverlayContentService from "./autofill-overlay-content.service"; +import { InlineMenuFieldQualificationService } from "./abstractions/inline-menu-field-qualifications.service"; +import { AutofillOverlayContentService } from "./autofill-overlay-content.service"; import CollectAutofillContentService from "./collect-autofill-content.service"; import DomElementVisibilityService from "./dom-element-visibility.service"; import InsertAutofillContentService from "./insert-autofill-content.service"; @@ -64,8 +67,11 @@ function setMockWindowLocation({ } describe("InsertAutofillContentService", () => { + const inlineMenuFieldQualificationService = mock(); const domElementVisibilityService = new DomElementVisibilityService(); - const autofillOverlayContentService = new AutofillOverlayContentService(); + const autofillOverlayContentService = new AutofillOverlayContentService( + inlineMenuFieldQualificationService, + ); const collectAutofillContentService = new CollectAutofillContentService( domElementVisibilityService, autofillOverlayContentService, diff --git a/apps/browser/src/autofill/spec/autofill-mocks.ts b/apps/browser/src/autofill/spec/autofill-mocks.ts index 021b7719b2b..2d4ffd7f217 100644 --- a/apps/browser/src/autofill/spec/autofill-mocks.ts +++ b/apps/browser/src/autofill/spec/autofill-mocks.ts @@ -7,16 +7,16 @@ import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { OverlayCipherData } from "../background/abstractions/overlay.background"; +import { InlineMenuCipherData } from "../background/abstractions/overlay.background"; import AutofillField from "../models/autofill-field"; import AutofillForm from "../models/autofill-form"; import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript, { FillScript } from "../models/autofill-script"; -import { InitAutofillOverlayButtonMessage } from "../overlay/abstractions/autofill-overlay-button"; -import { InitAutofillOverlayListMessage } from "../overlay/abstractions/autofill-overlay-list"; +import { InitAutofillInlineMenuButtonMessage } from "../overlay/inline-menu/abstractions/autofill-inline-menu-button"; +import { InitAutofillInlineMenuListMessage } from "../overlay/inline-menu/abstractions/autofill-inline-menu-list"; import { GenerateFillScriptOptions, PageDetail } from "../services/abstractions/autofill.service"; -function createAutofillFormMock(customFields = {}): AutofillForm { +export function createAutofillFormMock(customFields = {}): AutofillForm { return { opid: "default-form-opid", htmlID: "default-htmlID", @@ -27,7 +27,7 @@ function createAutofillFormMock(customFields = {}): AutofillForm { }; } -function createAutofillFieldMock(customFields = {}): AutofillField { +export function createAutofillFieldMock(customFields = {}): AutofillField { return { opid: "default-input-field-opid", elementNumber: 0, @@ -57,7 +57,7 @@ function createAutofillFieldMock(customFields = {}): AutofillField { }; } -function createPageDetailMock(customFields = {}): PageDetail { +export function createPageDetailMock(customFields = {}): PageDetail { return { frameId: 0, tab: createChromeTabMock(), @@ -66,7 +66,7 @@ function createPageDetailMock(customFields = {}): PageDetail { }; } -function createAutofillPageDetailsMock(customFields = {}): AutofillPageDetails { +export function createAutofillPageDetailsMock(customFields = {}): AutofillPageDetails { return { title: "title", url: "url", @@ -86,7 +86,7 @@ function createAutofillPageDetailsMock(customFields = {}): AutofillPageDetails { }; } -function createChromeTabMock(customFields = {}): chrome.tabs.Tab { +export function createChromeTabMock(customFields = {}): chrome.tabs.Tab { return { id: 1, index: 1, @@ -104,7 +104,7 @@ function createChromeTabMock(customFields = {}): chrome.tabs.Tab { }; } -function createGenerateFillScriptOptionsMock(customFields = {}): GenerateFillScriptOptions { +export function createGenerateFillScriptOptionsMock(customFields = {}): GenerateFillScriptOptions { return { skipUsernameOnlyFill: false, onlyEmptyFields: false, @@ -118,7 +118,7 @@ function createGenerateFillScriptOptionsMock(customFields = {}): GenerateFillScr }; } -function createAutofillScriptMock( +export function createAutofillScriptMock( customFields = {}, scriptTypes?: Record, ): AutofillScript { @@ -159,24 +159,28 @@ const overlayPagesTranslations = { unlockYourAccount: "unlockYourAccount", unlockAccount: "unlockAccount", fillCredentialsFor: "fillCredentialsFor", - partialUsername: "partialUsername", + username: "username", view: "view", noItemsToShow: "noItemsToShow", newItem: "newItem", addNewVaultItem: "addNewVaultItem", }; -function createInitAutofillOverlayButtonMessageMock( +export function createInitAutofillInlineMenuButtonMessageMock( customFields = {}, -): InitAutofillOverlayButtonMessage { +): InitAutofillInlineMenuButtonMessage { return { - command: "initAutofillOverlayButton", + command: "initAutofillInlineMenuButton", translations: overlayPagesTranslations, styleSheetUrl: "https://jest-testing-website.com", authStatus: AuthenticationStatus.Unlocked, + portKey: "portKey", ...customFields, }; } -function createAutofillOverlayCipherDataMock(index: number, customFields = {}): OverlayCipherData { +export function createAutofillOverlayCipherDataMock( + index: number, + customFields = {}, +): InlineMenuCipherData { return { id: String(index), name: `website login ${index}`, @@ -194,15 +198,16 @@ function createAutofillOverlayCipherDataMock(index: number, customFields = {}): }; } -function createInitAutofillOverlayListMessageMock( +export function createInitAutofillInlineMenuListMessageMock( customFields = {}, -): InitAutofillOverlayListMessage { +): InitAutofillInlineMenuListMessage { return { - command: "initAutofillOverlayList", + command: "initAutofillInlineMenuList", translations: overlayPagesTranslations, styleSheetUrl: "https://jest-testing-website.com", theme: ThemeType.Light, authStatus: AuthenticationStatus.Unlocked, + portKey: "portKey", ciphers: [ createAutofillOverlayCipherDataMock(1, { icon: { @@ -237,7 +242,7 @@ function createInitAutofillOverlayListMessageMock( }; } -function createFocusedFieldDataMock(customFields = {}) { +export function createFocusedFieldDataMock(customFields = {}) { return { focusedFieldRects: { top: 1, @@ -250,11 +255,12 @@ function createFocusedFieldDataMock(customFields = {}) { paddingLeft: "6px", }, tabId: 1, + frameId: 2, ...customFields, }; } -function createPortSpyMock(name: string) { +export function createPortSpyMock(name: string) { return mock({ name, onMessage: { @@ -273,16 +279,17 @@ function createPortSpyMock(name: string) { }); } -export { - createAutofillFormMock, - createAutofillFieldMock, - createPageDetailMock, - createAutofillPageDetailsMock, - createChromeTabMock, - createGenerateFillScriptOptionsMock, - createAutofillScriptMock, - createInitAutofillOverlayButtonMessageMock, - createInitAutofillOverlayListMessageMock, - createFocusedFieldDataMock, - createPortSpyMock, -}; +export function createMutationRecordMock(customFields = {}): MutationRecord { + return { + addedNodes: mock(), + attributeName: "default-attributeName", + attributeNamespace: "default-attributeNamespace", + nextSibling: null, + oldValue: "default-oldValue", + previousSibling: null, + removedNodes: mock(), + target: null, + type: "attributes", + ...customFields, + }; +} diff --git a/apps/browser/src/autofill/spec/testing-utils.ts b/apps/browser/src/autofill/spec/testing-utils.ts index ba7a5844987..1cef5186028 100644 --- a/apps/browser/src/autofill/spec/testing-utils.ts +++ b/apps/browser/src/autofill/spec/testing-utils.ts @@ -48,6 +48,22 @@ export function sendPortMessage(port: chrome.runtime.Port, message: any) { }); } +export function triggerPortOnConnectEvent(port: chrome.runtime.Port) { + (chrome.runtime.onConnect.addListener as unknown as jest.SpyInstance).mock.calls.forEach( + (call) => { + const callback = call[0]; + callback(port); + }, + ); +} + +export function triggerPortOnMessageEvent(port: chrome.runtime.Port, message: any) { + (port.onMessage.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { + const callback = call[0]; + callback(message, port); + }); +} + export function triggerPortOnDisconnectEvent(port: chrome.runtime.Port) { (port.onDisconnect.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { const callback = call[0]; @@ -105,6 +121,17 @@ export function triggerOnAlarmEvent(alarm: chrome.alarms.Alarm) { }); } +export function triggerWebNavigationOnCommittedEvent( + details: chrome.webNavigation.WebNavigationFramedCallbackDetails, +) { + (chrome.webNavigation.onCommitted.addListener as unknown as jest.SpyInstance).mock.calls.forEach( + (call) => { + const callback = call[0]; + callback(details); + }, + ); +} + export function mockQuerySelectorAllDefinedCall() { const originalDocumentQuerySelectorAll = document.querySelectorAll; document.querySelectorAll = function (selector: string) { diff --git a/apps/browser/src/autofill/utils/autofill-overlay.enum.ts b/apps/browser/src/autofill/utils/autofill-overlay.enum.ts deleted file mode 100644 index 486d68f7540..00000000000 --- a/apps/browser/src/autofill/utils/autofill-overlay.enum.ts +++ /dev/null @@ -1,17 +0,0 @@ -const AutofillOverlayElement = { - Button: "autofill-overlay-button", - List: "autofill-overlay-list", -} as const; - -const AutofillOverlayPort = { - Button: "autofill-overlay-button-port", - List: "autofill-overlay-list-port", -} as const; - -const RedirectFocusDirection = { - Current: "current", - Previous: "previous", - Next: "next", -} as const; - -export { AutofillOverlayElement, AutofillOverlayPort, RedirectFocusDirection }; diff --git a/apps/browser/src/autofill/utils/index.spec.ts b/apps/browser/src/autofill/utils/index.spec.ts index dcb5aa64696..116df044b37 100644 --- a/apps/browser/src/autofill/utils/index.spec.ts +++ b/apps/browser/src/autofill/utils/index.spec.ts @@ -1,4 +1,4 @@ -import { AutofillPort } from "../enums/autofill-port.enums"; +import { AutofillPort } from "../enums/autofill-port.enum"; import { triggerPortOnDisconnectEvent } from "../spec/testing-utils"; import { logoIcon, logoLockedIcon } from "./svg-icons"; @@ -38,9 +38,7 @@ describe("generateRandomCustomElementName", () => { describe("sendExtensionMessage", () => { it("sends a message to the extension", async () => { - const extensionMessagePromise = sendExtensionMessage("updateAutofillOverlayHidden", { - display: "none", - }); + const extensionMessagePromise = sendExtensionMessage("some-extension-message"); // Jest doesn't give anyway to select the typed overload of "sendMessage", // a cast is needed to get the correct spy type. diff --git a/apps/browser/src/autofill/utils/index.ts b/apps/browser/src/autofill/utils/index.ts index 873012d1dbb..a040fa50122 100644 --- a/apps/browser/src/autofill/utils/index.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -1,5 +1,24 @@ -import { AutofillPort } from "../enums/autofill-port.enums"; -import { FillableFormFieldElement, FormFieldElement } from "../types"; +import { AutofillPort } from "../enums/autofill-port.enum"; +import { FillableFormFieldElement, FormElementWithAttribute, FormFieldElement } from "../types"; + +/** + * Generates a random string of characters. + * + * @param length - The length of the random string to generate. + */ +export function generateRandomChars(length: number): string { + const chars = "abcdefghijklmnopqrstuvwxyz"; + const randomChars = []; + const randomBytes = new Uint8Array(length); + globalThis.crypto.getRandomValues(randomBytes); + + for (let byteIndex = 0; byteIndex < randomBytes.length; byteIndex++) { + const byte = randomBytes[byteIndex]; + randomChars.push(chars[byte % chars.length]); + } + + return randomChars.join(""); +} /** * Polyfills the requestIdleCallback API with a setTimeout fallback. @@ -34,21 +53,7 @@ export function cancelIdleCallbackPolyfill(id: NodeJS.Timeout | number) { /** * Generates a random string of characters that formatted as a custom element name. */ -function generateRandomCustomElementName(): string { - const generateRandomChars = (length: number): string => { - const chars = "abcdefghijklmnopqrstuvwxyz"; - const randomChars = []; - const randomBytes = new Uint8Array(length); - globalThis.crypto.getRandomValues(randomBytes); - - for (let byteIndex = 0; byteIndex < randomBytes.length; byteIndex++) { - const byte = randomBytes[byteIndex]; - randomChars.push(chars[byte % chars.length]); - } - - return randomChars.join(""); - }; - +export function generateRandomCustomElementName(): string { const length = Math.floor(Math.random() * 5) + 8; // Between 8 and 12 characters const numHyphens = Math.min(Math.max(Math.floor(Math.random() * 4), 1), length - 1); // At least 1, maximum of 3 hyphens @@ -81,7 +86,7 @@ function generateRandomCustomElementName(): string { * @param svgString - The SVG string to build the DOM element from. * @param ariaHidden - Determines whether the SVG should be hidden from screen readers. */ -function buildSvgDomElement(svgString: string, ariaHidden = true): HTMLElement { +export function buildSvgDomElement(svgString: string, ariaHidden = true): HTMLElement { const domParser = new DOMParser(); const svgDom = domParser.parseFromString(svgString, "image/svg+xml"); const domElement = svgDom.documentElement; @@ -96,14 +101,14 @@ function buildSvgDomElement(svgString: string, ariaHidden = true): HTMLElement { * @param command - The command to send. * @param options - The options to send with the command. */ -async function sendExtensionMessage( +export async function sendExtensionMessage( command: string, options: Record = {}, ): Promise { return new Promise((resolve) => { chrome.runtime.sendMessage(Object.assign({ command }, options), (response) => { if (chrome.runtime.lastError) { - return; + // Do nothing } resolve(response); @@ -118,7 +123,7 @@ async function sendExtensionMessage( * @param styles - The styles to set on the element. * @param priority - Determines whether the styles should be set as important. */ -function setElementStyles( +export function setElementStyles( element: HTMLElement, styles: Partial, priority?: boolean, @@ -141,9 +146,9 @@ function setElementStyles( * and triggers an onDisconnect event if the extension context * is invalidated. * - * @param callback - Callback function to run when the extension disconnects + * @param callback - Callback export function to run when the extension disconnects */ -function setupExtensionDisconnectAction(callback: (port: chrome.runtime.Port) => void) { +export function setupExtensionDisconnectAction(callback: (port: chrome.runtime.Port) => void) { const port = chrome.runtime.connect({ name: AutofillPort.InjectedScript }); const onDisconnectCallback = (disconnectedPort: chrome.runtime.Port) => { callback(disconnectedPort); @@ -158,7 +163,7 @@ function setupExtensionDisconnectAction(callback: (port: chrome.runtime.Port) => * * @param windowContext - The global window context */ -function setupAutofillInitDisconnectAction(windowContext: Window) { +export function setupAutofillInitDisconnectAction(windowContext: Window) { if (!windowContext.bitwardenAutofillInit) { return; } @@ -176,10 +181,10 @@ function setupAutofillInitDisconnectAction(windowContext: Window) { * * @param formFieldElement - The form field element to check. */ -function elementIsFillableFormField( +export function elementIsFillableFormField( formFieldElement: FormFieldElement, ): formFieldElement is FillableFormFieldElement { - return formFieldElement?.tagName.toLowerCase() !== "span"; + return !elementIsSpanElement(formFieldElement); } /** @@ -188,8 +193,11 @@ function elementIsFillableFormField( * @param element - The element to check. * @param tagName - The tag name to check against. */ -function elementIsInstanceOf(element: Element, tagName: string): element is T { - return element?.tagName.toLowerCase() === tagName; +export function elementIsInstanceOf( + element: Element, + tagName: string, +): element is T { + return nodeIsElement(element) && element.tagName.toLowerCase() === tagName; } /** @@ -197,7 +205,7 @@ function elementIsInstanceOf(element: Element, tagName: strin * * @param element - The element to check. */ -function elementIsSpanElement(element: Element): element is HTMLSpanElement { +export function elementIsSpanElement(element: Element): element is HTMLSpanElement { return elementIsInstanceOf(element, "span"); } @@ -206,7 +214,7 @@ function elementIsSpanElement(element: Element): element is HTMLSpanElement { * * @param element - The element to check. */ -function elementIsInputElement(element: Element): element is HTMLInputElement { +export function elementIsInputElement(element: Element): element is HTMLInputElement { return elementIsInstanceOf(element, "input"); } @@ -215,7 +223,7 @@ function elementIsInputElement(element: Element): element is HTMLInputElement { * * @param element - The element to check. */ -function elementIsSelectElement(element: Element): element is HTMLSelectElement { +export function elementIsSelectElement(element: Element): element is HTMLSelectElement { return elementIsInstanceOf(element, "select"); } @@ -224,7 +232,7 @@ function elementIsSelectElement(element: Element): element is HTMLSelectElement * * @param element - The element to check. */ -function elementIsTextAreaElement(element: Element): element is HTMLTextAreaElement { +export function elementIsTextAreaElement(element: Element): element is HTMLTextAreaElement { return elementIsInstanceOf(element, "textarea"); } @@ -233,7 +241,7 @@ function elementIsTextAreaElement(element: Element): element is HTMLTextAreaElem * * @param element - The element to check. */ -function elementIsFormElement(element: Element): element is HTMLFormElement { +export function elementIsFormElement(element: Element): element is HTMLFormElement { return elementIsInstanceOf(element, "form"); } @@ -242,7 +250,7 @@ function elementIsFormElement(element: Element): element is HTMLFormElement { * * @param element - The element to check. */ -function elementIsLabelElement(element: Element): element is HTMLLabelElement { +export function elementIsLabelElement(element: Element): element is HTMLLabelElement { return elementIsInstanceOf(element, "label"); } @@ -251,7 +259,7 @@ function elementIsLabelElement(element: Element): element is HTMLLabelElement { * * @param element - The element to check. */ -function elementIsDescriptionDetailsElement(element: Element): element is HTMLElement { +export function elementIsDescriptionDetailsElement(element: Element): element is HTMLElement { return elementIsInstanceOf(element, "dd"); } @@ -260,7 +268,7 @@ function elementIsDescriptionDetailsElement(element: Element): element is HTMLEl * * @param element - The element to check. */ -function elementIsDescriptionTermElement(element: Element): element is HTMLElement { +export function elementIsDescriptionTermElement(element: Element): element is HTMLElement { return elementIsInstanceOf(element, "dt"); } @@ -269,12 +277,12 @@ function elementIsDescriptionTermElement(element: Element): element is HTMLEleme * * @param node - The node to check. */ -function nodeIsElement(node: Node): node is Element { +export function nodeIsElement(node: Node): node is Element { if (!node) { return false; } - return node.nodeType === Node.ELEMENT_NODE; + return node?.nodeType === Node.ELEMENT_NODE; } /** @@ -282,7 +290,7 @@ function nodeIsElement(node: Node): node is Element { * * @param node - The node to check. */ -function nodeIsInputElement(node: Node): node is HTMLInputElement { +export function nodeIsInputElement(node: Node): node is HTMLInputElement { return nodeIsElement(node) && elementIsInputElement(node); } @@ -291,28 +299,56 @@ function nodeIsInputElement(node: Node): node is HTMLInputElement { * * @param node - The node to check. */ -function nodeIsFormElement(node: Node): node is HTMLFormElement { +export function nodeIsFormElement(node: Node): node is HTMLFormElement { return nodeIsElement(node) && elementIsFormElement(node); } -export { - generateRandomCustomElementName, - buildSvgDomElement, - sendExtensionMessage, - setElementStyles, - setupExtensionDisconnectAction, - setupAutofillInitDisconnectAction, - elementIsFillableFormField, - elementIsInstanceOf, - elementIsSpanElement, - elementIsInputElement, - elementIsSelectElement, - elementIsTextAreaElement, - elementIsFormElement, - elementIsLabelElement, - elementIsDescriptionDetailsElement, - elementIsDescriptionTermElement, - nodeIsElement, - nodeIsInputElement, - nodeIsFormElement, -}; +/** + * Returns a boolean representing the attribute value of an element. + * + * @param element + * @param attributeName + * @param checkString + */ +export function getAttributeBoolean( + element: HTMLElement, + attributeName: string, + checkString = false, +): boolean { + if (checkString) { + return getPropertyOrAttribute(element, attributeName) === "true"; + } + + return Boolean(getPropertyOrAttribute(element, attributeName)); +} + +/** + * Get the value of a property or attribute from a FormFieldElement. + * + * @param element + * @param attributeName + */ +export function getPropertyOrAttribute(element: HTMLElement, attributeName: string): string | null { + if (attributeName in element) { + return (element as FormElementWithAttribute)[attributeName]; + } + + return element.getAttribute(attributeName); +} + +/** + * Throttles a callback function to run at most once every `limit` milliseconds. + * + * @param callback - The callback function to throttle. + * @param limit - The time in milliseconds to throttle the callback. + */ +export function throttle(callback: () => void, limit: number) { + let waitingDelay = false; + return function (...args: unknown[]) { + if (!waitingDelay) { + callback.apply(this, args); + waitingDelay = true; + globalThis.setTimeout(() => (waitingDelay = false), limit); + } + }; +} diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 35e674cfd1c..9aac8464ab4 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -72,6 +72,7 @@ import { import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -197,14 +198,16 @@ import { VaultExportServiceAbstraction, } from "@bitwarden/vault-export-core"; +import { OverlayBackground as OverlayBackgroundInterface } from "../autofill/background/abstractions/overlay.background"; import ContextMenusBackground from "../autofill/background/context-menus.background"; import NotificationBackground from "../autofill/background/notification.background"; -import OverlayBackground from "../autofill/background/overlay.background"; +import { OverlayBackground } from "../autofill/background/overlay.background"; import TabsBackground from "../autofill/background/tabs.background"; import WebRequestBackground from "../autofill/background/web-request.background"; import { CipherContextMenuHandler } from "../autofill/browser/cipher-context-menu-handler"; import { ContextMenuClickedHandler } from "../autofill/browser/context-menu-clicked-handler"; import { MainContextMenuHandler } from "../autofill/browser/main-context-menu-handler"; +import LegacyOverlayBackground from "../autofill/deprecated/background/overlay.background.deprecated"; import { Fido2Background as Fido2BackgroundAbstraction } from "../autofill/fido2/background/abstractions/fido2.background"; import { Fido2Background } from "../autofill/fido2/background/fido2.background"; import { AutofillService as AutofillServiceAbstraction } from "../autofill/services/abstractions/autofill.service"; @@ -351,7 +354,7 @@ export default class MainBackground { private contextMenusBackground: ContextMenusBackground; private idleBackground: IdleBackground; private notificationBackground: NotificationBackground; - private overlayBackground: OverlayBackground; + private overlayBackground: OverlayBackgroundInterface; private filelessImporterBackground: FilelessImporterBackground; private runtimeBackground: RuntimeBackground; private tabsBackground: TabsBackground; @@ -901,6 +904,7 @@ export default class MainBackground { this.scriptInjectorService, this.accountService, this.authService, + this.configService, messageListener, ); this.auditService = new AuditService(this.cryptoFunctionService, this.apiService); @@ -1052,17 +1056,7 @@ export default class MainBackground { themeStateService, this.configService, ); - this.overlayBackground = new OverlayBackground( - this.cipherService, - this.autofillService, - this.authService, - this.environmentService, - this.domainSettingsService, - this.autofillSettingsService, - this.i18nService, - this.platformUtilsService, - themeStateService, - ); + this.filelessImporterBackground = new FilelessImporterBackground( this.configService, this.authService, @@ -1072,11 +1066,6 @@ export default class MainBackground { this.syncService, this.scriptInjectorService, ); - this.tabsBackground = new TabsBackground( - this, - this.notificationBackground, - this.overlayBackground, - ); const contextMenuClickedHandler = new ContextMenuClickedHandler( (options) => this.platformUtilsService.copyToClipboard(options.text), @@ -1156,6 +1145,47 @@ export default class MainBackground { } this.userAutoUnlockKeyService = new UserAutoUnlockKeyService(this.cryptoService); + + this.configService + .getFeatureFlag(FeatureFlag.InlineMenuPositioningImprovements) + .then(async (enabled) => { + if (!enabled) { + this.overlayBackground = new LegacyOverlayBackground( + this.cipherService, + this.autofillService, + this.authService, + this.environmentService, + this.domainSettingsService, + this.autofillSettingsService, + this.i18nService, + this.platformUtilsService, + themeStateService, + ); + } else { + this.overlayBackground = new OverlayBackground( + this.logService, + this.cipherService, + this.autofillService, + this.authService, + this.environmentService, + this.domainSettingsService, + this.autofillSettingsService, + this.i18nService, + this.platformUtilsService, + themeStateService, + ); + } + + this.tabsBackground = new TabsBackground( + this, + this.notificationBackground, + this.overlayBackground, + ); + + await this.overlayBackground.init(); + await this.tabsBackground.init(); + }) + .catch((error) => this.logService.error(`Error initializing OverlayBackground: ${error}`)); } async bootstrap() { @@ -1192,8 +1222,6 @@ export default class MainBackground { await this.notificationBackground.init(); this.filelessImporterBackground.init(); await this.commandsBackground.init(); - await this.overlayBackground.init(); - await this.tabsBackground.init(); this.contextMenusBackground?.init(); await this.idleBackground.init(); this.webRequestBackground?.startListening(); diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index b9ab9e0dd9d..1979d703641 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -67,7 +67,12 @@ "optional_permissions": ["nativeMessaging", "privacy"], "content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'", "sandbox": { - "pages": ["overlay/button.html", "overlay/list.html"], + "pages": [ + "overlay/menu-button.html", + "overlay/menu-list.html", + "overlay/button.html", + "overlay/list.html" + ], "content_security_policy": "sandbox allow-scripts; script-src 'self'" }, "commands": { @@ -107,6 +112,9 @@ "notification/bar.html", "images/icon38.png", "images/icon38_locked.png", + "overlay/menu-button.html", + "overlay/menu-list.html", + "overlay/menu.html", "overlay/button.html", "overlay/list.html", "popup/fonts/*" diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index b9eac49764d..c01117bfe1d 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -73,7 +73,12 @@ "sandbox": "sandbox allow-scripts; script-src 'self'" }, "sandbox": { - "pages": ["overlay/button.html", "overlay/list.html"] + "pages": [ + "overlay/menu-button.html", + "overlay/menu-list.html", + "overlay/button.html", + "overlay/list.html" + ] }, "commands": { "_execute_action": { @@ -113,6 +118,9 @@ "notification/bar.html", "images/icon38.png", "images/icon38_locked.png", + "overlay/menu-button.html", + "overlay/menu-list.html", + "overlay/menu.html", "overlay/button.html", "overlay/list.html", "popup/fonts/*" diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index c102f461a6e..01470f4d115 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -42,6 +42,7 @@ import { } from "@bitwarden/common/autofill/services/user-notification-settings.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; @@ -314,6 +315,7 @@ const safeProviders: SafeProvider[] = [ ScriptInjectorService, AccountServiceAbstraction, AuthService, + ConfigService, MessageListener, ], }), diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index eb1244bc26d..e6ef80bcd9e 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -106,12 +106,27 @@ const plugins = [ chunks: ["notification/bar"], }), new HtmlWebpackPlugin({ - template: "./src/autofill/overlay/pages/button/button.html", + template: "./src/autofill/overlay/inline-menu/pages/button/button.html", + filename: "overlay/menu-button.html", + chunks: ["overlay/menu-button"], + }), + new HtmlWebpackPlugin({ + template: "./src/autofill/overlay/inline-menu/pages/list/list.html", + filename: "overlay/menu-list.html", + chunks: ["overlay/menu-list"], + }), + new HtmlWebpackPlugin({ + template: "./src/autofill/overlay/inline-menu/pages/menu-container/menu-container.html", + filename: "overlay/menu.html", + chunks: ["overlay/menu"], + }), + new HtmlWebpackPlugin({ + template: "./src/autofill/deprecated/overlay/pages/button/legacy-button.html", filename: "overlay/button.html", chunks: ["overlay/button"], }), new HtmlWebpackPlugin({ - template: "./src/autofill/overlay/pages/list/list.html", + template: "./src/autofill/deprecated/overlay/pages/list/legacy-list.html", filename: "overlay/list.html", chunks: ["overlay/list"], }), @@ -161,6 +176,8 @@ const mainConfig = { "./src/autofill/content/trigger-autofill-script-injection.ts", "content/bootstrap-autofill": "./src/autofill/content/bootstrap-autofill.ts", "content/bootstrap-autofill-overlay": "./src/autofill/content/bootstrap-autofill-overlay.ts", + "content/bootstrap-legacy-autofill-overlay": + "./src/autofill/deprecated/content/bootstrap-legacy-autofill-overlay.ts", "content/autofiller": "./src/autofill/content/autofiller.ts", "content/notificationBar": "./src/autofill/content/notification-bar.ts", "content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts", @@ -168,8 +185,16 @@ const mainConfig = { "content/fido2-content-script": "./src/autofill/fido2/content/fido2-content-script.ts", "content/fido2-page-script": "./src/autofill/fido2/content/fido2-page-script.ts", "notification/bar": "./src/autofill/notification/bar.ts", - "overlay/button": "./src/autofill/overlay/pages/button/bootstrap-autofill-overlay-button.ts", - "overlay/list": "./src/autofill/overlay/pages/list/bootstrap-autofill-overlay-list.ts", + "overlay/menu-button": + "./src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts", + "overlay/menu-list": + "./src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts", + "overlay/menu": + "./src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts", + "overlay/button": + "./src/autofill/deprecated/overlay/pages/button/bootstrap-autofill-overlay-button.deprecated.ts", + "overlay/list": + "./src/autofill/deprecated/overlay/pages/list/bootstrap-autofill-overlay-list.deprecated.ts", "encrypt-worker": "../../libs/common/src/platform/services/cryptography/encrypt.worker.ts", "content/lp-fileless-importer": "./src/tools/content/lp-fileless-importer.ts", "content/send-on-installed-message": "./src/vault/content/send-on-installed-message.ts", diff --git a/libs/common/src/autofill/constants/index.ts b/libs/common/src/autofill/constants/index.ts index 6d5af41a17e..efbd0896428 100644 --- a/libs/common/src/autofill/constants/index.ts +++ b/libs/common/src/autofill/constants/index.ts @@ -13,13 +13,16 @@ export const EVENTS = { BLUR: "blur", CLICK: "click", FOCUS: "focus", + FOCUSIN: "focusin", + FOCUSOUT: "focusout", SCROLL: "scroll", RESIZE: "resize", DOMCONTENTLOADED: "DOMContentLoaded", LOAD: "load", MESSAGE: "message", VISIBILITYCHANGE: "visibilitychange", - FOCUSOUT: "focusout", + MOUSEENTER: "mouseenter", + MOUSELEAVE: "mouseleave", } as const; export const ClearClipboardDelay = { @@ -51,6 +54,8 @@ export const SEPARATOR_ID = "separator"; export const NOTIFICATION_BAR_LIFESPAN_MS = 150000; // 150 seconds +export const AUTOFILL_OVERLAY_HANDLE_REPOSITION = "autofill-overlay-handle-reposition-event"; + export const AutofillOverlayVisibility = { Off: 0, OnButtonClick: 1, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index ba23b90cd22..fb4bd1f9668 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -20,6 +20,7 @@ export enum FeatureFlag { MemberAccessReport = "ac-2059-member-access-report", TwoFactorComponentRefactor = "two-factor-component-refactor", EnableTimeThreshold = "PM-5864-dollar-threshold", + InlineMenuPositioningImprovements = "inline-menu-positioning-improvements", GroupsComponentRefactor = "groups-component-refactor", ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner", VaultBulkManagementAction = "vault-bulk-management-action", @@ -54,6 +55,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.MemberAccessReport]: FALSE, [FeatureFlag.TwoFactorComponentRefactor]: FALSE, [FeatureFlag.EnableTimeThreshold]: FALSE, + [FeatureFlag.InlineMenuPositioningImprovements]: FALSE, [FeatureFlag.GroupsComponentRefactor]: FALSE, [FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE, [FeatureFlag.VaultBulkManagementAction]: FALSE,