diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 1424533e73..cfc6879573 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -4599,5 +4599,158 @@ }, "authenticating": { "message": "Authenticating" + }, + "fillGeneratedPassword": { + "message": "Fill generated password", + "description": "Heading for the password generator within the inline menu" + }, + "passwordRegenerated": { + "message": "Password regenerated", + "description": "Notification message for when a password has been regenerated" + }, + "saveLoginToBitwarden": { + "message": "Save login to Bitwarden?", + "description": "Confirmation message for saving a login to Bitwarden" + }, + "spaceCharacterDescriptor": { + "message": "Space", + "description": "Represents the space key in screen reader content as a readable word" + }, + "tildeCharacterDescriptor": { + "message": "Tilde", + "description": "Represents the ~ key in screen reader content as a readable word" + }, + "backtickCharacterDescriptor": { + "message": "Backtick", + "description": "Represents the ` key in screen reader content as a readable word" + }, + "exclamationCharacterDescriptor": { + "message": "Exclamation mark", + "description": "Represents the ! key in screen reader content as a readable word" + }, + "atSignCharacterDescriptor": { + "message": "At sign", + "description": "Represents the @ key in screen reader content as a readable word" + }, + "hashSignCharacterDescriptor": { + "message": "Hash sign", + "description": "Represents the # key in screen reader content as a readable word" + }, + "dollarSignCharacterDescriptor": { + "message": "Dollar sign", + "description": "Represents the $ key in screen reader content as a readable word" + }, + "percentSignCharacterDescriptor": { + "message": "Percent sign", + "description": "Represents the % key in screen reader content as a readable word" + }, + "caretCharacterDescriptor": { + "message": "Caret", + "description": "Represents the ^ key in screen reader content as a readable word" + }, + "ampersandCharacterDescriptor": { + "message": "Ampersand", + "description": "Represents the & key in screen reader content as a readable word" + }, + "asteriskCharacterDescriptor": { + "message": "Asterisk", + "description": "Represents the * key in screen reader content as a readable word" + }, + "parenLeftCharacterDescriptor": { + "message": "Left parenthesis", + "description": "Represents the ( key in screen reader content as a readable word" + }, + "parenRightCharacterDescriptor": { + "message": "Right parenthesis", + "description": "Represents the ) key in screen reader content as a readable word" + }, + "hyphenCharacterDescriptor": { + "message": "Underscore", + "description": "Represents the _ key in screen reader content as a readable word" + }, + "underscoreCharacterDescriptor": { + "message": "Hyphen", + "description": "Represents the - key in screen reader content as a readable word" + }, + "plusCharacterDescriptor": { + "message": "Plus", + "description": "Represents the + key in screen reader content as a readable word" + }, + "equalsCharacterDescriptor": { + "message": "Equals", + "description": "Represents the = key in screen reader content as a readable word" + }, + "braceLeftCharacterDescriptor": { + "message": "Left brace", + "description": "Represents the { key in screen reader content as a readable word" + }, + "braceRightCharacterDescriptor": { + "message": "Right brace", + "description": "Represents the } key in screen reader content as a readable word" + }, + "bracketLeftCharacterDescriptor": { + "message": "Left bracket", + "description": "Represents the [ key in screen reader content as a readable word" + }, + "bracketRightCharacterDescriptor": { + "message": "Right bracket", + "description": "Represents the ] key in screen reader content as a readable word" + }, + "pipeCharacterDescriptor": { + "message": "Pipe", + "description": "Represents the | key in screen reader content as a readable word" + }, + "backSlashCharacterDescriptor": { + "message": "Back slash", + "description": "Represents the back slash key in screen reader content as a readable word" + }, + "colonCharacterDescriptor": { + "message": "Colon", + "description": "Represents the : key in screen reader content as a readable word" + }, + "semicolonCharacterDescriptor": { + "message": "Semicolon", + "description": "Represents the ; key in screen reader content as a readable word" + }, + "doubleQuoteCharacterDescriptor": { + "message": "Double quote", + "description": "Represents the double quote key in screen reader content as a readable word" + }, + "singleQuoteCharacterDescriptor": { + "message": "Single quote", + "description": "Represents the ' key in screen reader content as a readable word" + }, + "lessThanCharacterDescriptor": { + "message": "Less than", + "description": "Represents the < key in screen reader content as a readable word" + }, + "greaterThanCharacterDescriptor": { + "message": "Greater than", + "description": "Represents the > key in screen reader content as a readable word" + }, + "commaCharacterDescriptor": { + "message": "Comma", + "description": "Represents the , key in screen reader content as a readable word" + }, + "periodCharacterDescriptor": { + "message": "Period", + "description": "Represents the . key in screen reader content as a readable word" + }, + "questionCharacterDescriptor": { + "message": "Question mark", + "description": "Represents the ? key in screen reader content as a readable word" + }, + "forwardSlashCharacterDescriptor": { + "message": "Forward slash", + "description": "Represents the / key in screen reader content as a readable word" + }, + "lowercaseAriaLabel": { + "message": "Lowercase" + }, + "uppercaseAriaLabel": { + "message": "Uppercase" + }, + "generatedPassword": { + "message": "Generated password" } } diff --git a/apps/browser/src/autofill/background/abstractions/overlay.background.ts b/apps/browser/src/autofill/background/abstractions/overlay.background.ts index abe7d09701..68d3f32b80 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay.background.ts @@ -2,6 +2,7 @@ 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 { InlineMenuFillTypes } from "../../enums/autofill-overlay.enum"; import AutofillPageDetails from "../../models/autofill-page-details"; import { PageDetail } from "../../services/abstractions/autofill.service"; @@ -32,14 +33,18 @@ export type WebsiteIconData = { icon: string; }; +export type UpdateOverlayCiphersParams = { + updateAllCipherTypes: boolean; + refocusField: boolean; +}; + export type FocusedFieldData = { focusedFieldStyles: Partial; focusedFieldRects: Partial; - filledByCipherType?: CipherType; + inlineMenuFillType?: InlineMenuFillTypes; tabId?: number; frameId?: number; accountCreationFieldType?: string; - showInlineMenuAccountCreation?: boolean; showPasskeys?: boolean; }; @@ -111,6 +116,12 @@ export type ToggleInlineMenuHiddenMessage = { setTransparentInlineMenu?: boolean; }; +export type UpdateInlineMenuVisibilityMessage = { + overlayElement?: string; + isVisible?: boolean; + forceUpdate?: boolean; +}; + export type OverlayBackgroundExtensionMessage = { command: string; portKey?: string; @@ -119,14 +130,15 @@ export type OverlayBackgroundExtensionMessage = { details?: AutofillPageDetails; isFieldCurrentlyFocused?: boolean; isFieldCurrentlyFilling?: boolean; - isVisible?: boolean; subFrameData?: SubFrameOffsetData; focusedFieldData?: FocusedFieldData; + isOpeningFullInlineMenu?: boolean; styles?: Partial; data?: LockedVaultPendingNotificationsData; } & OverlayAddNewItemMessage & CloseInlineMenuMessage & - ToggleInlineMenuHiddenMessage; + ToggleInlineMenuHiddenMessage & + UpdateInlineMenuVisibilityMessage; export type OverlayPortMessage = { [key: string]: any; @@ -188,16 +200,12 @@ export type OverlayBackgroundExtensionMessageHandlers = { updateIsFieldCurrentlyFilling: ({ message }: BackgroundMessageParam) => void; checkIsFieldCurrentlyFilling: () => boolean; getAutofillInlineMenuVisibility: () => void; + openAutofillInlineMenu: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; getInlineMenuCardsVisibility: () => void; getInlineMenuIdentitiesVisibility: () => void; - openAutofillInlineMenu: () => void; closeAutofillInlineMenu: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; checkAutofillInlineMenuFocused: ({ sender }: BackgroundSenderParam) => void; focusAutofillInlineMenuList: () => void; - updateAutofillInlineMenuPosition: ({ - message, - sender, - }: BackgroundOnMessageHandlerParams) => Promise; getAutofillInlineMenuPosition: () => InlineMenuPosition; updateAutofillInlineMenuElementIsVisibleStatus: ({ message, @@ -219,6 +227,7 @@ export type OverlayBackgroundExtensionMessageHandlers = { addEditCipherSubmitted: () => void; editedCipher: () => void; deletedCipher: () => void; + bgSaveCipher: () => void; fido2AbortRequest: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; }; @@ -241,14 +250,16 @@ export type InlineMenuButtonPortMessageHandlers = { export type InlineMenuListPortMessageHandlers = { [key: string]: CallableFunction; - checkAutofillInlineMenuButtonFocused: () => void; - autofillInlineMenuBlurred: () => void; + checkAutofillInlineMenuButtonFocused: ({ port }: PortConnectionParam) => void; + autofillInlineMenuBlurred: ({ port }: PortConnectionParam) => void; unlockVault: ({ port }: PortConnectionParam) => void; fillAutofillInlineMenuCipher: ({ message, port }: PortOnMessageHandlerParams) => void; addNewVaultItem: ({ message, port }: PortOnMessageHandlerParams) => void; viewSelectedCipher: ({ message, port }: PortOnMessageHandlerParams) => void; redirectAutofillInlineMenuFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void; updateAutofillInlineMenuListHeight: ({ message, port }: PortOnMessageHandlerParams) => void; + refreshGeneratedPassword: () => Promise; + fillGeneratedPassword: ({ port }: PortConnectionParam) => Promise; }; export interface OverlayBackground { diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index 0ede9b9609..e043dbfdd2 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -1114,8 +1114,9 @@ describe("NotificationBackground", () => { it("skips saving the domain as a never value if the tab url does not match the queue message domain", async () => { const tab = createChromeTabMock({ id: 2, url: "https://example.com" }); - const sender = mock({ tab }); const message: NotificationBackgroundExtensionMessage = { command: "bgNeverSave" }; + const secondaryTab = createChromeTabMock({ id: 3, url: "https://another.com" }); + const sender = mock({ tab: secondaryTab }); notificationBackground["notificationQueue"] = [ mock({ type: NotificationQueueMessageType.AddLogin, diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 683e3d8f58..e77996fe90 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -173,13 +173,8 @@ export default class NotificationBackground { } private async doNotificationQueueCheck(tab: chrome.tabs.Tab): Promise { - const tabDomain = Utils.getDomain(tab?.url); - if (!tabDomain) { - return; - } - const queueMessage = this.notificationQueue.find( - (message) => message.tab.id === tab.id && message.domain === tabDomain, + (message) => message.tab.id === tab.id && this.queueMessageIsFromTabOrigin(message, tab), ); if (queueMessage) { await this.sendNotificationQueueMessage(tab, queueMessage); @@ -537,8 +532,7 @@ export default class NotificationBackground { continue; } - const tabDomain = Utils.getDomain(tab.url); - if (tabDomain != null && tabDomain !== queueMessage.domain) { + if (!this.queueMessageIsFromTabOrigin(queueMessage, tab)) { continue; } @@ -685,8 +679,7 @@ export default class NotificationBackground { continue; } - const tabDomain = Utils.getDomain(tab.url); - if (tabDomain != null && tabDomain !== queueMessage.domain) { + if (!this.queueMessageIsFromTabOrigin(queueMessage, tab)) { continue; } @@ -829,4 +822,18 @@ export default class NotificationBackground { .catch((error) => this.logService.error(error)); return true; }; + + /** + * Validates whether the queue message is associated with the passed tab. + * + * @param queueMessage - The queue message to check + * @param tab - The tab to check the queue message against + */ + private queueMessageIsFromTabOrigin( + queueMessage: NotificationQueueMessageItem, + tab: chrome.tabs.Tab, + ) { + const tabDomain = Utils.getDomain(tab.url); + return tabDomain === queueMessage.domain || tabDomain === Utils.getDomain(queueMessage.tab.url); + } } diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts b/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts index 8bac8ea691..5793049697 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts @@ -60,6 +60,27 @@ describe("OverlayNotificationsBackground", () => { jest.clearAllTimers(); }); + describe("feature flag behavior", () => { + let runtimeRemoveListenerSpy: jest.SpyInstance; + + beforeEach(() => { + runtimeRemoveListenerSpy = jest.spyOn(chrome.runtime.onMessage, "removeListener"); + }); + + it("removes the extension listeners if the current flag value is set to `false`", () => { + getFeatureFlagMock$.next(false); + + expect(runtimeRemoveListenerSpy).toHaveBeenCalled(); + }); + + it("ignores the feature flag change if the previous flag value is equal to the current flag value", () => { + getFeatureFlagMock$.next(false); + getFeatureFlagMock$.next(false); + + expect(runtimeRemoveListenerSpy).toHaveBeenCalledTimes(1); + }); + }); + describe("setting up the form submission listeners", () => { let fields: MockProxy[]; let details: MockProxy; @@ -180,6 +201,40 @@ describe("OverlayNotificationsBackground", () => { await flushPromises(); }); + it("ignores the store request if the sender is not within the website origins set", () => { + sendMockExtensionMessage( + { + command: "formFieldSubmitted", + uri: "example.com", + username: "username", + password: "password", + newPassword: "newPassword", + }, + mock({ tab: { id: 2 } }), + ); + + expect( + overlayNotificationsBackground["modifyLoginCipherFormData"].get(sender.tab.id), + ).toBeUndefined(); + }); + + it("ignores the store request if the form submission does not include a username, password, or newPassword", () => { + sendMockExtensionMessage( + { + command: "formFieldSubmitted", + uri: "example.com", + username: "", + password: "", + newPassword: "", + }, + sender, + ); + + expect( + overlayNotificationsBackground["modifyLoginCipherFormData"].get(sender.tab.id), + ).toBeUndefined(); + }); + it("stores the modified login cipher form data", async () => { sendMockExtensionMessage( { @@ -203,6 +258,41 @@ describe("OverlayNotificationsBackground", () => { }); }); + it("overrides previously stored modified login cipher form data with a subsequent store request", async () => { + sendMockExtensionMessage( + { + command: "formFieldSubmitted", + uri: "example.com", + username: "oldUsername", + password: "oldPassword", + newPassword: "oldNewPassword", + }, + sender, + ); + await flushPromises(); + + sendMockExtensionMessage( + { + command: "formFieldSubmitted", + uri: "example.com", + username: "username", + password: "", + newPassword: "", + }, + sender, + ); + await flushPromises(); + + expect( + overlayNotificationsBackground["modifyLoginCipherFormData"].get(sender.tab.id), + ).toEqual({ + uri: "example.com", + username: "username", + password: "oldPassword", + newPassword: "oldNewPassword", + }); + }); + it("clears the modified login cipher form data after 5 seconds", () => { sendMockExtensionMessage( { @@ -323,10 +413,9 @@ describe("OverlayNotificationsBackground", () => { it("ignores requests that are not part of an active form submission", async () => { triggerWebRequestOnCompletedEvent( - mock({ + mock({ url: sender.url, tabId: sender.tab.id, - method: "POST", requestId: "123345", }), ); @@ -348,6 +437,25 @@ describe("OverlayNotificationsBackground", () => { await flushPromises(); triggerWebRequestOnCompletedEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + requestId, + }), + ); + + expect(notificationChangedPasswordSpy).not.toHaveBeenCalled(); + expect(notificationAddLoginSpy).not.toHaveBeenCalled(); + }); + + it("clears the notification fallback timeout if the request is completed with an invalid status code", async () => { + const clearFallbackSpy = jest.spyOn( + overlayNotificationsBackground as any, + "clearNotificationFallbackTimeout", + ); + + const requestId = "123345"; + triggerWebRequestOnBeforeRequestEvent( mock({ url: sender.url, tabId: sender.tab.id, @@ -355,9 +463,19 @@ describe("OverlayNotificationsBackground", () => { requestId, }), ); + await flushPromises(); - expect(notificationChangedPasswordSpy).not.toHaveBeenCalled(); - expect(notificationAddLoginSpy).not.toHaveBeenCalled(); + triggerWebRequestOnCompletedEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + statusCode: 404, + requestId, + }), + ); + await flushPromises(); + + expect(clearFallbackSpy).toHaveBeenCalled(); }); }); @@ -402,10 +520,9 @@ describe("OverlayNotificationsBackground", () => { ); }); triggerWebRequestOnCompletedEvent( - mock({ + mock({ url: sender.url, tabId: sender.tab.id, - method: "POST", requestId, }), ); @@ -452,10 +569,9 @@ describe("OverlayNotificationsBackground", () => { }); triggerWebRequestOnCompletedEvent( - mock({ + mock({ url: sender.url, tabId: sender.tab.id, - method: "POST", requestId, }), ); @@ -560,14 +676,59 @@ describe("OverlayNotificationsBackground", () => { expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(0); }); - it("clears all associated data with a tab that is entering a `loading` state", () => { - triggerTabOnUpdatedEvent( - sender.tab.id, - mock({ status: "loading" }), - mock({ status: "loading" }), - ); + describe("tab onUpdated", () => { + it("skips clearing the website origins if the changeInfo does not contain a `loading` status", () => { + triggerTabOnUpdatedEvent( + sender.tab.id, + mock({ status: "complete" }), + mock({ status: "complete" }), + ); - expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(0); + expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(1); + }); + + it("skips clearing the website origins if the changeInfo does not contain a url", () => { + triggerTabOnUpdatedEvent( + sender.tab.id, + mock({ status: "loading", url: "" }), + mock({ status: "loading" }), + ); + + expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(1); + }); + + it("skips clearing the website origins if the tab does not contain known website origins", () => { + triggerTabOnUpdatedEvent( + 199, + mock({ status: "loading", url: "https://example.com" }), + mock({ status: "loading", id: 199 }), + ); + + expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(1); + }); + + it("skips clearing the website origins if the changeInfo's url is present as part of the know website origin match patterns", () => { + triggerTabOnUpdatedEvent( + sender.tab.id, + mock({ + status: "loading", + url: "https://subdomain.example.com", + }), + mock({ status: "loading" }), + ); + + expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(1); + }); + + it("clears all associated data with a tab that is entering a `loading` state", () => { + triggerTabOnUpdatedEvent( + sender.tab.id, + mock({ status: "loading" }), + mock({ status: "loading" }), + ); + + expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(0); + }); }); }); }); diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.ts b/apps/browser/src/autofill/background/overlay-notifications.background.ts index 5ea3e8b8d6..3472bfcc72 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.ts @@ -333,7 +333,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg const response = (await BrowserApi.tabSendMessage( tab, - { command: "getFormFieldDataForNotification" }, + { command: "getInlineMenuFormFieldData" }, { frameId }, )) as OverlayNotificationsExtensionMessage; if (response) { @@ -471,7 +471,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg private shouldTriggerChangePasswordNotification = ( modifyLoginData: ModifyLoginCipherFormData, ) => { - return modifyLoginData.newPassword && !modifyLoginData.username; + return modifyLoginData?.newPassword && !modifyLoginData.username; }; /** @@ -480,7 +480,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg * @param modifyLoginData - The modified login form data */ private shouldTriggerAddLoginNotification = (modifyLoginData: ModifyLoginCipherFormData) => { - return modifyLoginData.username && (modifyLoginData.password || modifyLoginData.newPassword); + return modifyLoginData?.username && (modifyLoginData.password || modifyLoginData.newPassword); }; /** @@ -576,8 +576,20 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg * @param changeInfo - The change info of the tab */ private handleTabUpdated = (tabId: number, changeInfo: chrome.tabs.TabChangeInfo) => { - if (changeInfo.status === "loading" && this.websiteOriginsWithFields.has(tabId)) { - this.websiteOriginsWithFields.delete(tabId); + if (changeInfo.status !== "loading" || !changeInfo.url) { + return; } + + const originPatterns = this.websiteOriginsWithFields.get(tabId); + if (!originPatterns) { + return; + } + + const matchPatters = generateDomainMatchPatterns(changeInfo.url); + if (matchPatters.some((pattern) => originPatterns.has(pattern))) { + return; + } + + this.websiteOriginsWithFields.delete(tabId); }; } diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index b6a04f63d5..ebd73fa4cc 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -42,11 +42,16 @@ import { BrowserPlatformUtilsService } from "../../platform/services/platform-ut import { AutofillOverlayElement, AutofillOverlayPort, + InlineMenuAccountCreationFieldType, + InlineMenuFillType, MAX_SUB_FRAME_DEPTH, RedirectFocusDirection, } from "../enums/autofill-overlay.enum"; +import { InlineMenuFormFieldData } from "../services/abstractions/autofill-overlay-content.service"; import { AutofillService } from "../services/abstractions/autofill.service"; +import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service"; import { + createAutofillFieldMock, createAutofillPageDetailsMock, createChromeTabMock, createFocusedFieldDataMock, @@ -66,6 +71,7 @@ import { import { FocusedFieldData, + InlineMenuPosition, PageDetailsForTab, SubFrameOffsetData, SubFrameOffsetsForTab, @@ -73,6 +79,9 @@ import { import { OverlayBackground } from "./overlay.background"; describe("OverlayBackground", () => { + const generatedPassword = "generated-password"; + const generatedPasswordCallbackMock = jest.fn().mockResolvedValue(generatedPassword); + const addPasswordCallbackMock = jest.fn(); const mockUserId = Utils.newGuid() as UserId; const sendResponse = jest.fn(); let accountService: FakeAccountService; @@ -95,6 +104,7 @@ describe("OverlayBackground", () => { let vaultSettingsServiceMock: MockProxy; let fido2ActiveRequestManager: Fido2ActiveRequestManager; let selectedThemeMock$: BehaviorSubject; + let inlineMenuFieldQualificationService: InlineMenuFieldQualificationService; let themeStateService: MockProxy; let overlayBackground: OverlayBackground; let portKeyForTabSpy: Record; @@ -117,6 +127,7 @@ describe("OverlayBackground", () => { const { initList, initButton } = options; if (initButton) { triggerPortOnConnectEvent(createPortSpyMock(AutofillOverlayPort.Button)); + await flushPromises(); buttonPortSpy = overlayBackground["inlineMenuButtonPort"]; buttonMessageConnectorSpy = createPortSpyMock(AutofillOverlayPort.ButtonMessageConnector); @@ -125,6 +136,7 @@ describe("OverlayBackground", () => { if (initList) { triggerPortOnConnectEvent(createPortSpyMock(AutofillOverlayPort.List)); + await flushPromises(); listPortSpy = overlayBackground["inlineMenuListPort"]; listMessageConnectorSpy = createPortSpyMock(AutofillOverlayPort.ListMessageConnector); @@ -143,7 +155,9 @@ describe("OverlayBackground", () => { domainSettingsService.showFavicons$ = showFaviconsMock$; domainSettingsService.neverDomains$ = neverDomainsMock$; logService = mock(); - cipherService = mock(); + cipherService = mock({ + getAllDecryptedForUrl: jest.fn().mockResolvedValue([]), + }); autofillService = mock(); activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked); authService = mock(); @@ -167,6 +181,7 @@ describe("OverlayBackground", () => { vaultSettingsServiceMock.enablePasskeys$ = enablePasskeysMock$; fido2ActiveRequestManager = new Fido2ActiveRequestManager(); selectedThemeMock$ = new BehaviorSubject(ThemeType.Light); + inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); themeStateService = mock(); themeStateService.selectedTheme$ = selectedThemeMock$; overlayBackground = new OverlayBackground( @@ -181,7 +196,10 @@ describe("OverlayBackground", () => { platformUtilsService, vaultSettingsServiceMock, fido2ActiveRequestManager, + inlineMenuFieldQualificationService, themeStateService, + generatedPasswordCallbackMock, + addPasswordCallbackMock, ); portKeyForTabSpy = overlayBackground["portKeyForTab"]; pageDetailsForTabSpy = overlayBackground["pageDetailsForTab"]; @@ -552,7 +570,10 @@ describe("OverlayBackground", () => { command: "updateIsFieldCurrentlyFocused", isFieldCurrentlyFocused: false, }, - mock({ frameId: 20 }), + mock({ + tab: createChromeTabMock({ id: 1 }), + frameId: 20, + }), ); sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); @@ -609,7 +630,7 @@ describe("OverlayBackground", () => { 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") { + if (message.command === "checkFocusedFieldHasValue") { return Promise.resolve(true); } @@ -640,7 +661,7 @@ describe("OverlayBackground", () => { 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") { + if (message.command === "checkFocusedFieldHasValue") { return Promise.resolve(true); } @@ -792,21 +813,6 @@ describe("OverlayBackground", () => { expect(cipherService.getAllDecryptedForUrl).not.toHaveBeenCalled(); }); - 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(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([loginCipher1, cardCipher]); @@ -816,6 +822,7 @@ describe("OverlayBackground", () => { getTabSpy.mockResolvedValueOnce(previousTab); await overlayBackground.updateOverlayCiphers(); + await flushPromises(); expect(tabsSendMessageSpy).toHaveBeenCalledWith( previousTab, @@ -830,6 +837,7 @@ describe("OverlayBackground", () => { cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); await overlayBackground.updateOverlayCiphers(); + await flushPromises(); expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url, [ @@ -852,6 +860,7 @@ describe("OverlayBackground", () => { cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); await overlayBackground.updateOverlayCiphers(false); + await flushPromises(); expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url); @@ -870,6 +879,7 @@ describe("OverlayBackground", () => { cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); await overlayBackground.updateOverlayCiphers(false); + await flushPromises(); expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url, [ @@ -885,18 +895,20 @@ describe("OverlayBackground", () => { ); }); - 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 () => { + it("posts an `updateAutofillInlineMenuListCiphers` message to the overlay list port, and send a `updateAutofillInlineMenuListCiphers` message to the tab indicating that the list of ciphers is populated", async () => { overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: tab.id }); cipherService.getAllDecryptedForUrl.mockResolvedValue([loginCipher1]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); await overlayBackground.updateOverlayCiphers(); + await flushPromises(); expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "updateAutofillInlineMenuListCiphers", showInlineMenuAccountCreation: false, showPasskeysLabels: false, + focusedFieldHasValue: false, ciphers: [ { accountCreationFieldType: undefined, @@ -923,18 +935,20 @@ describe("OverlayBackground", () => { it("updates the inline menu list with card ciphers", async () => { overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: tab.id, - filledByCipherType: CipherType.Card, + inlineMenuFillType: CipherType.Card, }); cipherService.getAllDecryptedForUrl.mockResolvedValue([loginCipher1, cardCipher]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); await overlayBackground.updateOverlayCiphers(); + await flushPromises(); expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "updateAutofillInlineMenuListCiphers", showInlineMenuAccountCreation: false, showPasskeysLabels: false, + focusedFieldHasValue: false, ciphers: [ { accountCreationFieldType: undefined, @@ -960,18 +974,19 @@ describe("OverlayBackground", () => { overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: tab.id, accountCreationFieldType: "text", - showInlineMenuAccountCreation: true, }); cipherService.getAllDecryptedForUrl.mockResolvedValue([identityCipher, cardCipher]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); await overlayBackground.updateOverlayCiphers(); + await flushPromises(); expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "updateAutofillInlineMenuListCiphers", showInlineMenuAccountCreation: true, showPasskeysLabels: false, + focusedFieldHasValue: false, ciphers: [ { accountCreationFieldType: "text", @@ -999,18 +1014,20 @@ describe("OverlayBackground", () => { overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: tab.id, accountCreationFieldType: "text", - showInlineMenuAccountCreation: true, + inlineMenuFillType: InlineMenuFillType.AccountCreationUsername, }); cipherService.getAllDecryptedForUrl.mockResolvedValue([loginCipher1, identityCipher]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); await overlayBackground.updateOverlayCiphers(); + await flushPromises(); expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "updateAutofillInlineMenuListCiphers", showInlineMenuAccountCreation: true, showPasskeysLabels: false, + focusedFieldHasValue: false, ciphers: [ { accountCreationFieldType: "text", @@ -1056,7 +1073,6 @@ describe("OverlayBackground", () => { overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: tab.id, accountCreationFieldType: "email", - showInlineMenuAccountCreation: true, }); const identityCipherWithoutUsername = mock({ id: "id-5", @@ -1076,11 +1092,13 @@ describe("OverlayBackground", () => { getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); await overlayBackground.updateOverlayCiphers(); + await flushPromises(); expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "updateAutofillInlineMenuListCiphers", showInlineMenuAccountCreation: true, showPasskeysLabels: false, + focusedFieldHasValue: false, ciphers: [ { accountCreationFieldType: "email", @@ -1108,18 +1126,19 @@ describe("OverlayBackground", () => { overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: tab.id, accountCreationFieldType: "password", - showInlineMenuAccountCreation: true, }); cipherService.getAllDecryptedForUrl.mockResolvedValue([identityCipher]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); await overlayBackground.updateOverlayCiphers(); + await flushPromises(); expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "updateAutofillInlineMenuListCiphers", showInlineMenuAccountCreation: true, showPasskeysLabels: false, + focusedFieldHasValue: false, ciphers: [], }); }); @@ -1133,7 +1152,7 @@ describe("OverlayBackground", () => { ); overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: tab.id, - filledByCipherType: CipherType.Login, + inlineMenuFillType: CipherType.Login, showPasskeys: true, }); cipherService.getAllDecryptedForUrl.mockResolvedValue([loginCipher1, passkeyCipher]); @@ -1141,6 +1160,7 @@ describe("OverlayBackground", () => { getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); await overlayBackground.updateOverlayCiphers(); + await flushPromises(); expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "updateAutofillInlineMenuListCiphers", @@ -1205,6 +1225,7 @@ describe("OverlayBackground", () => { ], showInlineMenuAccountCreation: false, showPasskeysLabels: true, + focusedFieldHasValue: false, }); }); @@ -1216,7 +1237,7 @@ describe("OverlayBackground", () => { ); overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: tab.id, - filledByCipherType: CipherType.Login, + inlineMenuFillType: CipherType.Login, showPasskeys: true, }); cipherService.getAllDecryptedForUrl.mockResolvedValue([loginCipher1, passkeyCipher]); @@ -1225,6 +1246,7 @@ describe("OverlayBackground", () => { neverDomainsMock$.next({ "jest-testing-website.com": null }); await overlayBackground.updateOverlayCiphers(); + await flushPromises(); expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "updateAutofillInlineMenuListCiphers", @@ -1268,6 +1290,7 @@ describe("OverlayBackground", () => { ], showInlineMenuAccountCreation: false, showPasskeysLabels: false, + focusedFieldHasValue: false, }); }); @@ -1280,7 +1303,7 @@ describe("OverlayBackground", () => { ); overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: tab.id, - filledByCipherType: CipherType.Login, + inlineMenuFillType: CipherType.Login, showPasskeys: true, }); cipherService.getAllDecryptedForUrl.mockResolvedValue([loginCipher1, passkeyCipher]); @@ -1288,6 +1311,7 @@ describe("OverlayBackground", () => { getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); await overlayBackground.updateOverlayCiphers(); + await flushPromises(); expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "updateAutofillInlineMenuListCiphers", @@ -1331,8 +1355,70 @@ describe("OverlayBackground", () => { ], showInlineMenuAccountCreation: false, showPasskeysLabels: false, + focusedFieldHasValue: false, }); }); + + it("updates the inline menu list with login ciphers when the field fill type is for updating the current password", async () => { + sendMockExtensionMessage( + { + command: "updateFocusedFieldData", + focusedFieldData: createFocusedFieldDataMock({ + inlineMenuFillType: InlineMenuFillType.CurrentPasswordUpdate, + }), + }, + mock({ tab }), + ); + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); + cipherService.getAllDecryptedForUrl.mockResolvedValue([loginCipher2, loginCipher1]); + cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); + + await overlayBackground.updateOverlayCiphers(false); + await flushPromises(); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + ciphers: [ + { + id: "inline-menu-cipher-0", + name: loginCipher1.name, + type: CipherType.Login, + reprompt: loginCipher1.reprompt, + favorite: loginCipher1.favorite, + icon: { + fallbackImage: "images/bwi-globe.png", + icon: "bwi-globe", + image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + imageEnabled: true, + }, + accountCreationFieldType: undefined, + login: { + username: loginCipher1.login.username, + passkey: null, + }, + }, + { + id: "inline-menu-cipher-1", + name: loginCipher2.name, + type: CipherType.Login, + reprompt: loginCipher2.reprompt, + favorite: loginCipher2.favorite, + icon: { + fallbackImage: "images/bwi-globe.png", + icon: "bwi-globe", + image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + imageEnabled: true, + }, + accountCreationFieldType: undefined, + login: { + username: loginCipher2.login.username, + passkey: null, + }, + }, + ], + }), + ); + }); }); describe("extension message handlers", () => { @@ -1874,7 +1960,6 @@ describe("OverlayBackground", () => { const focusedFieldData = createFocusedFieldDataMock({ tabId: tab.id, frameId: sender.frameId, - showInlineMenuAccountCreation: true, }); sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); @@ -1885,6 +1970,7 @@ describe("OverlayBackground", () => { ciphers: [], showInlineMenuAccountCreation: true, showPasskeysLabels: false, + focusedFieldHasValue: false, }); }); @@ -1895,7 +1981,7 @@ describe("OverlayBackground", () => { const focusedFieldData = createFocusedFieldDataMock({ tabId: tab.id, frameId: sender.frameId, - filledByCipherType: CipherType.Login, + inlineMenuFillType: CipherType.Login, }); sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); await flushPromises(); @@ -1903,7 +1989,7 @@ describe("OverlayBackground", () => { const newFocusedFieldData = createFocusedFieldDataMock({ tabId: tab.id, frameId: sender.frameId, - filledByCipherType: CipherType.Card, + inlineMenuFillType: CipherType.Card, }); sendMockExtensionMessage( { command: "updateFocusedFieldData", focusedFieldData: newFocusedFieldData }, @@ -1913,6 +1999,109 @@ describe("OverlayBackground", () => { expect(updateOverlayCiphersSpy).toHaveBeenCalled(); }); + + describe("displaying the password generation menu", () => { + const tab = createChromeTabMock({ id: 2 }); + const sender = mock({ tab, frameId: 100 }); + let focusedFieldData: FocusedFieldData; + + beforeEach(async () => { + await initOverlayElementPorts(); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + overlayBackground["focusedFieldData"] = createFocusedFieldDataMock(); + overlayBackground["isInlineMenuButtonVisible"] = true; + focusedFieldData = createFocusedFieldDataMock({ + tabId: tab.id, + frameId: sender.frameId, + }); + }); + + it("displays the password generator when the focused field is for password generation", async () => { + focusedFieldData.inlineMenuFillType = InlineMenuFillType.PasswordGeneration; + + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); + await flushPromises(); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + command: "updateAutofillInlineMenuGeneratedPassword", + }), + ); + }); + + it("displays the password generator when the focused field is for login and the field has an account creation type of password", async () => { + focusedFieldData.inlineMenuFillType = CipherType.Login; + focusedFieldData.accountCreationFieldType = InlineMenuAccountCreationFieldType.Password; + + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); + await flushPromises(); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + command: "updateAutofillInlineMenuGeneratedPassword", + }), + ); + }); + }); + + describe("displaying the save login menu", () => { + const tab = createChromeTabMock({ id: 2 }); + const sender = mock({ tab, frameId: 100 }); + let focusedFieldData: FocusedFieldData; + let formData: InlineMenuFormFieldData; + + beforeEach(async () => { + await initOverlayElementPorts(); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + overlayBackground["focusedFieldData"] = createFocusedFieldDataMock(); + overlayBackground["isInlineMenuButtonVisible"] = true; + focusedFieldData = createFocusedFieldDataMock({ + tabId: tab.id, + frameId: sender.frameId, + }); + formData = { + uri: "https://example.com", + username: "username", + password: "password", + newPassword: "newPassword", + }; + tabsSendMessageSpy.mockImplementation((_tab, message) => { + if (message.command === "getInlineMenuFormFieldData") { + return Promise.resolve(formData); + } + + return Promise.resolve(); + }); + }); + + it("shows the save login menu when the focused field type is for password generation and the field is filled", async () => { + focusedFieldData.inlineMenuFillType = InlineMenuFillType.PasswordGeneration; + + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData, focusedFieldHasValue: true }, + sender, + ); + await flushPromises(); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "showSaveLoginInlineMenuList", + }); + }); + + it("shows the save login menu when the focused field type is for a login cipher and the field is filled", async () => { + focusedFieldData.inlineMenuFillType = CipherType.Login; + + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData, focusedFieldHasValue: true }, + sender, + ); + await flushPromises(); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "showSaveLoginInlineMenuList", + }); + }); + }); }); describe("updateIsFieldCurrentlyFocused message handler", () => { @@ -2004,48 +2193,195 @@ describe("OverlayBackground", () => { describe("openAutofillInlineMenu message handler", () => { let sender: chrome.runtime.MessageSender; + const topFrameSendOptions = { frameId: 0 }; beforeEach(() => { - sender = mock({ tab: { id: 1 } }); + sender = mock({ + tab: createChromeTabMock({ id: 1, url: "https://jest-testing-website.com" }), + }); getTabFromCurrentWindowIdSpy.mockResolvedValue(sender.tab); tabsSendMessageSpy.mockImplementation(); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData: createFocusedFieldDataMock() }, + sender, + ); }); - it("opens the autofill inline menu by sending a message to the current tab", async () => { - sendMockExtensionMessage({ command: "openAutofillInlineMenu" }, sender); + it("updates the inline menu position of both button and list elements if the inline menu is being forced open", async () => { + sendMockExtensionMessage( + { command: "openAutofillInlineMenu", isOpeningFullInlineMenu: true }, + sender, + ); await flushPromises(); expect(tabsSendMessageSpy).toHaveBeenCalledWith( sender.tab, { - command: "openAutofillInlineMenu", - isFocusingFieldElement: false, - isOpeningFullInlineMenu: false, - authStatus: AuthenticationStatus.Unlocked, + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.Button, }, - { frameId: 0 }, + topFrameSendOptions, ); - }); - - 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, + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.List, }, - { frameId: 10 }, + topFrameSendOptions, ); }); + + describe("when the focused field does not have a value", () => { + beforeEach(() => { + jest + .spyOn(overlayBackground as any, "checkFocusedFieldHasValue") + .mockResolvedValue(false); + }); + + it("updates the position of the both button and list elements if the user has the inline menu set to show on field focus", async () => { + inlineMenuVisibilityMock$.next(AutofillOverlayVisibility.OnFieldFocus); + + sendMockExtensionMessage({ command: "openAutofillInlineMenu" }, sender); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.Button, + }, + topFrameSendOptions, + ); + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.List, + }, + topFrameSendOptions, + ); + }); + + it("closes the list if the user has the inline menu set to show on button click and the list is open", async () => { + overlayBackground["isInlineMenuListVisible"] = true; + inlineMenuVisibilityMock$.next(AutofillOverlayVisibility.OnButtonClick); + + sendMockExtensionMessage({ command: "openAutofillInlineMenu" }, sender); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { command: "closeAutofillInlineMenu", overlayElement: AutofillOverlayElement.List }, + topFrameSendOptions, + ); + }); + + it("updates the position of the button if the user has the inline menu set to show on button click", async () => { + inlineMenuVisibilityMock$.next(AutofillOverlayVisibility.OnButtonClick); + + sendMockExtensionMessage({ command: "openAutofillInlineMenu" }, sender); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.Button, + }, + topFrameSendOptions, + ); + expect(tabsSendMessageSpy).not.toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.List, + }, + topFrameSendOptions, + ); + }); + }); + + describe("when the focused field has a value", () => { + beforeEach(() => { + overlayBackground["inlineMenuCiphers"] = new Map([ + ["inline-menu-cipher-1", mock({ id: "inline-menu-cipher-1" })], + ]); + jest.spyOn(overlayBackground as any, "checkFocusedFieldHasValue").mockResolvedValue(true); + }); + + it("updates the position of both button and list elements if the inline menu is showing the save login view", async () => { + overlayBackground["inlineMenuCiphers"] = new Map([]); + const formData = { + uri: "https://example.com", + username: "username", + password: "password", + newPassword: "newPassword", + }; + tabsSendMessageSpy.mockImplementation((_tab, message) => { + if (message.command === "getInlineMenuFormFieldData") { + return Promise.resolve(formData); + } + + return Promise.resolve(); + }); + + sendMockExtensionMessage({ command: "openAutofillInlineMenu" }, sender); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.Button, + }, + topFrameSendOptions, + ); + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.List, + }, + topFrameSendOptions, + ); + }); + + it("closes the inline menu list if it is visible", async () => { + overlayBackground["isInlineMenuListVisible"] = true; + + sendMockExtensionMessage({ command: "openAutofillInlineMenu" }, sender); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { command: "closeAutofillInlineMenu", overlayElement: AutofillOverlayElement.List }, + topFrameSendOptions, + ); + }); + + it("updates the position of the inline menu button", async () => { + sendMockExtensionMessage({ command: "openAutofillInlineMenu" }, sender); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.Button, + }, + topFrameSendOptions, + ); + expect(tabsSendMessageSpy).not.toHaveBeenCalledWith( + sender.tab, + { + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.List, + }, + topFrameSendOptions, + ); + }); + }); }); describe("closeAutofillInlineMenu", () => { @@ -2239,192 +2575,19 @@ describe("OverlayBackground", () => { }); }); - 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", - }); - }); - - describe("getAutofillInlineMenuPosition", () => { - it("returns the current inline menu position", async () => { - overlayBackground["inlineMenuPosition"] = { - button: { left: 1, top: 2, width: 3, height: 4 }, - }; - - sendMockExtensionMessage( - { command: "getAutofillInlineMenuPosition" }, - mock(), - sendResponse, - ); - await flushPromises(); - - expect(sendResponse).toHaveBeenCalledWith({ - button: { left: 1, top: 2, width: 3, height: 4 }, - }); - }); - }); - - 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], - ]); - jest.spyOn(overlayBackground as any, "updateInlineMenuPositionAfterRepositionEvent"); + describe("getAutofillInlineMenuPosition", () => { + it("returns the current inline menu positio", async () => { + const inlineMenuPosition: InlineMenuPosition = mock(); + overlayBackground["inlineMenuPosition"] = inlineMenuPosition; sendMockExtensionMessage( - { - command: "updateAutofillInlineMenuPosition", - overlayElement: AutofillOverlayElement.List, - }, - sender, + { command: "getAutofillInlineMenuPosition" }, + mock(), + sendResponse, ); await flushPromises(); - jest.advanceTimersByTime(150); - expect( - overlayBackground["updateInlineMenuPositionAfterRepositionEvent"], - ).toHaveBeenCalled(); + expect(sendResponse).toHaveBeenCalledWith(inlineMenuPosition); }); }); @@ -2553,10 +2716,8 @@ describe("OverlayBackground", () => { }); describe("unlockCompleted", () => { - let updateInlineMenuCiphersSpy: jest.SpyInstance; - beforeEach(async () => { - updateInlineMenuCiphersSpy = jest.spyOn(overlayBackground, "updateOverlayCiphers"); + jest.spyOn(overlayBackground, "updateOverlayCiphers"); await initOverlayElementPorts(); }); @@ -2578,8 +2739,8 @@ describe("OverlayBackground", () => { expect(updateInlineMenuCiphersSpy).toHaveBeenCalled(); }); - it("opens the inline menu if a retry command is present in the message", async () => { - updateInlineMenuCiphersSpy.mockImplementation(); + it("focuses the most recently focused field if a retry command is present in the message", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(createChromeTabMock({ id: 1 })); sendMockExtensionMessage({ command: "unlockCompleted", @@ -2589,16 +2750,9 @@ describe("OverlayBackground", () => { }); await flushPromises(); - expect(tabsSendMessageSpy).toHaveBeenCalledWith( - expect.any(Object), - { - command: "openAutofillInlineMenu", - isFocusingFieldElement: true, - isOpeningFullInlineMenu: false, - authStatus: AuthenticationStatus.Unlocked, - }, - { frameId: 0 }, - ); + expect(tabsSendMessageSpy).toHaveBeenCalledWith(expect.any(Object), { + command: "focusMostRecentlyFocusedField", + }); }); }); @@ -2667,7 +2821,7 @@ describe("OverlayBackground", () => { const portKey = "inlineMenuButtonPort"; beforeEach(async () => { - sender = mock({ tab: { id: 1 } }); + sender = mock({ tab: createChromeTabMock({ id: 1 }) }); portKeyForTabSpy[sender.tab.id] = portKey; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); await initOverlayElementPorts(); @@ -2703,6 +2857,16 @@ describe("OverlayBackground", () => { it("opens the inline menu if the user auth status is unlocked", async () => { getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(sender.tab); + sendMockExtensionMessage( + { + command: "updateFocusedFieldData", + focusedFieldData: mock(), + }, + mock({ tab: buttonMessageConnectorSpy.sender.tab }), + ); + jest + .spyOn(overlayBackground as any, "getInlineMenuButtonPosition") + .mockReturnValueOnce({ x: 0, y: 0 }); sendPortMessage(buttonMessageConnectorSpy, { command: "autofillInlineMenuButtonClicked", portKey, @@ -2712,10 +2876,8 @@ describe("OverlayBackground", () => { expect(tabsSendMessageSpy).toHaveBeenCalledWith( sender.tab, { - command: "openAutofillInlineMenu", - isFocusingFieldElement: false, - isOpeningFullInlineMenu: true, - authStatus: AuthenticationStatus.Unlocked, + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.List, }, { frameId: 0 }, ); @@ -2936,7 +3098,7 @@ describe("OverlayBackground", () => { expect(autofillService.doAutoFill).not.toHaveBeenCalled(); }); - it("autofills the selected cipher and move it to the top of the front of the ciphers map", async () => { + it("autofills the selected cipher and moves 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" }); @@ -3094,6 +3256,54 @@ describe("OverlayBackground", () => { }); }); }); + + it("fills the current password fields exclusively when filling for a current password update", async () => { + globalThis.structuredClone = jest.fn((value) => value); + sendMockExtensionMessage( + { + command: "updateFocusedFieldData", + focusedFieldData: createFocusedFieldDataMock({ + inlineMenuFillType: InlineMenuFillType.CurrentPasswordUpdate, + }), + }, + sender, + ); + const currentPasswordField = createAutofillFieldMock({ + autoCompleteType: "current-password", + title: "Current Password", + type: "password", + }); + const newPasswordField = createAutofillFieldMock({ + autoCompleteType: "new-password", + title: "New Password", + type: "password", + }); + const confirmNewPasswordField = createAutofillFieldMock({ + autoCompleteType: "new-password", + title: "Confirm New Password", + type: "password", + }); + const pageDetails = createAutofillPageDetailsMock({ + fields: [currentPasswordField, newPasswordField, confirmNewPasswordField], + }); + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails }], + ]); + + sendPortMessage(listMessageConnectorSpy, { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "inline-menu-cipher-2", + portKey, + }); + await flushPromises(); + + pageDetails.fields = [currentPasswordField]; + expect(autofillService.doAutoFill).toHaveBeenCalledWith( + expect.objectContaining({ + pageDetails: [expect.objectContaining({ details: pageDetails })], + }), + ); + }); }); describe("addNewVaultItem message handler", () => { @@ -3102,10 +3312,17 @@ describe("OverlayBackground", () => { sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); await flushPromises(); - sendPortMessage(listMessageConnectorSpy, { command: "addNewVaultItem", portKey }); + sendPortMessage(listMessageConnectorSpy, { + command: "addNewVaultItem", + portKey, + addNewCipherType: CipherType.Login, + }); await flushPromises(); - expect(tabsSendMessageSpy).not.toHaveBeenCalled(); + expect(tabsSendMessageSpy).not.toHaveBeenCalledWith(sender.tab, { + command: "addNewVaultItemFromOverlay", + addNewCipherType: CipherType.Login, + }); }); it("sends a message to the tab to add a new vault item", async () => { @@ -3219,6 +3436,113 @@ describe("OverlayBackground", () => { }); }); }); + + describe("refreshGeneratedPassword", () => { + it("refreshes the generated password", async () => { + overlayBackground["generatedPassword"] = "populated"; + + sendPortMessage(listMessageConnectorSpy, { command: "refreshGeneratedPassword", portKey }); + await flushPromises(); + + expect(generatedPasswordCallbackMock).toHaveBeenCalled(); + }); + + it("sends a message to the list port indicating that the generated password should be updated", async () => { + sendPortMessage(listMessageConnectorSpy, { command: "refreshGeneratedPassword", portKey }); + await flushPromises(); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuGeneratedPassword", + generatedPassword, + refreshPassword: true, + }); + }); + }); + + describe("fillGeneratedPassword", () => { + const focusedFieldData = createFocusedFieldDataMock({ + inlineMenuFillType: InlineMenuFillType.PasswordGeneration, + }); + + beforeEach(() => { + globalThis.structuredClone = jest.fn((value) => value); + sendMockExtensionMessage( + { + command: "updateFocusedFieldData", + focusedFieldData, + }, + sender, + ); + overlayBackground["generatedPassword"] = generatedPassword; + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, createPageDetailMock()], + ]); + }); + + describe("skipping filling the generated password", () => { + it("skips filling when the password has not been created", () => { + overlayBackground["generatedPassword"] = ""; + + sendPortMessage(listMessageConnectorSpy, { command: "fillGeneratedPassword", portKey }); + + expect(autofillService.doAutoFill).not.toHaveBeenCalled(); + }); + + it("skips filling when the page details for the tab are not set", () => { + overlayBackground["pageDetailsForTab"][sender.tab.id] = undefined; + + sendPortMessage(listMessageConnectorSpy, { command: "fillGeneratedPassword", portKey }); + + expect(autofillService.doAutoFill).not.toHaveBeenCalled(); + }); + + it("skips filling when the page details for the tab does not contain a value", () => { + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([]); + + sendPortMessage(listMessageConnectorSpy, { command: "fillGeneratedPassword", portKey }); + + expect(autofillService.doAutoFill).not.toHaveBeenCalled(); + }); + }); + + it("filters the page details to only include the new password fields before filling", async () => { + sendPortMessage(listMessageConnectorSpy, { command: "fillGeneratedPassword", portKey }); + await flushPromises(); + + expect(autofillService.doAutoFill).toHaveBeenCalledWith({ + tab: sender.tab, + cipher: expect.any(Object), + pageDetails: [overlayBackground["pageDetailsForTab"][sender.tab.id].get(sender.frameId)], + fillNewPassword: true, + allowTotpAutofill: false, + }); + }); + + it("opens the inline menu for fields that fill a generated password", async () => { + jest.useFakeTimers(); + const formData = { + uri: "https://example.com", + username: "username", + password: "password", + newPassword: "newPassword", + }; + tabsSendMessageSpy.mockImplementation((_tab, message) => { + if (message.command === "getInlineMenuFormFieldData") { + return Promise.resolve(formData); + } + + return Promise.resolve(); + }); + const openInlineMenuSpy = jest.spyOn(overlayBackground as any, "openInlineMenu"); + + sendPortMessage(listMessageConnectorSpy, { command: "fillGeneratedPassword", portKey }); + await flushPromises(); + jest.advanceTimersByTime(400); + await flushPromises(); + + expect(openInlineMenuSpy).toHaveBeenCalled(); + }); + }); }); describe("handle web navigation on committed events", () => { @@ -3302,6 +3626,20 @@ describe("OverlayBackground", () => { expect(overlayBackground["expiredPorts"].length).toBe(1); }); + + it("generates a password for the password generator view", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + const focusedFieldData = createFocusedFieldDataMock({ + inlineMenuFillType: CipherType.Login, + accountCreationFieldType: InlineMenuAccountCreationFieldType.Password, + }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + await initOverlayElementPorts(); + await flushPromises(); + + expect(generatedPasswordCallbackMock).toHaveBeenCalled(); + }); }); describe("handle overlay element port onMessage", () => { diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 49788d6740..2b8f2c273c 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -1,13 +1,13 @@ import { + debounceTime, firstValueFrom, + map, merge, + Observable, ReplaySubject, Subject, - throttleTime, switchMap, - debounceTime, - Observable, - map, + throttleTime, } from "rxjs"; import { parse } from "tldts"; @@ -52,13 +52,21 @@ import { import { AutofillOverlayElement, AutofillOverlayPort, + InlineMenuAccountCreationFieldType, + InlineMenuAccountCreationFieldTypes, + InlineMenuFillType, + InlineMenuFillTypes, MAX_SUB_FRAME_DEPTH, } from "../enums/autofill-overlay.enum"; -import { AutofillService } from "../services/abstractions/autofill.service"; +import AutofillField from "../models/autofill-field"; +import { InlineMenuFormFieldData } from "../services/abstractions/autofill-overlay-content.service"; +import { AutofillService, PageDetail } from "../services/abstractions/autofill.service"; +import { InlineMenuFieldQualificationService } from "../services/abstractions/inline-menu-field-qualifications.service"; import { generateDomainMatchPatterns, generateRandomChars, isInvalidResponseStatusCode, + specialCharacterToKeyMap, } from "../utils"; import { LockedVaultPendingNotificationsData } from "./abstractions/notification.background"; @@ -83,33 +91,39 @@ import { SubFrameOffsetData, SubFrameOffsetsForTab, ToggleInlineMenuHiddenMessage, + UpdateInlineMenuVisibilityMessage, + UpdateOverlayCiphersParams, } from "./abstractions/overlay.background"; export class OverlayBackground implements OverlayBackgroundInterface { private readonly openUnlockPopout = openUnlockPopout; private readonly openViewVaultItemPopout = openViewVaultItemPopout; private readonly openAddEditVaultItemPopout = openAddEditVaultItemPopout; - private readonly storeInlineMenuFido2CredentialsSubject = new ReplaySubject(1); + private readonly updateOverlayCiphers$ = new Subject(); + private readonly storeInlineMenuFido2Credentials$ = new ReplaySubject(1); + private readonly startInlineMenuDelayedClose$ = new Subject(); + private readonly cancelInlineMenuDelayedClose$ = new Subject(); + private readonly startInlineMenuFadeIn$ = new Subject(); + private readonly cancelInlineMenuFadeIn$ = new Subject(); + private readonly startUpdateInlineMenuPosition$ = new Subject(); + private readonly cancelUpdateInlineMenuPosition$ = new Subject(); + private readonly repositionInlineMenu$ = new Subject(); + private readonly rebuildSubFrameOffsets$ = new Subject(); + private readonly addNewVaultItem$ = new Subject(); private pageDetailsForTab: PageDetailsForTab = {}; private subFrameOffsetsForTab: SubFrameOffsetsForTab = {}; private portKeyForTab: Record = {}; private expiredPorts: chrome.runtime.Port[] = []; private inlineMenuButtonPort: chrome.runtime.Port; + private inlineMenuButtonMessageConnectorPort: chrome.runtime.Port; private inlineMenuListPort: chrome.runtime.Port; + private inlineMenuListMessageConnectorPort: chrome.runtime.Port; private inlineMenuCiphers: Map = new Map(); private inlineMenuFido2Credentials: Set = new Set(); private inlineMenuPageTranslations: Record; private inlineMenuPosition: InlineMenuPosition = {}; private cardAndIdentityCiphers: Set | null = null; private currentInlineMenuCiphersCount: number = 0; - 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 addNewVaultItemSubject = new Subject(); private currentAddNewItemData: CurrentAddNewItemData; private focusedFieldData: FocusedFieldData; private isFieldCurrentlyFocused: boolean = false; @@ -118,6 +132,13 @@ export class OverlayBackground implements OverlayBackgroundInterface { private isInlineMenuListVisible: boolean = false; private showPasskeysLabelsWithinInlineMenu: boolean = false; private iconsServerUrl: string; + private generatedPassword: string; + private readonly validPortConnections: Set = new Set([ + AutofillOverlayPort.Button, + AutofillOverlayPort.ButtonMessageConnector, + AutofillOverlayPort.List, + AutofillOverlayPort.ListMessageConnector, + ]); private readonly extensionMessageHandlers: OverlayBackgroundExtensionMessageHandlers = { autofillOverlayElementClosed: ({ message, sender }) => this.overlayElementClosed(message, sender), @@ -132,14 +153,13 @@ export class OverlayBackground implements OverlayBackgroundInterface { updateIsFieldCurrentlyFilling: ({ message }) => this.updateIsFieldCurrentlyFilling(message), checkIsFieldCurrentlyFilling: () => this.checkIsFieldCurrentlyFilling(), getAutofillInlineMenuVisibility: () => this.getInlineMenuVisibility(), + openAutofillInlineMenu: ({ message, sender }) => + this.openInlineMenu(sender, message.isOpeningFullInlineMenu), getInlineMenuCardsVisibility: () => this.getInlineMenuCardsVisibility(), getInlineMenuIdentitiesVisibility: () => this.getInlineMenuIdentitiesVisibility(), - 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), getAutofillInlineMenuPosition: () => this.getInlineMenuPosition(), updateAutofillInlineMenuElementIsVisibleStatus: ({ message, sender }) => this.updateInlineMenuElementIsVisibleStatus(message, sender), @@ -157,10 +177,11 @@ export class OverlayBackground implements OverlayBackgroundInterface { addEditCipherSubmitted: () => this.updateOverlayCiphers(), editedCipher: () => this.updateOverlayCiphers(), deletedCipher: () => this.updateOverlayCiphers(), + bgSaveCipher: () => this.updateOverlayCiphers(), fido2AbortRequest: ({ sender }) => this.abortFido2ActiveRequest(sender.tab.id), }; private readonly inlineMenuButtonPortMessageHandlers: InlineMenuButtonPortMessageHandlers = { - triggerDelayedAutofillInlineMenuClosure: () => this.triggerDelayedInlineMenuClosure(), + triggerDelayedAutofillInlineMenuClosure: () => this.startInlineMenuDelayedClose$.next(), autofillInlineMenuButtonClicked: ({ port }) => this.handleInlineMenuButtonClicked(port), autofillInlineMenuBlurred: () => this.checkInlineMenuListFocused(), redirectAutofillInlineMenuFocusOut: ({ message, port }) => @@ -168,8 +189,9 @@ export class OverlayBackground implements OverlayBackgroundInterface { updateAutofillInlineMenuColorScheme: () => this.updateInlineMenuButtonColorScheme(), }; private readonly inlineMenuListPortMessageHandlers: InlineMenuListPortMessageHandlers = { - checkAutofillInlineMenuButtonFocused: () => this.checkInlineMenuButtonFocused(), - autofillInlineMenuBlurred: () => this.checkInlineMenuButtonFocused(), + checkAutofillInlineMenuButtonFocused: ({ port }) => + this.checkInlineMenuButtonFocused(port.sender), + autofillInlineMenuBlurred: ({ port }) => this.checkInlineMenuButtonFocused(port.sender), unlockVault: ({ port }) => this.unlockVault(port), fillAutofillInlineMenuCipher: ({ message, port }) => this.fillInlineMenuCipher(message, port), addNewVaultItem: ({ message, port }) => this.getNewVaultItemDetails(message, port), @@ -177,6 +199,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { redirectAutofillInlineMenuFocusOut: ({ message, port }) => this.redirectInlineMenuFocusOut(message, port), updateAutofillInlineMenuListHeight: ({ message }) => this.updateInlineMenuListHeight(message), + refreshGeneratedPassword: () => this.updateGeneratedPassword(true), + fillGeneratedPassword: ({ port }) => this.fillGeneratedPassword(port), }; constructor( @@ -191,7 +215,10 @@ export class OverlayBackground implements OverlayBackgroundInterface { private platformUtilsService: PlatformUtilsService, private vaultSettingsService: VaultSettingsService, private fido2ActiveRequestManager: Fido2ActiveRequestManager, + private inlineMenuFieldQualificationService: InlineMenuFieldQualificationService, private themeStateService: ThemeStateService, + private generatePasswordCallback: () => Promise, + private addPasswordCallback: (password: string) => Promise, ) { this.initOverlayEventObservables(); } @@ -210,22 +237,30 @@ export class OverlayBackground implements OverlayBackgroundInterface { * Initializes event observables that handle events which affect the overlay's behavior. */ private initOverlayEventObservables() { - this.storeInlineMenuFido2CredentialsSubject + this.updateOverlayCiphers$ + .pipe( + throttleTime(100, null, { leading: true, trailing: true }), + switchMap((updateOverlayCiphersParams) => + this.handleOverlayCiphersUpdate(updateOverlayCiphersParams), + ), + ) + .subscribe(); + this.storeInlineMenuFido2Credentials$ .pipe(switchMap((tabId) => this.availablePasskeyAuthCredentials$(tabId))) .subscribe((credentials) => this.storeInlineMenuFido2Credentials(credentials)); - this.repositionInlineMenuSubject + this.repositionInlineMenu$ .pipe( debounceTime(1000), switchMap((sender) => this.repositionInlineMenu(sender)), ) .subscribe(); - this.rebuildSubFrameOffsetsSubject + this.rebuildSubFrameOffsets$ .pipe( - throttleTime(100), + throttleTime(100, null, { leading: true, trailing: true }), switchMap((sender) => this.rebuildSubFrameOffsets(sender)), ) .subscribe(); - this.addNewVaultItemSubject + this.addNewVaultItem$ .pipe( debounceTime(100), switchMap((addNewItemData) => @@ -234,19 +269,24 @@ export class OverlayBackground implements OverlayBackgroundInterface { ) .subscribe(); + // Delayed close of the inline menu + merge( + this.startInlineMenuDelayedClose$.pipe(debounceTime(100)), + this.cancelInlineMenuDelayedClose$, + ) + .pipe(switchMap((cancelSignal) => this.triggerDelayedInlineMenuClosure(!!cancelSignal))) + .subscribe(); + // Debounce used to update inline menu position merge( - this.startUpdateInlineMenuPositionSubject.pipe(debounceTime(150)), - this.cancelUpdateInlineMenuPositionSubject, + this.startUpdateInlineMenuPosition$.pipe(debounceTime(150)), + this.cancelUpdateInlineMenuPosition$, ) .pipe(switchMap((sender) => this.updateInlineMenuPositionAfterRepositionEvent(sender))) .subscribe(); // FadeIn Observable behavior - merge( - this.startInlineMenuFadeInSubject.pipe(debounceTime(150)), - this.cancelInlineMenuFadeInSubject, - ) + merge(this.startInlineMenuFadeIn$.pipe(debounceTime(150)), this.cancelInlineMenuFadeIn$) .pipe(switchMap((cancelSignal) => this.triggerInlineMenuFadeIn(!!cancelSignal))) .subscribe(); } @@ -266,25 +306,43 @@ export class OverlayBackground implements OverlayBackgroundInterface { if (this.portKeyForTab[tabId]) { delete this.portKeyForTab[tabId]; } + + this.generatedPassword = null; + this.focusedFieldData = null; } /** * 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. + * + * @param updateAllCipherTypes - Identifies credit card and identity cipher types should also be updated + * @param refocusField - Identifies whether the most recently focused field should be refocused */ - async updateOverlayCiphers(updateAllCipherTypes = true) { + async updateOverlayCiphers(updateAllCipherTypes = true, refocusField = false) { const authStatus = await firstValueFrom(this.authService.activeAccountStatus$); - if (authStatus !== AuthenticationStatus.Unlocked) { - if (this.focusedFieldData) { - this.closeInlineMenuAfterCiphersUpdate().catch((error) => this.logService.error(error)); - } - return; + if (authStatus === AuthenticationStatus.Unlocked) { + this.inlineMenuCiphers = new Map(); + this.updateOverlayCiphers$.next({ updateAllCipherTypes, refocusField }); } + } + /** + * Handles a throttled update of the inline menu ciphers, acting on the emission of a value from + * an observable. Will update on the first and last emissions within a 100ms time frame. + * + * @param updateAllCipherTypes - Identifies credit card and identity cipher types should also be updated + * @param refocusField - Identifies whether the most recently focused field should be refocused + */ + async handleOverlayCiphersUpdate({ + updateAllCipherTypes, + refocusField, + }: UpdateOverlayCiphersParams) { const currentTab = await BrowserApi.getTabFromCurrentWindowId(); + if (this.focusedFieldData && currentTab?.id !== this.focusedFieldData.tabId) { - this.closeInlineMenuAfterCiphersUpdate().catch((error) => this.logService.error(error)); + const focusedFieldTab = await BrowserApi.getTab(this.focusedFieldData.tabId); + this.closeInlineMenu({ tab: focusedFieldTab }, { forceCloseInlineMenu: true }); } if (!currentTab || !currentTab.url?.startsWith("http")) { @@ -300,20 +358,34 @@ export class OverlayBackground implements OverlayBackgroundInterface { } this.inlineMenuFido2Credentials.clear(); - this.storeInlineMenuFido2CredentialsSubject.next(currentTab.id); + this.storeInlineMenuFido2Credentials$.next(currentTab.id); + + await this.generatePassword(); - this.inlineMenuCiphers = new Map(); const ciphersViews = await this.getCipherViews(currentTab, updateAllCipherTypes); for (let cipherIndex = 0; cipherIndex < ciphersViews.length; cipherIndex++) { this.inlineMenuCiphers.set(`inline-menu-cipher-${cipherIndex}`, ciphersViews[cipherIndex]); } - const ciphers = await this.getInlineMenuCipherData(); - this.inlineMenuListPort?.postMessage({ + await this.updateInlineMenuListCiphers(currentTab); + + if (refocusField) { + await BrowserApi.tabSendMessage(currentTab, { command: "focusMostRecentlyFocusedField" }); + } + } + + /** + * Updates the inline menu list's ciphers and sends the updated list to the inline menu list iframe. + * + * @param tab - The current tab + */ + private async updateInlineMenuListCiphers(tab: chrome.tabs.Tab) { + this.postMessageToPort(this.inlineMenuListPort, { command: "updateAutofillInlineMenuListCiphers", - ciphers, - showInlineMenuAccountCreation: this.showInlineMenuAccountCreation(), + ciphers: await this.getInlineMenuCipherData(), + showInlineMenuAccountCreation: this.shouldShowInlineMenuAccountCreation(), showPasskeysLabels: this.showPasskeysLabelsWithinInlineMenu, + focusedFieldHasValue: await this.checkFocusedFieldHasValue(tab), }); } @@ -357,6 +429,11 @@ export class OverlayBackground implements OverlayBackgroundInterface { CipherType.Identity, ]) ).sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b)); + + if (!this.cardAndIdentityCiphers) { + return cipherViews; + } + for (let cipherIndex = 0; cipherIndex < cipherViews.length; cipherIndex++) { const cipherView = cipherViews[cipherIndex]; if ( @@ -384,7 +461,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { let inlineMenuCipherData: InlineMenuCipherData[]; this.showPasskeysLabelsWithinInlineMenu = false; - if (this.showInlineMenuAccountCreation()) { + if (this.shouldShowInlineMenuAccountCreation()) { inlineMenuCipherData = this.buildInlineMenuAccountCreationCiphers( inlineMenuCiphersArray, true, @@ -476,7 +553,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { for (let cipherIndex = 0; cipherIndex < inlineMenuCiphersArray.length; cipherIndex++) { const [inlineMenuCipherId, cipher] = inlineMenuCiphersArray[cipherIndex]; - if (this.focusedFieldData?.filledByCipherType !== cipher.type) { + if (!this.focusedFieldMatchesFillType(cipher.type)) { continue; } @@ -617,29 +694,66 @@ export class OverlayBackground implements OverlayBackgroundInterface { if ( !showInlineMenuAccountCreation || !this.focusedFieldData?.accountCreationFieldType || - this.focusedFieldData.accountCreationFieldType === "password" + this.focusedFieldMatchesAccountCreationType(InlineMenuAccountCreationFieldType.Password) ) { return { fullName }; } return { fullName, - username: - this.focusedFieldData.accountCreationFieldType === "email" - ? cipher.identity.email - : cipher.identity.username, + username: this.focusedFieldMatchesAccountCreationType( + InlineMenuAccountCreationFieldType.Email, + ) + ? cipher.identity.email + : cipher.identity.username, }; } + /** + * Validates whether the currently focused field has an account + * creation field type that matches the provided field type. + * + * @param fieldType - The field type to validate against + */ + private focusedFieldMatchesAccountCreationType(fieldType: InlineMenuAccountCreationFieldTypes) { + return this.focusedFieldData?.accountCreationFieldType === fieldType; + } + + /** + * Validates whether the most recently focused field has a fill + * type value that matches the provided fill type. + * + * @param fillType - The fill type to validate against + * @param focusedFieldData - Optional focused field data to validate against + */ + private focusedFieldMatchesFillType( + fillType: InlineMenuFillTypes, + focusedFieldData?: FocusedFieldData, + ) { + const focusedFieldFillType = focusedFieldData + ? focusedFieldData.inlineMenuFillType + : this.focusedFieldData?.inlineMenuFillType; + + // When updating the current password for a field, it should fill with a login cipher + if ( + focusedFieldFillType === InlineMenuFillType.CurrentPasswordUpdate && + fillType === CipherType.Login + ) { + return true; + } + + return focusedFieldFillType === fillType; + } + /** * Identifies whether the inline menu is being shown on an account creation field. */ - private showInlineMenuAccountCreation(): boolean { - if (typeof this.focusedFieldData?.showInlineMenuAccountCreation !== "undefined") { - return this.focusedFieldData?.showInlineMenuAccountCreation; + private shouldShowInlineMenuAccountCreation(): boolean { + if (this.focusedFieldMatchesFillType(InlineMenuFillType.AccountCreationUsername)) { + return true; } - if (this.focusedFieldData?.filledByCipherType !== CipherType.Login) { + if (!this.focusedFieldMatchesFillType(CipherType.Login)) { return false; } @@ -692,14 +806,6 @@ export class OverlayBackground implements OverlayBackgroundInterface { return await firstValueFrom(this.domainSettingsService.neverDomains$); } - /** - * 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 }); - } - /** * Handles aggregation of page details for a tab. Stores the page details * in association with the tabId of the tab that sent the message. @@ -864,8 +970,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { * @param sender - The sender of the message */ private async rebuildSubFrameOffsets(sender: chrome.runtime.MessageSender) { - this.cancelUpdateInlineMenuPositionSubject.next(); - this.clearDelayedInlineMenuClosure(); + this.cancelUpdateInlineMenuPosition$.next(); + this.cancelInlineMenuDelayedClose$.next(true); const subFrameOffsetsForTab = this.subFrameOffsetsForTab[sender.tab.id]; if (subFrameOffsetsForTab) { @@ -897,14 +1003,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { ).catch((error) => this.logService.error(error)); } - this.updateInlineMenuPosition({ overlayElement: AutofillOverlayElement.Button }, sender).catch( - (error) => this.logService.error(error), - ); - - const mostRecentlyFocusedFieldHasValue = await BrowserApi.tabSendMessage( - sender.tab, - { command: "checkMostRecentlyFocusedFieldHasValue" }, - { frameId: this.focusedFieldData?.frameId }, + this.updateInlineMenuPosition(sender, AutofillOverlayElement.Button).catch((error) => + this.logService.error(error), ); if ((await this.getInlineMenuVisibility()) === AutofillOverlayVisibility.OnButtonClick) { @@ -912,18 +1012,31 @@ export class OverlayBackground implements OverlayBackgroundInterface { } if ( - mostRecentlyFocusedFieldHasValue && + (await this.checkFocusedFieldHasValue(sender.tab)) && (this.checkIsInlineMenuCiphersPopulated(sender) || (await this.getAuthStatus()) !== AuthenticationStatus.Unlocked) ) { return; } - this.updateInlineMenuPosition({ overlayElement: AutofillOverlayElement.List }, sender).catch( - (error) => this.logService.error(error), + this.updateInlineMenuPosition(sender, AutofillOverlayElement.List).catch((error) => + this.logService.error(error), ); } + /** + * Indicates whether the most recently focused field contains a value. + * + * @param tab - The tab to check the focused field for + */ + private async checkFocusedFieldHasValue(tab: chrome.tabs.Tab) { + return !!(await BrowserApi.tabSendMessage( + tab, + { command: "checkMostRecentlyFocusedFieldHasValue" }, + { frameId: this.focusedFieldData?.frameId || 0 }, + )); + } + /** * Triggers autofill for the selected cipher in the inline menu list. Also places * the selected cipher at the top of the list of ciphers. @@ -936,8 +1049,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { { inlineMenuCipherId, usePasskey }: OverlayPortMessage, { sender }: chrome.runtime.Port, ) { - const pageDetails = this.pageDetailsForTab[sender.tab.id]; - if (!inlineMenuCipherId || !pageDetails?.size) { + const pageDetailsForTab = this.pageDetailsForTab[sender.tab.id]; + if (!inlineMenuCipherId || !pageDetailsForTab?.size) { return; } @@ -956,10 +1069,19 @@ export class OverlayBackground implements OverlayBackgroundInterface { if (await this.autofillService.isPasswordRepromptRequired(cipher, sender.tab)) { return; } + + let pageDetails = Array.from(pageDetailsForTab.values()); + if (this.focusedFieldMatchesFillType(InlineMenuFillType.CurrentPasswordUpdate)) { + pageDetails = this.getFilteredPageDetails( + pageDetails, + this.inlineMenuFieldQualificationService.isUpdateCurrentPasswordField, + ); + } + const totpCode = await this.autofillService.doAutoFill({ tab: sender.tab, - cipher: cipher, - pageDetails: Array.from(pageDetails.values()), + cipher, + pageDetails, fillNewPassword: true, allowTotpAutofill: true, }); @@ -971,6 +1093,30 @@ export class OverlayBackground implements OverlayBackgroundInterface { this.updateLastUsedInlineMenuCipher(inlineMenuCipherId, cipher); } + /** + * Filters the passed page details in order to selectively fill elements based + * on the provided callback. + * + * @param pageDetails - The page details to filter + * @param fieldsFilter - The callback to filter the fields + */ + private getFilteredPageDetails( + pageDetails: PageDetail[], + fieldsFilter: (field: AutofillField) => boolean, + ): PageDetail[] { + let filteredPageDetails: PageDetail[] = structuredClone(pageDetails); + if (!filteredPageDetails?.length) { + return []; + } + + filteredPageDetails = filteredPageDetails.map((pageDetail) => { + pageDetail.details.fields = pageDetail.details.fields.filter(fieldsFilter); + return pageDetail; + }); + + return filteredPageDetails; + } + /** * Triggers a FIDO2 authentication from the inline menu using the passed credential ID. * @@ -1040,21 +1186,32 @@ export class OverlayBackground implements OverlayBackgroundInterface { return; } - this.checkInlineMenuButtonFocused(); + this.checkInlineMenuButtonFocused(sender); } /** * Posts a message to the inline menu button iframe to check if it is focused. + * + * @param sender - The sender of the port message */ - private checkInlineMenuButtonFocused() { - this.inlineMenuButtonPort?.postMessage({ command: "checkAutofillInlineMenuButtonFocused" }); + private checkInlineMenuButtonFocused(sender: chrome.runtime.MessageSender) { + if (!this.inlineMenuButtonPort) { + this.closeInlineMenu(sender, { forceCloseInlineMenu: true }); + return; + } + + this.postMessageToPort(this.inlineMenuButtonPort, { + command: "checkAutofillInlineMenuButtonFocused", + }); } /** * Posts a message to the inline menu list iframe to check if it is focused. */ private checkInlineMenuListFocused() { - this.inlineMenuListPort?.postMessage({ command: "checkAutofillInlineMenuListFocused" }); + this.postMessageToPort(this.inlineMenuListPort, { + command: "checkAutofillInlineMenuListFocused", + }); } /** @@ -1070,12 +1227,15 @@ export class OverlayBackground implements OverlayBackgroundInterface { ) { const command = "closeAutofillInlineMenu"; const sendOptions = { frameId: 0 }; + const updateVisibilityDefaults = { overlayElement, isVisible: false, forceUpdate: true }; + this.generatedPassword = null; + if (forceCloseInlineMenu) { BrowserApi.tabSendMessage(sender.tab, { command, overlayElement }, sendOptions).catch( (error) => this.logService.error(error), ); - this.isInlineMenuButtonVisible = false; - this.isInlineMenuListVisible = false; + this.updateInlineMenuElementIsVisibleStatus(updateVisibilityDefaults, sender); + return; } @@ -1089,26 +1249,17 @@ export class OverlayBackground implements OverlayBackgroundInterface { { command, overlayElement: AutofillOverlayElement.List }, sendOptions, ).catch((error) => this.logService.error(error)); - this.isInlineMenuListVisible = false; + this.updateInlineMenuElementIsVisibleStatus( + Object.assign(updateVisibilityDefaults, { overlayElement: AutofillOverlayElement.List }), + sender, + ); return; } - if (overlayElement === AutofillOverlayElement.Button) { - this.isInlineMenuButtonVisible = false; - } - - if (overlayElement === AutofillOverlayElement.List) { - this.isInlineMenuListVisible = false; - } - - if (!overlayElement) { - this.isInlineMenuButtonVisible = false; - this.isInlineMenuListVisible = false; - } - BrowserApi.tabSendMessage(sender.tab, { command, overlayElement }, sendOptions).catch((error) => this.logService.error(error), ); + this.updateInlineMenuElementIsVisibleStatus(updateVisibilityDefaults, sender); } /** @@ -1116,27 +1267,14 @@ export class OverlayBackground implements OverlayBackgroundInterface { * 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) { + private async triggerDelayedInlineMenuClosure(cancelDelayedClose: boolean = false) { + if (cancelDelayedClose || 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); - } + const message = { command: "triggerDelayedAutofillInlineMenuClosure" }; + this.postMessageToPort(this.inlineMenuButtonPort, message); + this.postMessageToPort(this.inlineMenuListPort, message); } /** @@ -1160,6 +1298,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { if (overlayElement === AutofillOverlayElement.Button) { this.inlineMenuButtonPort?.disconnect(); this.inlineMenuButtonPort = null; + this.inlineMenuButtonMessageConnectorPort?.disconnect(); + this.inlineMenuButtonMessageConnectorPort = null; this.isInlineMenuButtonVisible = false; return; @@ -1167,6 +1307,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { this.inlineMenuListPort?.disconnect(); this.inlineMenuListPort = null; + this.inlineMenuListMessageConnectorPort?.disconnect(); + this.inlineMenuListMessageConnectorPort = null; this.isInlineMenuListVisible = false; } @@ -1174,12 +1316,12 @@ export class OverlayBackground implements OverlayBackgroundInterface { * 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 + * @param overlayElement - The overlay element to update, either the list or button */ private async updateInlineMenuPosition( - { overlayElement }: { overlayElement?: string }, sender: chrome.runtime.MessageSender, + overlayElement?: string, ) { if (!overlayElement || !this.senderTabHasFocusedField(sender)) { return; @@ -1193,32 +1335,32 @@ export class OverlayBackground implements OverlayBackgroundInterface { { frameId: 0 }, ); - const subFrameOffsetsForTab = this.subFrameOffsetsForTab[this.focusedFieldData.tabId]; + 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); + this.rebuildSubFrameOffsets$.next(sender); + this.startUpdateInlineMenuPosition$.next(sender); return; } } if (overlayElement === AutofillOverlayElement.Button) { - this.inlineMenuButtonPort?.postMessage({ + this.postMessageToPort(this.inlineMenuButtonPort, { command: "updateAutofillInlineMenuPosition", styles: this.getInlineMenuButtonPosition(subFrameOffsets), }); - this.startInlineMenuFadeIn(); + this.startInlineMenuFadeIn$.next(); return; } - this.inlineMenuListPort?.postMessage({ + this.postMessageToPort(this.inlineMenuListPort, { command: "updateAutofillInlineMenuPosition", styles: this.getInlineMenuListPosition(subFrameOffsets), }); - this.startInlineMenuFadeIn(); + this.startInlineMenuFadeIn$.next(); } /** @@ -1229,20 +1371,18 @@ export class OverlayBackground implements OverlayBackgroundInterface { * @param sender - The sender of the port message */ private updateInlineMenuElementIsVisibleStatus( - message: OverlayBackgroundExtensionMessage, + { overlayElement, isVisible, forceUpdate }: UpdateInlineMenuVisibilityMessage, sender: chrome.runtime.MessageSender, ) { - if (!this.senderTabHasFocusedField(sender)) { + if (!forceUpdate && !this.senderTabHasFocusedField(sender)) { return; } - const { overlayElement, isVisible } = message; - if (overlayElement === AutofillOverlayElement.Button) { + if (!overlayElement || overlayElement === AutofillOverlayElement.Button) { this.isInlineMenuButtonVisible = isVisible; - return; } - if (overlayElement === AutofillOverlayElement.List) { + if (!overlayElement || overlayElement === AutofillOverlayElement.List) { this.isInlineMenuListVisible = isVisible; } } @@ -1254,22 +1394,6 @@ export class OverlayBackground implements OverlayBackgroundInterface { return this.inlineMenuPosition; } - /** - * 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. * @@ -1281,8 +1405,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { } const message = { command: "fadeInAutofillInlineMenuIframe" }; - this.inlineMenuButtonPort?.postMessage(message); - this.inlineMenuListPort?.postMessage(message); + this.postMessageToPort(this.inlineMenuButtonPort, message); + this.postMessageToPort(this.inlineMenuListPort, message); } /** @@ -1359,7 +1483,11 @@ export class OverlayBackground implements OverlayBackgroundInterface { { focusedFieldData }: OverlayBackgroundExtensionMessage, sender: chrome.runtime.MessageSender, ) { - if (this.focusedFieldData && !this.senderFrameHasFocusedField(sender)) { + if ( + this.focusedFieldData && + this.senderTabHasFocusedField(sender) && + !this.senderFrameHasFocusedField(sender) + ) { BrowserApi.tabSendMessage( sender.tab, { command: "unsetMostRecentlyFocusedField" }, @@ -1371,31 +1499,76 @@ export class OverlayBackground implements OverlayBackgroundInterface { this.focusedFieldData = { ...focusedFieldData, tabId: sender.tab.id, frameId: sender.frameId }; this.isFieldCurrentlyFocused = true; - const accountCreationFieldBlurred = - previousFocusedFieldData?.showInlineMenuAccountCreation && - !this.focusedFieldData.showInlineMenuAccountCreation; - - if (accountCreationFieldBlurred || this.showInlineMenuAccountCreation()) { - this.updateIdentityCiphersOnLoginField(previousFocusedFieldData).catch((error) => + if (this.shouldUpdatePasswordGeneratorMenuOnFieldFocus()) { + this.updateInlineMenuGeneratedPasswordOnFocus(sender.tab).catch((error) => this.logService.error(error), ); return; } - if (previousFocusedFieldData?.filledByCipherType !== focusedFieldData?.filledByCipherType) { - const updateAllCipherTypes = focusedFieldData.filledByCipherType !== CipherType.Login; + if (this.shouldUpdateAccountCreationMenuOnFieldFocus(previousFocusedFieldData)) { + this.updateInlineMenuAccountCreationDataOnFocus(previousFocusedFieldData, sender).catch( + (error) => this.logService.error(error), + ); + return; + } + + if ( + !this.focusedFieldMatchesFillType( + focusedFieldData?.inlineMenuFillType, + previousFocusedFieldData, + ) + ) { + const updateAllCipherTypes = !this.focusedFieldMatchesFillType( + CipherType.Login, + focusedFieldData, + ); this.updateOverlayCiphers(updateAllCipherTypes).catch((error) => this.logService.error(error), ); } } + /** + * Identifies if a recently focused field should update as a password generation field. + */ + private shouldUpdatePasswordGeneratorMenuOnFieldFocus() { + return ( + this.isInlineMenuButtonVisible && + this.focusedFieldMatchesFillType(InlineMenuFillType.PasswordGeneration) + ); + } + + /** + * Handles updating the inline menu password generator on focus of a field. + * In the case that the field has a value, will show the save login view. + * + * @param tab - The tab that the field is focused within + */ + private async updateInlineMenuGeneratedPasswordOnFocus(tab: chrome.tabs.Tab) { + if (await this.shouldShowSaveLoginInlineMenuList(tab)) { + this.showSaveLoginInlineMenuList(); + return; + } + + await this.updateGeneratedPassword(); + } + /** * Triggers an update of populated identity ciphers when a login field is focused. * * @param previousFocusedFieldData - The data set of the previously focused field + * @param sender - The sender of the extension message */ - private async updateIdentityCiphersOnLoginField(previousFocusedFieldData: FocusedFieldData) { + private async updateInlineMenuAccountCreationDataOnFocus( + previousFocusedFieldData: FocusedFieldData, + sender: chrome.runtime.MessageSender, + ) { + if (await this.shouldShowSaveLoginInlineMenuList(sender.tab)) { + this.showSaveLoginInlineMenuList(); + return; + } + if ( !previousFocusedFieldData || !this.isInlineMenuButtonVisible || @@ -1404,14 +1577,158 @@ export class OverlayBackground implements OverlayBackgroundInterface { return; } - this.inlineMenuListPort?.postMessage({ - command: "updateAutofillInlineMenuListCiphers", - ciphers: await this.getInlineMenuCipherData(), - showInlineMenuAccountCreation: this.showInlineMenuAccountCreation(), - showPasskeysLabels: this.showPasskeysLabelsWithinInlineMenu, + if ( + this.focusedFieldMatchesFillType(CipherType.Login) && + this.focusedFieldMatchesAccountCreationType(InlineMenuAccountCreationFieldType.Password) + ) { + await this.updateGeneratedPassword(); + return; + } + + await this.updateInlineMenuListCiphers(sender.tab); + } + + /** + * Identifies whether a newly focused field should trigger an update that + * displays the account creation view within the inline menu. + * + * @param previousFocusedFieldData - The data set of the previously focused field + */ + private shouldUpdateAccountCreationMenuOnFieldFocus(previousFocusedFieldData: FocusedFieldData) { + const accountCreationFieldBlurred = + this.focusedFieldMatchesFillType( + InlineMenuFillType.AccountCreationUsername, + previousFocusedFieldData, + ) && !this.focusedFieldMatchesFillType(InlineMenuFillType.AccountCreationUsername); + return accountCreationFieldBlurred || this.shouldShowInlineMenuAccountCreation(); + } + + /** + * Sends a message to the list to show the save login inline menu list view. This view + * is shown after a field is filled with a generated password. + */ + private showSaveLoginInlineMenuList() { + this.postMessageToPort(this.inlineMenuListPort, { command: "showSaveLoginInlineMenuList" }); + } + + /** + * Generates a password based on the user defined password generation options. + */ + private async generatePassword(): Promise { + this.generatedPassword = await this.generatePasswordCallback(); + await this.addPasswordCallback(this.generatedPassword); + } + + /** + * Updates the generated password in the inline menu list. + * + * @param refreshPassword - Identifies whether the generated password should be refreshed + */ + private async updateGeneratedPassword(refreshPassword: boolean = false) { + if (!this.generatedPassword || refreshPassword) { + await this.generatePassword(); + } + + this.postMessageToPort(this.inlineMenuListPort, { + command: "updateAutofillInlineMenuGeneratedPassword", + generatedPassword: this.generatedPassword, + refreshPassword, }); } + /** + * Triggers a fill of the generated password into the current tab. Will trigger + * a focus of the last focused field after filling the password. + * + * @param port - The port of the sender + */ + private async fillGeneratedPassword(port: chrome.runtime.Port) { + if (!this.generatedPassword) { + return; + } + + const pageDetailsForTab = this.pageDetailsForTab[port.sender.tab.id]; + if (!pageDetailsForTab) { + return; + } + + let pageDetails: PageDetail[] = Array.from(pageDetailsForTab.values()); + if (!pageDetails.length) { + return; + } + + // If our currently focused field is for a login form, we want to fill the current password field. + // Otherwise, map over all page details and filter out fields that are not new password fields. + if (!this.focusedFieldMatchesFillType(CipherType.Login)) { + pageDetails = this.getFilteredPageDetails( + pageDetails, + this.inlineMenuFieldQualificationService.isNewPasswordField, + ); + } + + const cipher = this.buildLoginCipherView({ + username: "", + password: this.generatedPassword, + hostname: "", + uri: "", + }); + + await this.autofillService.doAutoFill({ + tab: port.sender.tab, + cipher, + pageDetails, + fillNewPassword: true, + allowTotpAutofill: false, + }); + + globalThis.setTimeout(async () => { + if (await this.shouldShowSaveLoginInlineMenuList(port.sender.tab)) { + await this.openInlineMenu(port.sender, true); + } + }, 300); + } + + /** + * Verifies whether the save login inline menu view should be shown. This requires that + * the login data on the page contains a username and either a current or new password. + * + * @param tab - The tab to check for login data + */ + private async shouldShowSaveLoginInlineMenuList(tab: chrome.tabs.Tab) { + if (this.focusedFieldData?.tabId !== tab.id) { + return false; + } + + const loginData = await this.getInlineMenuFormFieldData(tab); + if (!loginData) { + return false; + } + + return ( + (this.shouldShowInlineMenuAccountCreation() || + this.focusedFieldMatchesFillType(InlineMenuFillType.PasswordGeneration)) && + !!(loginData.username && (loginData.password || loginData.newPassword)) + ); + } + + /** + * Gets the inline menu form field data from the provided tab. + * + * @param tab - The tab to get the form field data from + */ + private async getInlineMenuFormFieldData(tab: chrome.tabs.Tab): Promise { + return await BrowserApi.tabSendMessage( + tab, + { + command: "getInlineMenuFormFieldData", + ignoreFieldFocus: true, + }, + { + frameId: this.focusedFieldData.frameId || 0, + }, + ); + } + /** * Updates the inline menu's visibility based on the display property passed in the extension message. * @@ -1426,7 +1743,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { return; } - this.cancelInlineMenuFadeIn(); + this.cancelInlineMenuFadeIn$.next(true); const display = isInlineMenuHidden ? "none" : "block"; let styles: { display: string; opacity?: string } = { display }; @@ -1437,45 +1754,94 @@ export class OverlayBackground implements OverlayBackgroundInterface { const portMessage = { command: "toggleAutofillInlineMenuHidden", styles }; if (this.inlineMenuButtonPort) { - this.isInlineMenuButtonVisible = !isInlineMenuHidden; - this.inlineMenuButtonPort.postMessage(portMessage); + this.updateInlineMenuElementIsVisibleStatus( + { overlayElement: AutofillOverlayElement.Button, isVisible: !isInlineMenuHidden }, + sender, + ); + this.postMessageToPort(this.inlineMenuButtonPort, portMessage); } if (this.inlineMenuListPort) { this.isInlineMenuListVisible = !isInlineMenuHidden; - this.inlineMenuListPort.postMessage(portMessage); + this.updateInlineMenuElementIsVisibleStatus( + { overlayElement: AutofillOverlayElement.List, isVisible: !isInlineMenuHidden }, + sender, + ); + this.postMessageToPort(this.inlineMenuListPort, portMessage); } if (setTransparentInlineMenu) { - this.startInlineMenuFadeIn(); + this.startInlineMenuFadeIn$.next(); } } /** * 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 inline menu is opened + * @param sender - The sender of the port message * @param isOpeningFullInlineMenu - Identifies whether the full inline menu should be forced open regardless of other states */ - private async openInlineMenu(isFocusingFieldElement = false, isOpeningFullInlineMenu = false) { - this.clearDelayedInlineMenuClosure(); - const currentTab = await BrowserApi.getTabFromCurrentWindowId(); - if (!currentTab) { + private async openInlineMenu( + sender: chrome.runtime.MessageSender, + isOpeningFullInlineMenu = false, + ) { + this.cancelInlineMenuDelayedClose$.next(true); + + if (isOpeningFullInlineMenu) { + await this.updateInlineMenuPosition(sender, AutofillOverlayElement.Button); + await this.updateInlineMenuPosition(sender, AutofillOverlayElement.List); return; } - await BrowserApi.tabSendMessage( - currentTab, - { - command: "openAutofillInlineMenu", - isFocusingFieldElement, - isOpeningFullInlineMenu, - authStatus: await this.getAuthStatus(), - }, - { - frameId: this.focusedFieldData?.tabId === currentTab.id ? this.focusedFieldData.frameId : 0, - }, - ); + if (!(await this.checkFocusedFieldHasValue(sender.tab))) { + await this.openInlineMenuOnEmptyField(sender); + return; + } + + await this.openInlineMenuOnFilledField(sender); + } + + /** + * Triggers logic that handles opening the inline menu on an empty form field. + * + * @param sender - The sender of the port message + */ + private async openInlineMenuOnEmptyField(sender: chrome.runtime.MessageSender) { + if ((await this.getInlineMenuVisibility()) === AutofillOverlayVisibility.OnFieldFocus) { + await this.updateInlineMenuPosition(sender, AutofillOverlayElement.Button); + await this.updateInlineMenuPosition(sender, AutofillOverlayElement.List); + + return; + } + + if (this.isInlineMenuListVisible) { + this.closeInlineMenu(sender, { + forceCloseInlineMenu: true, + overlayElement: AutofillOverlayElement.List, + }); + } + await this.updateInlineMenuPosition(sender, AutofillOverlayElement.Button); + } + + /** + * Triggers logic that handles opening the inline menu on a form field that has a value. + * + * @param sender - The sender of the port message + */ + private async openInlineMenuOnFilledField(sender: chrome.runtime.MessageSender) { + if (await this.shouldShowSaveLoginInlineMenuList(sender.tab)) { + await this.updateInlineMenuPosition(sender, AutofillOverlayElement.Button); + await this.updateInlineMenuPosition(sender, AutofillOverlayElement.List); + return; + } + + if (this.isInlineMenuListVisible) { + this.closeInlineMenu(sender, { + forceCloseInlineMenu: true, + overlayElement: AutofillOverlayElement.List, + }); + } + await this.updateInlineMenuPosition(sender, AutofillOverlayElement.Button); } /** @@ -1510,7 +1876,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { * Sends a message to the inline menu button to update its authentication status. */ private async updateInlineMenuButtonAuthStatus() { - this.inlineMenuButtonPort?.postMessage({ + this.postMessageToPort(this.inlineMenuButtonPort, { command: "updateInlineMenuButtonAuthStatus", authStatus: await this.getAuthStatus(), }); @@ -1524,7 +1890,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { * @param port - The port of the inline menu button */ private async handleInlineMenuButtonClicked(port: chrome.runtime.Port) { - this.clearDelayedInlineMenuClosure(); + this.cancelInlineMenuDelayedClose$.next(true); this.cancelInlineMenuFadeInAndPositionUpdate(); if ((await this.getAuthStatus()) !== AuthenticationStatus.Unlocked) { @@ -1532,7 +1898,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { return; } - await this.openInlineMenu(false, true); + await this.openInlineMenu(port.sender, true); } /** @@ -1543,7 +1909,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { private async unlockVault(port: chrome.runtime.Port) { const { sender } = port; - this.closeInlineMenu(port.sender); + this.closeInlineMenu(port.sender, { forceCloseInlineMenu: true }); const retryMessage: LockedVaultPendingNotificationsData = { commandToRetry: { message: { command: "openAutofillInlineMenu" }, sender }, target: "overlay.background", @@ -1582,7 +1948,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { * Facilitates redirecting focus to the inline menu list. */ private focusInlineMenuList() { - this.inlineMenuListPort?.postMessage({ command: "focusAutofillInlineMenuList" }); + this.postMessageToPort(this.inlineMenuListPort, { command: "focusAutofillInlineMenuList" }); } /** @@ -1593,11 +1959,10 @@ export class OverlayBackground implements OverlayBackgroundInterface { */ private async unlockCompleted(message: OverlayBackgroundExtensionMessage) { await this.updateInlineMenuButtonAuthStatus(); - await this.updateOverlayCiphers(); - if (message.data?.commandToRetry?.message?.command === "openAutofillInlineMenu") { - await this.openInlineMenu(true); - } + const openInlineMenu = + message.data?.commandToRetry?.message?.command === "openAutofillInlineMenu"; + await this.updateOverlayCiphers(true, openInlineMenu); } /** @@ -1605,33 +1970,45 @@ export class OverlayBackground implements OverlayBackgroundInterface { */ private getInlineMenuTranslations() { if (!this.inlineMenuPageTranslations) { - this.inlineMenuPageTranslations = { - 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("unlockYourAccountToViewAutofillSuggestions"), - unlockAccount: this.i18nService.translate("unlockAccount"), - unlockAccountAria: this.i18nService.translate("unlockAccountAria"), - fillCredentialsFor: this.i18nService.translate("fillCredentialsFor"), - username: this.i18nService.translate("username")?.toLowerCase(), - view: this.i18nService.translate("view"), - noItemsToShow: this.i18nService.translate("noItemsToShow"), - newItem: this.i18nService.translate("newItem"), - addNewVaultItem: this.i18nService.translate("addNewVaultItem"), - newLogin: this.i18nService.translate("newLogin"), - addNewLoginItem: this.i18nService.translate("addNewLoginItemAria"), - newCard: this.i18nService.translate("newCard"), - addNewCardItem: this.i18nService.translate("addNewCardItemAria"), - newIdentity: this.i18nService.translate("newIdentity"), - addNewIdentityItem: this.i18nService.translate("addNewIdentityItemAria"), - cardNumberEndsWith: this.i18nService.translate("cardNumberEndsWith"), - passkeys: this.i18nService.translate("passkeys"), - passwords: this.i18nService.translate("passwords"), - logInWithPasskey: this.i18nService.translate("logInWithPasskeyAriaLabel"), - authenticating: this.i18nService.translate("authenticating"), - }; + const translationKeys = [ + "opensInANewWindow", + "toggleBitwardenVaultOverlay", + "unlockYourAccountToViewAutofillSuggestions", + "unlockAccount", + "unlockAccountAria", + "fillCredentialsFor", + "username", + "view", + "noItemsToShow", + "newItem", + "addNewVaultItem", + "newLogin", + "addNewLoginItemAria", + "newCard", + "addNewCardItemAria", + "newIdentity", + "addNewIdentityItemAria", + "cardNumberEndsWith", + "passkeys", + "passwords", + "logInWithPasskeyAriaLabel", + "authenticating", + "fillGeneratedPassword", + "regeneratePassword", + "passwordRegenerated", + "saveLoginToBitwarden", + "lowercaseAriaLabel", + "uppercaseAriaLabel", + "generatedPassword", + ...Object.values(specialCharacterToKeyMap), + ]; + this.inlineMenuPageTranslations = translationKeys.reduce( + (acc: Record, key) => { + acc[key] = this.i18nService.translate(key); + return acc; + }, + {}, + ); } return this.inlineMenuPageTranslations; @@ -1714,7 +2091,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { this.updateCurrentAddNewItemIdentity(identity); } - this.addNewVaultItemSubject.next(this.currentAddNewItemData); + this.addNewVaultItem$.next(this.currentAddNewItemData); } /** @@ -2101,7 +2478,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { * the same value as the page's meta "color-scheme" value. */ private updateInlineMenuButtonColorScheme() { - this.inlineMenuButtonPort?.postMessage({ + this.postMessageToPort(this.inlineMenuButtonPort, { command: "updateAutofillInlineMenuColorScheme", }); } @@ -2117,7 +2494,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { this.inlineMenuPosition.list.height = parsedHeight; } - this.inlineMenuListPort?.postMessage({ + this.postMessageToPort(this.inlineMenuListPort, { command: "updateAutofillInlineMenuPosition", styles: message.styles, }); @@ -2165,7 +2542,12 @@ export class OverlayBackground implements OverlayBackgroundInterface { * @param sender - The sender of the message */ private senderFrameHasFocusedField(sender: chrome.runtime.MessageSender) { - return sender.frameId === this.focusedFieldData?.frameId; + if (!this.focusedFieldData) { + return false; + } + + const { tabId, frameId } = this.focusedFieldData; + return sender.tab.id === tabId && sender.frameId === frameId; } /** @@ -2184,7 +2566,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { this.toggleInlineMenuHidden({ isInlineMenuHidden: true }, sender).catch((error) => this.logService.error(error), ); - this.repositionInlineMenuSubject.next(sender); + this.repositionInlineMenu$.next(sender); } /** @@ -2208,8 +2590,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { */ private async triggerSubFrameFocusInRebuild(sender: chrome.runtime.MessageSender) { this.cancelInlineMenuFadeInAndPositionUpdate(); - this.rebuildSubFrameOffsetsSubject.next(sender); - this.repositionInlineMenuSubject.next(sender); + this.rebuildSubFrameOffsets$.next(sender); + this.repositionInlineMenu$.next(sender); } /** @@ -2228,25 +2610,25 @@ export class OverlayBackground implements OverlayBackgroundInterface { const isFieldWithinViewport = await BrowserApi.tabSendMessage( sender.tab, { command: "checkIsMostRecentlyFocusedFieldWithinViewport" }, - { frameId: this.focusedFieldData.frameId }, + { frameId: this.focusedFieldData?.frameId }, ); if (!isFieldWithinViewport) { await this.closeInlineMenuAfterReposition(sender); return; } - if (this.focusedFieldData.frameId > 0) { - this.rebuildSubFrameOffsetsSubject.next(sender); + if (this.focusedFieldData?.frameId > 0) { + this.rebuildSubFrameOffsets$.next(sender); } - this.startUpdateInlineMenuPositionSubject.next(sender); + this.startUpdateInlineMenuPosition$.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 }, @@ -2259,8 +2641,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { * Cancels the observables that update the position and fade in of the inline menu. */ private cancelInlineMenuFadeInAndPositionUpdate() { - this.cancelInlineMenuFadeIn(); - this.cancelUpdateInlineMenuPositionSubject.next(); + this.cancelInlineMenuFadeIn$.next(true); + this.cancelUpdateInlineMenuPosition$.next(); } /** @@ -2330,14 +2712,13 @@ export class OverlayBackground implements OverlayBackgroundInterface { * @param port - The port that connected to the extension background */ private handlePortOnConnect = async (port: chrome.runtime.Port) => { - const isInlineMenuListMessageConnector = port.name === AutofillOverlayPort.ListMessageConnector; - const isInlineMenuButtonMessageConnector = - port.name === AutofillOverlayPort.ButtonMessageConnector; - if (isInlineMenuListMessageConnector || isInlineMenuButtonMessageConnector) { - port.onMessage.addListener(this.handleOverlayElementPortMessage); + if (!this.validPortConnections.has(port.name)) { return; } + this.storeOverlayPort(port); + port.onMessage.addListener(this.handleOverlayElementPortMessage); + const isInlineMenuListPort = port.name === AutofillOverlayPort.List; const isInlineMenuButtonPort = port.name === AutofillOverlayPort.Button; if (!isInlineMenuListPort && !isInlineMenuButtonPort) { @@ -2348,10 +2729,20 @@ export class OverlayBackground implements OverlayBackgroundInterface { this.portKeyForTab[port.sender.tab.id] = generateRandomChars(12); } - this.storeOverlayPort(port); port.onDisconnect.addListener(this.handlePortOnDisconnect); - port.onMessage.addListener(this.handleOverlayElementPortMessage); - port.postMessage({ + + const authStatus = await this.getAuthStatus(); + const showInlineMenuAccountCreation = this.shouldShowInlineMenuAccountCreation(); + const showInlineMenuPasswordGenerator = await this.shouldInitInlineMenuPasswordGenerator( + authStatus, + isInlineMenuListPort, + showInlineMenuAccountCreation, + ); + const showSaveLoginMenu = + (await this.checkFocusedFieldHasValue(port.sender.tab)) && + (await this.shouldShowSaveLoginInlineMenuList(port.sender.tab)); + + this.postMessageToPort(port, { command: `initAutofillInlineMenu${isInlineMenuListPort ? "List" : "Button"}`, iframeUrl: chrome.runtime.getURL( `overlay/menu-${isInlineMenuListPort ? "list" : "button"}.html`, @@ -2359,7 +2750,6 @@ export class OverlayBackground implements OverlayBackgroundInterface { pageTitle: chrome.i18n.getMessage( isInlineMenuListPort ? "bitwardenVault" : "bitwardenOverlayButton", ), - authStatus: await this.getAuthStatus(), styleSheetUrl: chrome.runtime.getURL( `overlay/menu-${isInlineMenuListPort ? "list" : "button"}.css`, ), @@ -2370,25 +2760,42 @@ export class OverlayBackground implements OverlayBackgroundInterface { portName: isInlineMenuListPort ? AutofillOverlayPort.ListMessageConnector : AutofillOverlayPort.ButtonMessageConnector, - filledByCipherType: this.focusedFieldData?.filledByCipherType, - showInlineMenuAccountCreation: this.showInlineMenuAccountCreation(), + inlineMenuFillType: this.focusedFieldData?.inlineMenuFillType, showPasskeysLabels: this.showPasskeysLabelsWithinInlineMenu, + generatedPassword: showInlineMenuPasswordGenerator ? this.generatedPassword : null, + showSaveLoginMenu, + showInlineMenuAccountCreation, + authStatus, }); this.updateInlineMenuPosition( - { - overlayElement: isInlineMenuListPort - ? AutofillOverlayElement.List - : AutofillOverlayElement.Button, - }, port.sender, + isInlineMenuListPort ? AutofillOverlayElement.List : AutofillOverlayElement.Button, ).catch((error) => this.logService.error(error)); }; + /** + * Wraps the port.postMessage method to handle any errors that may occur. + * + * @param port - The port to send the message to + * @param message - The message to send to the port + */ + private postMessageToPort = (port: chrome.runtime.Port, message: Record) => { + if (!port) { + return; + } + + try { + port.postMessage(message); + } catch { + // Catch when the port.postMessage call triggers an error to ensure login execution continues. + } + }; + /** * 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.inlineMenuListPort); @@ -2399,6 +2806,19 @@ export class OverlayBackground implements OverlayBackgroundInterface { if (port.name === AutofillOverlayPort.Button) { this.storeExpiredOverlayPort(this.inlineMenuButtonPort); this.inlineMenuButtonPort = port; + return; + } + + if (port.name === AutofillOverlayPort.ButtonMessageConnector) { + this.storeExpiredOverlayPort(this.inlineMenuButtonMessageConnectorPort); + this.inlineMenuButtonMessageConnectorPort = port; + return; + } + + if (port.name === AutofillOverlayPort.ListMessageConnector) { + this.storeExpiredOverlayPort(this.inlineMenuListMessageConnectorPort); + this.inlineMenuListMessageConnectorPort = port; + return; } } @@ -2415,6 +2835,38 @@ export class OverlayBackground implements OverlayBackgroundInterface { } } + /** + * Identifies if the focused field should show the inline menu + * password generator when the inline menu is opened. + * + * @param authStatus - The current authentication status + * @param isInlineMenuListPort - Identifies if the port is for the inline menu list + * @param showInlineMenuAccountCreation - Identifies if the inline menu account creation should be shown + */ + private async shouldInitInlineMenuPasswordGenerator( + authStatus: AuthenticationStatus, + isInlineMenuListPort: boolean, + showInlineMenuAccountCreation: boolean, + ) { + if (!isInlineMenuListPort || authStatus !== AuthenticationStatus.Unlocked) { + return false; + } + + const focusFieldShouldShowPasswordGenerator = + this.focusedFieldMatchesFillType(InlineMenuFillType.PasswordGeneration) || + (showInlineMenuAccountCreation && + this.focusedFieldMatchesAccountCreationType(InlineMenuAccountCreationFieldType.Password)); + if (!focusFieldShouldShowPasswordGenerator) { + return false; + } + + if (!this.generatedPassword) { + await this.generatePassword(); + } + + return true; + } + /** * Handles messages sent to the overlay list or button ports. * @@ -2455,15 +2907,27 @@ export class OverlayBackground implements OverlayBackgroundInterface { * @param port - The port that was disconnected */ private handlePortOnDisconnect = (port: chrome.runtime.Port) => { + const updateVisibilityDefaults = { isVisible: false, forceUpdate: true }; + if (port.name === AutofillOverlayPort.List) { this.inlineMenuListPort = null; - this.isInlineMenuListVisible = false; + this.inlineMenuListMessageConnectorPort?.disconnect(); + this.inlineMenuListMessageConnectorPort = null; + this.updateInlineMenuElementIsVisibleStatus( + Object.assign(updateVisibilityDefaults, { overlayElement: AutofillOverlayElement.List }), + port.sender, + ); this.inlineMenuPosition.list = null; } if (port.name === AutofillOverlayPort.Button) { this.inlineMenuButtonPort = null; - this.isInlineMenuButtonVisible = false; + this.inlineMenuButtonMessageConnectorPort?.disconnect(); + this.inlineMenuButtonMessageConnectorPort = null; + this.updateInlineMenuElementIsVisibleStatus( + Object.assign(updateVisibilityDefaults, { overlayElement: AutofillOverlayElement.List }), + port.sender, + ); this.inlineMenuPosition.button = null; } }; diff --git a/apps/browser/src/autofill/background/tabs.background.ts b/apps/browser/src/autofill/background/tabs.background.ts index 0513220c27..ae57bd51ce 100644 --- a/apps/browser/src/autofill/background/tabs.background.ts +++ b/apps/browser/src/autofill/background/tabs.background.ts @@ -92,7 +92,7 @@ export default class TabsBackground { FeatureFlag.InlineMenuPositioningImprovements, ); const removePageDetailsStatus = new Set(["loading", "unloaded"]); - if (!!overlayImprovementsFlag && 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 529607949d..8a9c97e67d 100644 --- a/apps/browser/src/autofill/content/abstractions/autofill-init.ts +++ b/apps/browser/src/autofill/content/abstractions/autofill-init.ts @@ -1,5 +1,4 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; import { CipherType } from "@bitwarden/common/vault/enums"; import { AutofillOverlayElementType } from "../../enums/autofill-overlay.enum"; @@ -21,10 +20,10 @@ export type AutofillExtensionMessage = { authStatus?: AuthenticationStatus; isOpeningFullInlineMenu?: boolean; addNewCipherType?: CipherType; + ignoreFieldFocus?: boolean; data?: { direction?: "previous" | "next" | "current"; forceCloseInlineMenu?: boolean; - newSettingValue?: InlineMenuVisibilitySetting; }; }; diff --git a/apps/browser/src/autofill/content/autofill-init.spec.ts b/apps/browser/src/autofill/content/autofill-init.spec.ts index b98d297d13..d612e63f82 100644 --- a/apps/browser/src/autofill/content/autofill-init.spec.ts +++ b/apps/browser/src/autofill/content/autofill-init.spec.ts @@ -4,6 +4,7 @@ import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript from "../models/autofill-script"; import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service"; import { OverlayNotificationsContentService } from "../overlay/notifications/abstractions/overlay-notifications-content.service"; +import { DomElementVisibilityService } from "../services/abstractions/dom-element-visibility.service"; import { DomQueryService } from "../services/abstractions/dom-query.service"; import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service"; import { @@ -17,6 +18,7 @@ import AutofillInit from "./autofill-init"; describe("AutofillInit", () => { let domQueryService: MockProxy; + let domElementVisibilityService: MockProxy; let overlayNotificationsContentService: MockProxy; let inlineMenuElements: MockProxy; let autofillOverlayContentService: MockProxy; @@ -32,11 +34,13 @@ describe("AutofillInit", () => { }, }); domQueryService = mock(); + domElementVisibilityService = mock(); overlayNotificationsContentService = mock(); inlineMenuElements = mock(); autofillOverlayContentService = mock(); autofillInit = new AutofillInit( domQueryService, + domElementVisibilityService, autofillOverlayContentService, inlineMenuElements, overlayNotificationsContentService, diff --git a/apps/browser/src/autofill/content/autofill-init.ts b/apps/browser/src/autofill/content/autofill-init.ts index 6c34508cb0..42933c57b1 100644 --- a/apps/browser/src/autofill/content/autofill-init.ts +++ b/apps/browser/src/autofill/content/autofill-init.ts @@ -4,9 +4,9 @@ import AutofillPageDetails from "../models/autofill-page-details"; import { AutofillInlineMenuContentService } from "../overlay/inline-menu/abstractions/autofill-inline-menu-content.service"; import { OverlayNotificationsContentService } from "../overlay/notifications/abstractions/overlay-notifications-content.service"; import { AutofillOverlayContentService } from "../services/abstractions/autofill-overlay-content.service"; +import { DomElementVisibilityService } from "../services/abstractions/dom-element-visibility.service"; import { DomQueryService } from "../services/abstractions/dom-query.service"; 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"; @@ -18,7 +18,6 @@ import { class AutofillInit implements AutofillInitInterface { private readonly sendExtensionMessage = sendExtensionMessage; - private readonly domElementVisibilityService: DomElementVisibilityService; private readonly collectAutofillContentService: CollectAutofillContentService; private readonly insertAutofillContentService: InsertAutofillContentService; private collectPageDetailsOnLoadTimeout: number | NodeJS.Timeout | undefined; @@ -33,26 +32,25 @@ class AutofillInit implements AutofillInitInterface { * CollectAutofillContentService and InsertAutofillContentService classes. * * @param domQueryService - Service used to handle DOM queries. + * @param domElementVisibilityService - Used to check if an element is viewable. * @param autofillOverlayContentService - The autofill overlay content service, potentially undefined. * @param autofillInlineMenuContentService - The inline menu content service, potentially undefined. * @param overlayNotificationsContentService - The overlay notifications content service, potentially undefined. */ constructor( domQueryService: DomQueryService, + domElementVisibilityService: DomElementVisibilityService, private autofillOverlayContentService?: AutofillOverlayContentService, private autofillInlineMenuContentService?: AutofillInlineMenuContentService, private overlayNotificationsContentService?: OverlayNotificationsContentService, ) { - this.domElementVisibilityService = new DomElementVisibilityService( - this.autofillInlineMenuContentService, - ); this.collectAutofillContentService = new CollectAutofillContentService( - this.domElementVisibilityService, + domElementVisibilityService, domQueryService, this.autofillOverlayContentService, ); this.insertAutofillContentService = new InsertAutofillContentService( - this.domElementVisibilityService, + domElementVisibilityService, this.collectAutofillContentService, ); } diff --git a/apps/browser/src/autofill/content/bootstrap-autofill-overlay-menu.ts b/apps/browser/src/autofill/content/bootstrap-autofill-overlay-menu.ts index aed0f6cb94..3593064792 100644 --- a/apps/browser/src/autofill/content/bootstrap-autofill-overlay-menu.ts +++ b/apps/browser/src/autofill/content/bootstrap-autofill-overlay-menu.ts @@ -1,5 +1,6 @@ import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service"; import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service"; +import DomElementVisibilityService from "../services/dom-element-visibility.service"; import { DomQueryService } from "../services/dom-query.service"; import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service"; import { setupAutofillInitDisconnectAction } from "../utils"; @@ -8,20 +9,25 @@ import AutofillInit from "./autofill-init"; (function (windowContext) { if (!windowContext.bitwardenAutofillInit) { + let inlineMenuContentService: AutofillInlineMenuContentService; + if (globalThis.self === globalThis.top) { + inlineMenuContentService = new AutofillInlineMenuContentService(); + } + const domQueryService = new DomQueryService(); + const domElementVisibilityService = new DomElementVisibilityService(inlineMenuContentService); const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); const autofillOverlayContentService = new AutofillOverlayContentService( domQueryService, + domElementVisibilityService, inlineMenuFieldQualificationService, ); - let inlineMenuElements: AutofillInlineMenuContentService; - if (globalThis.self === globalThis.top) { - inlineMenuElements = new AutofillInlineMenuContentService(); - } + windowContext.bitwardenAutofillInit = new AutofillInit( domQueryService, + domElementVisibilityService, autofillOverlayContentService, - inlineMenuElements, + inlineMenuContentService, ); setupAutofillInitDisconnectAction(windowContext); diff --git a/apps/browser/src/autofill/content/bootstrap-autofill-overlay-notifications.ts b/apps/browser/src/autofill/content/bootstrap-autofill-overlay-notifications.ts index 0a810c68f5..6fbb076389 100644 --- a/apps/browser/src/autofill/content/bootstrap-autofill-overlay-notifications.ts +++ b/apps/browser/src/autofill/content/bootstrap-autofill-overlay-notifications.ts @@ -1,5 +1,6 @@ import { OverlayNotificationsContentService } from "../overlay/notifications/content/overlay-notifications-content.service"; import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service"; +import DomElementVisibilityService from "../services/dom-element-visibility.service"; import { DomQueryService } from "../services/dom-query.service"; import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service"; import { setupAutofillInitDisconnectAction } from "../utils"; @@ -9,9 +10,11 @@ import AutofillInit from "./autofill-init"; (function (windowContext) { if (!windowContext.bitwardenAutofillInit) { const domQueryService = new DomQueryService(); + const domElementVisibilityService = new DomElementVisibilityService(); const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); const autofillOverlayContentService = new AutofillOverlayContentService( domQueryService, + domElementVisibilityService, inlineMenuFieldQualificationService, ); @@ -22,6 +25,7 @@ import AutofillInit from "./autofill-init"; windowContext.bitwardenAutofillInit = new AutofillInit( domQueryService, + domElementVisibilityService, autofillOverlayContentService, null, overlayNotificationsContentService, diff --git a/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts b/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts index 6df9397f6d..174a695b76 100644 --- a/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts +++ b/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts @@ -1,6 +1,7 @@ import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service"; import { OverlayNotificationsContentService } from "../overlay/notifications/content/overlay-notifications-content.service"; import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service"; +import DomElementVisibilityService from "../services/dom-element-visibility.service"; import { DomQueryService } from "../services/dom-query.service"; import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service"; import { setupAutofillInitDisconnectAction } from "../utils"; @@ -9,24 +10,27 @@ import AutofillInit from "./autofill-init"; (function (windowContext) { if (!windowContext.bitwardenAutofillInit) { - const domQueryService = new DomQueryService(); - const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); - const autofillOverlayContentService = new AutofillOverlayContentService( - domQueryService, - inlineMenuFieldQualificationService, - ); - - let inlineMenuElements: AutofillInlineMenuContentService; + let inlineMenuContentService: AutofillInlineMenuContentService; let overlayNotificationsContentService: OverlayNotificationsContentService; if (globalThis.self === globalThis.top) { - inlineMenuElements = new AutofillInlineMenuContentService(); + inlineMenuContentService = new AutofillInlineMenuContentService(); overlayNotificationsContentService = new OverlayNotificationsContentService(); } + const domQueryService = new DomQueryService(); + const domElementVisibilityService = new DomElementVisibilityService(inlineMenuContentService); + const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); + const autofillOverlayContentService = new AutofillOverlayContentService( + domQueryService, + domElementVisibilityService, + inlineMenuFieldQualificationService, + ); + windowContext.bitwardenAutofillInit = new AutofillInit( domQueryService, + domElementVisibilityService, autofillOverlayContentService, - inlineMenuElements, + inlineMenuContentService, overlayNotificationsContentService, ); setupAutofillInitDisconnectAction(windowContext); diff --git a/apps/browser/src/autofill/content/bootstrap-autofill.ts b/apps/browser/src/autofill/content/bootstrap-autofill.ts index 3de750cd67..ada66f233c 100644 --- a/apps/browser/src/autofill/content/bootstrap-autofill.ts +++ b/apps/browser/src/autofill/content/bootstrap-autofill.ts @@ -1,3 +1,4 @@ +import DomElementVisibilityService from "../services/dom-element-visibility.service"; import { DomQueryService } from "../services/dom-query.service"; import { setupAutofillInitDisconnectAction } from "../utils"; @@ -6,7 +7,11 @@ import AutofillInit from "./autofill-init"; (function (windowContext) { if (!windowContext.bitwardenAutofillInit) { const domQueryService = new DomQueryService(); - windowContext.bitwardenAutofillInit = new AutofillInit(domQueryService); + const domElementVisibilityService = new DomElementVisibilityService(); + windowContext.bitwardenAutofillInit = new AutofillInit( + domQueryService, + domElementVisibilityService, + ); setupAutofillInitDisconnectAction(windowContext); windowContext.bitwardenAutofillInit.init(); diff --git a/apps/browser/src/autofill/deprecated/services/autofill-overlay-content.service.deprecated.ts b/apps/browser/src/autofill/deprecated/services/autofill-overlay-content.service.deprecated.ts index 87af2518dd..27ec68bc67 100644 --- a/apps/browser/src/autofill/deprecated/services/autofill-overlay-content.service.deprecated.ts +++ b/apps/browser/src/autofill/deprecated/services/autofill-overlay-content.service.deprecated.ts @@ -73,6 +73,10 @@ class LegacyAutofillOverlayContentService implements LegacyAutofillOverlayConten * Satisfy the AutofillOverlayContentService interface. */ messageHandlers = {} as AutofillOverlayContentExtensionMessageHandlers; + clearUserFilledFields() { + // do nothing + } + async setupOverlayListeners( autofillFieldElement: ElementWithOpId, autofillFieldData: AutofillField, diff --git a/apps/browser/src/autofill/enums/autofill-overlay.enum.ts b/apps/browser/src/autofill/enums/autofill-overlay.enum.ts index 53f325d520..66ad0da546 100644 --- a/apps/browser/src/autofill/enums/autofill-overlay.enum.ts +++ b/apps/browser/src/autofill/enums/autofill-overlay.enum.ts @@ -1,3 +1,5 @@ +import { CipherType } from "@bitwarden/common/vault/enums"; + export const AutofillOverlayElement = { Button: "autofill-inline-menu-button", List: "autofill-inline-menu-list", @@ -19,4 +21,20 @@ export const RedirectFocusDirection = { Next: "next", } as const; +export enum InlineMenuFillType { + AccountCreationUsername = 5, + PasswordGeneration = 6, + CurrentPasswordUpdate = 7, +} +export type InlineMenuFillTypes = InlineMenuFillType | CipherType; + +export const InlineMenuAccountCreationFieldType = { + Text: "text", + Email: "email", + Password: "password", +} as const; + +export type InlineMenuAccountCreationFieldTypes = + (typeof InlineMenuAccountCreationFieldType)[keyof typeof InlineMenuAccountCreationFieldType]; + export const MAX_SUB_FRAME_DEPTH = 8; diff --git a/apps/browser/src/autofill/models/autofill-field.ts b/apps/browser/src/autofill/models/autofill-field.ts index 0701ef5f65..cc9ba61f4e 100644 --- a/apps/browser/src/autofill/models/autofill-field.ts +++ b/apps/browser/src/autofill/models/autofill-field.ts @@ -1,6 +1,8 @@ -import { CipherType } from "@bitwarden/common/vault/enums"; - import { AutofillFieldQualifierType } from "../enums/autofill-field.enums"; +import { + InlineMenuAccountCreationFieldTypes, + InlineMenuFillTypes, +} from "../enums/autofill-overlay.enum"; /** * Represents a single field that is collected from the page source and is potentially autofilled. @@ -107,15 +109,17 @@ export default class AutofillField { */ maxLength?: number | null; + dataSetValues?: string; + rel?: string | null; checked?: boolean; - filledByCipherType?: CipherType; - - showInlineMenuAccountCreation?: boolean; + inlineMenuFillType?: InlineMenuFillTypes; showPasskeys?: boolean; fieldQualifier?: AutofillFieldQualifierType; + + accountCreationFieldType?: InlineMenuAccountCreationFieldTypes; } diff --git a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-iframe.service.ts b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-iframe.service.ts index f5aff5d65f..f55faec887 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-iframe.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-iframe.service.ts @@ -3,6 +3,8 @@ export type AutofillInlineMenuIframeExtensionMessage = { styles?: Partial; theme?: string; portKey?: string; + generatedPassword?: string; + refreshPassword?: boolean; }; export type AutofillInlineMenuIframeExtensionMessageParam = { @@ -23,6 +25,9 @@ export type BackgroundPortMessageHandlers = { }: AutofillInlineMenuIframeExtensionMessageParam) => void; updateAutofillInlineMenuColorScheme: () => void; fadeInAutofillInlineMenuIframe: () => void; + updateAutofillInlineMenuGeneratedPassword: ({ + message, + }: AutofillInlineMenuIframeExtensionMessageParam) => void; }; export interface AutofillInlineMenuIframeService { diff --git a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-list.ts b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-list.ts index ea584165b4..a20bd3c531 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-list.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-list.ts @@ -1,25 +1,34 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { CipherType } from "@bitwarden/common/vault/enums"; import { InlineMenuCipherData } from "../../../background/abstractions/overlay.background"; +import { InlineMenuFillTypes } from "../../../enums/autofill-overlay.enum"; type AutofillInlineMenuListMessage = { command: string }; -export type UpdateAutofillInlineMenuListCiphersMessage = AutofillInlineMenuListMessage & { +export type UpdateAutofillInlineMenuListCiphersParams = { ciphers: InlineMenuCipherData[]; showInlineMenuAccountCreation?: boolean; }; +export type UpdateAutofillInlineMenuListCiphersMessage = AutofillInlineMenuListMessage & + UpdateAutofillInlineMenuListCiphersParams; + +export type UpdateAutofillInlineMenuGeneratedPasswordMessage = AutofillInlineMenuListMessage & { + generatedPassword: string; +}; + export type InitAutofillInlineMenuListMessage = AutofillInlineMenuListMessage & { authStatus: AuthenticationStatus; styleSheetUrl: string; theme: string; translations: Record; ciphers?: InlineMenuCipherData[]; - filledByCipherType?: CipherType; + inlineMenuFillType?: InlineMenuFillTypes; showInlineMenuAccountCreation?: boolean; showPasskeysLabels?: boolean; portKey: string; + generatedPassword?: string; + showSaveLoginMenu?: boolean; }; export type AutofillInlineMenuListWindowMessageHandlers = { @@ -31,5 +40,10 @@ export type AutofillInlineMenuListWindowMessageHandlers = { }: { message: UpdateAutofillInlineMenuListCiphersMessage; }) => void; + updateAutofillInlineMenuGeneratedPassword: ({ + message, + }: { + message: UpdateAutofillInlineMenuGeneratedPasswordMessage; + }) => void; focusAutofillInlineMenuList: () => void; }; diff --git a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts index c9d86cffc5..8a3a7e6fa8 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts @@ -3,6 +3,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import AutofillInit from "../../../content/autofill-init"; import { AutofillOverlayElement } from "../../../enums/autofill-overlay.enum"; import { DomQueryService } from "../../../services/abstractions/dom-query.service"; +import DomElementVisibilityService from "../../../services/dom-element-visibility.service"; import { createMutationRecordMock } from "../../../spec/autofill-mocks"; import { flushPromises, sendMockExtensionMessage } from "../../../spec/testing-utils"; import { ElementWithOpId } from "../../../types"; @@ -11,6 +12,7 @@ import { AutofillInlineMenuContentService } from "./autofill-inline-menu-content describe("AutofillInlineMenuContentService", () => { let domQueryService: MockProxy; + let domElementVisibilityService: DomElementVisibilityService; let autofillInlineMenuContentService: AutofillInlineMenuContentService; let autofillInit: AutofillInit; let sendExtensionMessageSpy: jest.SpyInstance; @@ -22,8 +24,14 @@ describe("AutofillInlineMenuContentService", () => { globalThis.document.body.innerHTML = ""; globalThis.requestIdleCallback = jest.fn((cb, options) => setTimeout(cb, 100)); domQueryService = mock(); + domElementVisibilityService = new DomElementVisibilityService(); autofillInlineMenuContentService = new AutofillInlineMenuContentService(); - autofillInit = new AutofillInit(domQueryService, null, autofillInlineMenuContentService); + autofillInit = new AutofillInit( + domQueryService, + domElementVisibilityService, + null, + autofillInlineMenuContentService, + ); autofillInit.init(); observeContainerMutationsSpy = jest.spyOn( autofillInlineMenuContentService["containerElementMutationObserver"] as any, @@ -37,6 +45,11 @@ describe("AutofillInlineMenuContentService", () => { afterEach(() => { jest.clearAllMocks(); + + Object.defineProperty(document, "activeElement", { + value: null, + writable: true, + }); }); describe("isElementInlineMenu", () => { @@ -197,6 +210,31 @@ describe("AutofillInlineMenuContentService", () => { ); }); }); + + it("appends the inline menu element to a containing `dialog` element if the element is a modal", async () => { + isInlineMenuButtonVisibleSpy.mockResolvedValue(false); + const dialogElement = document.createElement("dialog"); + dialogElement.setAttribute("open", "true"); + jest.spyOn(dialogElement, "matches").mockReturnValue(true); + const dialogAppendSpy = jest.spyOn(dialogElement, "appendChild"); + const inputElement = document.createElement("input"); + dialogElement.appendChild(inputElement); + document.body.appendChild(dialogElement); + Object.defineProperty(document, "activeElement", { + value: inputElement, + writable: true, + }); + + sendMockExtensionMessage({ + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.Button, + }); + await flushPromises(); + + expect(dialogAppendSpy).toHaveBeenCalledWith( + autofillInlineMenuContentService["buttonElement"], + ); + }); }); }); diff --git a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts index 110c1be7db..da27429173 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts @@ -88,7 +88,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte /** * Removes the autofill inline menu from the page. This will initially - * unobserve the body element to ensure the mutation observer no + * unobserve the menu container to ensure the mutation observer no * longer triggers. */ private closeInlineMenu = (message?: AutofillExtensionMessage) => { @@ -190,15 +190,15 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte } /** - * Appends the inline menu element to the body element. This method will also - * observe the body element to ensure that the inline menu element is not + * Appends the inline menu element to the menu container. This method will also + * observe the menu container to ensure that the inline menu element is not * interfered with by any DOM changes. * - * @param element - The inline menu element to append to the body element. + * @param element - The inline menu element to append to the menu container. */ private appendInlineMenuElementToDom(element: HTMLElement) { const parentDialogElement = globalThis.document.activeElement?.closest("dialog"); - if (parentDialogElement && parentDialogElement.open && parentDialogElement.matches(":modal")) { + if (parentDialogElement?.open && parentDialogElement.matches(":modal")) { this.observeContainerElement(parentDialogElement); parentDialogElement.appendChild(element); return; @@ -273,10 +273,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte } /** - * Sets up mutation observers for the inline menu elements, the body element, and + * Sets up mutation observers for the inline menu elements, the menu container, and * the document element. The mutation observers are used to remove any styles that * are added to the inline menu elements by the website. They are also used to ensure - * that the inline menu elements are always present at the bottom of the body element. + * that the inline menu elements are always present at the bottom of the menu container. */ private setupMutationObserver = () => { this.inlineMenuElementsMutationObserver = new MutationObserver( @@ -441,10 +441,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte /** * Handles the behavior of a persistent child element that is forcing itself to - * the bottom of the body element. This method will ensure that the inline menu + * the bottom of the menu container. This method will ensure that the inline menu * elements are not obscured by the persistent child element. * - * @param lastChild - The last child of the body element. + * @param lastChild - The last child of the menu container. */ private handlePersistentLastChildOverride(lastChild: Element) { const lastChildZIndex = parseInt((lastChild as HTMLElement).style.zIndex); @@ -460,11 +460,11 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte } /** - * Verifies if the last child of the body element is overlaying the inline menu elements. - * This is triggered when the last child of the body is being forced by some script to - * be an element other than the inline menu elements. + * Verifies if the last child of the menu container is overlaying the inline menu elements. + * This is triggered when the last child of the menu container is being forced by some + * script to be an element other than the inline menu elements. * - * @param lastChild - The last child of the body element. + * @param lastChild - The last child of the menu container. */ private verifyInlineMenuIsNotObscured = async (lastChild: Element) => { const inlineMenuPosition: InlineMenuPosition = await this.sendExtensionMessage( @@ -495,7 +495,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte } /** - * Clears the timeout that is used to verify that the last child of the body element + * Clears the timeout that is used to verify that the last child of the menu container * is not overlaying the inline menu elements. */ private clearPersistentLastChildOverrideTimeout() { diff --git a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/__snapshots__/autofill-inline-menu-iframe.service.spec.ts.snap b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/__snapshots__/autofill-inline-menu-iframe.service.spec.ts.snap index 4400b528d0..8aac4f3c43 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/__snapshots__/autofill-inline-menu-iframe.service.spec.ts.snap +++ b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/__snapshots__/autofill-inline-menu-iframe.service.spec.ts.snap @@ -3,6 +3,7 @@ exports[`AutofillInlineMenuIframeService initMenuIframe sets up the iframe's attributes 1`] = `