diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index d908b267f4..bf2b26a11b 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3054,6 +3054,10 @@ "message": "Unlock account", "description": "Button text to display in overlay when the account is locked." }, + "unlockAccountAria": { + "message": "Unlock your account, opens in a new window", + "description": "Screen reader text (aria-label) for unlock account button in overlay" + }, "fillCredentialsFor": { "message": "Fill credentials for", "description": "Screen reader text for when overlay item is in focused" @@ -3078,18 +3082,26 @@ "message": "New login", "description": "Button text to display within inline menu when there are no matching items on a login field" }, - "addNewLoginItem": { - "message": "Add new vault login item", + "addNewLoginItemAria": { + "message": "Add new vault login item, opens in a new window", "description": "Screen reader text (aria-label) for new login button within inline menu" }, "newCard": { "message": "New card", "description": "Button text to display within inline menu when there are no matching items on a credit card field" }, - "addNewCardItem": { - "message": "Add new vault card item", + "addNewCardItemAria": { + "message": "Add new vault card item, opens in a new window", "description": "Screen reader text (aria-label) for new card button within inline menu" }, + "newIdentity": { + "message": "New identity", + "description": "Button text to display within inline menu when there are no matching items on an identity field" + }, + "addNewIdentityItemAria": { + "message": "Add new vault identity item, opens in a new window", + "description": "Screen reader text (aria-label) for new identity button within inline menu" + }, "bitwardenOverlayMenuAvailable": { "message": "Bitwarden auto-fill menu available. Press the down arrow key to select.", "description": "Screen reader text for announcing when the overlay opens on the page" diff --git a/apps/browser/src/autofill/background/abstractions/overlay.background.ts b/apps/browser/src/autofill/background/abstractions/overlay.background.ts index 763261cae2..f1bfd3642f 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay.background.ts @@ -37,6 +37,8 @@ export type FocusedFieldData = { filledByCipherType?: CipherType; tabId?: number; frameId?: number; + accountCreationFieldType?: string; + showInlineMenuAccountCreation?: boolean; }; export type InlineMenuElementPosition = { @@ -67,10 +69,30 @@ export type NewCardCipherData = { cvv: string; }; +export type NewIdentityCipherData = { + title: string; + firstName: string; + middleName: string; + lastName: string; + fullName: string; + address1: string; + address2: string; + address3: string; + city: string; + state: string; + postalCode: string; + country: string; + company: string; + phone: string; + email: string; + username: string; +}; + export type OverlayAddNewItemMessage = { addNewCipherType?: CipherType; login?: NewLoginCipherData; card?: NewCardCipherData; + identity?: NewIdentityCipherData; }; export type CloseInlineMenuMessage = { @@ -115,8 +137,13 @@ export type InlineMenuCipherData = { reprompt: CipherRepromptType; favorite: boolean; icon: WebsiteIconData; + accountCreationFieldType?: string; login?: { username: string }; card?: string; + identity?: { + fullName: string; + username?: string; + }; }; export type BackgroundMessageParam = { @@ -180,7 +207,7 @@ export type PortOnMessageHandlerParams = PortMessageParam & PortConnectionParam; export type InlineMenuButtonPortMessageHandlers = { [key: string]: CallableFunction; - triggerDelayedAutofillInlineMenuClosure: ({ port }: PortConnectionParam) => void; + triggerDelayedAutofillInlineMenuClosure: () => void; autofillInlineMenuButtonClicked: ({ port }: PortConnectionParam) => void; autofillInlineMenuBlurred: () => void; redirectAutofillInlineMenuFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void; diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index de668cd817..3a3bb7dd5e 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -43,11 +43,11 @@ import { } from "../enums/autofill-overlay.enum"; import { AutofillService } from "../services/abstractions/autofill.service"; import { - createChromeTabMock, createAutofillPageDetailsMock, - createPortSpyMock, + createChromeTabMock, createFocusedFieldDataMock, createPageDetailMock, + createPortSpyMock, } from "../spec/autofill-mocks"; import { flushPromises, @@ -713,9 +713,22 @@ describe("OverlayBackground", () => { type: CipherType.Login, login: { username: "username-3", uri: url }, }); + const cipher4 = mock({ + id: "id-4", + localData: { lastUsedDate: 222 }, + name: "name-4", + type: CipherType.Identity, + identity: { + username: "username", + firstName: "Test", + lastName: "User", + email: "email@example.com", + }, + }); - beforeEach(() => { + beforeEach(async () => { activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + await initOverlayElementPorts(); }); it("skips updating the overlay ciphers if the user's auth status is not unlocked", async () => { @@ -767,7 +780,10 @@ describe("OverlayBackground", () => { await overlayBackground.updateOverlayCiphers(); expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); - expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url, [CipherType.Card]); + expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url, [ + CipherType.Card, + CipherType.Identity, + ]); expect(cipherService.sortCiphersByLastUsedThenName).toHaveBeenCalled(); expect(overlayBackground["inlineMenuCiphers"]).toStrictEqual( new Map([ @@ -804,7 +820,10 @@ describe("OverlayBackground", () => { await overlayBackground.updateOverlayCiphers(false); expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); - expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url, [CipherType.Card]); + expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url, [ + CipherType.Card, + CipherType.Identity, + ]); expect(cipherService.sortCiphersByLastUsedThenName).toHaveBeenCalled(); expect(overlayBackground["inlineMenuCiphers"]).toStrictEqual( new Map([ @@ -815,7 +834,6 @@ 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 () => { - overlayBackground["inlineMenuListPort"] = mock(); overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: tab.id }); cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); @@ -823,11 +841,12 @@ describe("OverlayBackground", () => { await overlayBackground.updateOverlayCiphers(); - expect(overlayBackground["inlineMenuListPort"].postMessage).toHaveBeenCalledWith({ + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "updateAutofillInlineMenuListCiphers", + showInlineMenuAccountCreation: false, ciphers: [ { - card: null, + accountCreationFieldType: undefined, favorite: cipher1.favorite, icon: { fallbackImage: "images/bwi-globe.png", @@ -846,6 +865,205 @@ describe("OverlayBackground", () => { ], }); }); + + it("updates the inline menu list with card ciphers", async () => { + overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ + tabId: tab.id, + filledByCipherType: CipherType.Card, + }); + cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); + cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); + + await overlayBackground.updateOverlayCiphers(); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuListCiphers", + showInlineMenuAccountCreation: false, + ciphers: [ + { + accountCreationFieldType: undefined, + favorite: cipher2.favorite, + icon: { + fallbackImage: "", + icon: "bwi-credit-card", + image: undefined, + imageEnabled: true, + }, + id: "inline-menu-cipher-0", + card: cipher2.card.subTitle, + name: cipher2.name, + reprompt: cipher2.reprompt, + type: CipherType.Card, + }, + ], + }); + }); + + describe("updating ciphers for an account creation inline menu", () => { + it("updates the ciphers with a list of identity ciphers that contain a username", async () => { + overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ + tabId: tab.id, + accountCreationFieldType: "text", + showInlineMenuAccountCreation: true, + }); + cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher4, cipher2]); + cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); + + await overlayBackground.updateOverlayCiphers(); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuListCiphers", + showInlineMenuAccountCreation: true, + ciphers: [ + { + accountCreationFieldType: "text", + favorite: cipher4.favorite, + icon: { + fallbackImage: "", + icon: "bwi-id-card", + image: undefined, + imageEnabled: true, + }, + id: "inline-menu-cipher-1", + name: cipher4.name, + reprompt: cipher4.reprompt, + type: CipherType.Identity, + identity: { + fullName: `${cipher4.identity.firstName} ${cipher4.identity.lastName}`, + username: cipher4.identity.username, + }, + }, + ], + }); + }); + + it("appends any found login ciphers to the list of identity ciphers", async () => { + overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ + tabId: tab.id, + accountCreationFieldType: "text", + showInlineMenuAccountCreation: true, + }); + cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher4]); + cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); + + await overlayBackground.updateOverlayCiphers(); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuListCiphers", + showInlineMenuAccountCreation: true, + ciphers: [ + { + accountCreationFieldType: "text", + favorite: cipher4.favorite, + icon: { + fallbackImage: "", + icon: "bwi-id-card", + image: undefined, + imageEnabled: true, + }, + id: "inline-menu-cipher-0", + name: cipher4.name, + reprompt: cipher4.reprompt, + type: CipherType.Identity, + identity: { + fullName: `${cipher4.identity.firstName} ${cipher4.identity.lastName}`, + username: cipher4.identity.username, + }, + }, + { + accountCreationFieldType: "text", + favorite: cipher1.favorite, + icon: { + fallbackImage: "images/bwi-globe.png", + icon: "bwi-globe", + image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + imageEnabled: true, + }, + id: "inline-menu-cipher-1", + login: { + username: cipher1.login.username, + }, + name: cipher1.name, + reprompt: cipher1.reprompt, + type: CipherType.Login, + }, + ], + }); + }); + + it("skips any identity ciphers that do not contain a username or an email address", async () => { + overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ + tabId: tab.id, + accountCreationFieldType: "email", + showInlineMenuAccountCreation: true, + }); + const identityCipherWithoutUsername = mock({ + id: "id-5", + localData: { lastUsedDate: 222 }, + name: "name-5", + type: CipherType.Identity, + identity: { + username: "", + email: "", + }, + }); + cipherService.getAllDecryptedForUrl.mockResolvedValue([ + cipher4, + identityCipherWithoutUsername, + ]); + cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); + + await overlayBackground.updateOverlayCiphers(); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuListCiphers", + showInlineMenuAccountCreation: true, + ciphers: [ + { + accountCreationFieldType: "email", + favorite: cipher4.favorite, + icon: { + fallbackImage: "", + icon: "bwi-id-card", + image: undefined, + imageEnabled: true, + }, + id: "inline-menu-cipher-1", + name: cipher4.name, + reprompt: cipher4.reprompt, + type: CipherType.Identity, + identity: { + fullName: `${cipher4.identity.firstName} ${cipher4.identity.lastName}`, + username: cipher4.identity.email, + }, + }, + ], + }); + }); + + it("does not add the identity ciphers if the field is for a password field", async () => { + overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ + tabId: tab.id, + accountCreationFieldType: "password", + showInlineMenuAccountCreation: true, + }); + cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher4]); + cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); + + await overlayBackground.updateOverlayCiphers(); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuListCiphers", + showInlineMenuAccountCreation: true, + ciphers: [], + }); + }); + }); }); describe("extension message handlers", () => { @@ -954,6 +1172,95 @@ describe("OverlayBackground", () => { expect(sendMessageSpy).toHaveBeenCalledWith("inlineAutofillMenuRefreshAddEditCipher"); expect(openAddEditVaultItemPopoutSpy).toHaveBeenCalled(); }); + + describe("creating a new identity cipher", () => { + it("populates an identity cipher view and creates it", async () => { + sendMockExtensionMessage( + { + command: "autofillOverlayAddNewVaultItem", + addNewCipherType: CipherType.Identity, + identity: { + title: "title", + firstName: "firstName", + middleName: "middleName", + lastName: "lastName", + fullName: "fullName", + address1: "address1", + address2: "address2", + address3: "address3", + city: "city", + state: "state", + postalCode: "postalCode", + country: "country", + company: "company", + phone: "phone", + email: "email", + username: "username", + }, + }, + sender, + ); + await flushPromises(); + + expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledWith("inlineAutofillMenuRefreshAddEditCipher"); + expect(openAddEditVaultItemPopoutSpy).toHaveBeenCalled(); + }); + + it("saves the first name based on the full name value", async () => { + sendMockExtensionMessage( + { + command: "autofillOverlayAddNewVaultItem", + addNewCipherType: CipherType.Identity, + identity: { + firstName: "", + lastName: "", + fullName: "fullName", + }, + }, + sender, + ); + await flushPromises(); + + expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled(); + }); + + it("saves the first and middle names based on the full name value", async () => { + sendMockExtensionMessage( + { + command: "autofillOverlayAddNewVaultItem", + addNewCipherType: CipherType.Identity, + identity: { + firstName: "", + lastName: "", + fullName: "firstName middleName", + }, + }, + sender, + ); + await flushPromises(); + + expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled(); + }); + + it("saves the first, middle, and last names based on the full name value", async () => { + sendMockExtensionMessage( + { + command: "autofillOverlayAddNewVaultItem", + addNewCipherType: CipherType.Identity, + identity: { + firstName: "", + lastName: "", + fullName: "firstName middleName lastName", + }, + }, + sender, + ); + await flushPromises(); + + expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled(); + }); + }); }); describe("checkIsInlineMenuCiphersPopulated message handler", () => { @@ -1030,6 +1337,29 @@ describe("OverlayBackground", () => { { frameId: firstSender.frameId }, ); }); + + it("triggers an update of the identity ciphers present on a login field", async () => { + await initOverlayElementPorts(); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + const tab = createChromeTabMock({ id: 2 }); + overlayBackground["focusedFieldData"] = createFocusedFieldDataMock(); + overlayBackground["isInlineMenuButtonVisible"] = true; + const sender = mock({ tab, frameId: 100 }); + const focusedFieldData = createFocusedFieldDataMock({ + tabId: tab.id, + frameId: sender.frameId, + showInlineMenuAccountCreation: true, + }); + + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); + await flushPromises(); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuListCiphers", + ciphers: [], + showInlineMenuAccountCreation: true, + }); + }); }); describe("checkIsFieldCurrentlyFocused message handler", () => { diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 76b0f4b76e..eea72979dd 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -21,6 +21,7 @@ import { CipherType } from "@bitwarden/common/vault/enums"; import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon"; import { CardView } from "@bitwarden/common/vault/models/view/card.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; @@ -40,23 +41,24 @@ import { generateRandomChars } from "../utils"; import { LockedVaultPendingNotificationsData } from "./abstractions/notification.background"; import { + CloseInlineMenuMessage, FocusedFieldData, + InlineMenuButtonPortMessageHandlers, + InlineMenuCipherData, + InlineMenuListPortMessageHandlers, + InlineMenuPosition, + NewCardCipherData, + NewIdentityCipherData, + NewLoginCipherData, OverlayAddNewItemMessage, OverlayBackground as OverlayBackgroundInterface, OverlayBackgroundExtensionMessage, OverlayBackgroundExtensionMessageHandlers, - InlineMenuButtonPortMessageHandlers, - InlineMenuCipherData, - InlineMenuListPortMessageHandlers, OverlayPortMessage, PageDetailsForTab, SubFrameOffsetData, SubFrameOffsetsForTab, - CloseInlineMenuMessage, - InlineMenuPosition, ToggleInlineMenuHiddenMessage, - NewLoginCipherData, - NewCardCipherData, } from "./abstractions/overlay.background"; export class OverlayBackground implements OverlayBackgroundInterface { @@ -125,7 +127,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { deletedCipher: () => this.updateOverlayCiphers(), }; private readonly inlineMenuButtonPortMessageHandlers: InlineMenuButtonPortMessageHandlers = { - triggerDelayedAutofillInlineMenuClosure: ({ port }) => this.triggerDelayedInlineMenuClosure(), + triggerDelayedAutofillInlineMenuClosure: () => this.triggerDelayedInlineMenuClosure(), autofillInlineMenuButtonClicked: ({ port }) => this.handleInlineMenuButtonClicked(port), autofillInlineMenuBlurred: () => this.checkInlineMenuListFocused(), redirectAutofillInlineMenuFocusOut: ({ message, port }) => @@ -249,6 +251,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { this.inlineMenuListPort?.postMessage({ command: "updateAutofillInlineMenuListCiphers", ciphers, + showInlineMenuAccountCreation: this.showInlineMenuAccountCreation(), }); } @@ -285,15 +288,25 @@ export class OverlayBackground implements OverlayBackgroundInterface { this.cardAndIdentityCiphers.clear(); const cipherViews = ( - await this.cipherService.getAllDecryptedForUrl(currentTab.url, [CipherType.Card]) + await this.cipherService.getAllDecryptedForUrl(currentTab.url, [ + CipherType.Card, + CipherType.Identity, + ]) ).sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b)); for (let cipherIndex = 0; cipherIndex < cipherViews.length; cipherIndex++) { const cipherView = cipherViews[cipherIndex]; - if (cipherView.type === CipherType.Card && !this.cardAndIdentityCiphers.has(cipherView)) { + if ( + !this.cardAndIdentityCiphers.has(cipherView) && + [CipherType.Card, CipherType.Identity].includes(cipherView.type) + ) { this.cardAndIdentityCiphers.add(cipherView); } } + if (!this.cardAndIdentityCiphers.size) { + this.cardAndIdentityCiphers = null; + } + return cipherViews; } @@ -304,6 +317,75 @@ export class OverlayBackground implements OverlayBackgroundInterface { private async getInlineMenuCipherData(): Promise { const showFavicons = await firstValueFrom(this.domainSettingsService.showFavicons$); const inlineMenuCiphersArray = Array.from(this.inlineMenuCiphers); + let inlineMenuCipherData: InlineMenuCipherData[] = []; + + if (this.showInlineMenuAccountCreation()) { + inlineMenuCipherData = this.buildInlineMenuAccountCreationCiphers( + inlineMenuCiphersArray, + true, + ); + } else { + inlineMenuCipherData = this.buildInlineMenuCiphers(inlineMenuCiphersArray, showFavicons); + } + + this.currentInlineMenuCiphersCount = inlineMenuCipherData.length; + return inlineMenuCipherData; + } + + /** + * Builds the inline menu ciphers for a form field that is meant for account creation. + * + * @param inlineMenuCiphersArray - Array of inline menu ciphers + * @param showFavicons - Identifies whether favicons should be shown + */ + private buildInlineMenuAccountCreationCiphers( + inlineMenuCiphersArray: [string, CipherView][], + showFavicons: boolean, + ) { + const inlineMenuCipherData: InlineMenuCipherData[] = []; + const accountCreationLoginCiphers: InlineMenuCipherData[] = []; + + for (let cipherIndex = 0; cipherIndex < inlineMenuCiphersArray.length; cipherIndex++) { + const [inlineMenuCipherId, cipher] = inlineMenuCiphersArray[cipherIndex]; + + if (cipher.type === CipherType.Login) { + accountCreationLoginCiphers.push( + this.buildCipherData(inlineMenuCipherId, cipher, showFavicons, true), + ); + continue; + } + + if (cipher.type !== CipherType.Identity || !this.focusedFieldData?.accountCreationFieldType) { + continue; + } + + const identity = this.getIdentityCipherData(cipher, true); + if (!identity?.username) { + continue; + } + + inlineMenuCipherData.push( + this.buildCipherData(inlineMenuCipherId, cipher, showFavicons, true, identity), + ); + } + + if (accountCreationLoginCiphers.length) { + return inlineMenuCipherData.concat(accountCreationLoginCiphers); + } + + return inlineMenuCipherData; + } + + /** + * Builds the inline menu ciphers for a form field that is not meant for account creation. + * + * @param inlineMenuCiphersArray - Array of inline menu ciphers + * @param showFavicons - Identifies whether favicons should be shown + */ + private buildInlineMenuCiphers( + inlineMenuCiphersArray: [string, CipherView][], + showFavicons: boolean, + ) { const inlineMenuCipherData: InlineMenuCipherData[] = []; for (let cipherIndex = 0; cipherIndex < inlineMenuCiphersArray.length; cipherIndex++) { @@ -312,22 +394,111 @@ export class OverlayBackground implements OverlayBackgroundInterface { continue; } - inlineMenuCipherData.push({ - id: inlineMenuCipherId, - name: cipher.name, - type: cipher.type, - reprompt: cipher.reprompt, - favorite: cipher.favorite, - icon: buildCipherIcon(this.iconsServerUrl, cipher, showFavicons), - login: cipher.type === CipherType.Login ? { username: cipher.login.username } : null, - card: cipher.type === CipherType.Card ? cipher.card.subTitle : null, - }); + inlineMenuCipherData.push(this.buildCipherData(inlineMenuCipherId, cipher, showFavicons)); } - this.currentInlineMenuCiphersCount = inlineMenuCipherData.length; return inlineMenuCipherData; } + /** + * Builds the cipher data for the inline menu list. + * + * @param inlineMenuCipherId - The ID of the inline menu cipher + * @param cipher - The cipher to build data for + * @param showFavicons - Identifies whether favicons should be shown + * @param showInlineMenuAccountCreation - Identifies whether the inline menu is for account creation + * @param identityData - Pre-created identity data + */ + private buildCipherData( + inlineMenuCipherId: string, + cipher: CipherView, + showFavicons: boolean, + showInlineMenuAccountCreation: boolean = false, + identityData?: { fullName: string; username?: string }, + ): InlineMenuCipherData { + const inlineMenuData: InlineMenuCipherData = { + id: inlineMenuCipherId, + name: cipher.name, + type: cipher.type, + reprompt: cipher.reprompt, + favorite: cipher.favorite, + icon: buildCipherIcon(this.iconsServerUrl, cipher, showFavicons), + accountCreationFieldType: this.focusedFieldData?.accountCreationFieldType, + }; + + if (cipher.type === CipherType.Login) { + inlineMenuData.login = { username: cipher.login.username }; + return inlineMenuData; + } + + if (cipher.type === CipherType.Card) { + inlineMenuData.card = cipher.card.subTitle; + return inlineMenuData; + } + + inlineMenuData.identity = + identityData || this.getIdentityCipherData(cipher, showInlineMenuAccountCreation); + return inlineMenuData; + } + + /** + * Gets the identity data for a cipher based on whether the inline menu is for account creation. + * + * @param cipher - The cipher to get the identity data for + * @param showInlineMenuAccountCreation - Identifies whether the inline menu is for account creation + */ + private getIdentityCipherData( + cipher: CipherView, + showInlineMenuAccountCreation: boolean = false, + ): { fullName: string; username?: string } { + const { firstName, lastName } = cipher.identity; + + let fullName = ""; + if (firstName) { + fullName += firstName; + } + + if (lastName) { + fullName += ` ${lastName}`; + fullName = fullName.trim(); + } + + if ( + !showInlineMenuAccountCreation || + !this.focusedFieldData?.accountCreationFieldType || + this.focusedFieldData.accountCreationFieldType === "password" + ) { + return { fullName }; + } + + return { + fullName, + username: + this.focusedFieldData.accountCreationFieldType === "email" + ? cipher.identity.email + : cipher.identity.username, + }; + } + + /** + * 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; + } + + if (this.focusedFieldData?.filledByCipherType !== CipherType.Login) { + return false; + } + + if (this.cardAndIdentityCiphers) { + return this.inlineMenuCiphers.size === this.cardAndIdentityCiphers.size; + } + + return this.inlineMenuCiphers.size === 0; + } + /** * Gets the currently focused field and closes the inline menu on that tab. */ @@ -926,7 +1097,37 @@ export class OverlayBackground implements OverlayBackgroundInterface { ); } + const previousFocusedFieldData = this.focusedFieldData; this.focusedFieldData = { ...focusedFieldData, tabId: sender.tab.id, frameId: sender.frameId }; + + const accountCreationFieldBlurred = + previousFocusedFieldData?.showInlineMenuAccountCreation && + !this.focusedFieldData.showInlineMenuAccountCreation; + + if (accountCreationFieldBlurred || this.showInlineMenuAccountCreation()) { + void this.updateIdentityCiphersOnLoginField(previousFocusedFieldData); + } + } + + /** + * Triggers an update of populated identity ciphers when a login field is focused. + * + * @param previousFocusedFieldData - The data set of the previously focused field + */ + private async updateIdentityCiphersOnLoginField(previousFocusedFieldData: FocusedFieldData) { + if ( + !previousFocusedFieldData || + !this.isInlineMenuButtonVisible || + (await this.getAuthStatus()) !== AuthenticationStatus.Unlocked + ) { + return; + } + + this.inlineMenuListPort?.postMessage({ + command: "updateAutofillInlineMenuListCiphers", + ciphers: await this.getInlineMenuCipherData(), + showInlineMenuAccountCreation: this.showInlineMenuAccountCreation(), + }); } /** @@ -1116,6 +1317,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { 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"), @@ -1123,9 +1325,11 @@ export class OverlayBackground implements OverlayBackgroundInterface { newItem: this.i18nService.translate("newItem"), addNewVaultItem: this.i18nService.translate("addNewVaultItem"), newLogin: this.i18nService.translate("newLogin"), - addNewLoginItem: this.i18nService.translate("addNewLoginItem"), + addNewLoginItem: this.i18nService.translate("addNewLoginItemAria"), newCard: this.i18nService.translate("newCard"), - addNewCardItem: this.i18nService.translate("addNewCardItem"), + addNewCardItem: this.i18nService.translate("addNewCardItemAria"), + newIdentity: this.i18nService.translate("newIdentity"), + addNewIdentityItem: this.i18nService.translate("addNewIdentityItemAria"), cardNumberEndsWith: this.i18nService.translate("cardNumberEndsWith"), }; } @@ -1184,10 +1388,11 @@ export class OverlayBackground implements OverlayBackgroundInterface { * @param addNewCipherType - The type of cipher to add * @param login - The login data captured from the extension message * @param card - The card data captured from the extension message + * @param identity - The identity data captured from the extension message * @param sender - The sender of the extension message */ private async addNewVaultItem( - { addNewCipherType, login, card }: OverlayAddNewItemMessage, + { addNewCipherType, login, card, identity }: OverlayAddNewItemMessage, sender: chrome.runtime.MessageSender, ) { if (!addNewCipherType) { @@ -1198,6 +1403,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { addNewCipherType, login, card, + identity, }); if (cipherView) { @@ -1218,8 +1424,14 @@ export class OverlayBackground implements OverlayBackgroundInterface { * @param addNewCipherType - The type of cipher to add * @param login - The login data captured from the extension message * @param card - The card data captured from the extension message + * @param identity - The identity data captured from the extension message */ - private buildNewVaultItemCipherView({ addNewCipherType, login, card }: OverlayAddNewItemMessage) { + private buildNewVaultItemCipherView({ + addNewCipherType, + login, + card, + identity, + }: OverlayAddNewItemMessage) { if (login && addNewCipherType === CipherType.Login) { return this.buildLoginCipherView(login); } @@ -1227,6 +1439,10 @@ export class OverlayBackground implements OverlayBackgroundInterface { if (card && addNewCipherType === CipherType.Card) { return this.buildCardCipherView(card); } + + if (identity && addNewCipherType === CipherType.Identity) { + return this.buildIdentityCipherView(identity); + } } /** @@ -1275,6 +1491,68 @@ export class OverlayBackground implements OverlayBackgroundInterface { return cipherView; } + /** + * Builds a new identity cipher view with the provided identity data. + * + * @param identity - The identity data captured from the extension message + */ + private buildIdentityCipherView(identity: NewIdentityCipherData) { + const identityView = new IdentityView(); + identityView.title = identity.title || ""; + identityView.firstName = identity.firstName || ""; + identityView.middleName = identity.middleName || ""; + identityView.lastName = identity.lastName || ""; + identityView.address1 = identity.address1 || ""; + identityView.address2 = identity.address2 || ""; + identityView.address3 = identity.address3 || ""; + identityView.city = identity.city || ""; + identityView.state = identity.state || ""; + identityView.postalCode = identity.postalCode || ""; + identityView.country = identity.country || ""; + identityView.company = identity.company || ""; + identityView.phone = identity.phone || ""; + identityView.email = identity.email || ""; + identityView.username = identity.username || ""; + + if (identity.fullName && !identityView.firstName && !identityView.lastName) { + this.buildIdentityNameParts(identity, identityView); + } + + const cipherView = new CipherView(); + cipherView.name = ""; + cipherView.folderId = null; + cipherView.type = CipherType.Identity; + cipherView.identity = identityView; + + return cipherView; + } + + /** + * Splits the identity full name into first, middle, and last name parts. + * + * @param identity - The identity data captured from the extension message + * @param identityView - The identity view to update + */ + private buildIdentityNameParts(identity: NewIdentityCipherData, identityView: IdentityView) { + const fullNameParts = identity.fullName.split(" "); + if (fullNameParts.length === 1) { + identityView.firstName = fullNameParts[0] || ""; + + return; + } + + if (fullNameParts.length === 2) { + identityView.firstName = fullNameParts[0] || ""; + identityView.lastName = fullNameParts[1] || ""; + + return; + } + + identityView.firstName = fullNameParts[0] || ""; + identityView.middleName = fullNameParts[1] || ""; + identityView.lastName = fullNameParts[2] || ""; + } + /** * Updates the property that identifies if a form field set up for the inline menu is currently focused. * @@ -1523,7 +1801,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { Promise.resolve(messageResponse) .then((response) => sendResponse(response)) - .catch(this.logService.error); + .catch((error) => this.logService.error(error)); return true; }; @@ -1598,6 +1876,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { ? AutofillOverlayPort.ListMessageConnector : AutofillOverlayPort.ButtonMessageConnector, filledByCipherType: this.focusedFieldData?.filledByCipherType, + showInlineMenuAccountCreation: this.showInlineMenuAccountCreation(), }); void this.updateInlineMenuPosition( { diff --git a/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts b/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts index 96d5e85ca3..6153a5c926 100644 --- a/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts +++ b/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts @@ -61,13 +61,10 @@ describe("AutofillInit", () => { autofillInit.init(); jest.advanceTimersByTime(250); - expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( - { - command: "bgCollectPageDetails", - sender: "autofillInit", - }, - expect.any(Function), - ); + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + command: "bgCollectPageDetails", + sender: "autofillInit", + }); }); it("registers a window load listener to collect the page details if the document is not in a `complete` ready state", () => { diff --git a/apps/browser/src/autofill/deprecated/overlay/pages/list/__snapshots__/autofill-overlay-list.deprecated.spec.ts.snap b/apps/browser/src/autofill/deprecated/overlay/pages/list/__snapshots__/autofill-overlay-list.deprecated.spec.ts.snap index 6ee8e737cb..d11fbd5079 100644 --- a/apps/browser/src/autofill/deprecated/overlay/pages/list/__snapshots__/autofill-overlay-list.deprecated.spec.ts.snap +++ b/apps/browser/src/autofill/deprecated/overlay/pages/list/__snapshots__/autofill-overlay-list.deprecated.spec.ts.snap @@ -506,16 +506,17 @@ exports[`AutofillOverlayList initAutofillOverlayList the overlay with an empty l aria-hidden="true" fill="none" height="17" - viewBox="0 0 16 17" - width="16" + width="17" xmlns="http://www.w3.org/2000/svg" > @@ -523,7 +524,7 @@ exports[`AutofillOverlayList initAutofillOverlayList the overlay with an empty l id="a" > diff --git a/apps/browser/src/autofill/enums/autofill-field.enums.ts b/apps/browser/src/autofill/enums/autofill-field.enums.ts new file mode 100644 index 0000000000..4fd7c0fe88 --- /dev/null +++ b/apps/browser/src/autofill/enums/autofill-field.enums.ts @@ -0,0 +1,29 @@ +export const AutofillFieldQualifier = { + password: "password", + username: "username", + cardholderName: "cardholderName", + cardNumber: "cardNumber", + cardExpirationMonth: "cardExpirationMonth", + cardExpirationYear: "cardExpirationYear", + cardExpirationDate: "cardExpirationDate", + cardCvv: "cardCvv", + identityTitle: "identityTitle", + identityFirstName: "identityFirstName", + identityMiddleName: "identityMiddleName", + identityLastName: "identityLastName", + identityFullName: "identityFullName", + identityAddress1: "identityAddress1", + identityAddress2: "identityAddress2", + identityAddress3: "identityAddress3", + identityCity: "identityCity", + identityState: "identityState", + identityPostalCode: "identityPostalCode", + identityCountry: "identityCountry", + identityCompany: "identityCompany", + identityPhone: "identityPhone", + identityEmail: "identityEmail", + identityUsername: "identityUsername", +} as const; + +export type AutofillFieldQualifierType = + (typeof AutofillFieldQualifier)[keyof typeof AutofillFieldQualifier]; diff --git a/apps/browser/src/autofill/models/autofill-field.ts b/apps/browser/src/autofill/models/autofill-field.ts index 26f01bdeac..5a95b92899 100644 --- a/apps/browser/src/autofill/models/autofill-field.ts +++ b/apps/browser/src/autofill/models/autofill-field.ts @@ -1,5 +1,7 @@ import { CipherType } from "@bitwarden/common/vault/enums"; +import { AutofillFieldQualifierType } from "../enums/autofill-field.enums"; + /** * Represents a single field that is collected from the page source and is potentially autofilled. */ @@ -110,4 +112,8 @@ export default class AutofillField { checked?: boolean; filledByCipherType?: CipherType; + + showInlineMenuAccountCreation?: boolean; + + fieldQualifier?: AutofillFieldQualifierType; } 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 5a00ffbaaa..090fb7887c 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 @@ -7,6 +7,7 @@ type AutofillInlineMenuListMessage = { command: string }; export type UpdateAutofillInlineMenuListCiphersMessage = AutofillInlineMenuListMessage & { ciphers: InlineMenuCipherData[]; + showInlineMenuAccountCreation?: boolean; }; export type InitAutofillInlineMenuListMessage = AutofillInlineMenuListMessage & { @@ -16,6 +17,7 @@ export type InitAutofillInlineMenuListMessage = AutofillInlineMenuListMessage & translations: Record; ciphers?: InlineMenuCipherData[]; filledByCipherType?: CipherType; + showInlineMenuAccountCreation?: boolean; portKey: string; }; 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 b8702c7443..ae94741591 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 @@ -373,7 +373,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * ensure that the inline menu elements are always present at the bottom of the * body element. */ - private handleBodyElementMutationObserverUpdate = async () => { + private handleBodyElementMutationObserverUpdate = () => { if ( (!this.buttonElement && !this.listElement) || this.isTriggeringExcessiveMutationObserverIterations() @@ -410,17 +410,18 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte return; } + const isInlineMenuListVisible = await this.isInlineMenuListVisible(); if ( !lastChild || (lastChildIsInlineMenuList && secondToLastChildIsInlineMenuButton) || - (lastChildIsInlineMenuButton && !(await this.isInlineMenuListVisible())) + (lastChildIsInlineMenuButton && !isInlineMenuListVisible) ) { return; } if ( (lastChildIsInlineMenuList && !secondToLastChildIsInlineMenuButton) || - (lastChildIsInlineMenuButton && (await this.isInlineMenuListVisible())) + (lastChildIsInlineMenuButton && isInlineMenuListVisible) ) { globalThis.document.body.insertBefore(this.buttonElement, this.listElement); return; diff --git a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts index afa2548930..fd305d23c9 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts @@ -80,10 +80,11 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe this.defaultIframeAttributes.title = this.iframeTitle; this.iframe = globalThis.document.createElement("iframe"); - this.updateElementStyles(this.iframe, { ...this.iframeStyles, ...this.initStyles }); for (const [attribute, value] of Object.entries(this.defaultIframeAttributes)) { this.iframe.setAttribute(attribute, value); } + this.iframeStyles = { ...this.iframeStyles, ...this.initStyles }; + this.setElementStyles(this.iframe, this.iframeStyles, true); this.iframe.addEventListener(EVENTS.LOAD, this.setupPortMessageListener); if (this.ariaAlert) { @@ -91,6 +92,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe } this.shadow.appendChild(this.iframe); + this.observeIframe(); } /** @@ -143,7 +145,10 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe clearTimeout(this.ariaAlertTimeout); } - this.ariaAlertTimeout = setTimeout(() => this.shadow.appendChild(this.ariaAlertElement), 2000); + this.ariaAlertTimeout = globalThis.setTimeout( + () => this.shadow.appendChild(this.ariaAlertElement), + 2000, + ); } /** @@ -255,7 +260,9 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe return; } - this.clearFadeInTimeout(); + if (this.fadeInTimeout) { + this.handleFadeInInlineMenuIframe(); + } this.updateElementStyles(this.iframe, position); this.announceAriaAlert(); } @@ -325,6 +332,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe private clearFadeInTimeout() { if (this.fadeInTimeout) { clearTimeout(this.fadeInTimeout); + this.fadeInTimeout = null; } } @@ -442,7 +450,10 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe } this.mutationObserverIterations++; - this.mutationObserverIterationsResetTimeout = setTimeout(() => resetCounters(), 2000); + this.mutationObserverIterationsResetTimeout = globalThis.setTimeout( + () => resetCounters(), + 2000, + ); if (this.mutationObserverIterations > 20) { clearTimeout(this.mutationObserverIterationsResetTimeout); diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap b/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap index 3b0e84514f..a8a4d5c4a7 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap @@ -13,8 +13,8 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the inline menu with class="inline-menu-list-button-container" > + + +`; + +exports[`AutofillInlineMenuList initAutofillInlineMenuList the inline menu with an empty list of ciphers creates the views for the no results inline menu that should be filled by an identity cipher 1`] = ` +
+
+ noItemsToShow +
+
+ +
+
+`; + +exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers for an authenticated user account creation elements creates the inline menu account creation view 1`] = ` +
+
    +
  • +
    + + +
    +
  • +
+
+ + +
+ + +
+`; + +exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers for an authenticated user creates the views for a list of identity ciphers 1`] = ` +
+
    +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    +