diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index f8ffeff747e..22330901579 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-fill" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generate password (copied)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "No matching logins" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Unlock your vault" }, diff --git a/apps/browser/src/autofill/browser/cipher-context-menu-handler.spec.ts b/apps/browser/src/autofill/browser/cipher-context-menu-handler.spec.ts index d7cac8d44b2..4532718809e 100644 --- a/apps/browser/src/autofill/browser/cipher-context-menu-handler.spec.ts +++ b/apps/browser/src/autofill/browser/cipher-context-menu-handler.spec.ts @@ -69,19 +69,20 @@ describe("CipherContextMenuHandler", () => { expect(mainContextMenuHandler.noLogins).toHaveBeenCalledTimes(1); }); - it("only adds login ciphers including ciphers that require reprompt", async () => { + it("only adds autofill ciphers including ciphers that require reprompt", async () => { authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked); mainContextMenuHandler.init.mockResolvedValue(true); - const realCipher = { + const loginCipher = { id: "5", type: CipherType.Login, reprompt: CipherRepromptType.None, name: "Test Cipher", login: { username: "Test Username" }, }; - const repromptCipher = { + + const repromptLoginCipher = { id: "6", type: CipherType.Login, reprompt: CipherRepromptType.Password, @@ -89,34 +90,49 @@ describe("CipherContextMenuHandler", () => { login: { username: "Test Username" }, }; + const cardCipher = { + id: "7", + type: CipherType.Card, + name: "Test Card Cipher", + card: { username: "Test Username" }, + }; + cipherService.getAllDecryptedForUrl.mockResolvedValue([ null, // invalid cipher undefined, // invalid cipher - { type: CipherType.Card }, // invalid cipher - realCipher, // valid cipher - repromptCipher, + { type: CipherType.SecureNote }, // invalid cipher + loginCipher, // valid cipher + repromptLoginCipher, + cardCipher, // valid cipher ] as any[]); await sut.update("https://test.com"); expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledTimes(1); - expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com"); + expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com", [ + CipherType.Card, + CipherType.Identity, + ]); - expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledTimes(2); + expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledTimes(3); expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledWith( "Test Cipher (Test Username)", "5", - "https://test.com", - realCipher + loginCipher ); expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledWith( "Test Reprompt Cipher (Test Username)", "6", - "https://test.com", - repromptCipher + repromptLoginCipher + ); + + expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledWith( + "Test Card Cipher", + "7", + cardCipher ); }); }); diff --git a/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts b/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts index 6140db260f5..f46442c4192 100644 --- a/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts @@ -18,6 +18,7 @@ import { cipherServiceFactory, CipherServiceInitOptions, } from "../../vault/background/service_factories/cipher-service.factory"; +import { AutofillCipherTypeId } from "../types"; import { MainContextMenuHandler } from "./main-context-menu-handler"; @@ -159,29 +160,67 @@ export class CipherContextMenuHandler { return; } - const ciphers = await this.cipherService.getAllDecryptedForUrl(url); + const ciphers = await this.cipherService.getAllDecryptedForUrl(url, [ + CipherType.Card, + CipherType.Identity, + ]); ciphers.sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b)); - if (ciphers.length === 0) { - await this.mainContextMenuHandler.noLogins(url); - return; + const groupedCiphers: Record = ciphers.reduce( + (ciphersByType, cipher) => { + if (!cipher?.type) { + return ciphersByType; + } + + const existingCiphersOfType = ciphersByType[cipher.type as AutofillCipherTypeId] || []; + + return { + ...ciphersByType, + [cipher.type]: [...existingCiphersOfType, cipher], + }; + }, + { + [CipherType.Login]: [], + [CipherType.Card]: [], + [CipherType.Identity]: [], + } + ); + + if (groupedCiphers[CipherType.Login].length === 0) { + await this.mainContextMenuHandler.noLogins(); + } + + if (groupedCiphers[CipherType.Identity].length === 0) { + await this.mainContextMenuHandler.noIdentities(); + } + + if (groupedCiphers[CipherType.Card].length === 0) { + await this.mainContextMenuHandler.noCards(); } for (const cipher of ciphers) { - await this.updateForCipher(url, cipher); + await this.updateForCipher(cipher); } } - private async updateForCipher(url: string, cipher: CipherView) { - if (cipher == null || cipher.type !== CipherType.Login) { + private async updateForCipher(cipher: CipherView) { + if ( + cipher == null || + !new Set([CipherType.Login, CipherType.Card, CipherType.Identity]).has(cipher.type) + ) { return; } let title = cipher.name; - if (!Utils.isNullOrEmpty(title)) { + + if (cipher.type === CipherType.Login && !Utils.isNullOrEmpty(title) && cipher.login?.username) { title += ` (${cipher.login.username})`; } - await this.mainContextMenuHandler.loadOptions(title, cipher.id, url, cipher); + if (cipher.type === CipherType.Card && cipher.card?.subTitle) { + title += ` ${cipher.card.subTitle}`; + } + + await this.mainContextMenuHandler.loadOptions(title, cipher.id, cipher); } } diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts index 021d15df89e..665c4e9acb8 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts @@ -10,6 +10,15 @@ import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + AUTOFILL_ID, + COPY_PASSWORD_ID, + COPY_USERNAME_ID, + COPY_VERIFICATIONCODE_ID, + GENERATE_PASSWORD_ID, + NOOP_COMMAND_SUFFIX, +} from "../constants"; + import { CopyToClipboardAction, ContextMenuClickedHandler, @@ -17,13 +26,6 @@ import { GeneratePasswordToClipboardAction, AutofillAction, } from "./context-menu-clicked-handler"; -import { - AUTOFILL_ID, - COPY_PASSWORD_ID, - COPY_USERNAME_ID, - COPY_VERIFICATIONCODE_ID, - GENERATE_PASSWORD_ID, -} from "./main-context-menu-handler"; describe("ContextMenuClickedHandler", () => { const createData = ( @@ -51,6 +53,7 @@ describe("ContextMenuClickedHandler", () => { type: CipherType.Login, } as any) ); + cipherView.login.username = username ?? "USERNAME"; cipherView.login.password = password ?? "PASSWORD"; cipherView.login.totp = totp ?? "TOTP"; @@ -106,7 +109,7 @@ describe("ContextMenuClickedHandler", () => { const cipher = createCipher(); cipherService.getAllDecrypted.mockResolvedValue([cipher]); - await sut.run(createData("T_1", AUTOFILL_ID), { id: 5 } as any); + await sut.run(createData(`${AUTOFILL_ID}_1`, AUTOFILL_ID), { id: 5 } as any); expect(autofill).toBeCalledTimes(1); @@ -118,11 +121,16 @@ describe("ContextMenuClickedHandler", () => { createCipher({ username: "TEST_USERNAME" }), ]); - await sut.run(createData("T_1", COPY_USERNAME_ID)); + await sut.run(createData(`${COPY_USERNAME_ID}_1`, COPY_USERNAME_ID), { + url: "https://test.com", + } as any); expect(copyToClipboard).toBeCalledTimes(1); - expect(copyToClipboard).toHaveBeenCalledWith({ text: "TEST_USERNAME", options: undefined }); + expect(copyToClipboard).toHaveBeenCalledWith({ + text: "TEST_USERNAME", + tab: { url: "https://test.com" }, + }); }); it("copies password to clipboard", async () => { @@ -130,11 +138,16 @@ describe("ContextMenuClickedHandler", () => { createCipher({ password: "TEST_PASSWORD" }), ]); - await sut.run(createData("T_1", COPY_PASSWORD_ID)); + await sut.run(createData(`${COPY_PASSWORD_ID}_1`, COPY_PASSWORD_ID), { + url: "https://test.com", + } as any); expect(copyToClipboard).toBeCalledTimes(1); - expect(copyToClipboard).toHaveBeenCalledWith({ text: "TEST_PASSWORD", options: undefined }); + expect(copyToClipboard).toHaveBeenCalledWith({ + text: "TEST_PASSWORD", + tab: { url: "https://test.com" }, + }); }); it("copies totp code to clipboard", async () => { @@ -148,11 +161,16 @@ describe("ContextMenuClickedHandler", () => { return Promise.resolve("654321"); }); - await sut.run(createData("T_1", COPY_VERIFICATIONCODE_ID)); + await sut.run(createData(`${COPY_VERIFICATIONCODE_ID}_1`, COPY_VERIFICATIONCODE_ID), { + url: "https://test.com", + } as any); expect(totpService.getCode).toHaveBeenCalledTimes(1); - expect(copyToClipboard).toHaveBeenCalledWith({ text: "123456" }); + expect(copyToClipboard).toHaveBeenCalledWith({ + text: "123456", + tab: { url: "https://test.com" }, + }); }); it("attempts to find a cipher when noop but unlocked", async () => { @@ -163,11 +181,13 @@ describe("ContextMenuClickedHandler", () => { } as any, ]); - await sut.run(createData("T_noop", COPY_USERNAME_ID), { url: "https://test.com" } as any); + await sut.run(createData(`${COPY_USERNAME_ID}_${NOOP_COMMAND_SUFFIX}`, COPY_USERNAME_ID), { + url: "https://test.com", + } as any); expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledTimes(1); - expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com"); + expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com", []); expect(copyToClipboard).toHaveBeenCalledTimes(1); @@ -185,11 +205,13 @@ describe("ContextMenuClickedHandler", () => { } as any, ]); - await sut.run(createData("T_noop", COPY_USERNAME_ID), { url: "https://test.com" } as any); + await sut.run(createData(`${COPY_USERNAME_ID}_${NOOP_COMMAND_SUFFIX}`, COPY_USERNAME_ID), { + url: "https://test.com", + } as any); expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledTimes(1); - expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com"); + expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com", []); }); }); }); diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts index 62c6c9c25a4..2df42fc2a90 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts @@ -8,6 +8,7 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { @@ -30,16 +31,21 @@ import { import { autofillServiceFactory } from "../background/service_factories/autofill-service.factory"; import { copyToClipboard, GeneratePasswordToClipboardCommand } from "../clipboard"; import { AutofillTabCommand } from "../commands/autofill-tab-command"; - import { + AUTOFILL_CARD_ID, AUTOFILL_ID, + AUTOFILL_IDENTITY_ID, COPY_IDENTIFIER_ID, COPY_PASSWORD_ID, COPY_USERNAME_ID, COPY_VERIFICATIONCODE_ID, + CREATE_CARD_ID, + CREATE_IDENTITY_ID, + CREATE_LOGIN_ID, GENERATE_PASSWORD_ID, NOOP_COMMAND_SUFFIX, -} from "./main-context-menu-handler"; +} from "../constants"; +import { AutofillCipherTypeId } from "../types"; export type CopyToClipboardOptions = { text: string; tab: chrome.tabs.Tab }; export type CopyToClipboardAction = (options: CopyToClipboardOptions) => void; @@ -142,18 +148,16 @@ export class ContextMenuClickedHandler { ); } - async run(info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab) { + async run(info: chrome.contextMenus.OnClickData, tab: chrome.tabs.Tab) { + if (!tab) { + return; + } + switch (info.menuItemId) { case GENERATE_PASSWORD_ID: - if (!tab) { - return; - } await this.generatePasswordToClipboard(tab); break; case COPY_IDENTIFIER_ID: - if (!tab) { - return; - } this.copyToClipboard({ text: await this.getIdentifier(tab, info), tab: tab }); break; default: @@ -161,7 +165,11 @@ export class ContextMenuClickedHandler { } } - async cipherAction(info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab) { + async cipherAction(info: chrome.contextMenus.OnClickData, tab: chrome.tabs.Tab) { + if (!tab) { + return; + } + if ((await this.authService.getAuthStatus()) < AuthenticationStatus.Unlocked) { const retryMessage: LockedVaultPendingNotificationsItem = { commandToRetry: { @@ -182,32 +190,57 @@ export class ContextMenuClickedHandler { // NOTE: We don't actually use the first part of this ID, we further switch based on the parentMenuItemId // I would really love to not add it but that is a departure from how it currently works. - const id = (info.menuItemId as string).split("_")[1]; // We create all the ids, we can guarantee they are strings + const menuItemId = (info.menuItemId as string).split("_")[1]; // We create all the ids, we can guarantee they are strings let cipher: CipherView | undefined; - if (id === NOOP_COMMAND_SUFFIX) { + const isCreateCipherAction = [CREATE_LOGIN_ID, CREATE_IDENTITY_ID, CREATE_CARD_ID].includes( + menuItemId as string + ); + + if (isCreateCipherAction) { + // pass; defer to logic below + } else if (menuItemId === NOOP_COMMAND_SUFFIX) { + const additionalCiphersToGet = + info.parentMenuItemId === AUTOFILL_IDENTITY_ID + ? [CipherType.Identity] + : info.parentMenuItemId === AUTOFILL_CARD_ID + ? [CipherType.Card] + : []; + // This NOOP item has come through which is generally only for no access state but since we got here // we are actually unlocked we will do our best to find a good match of an item to autofill this is useful // in scenarios like unlock on autofill - const ciphers = await this.cipherService.getAllDecryptedForUrl(tab.url); + const ciphers = await this.cipherService.getAllDecryptedForUrl( + tab.url, + additionalCiphersToGet + ); + cipher = ciphers[0]; } else { const ciphers = await this.cipherService.getAllDecrypted(); - cipher = ciphers.find((c) => c.id === id); + cipher = ciphers.find(({ id }) => id === menuItemId); } - if (cipher == null) { + if (!cipher && !isCreateCipherAction) { return; } switch (info.parentMenuItemId) { case AUTOFILL_ID: - if (tab == null) { - return; + case AUTOFILL_IDENTITY_ID: + case AUTOFILL_CARD_ID: { + const cipherType = this.getCipherCreationType(menuItemId); + + if (cipherType) { + await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", { + cipherType, + }); + break; } if (await this.isPasswordRepromptRequired(cipher)) { await BrowserApi.tabSendMessageData(tab, "passwordReprompt", { cipherId: cipher.id, + // The action here is passed on to the single-use reprompt window and doesn't change based on cipher type action: AUTOFILL_ID, }); } else { @@ -215,14 +248,29 @@ export class ContextMenuClickedHandler { } break; + } case COPY_USERNAME_ID: + if (menuItemId === CREATE_LOGIN_ID) { + await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", { + cipherType: CipherType.Login, + }); + break; + } + this.copyToClipboard({ text: cipher.login.username, tab: tab }); break; case COPY_PASSWORD_ID: + if (menuItemId === CREATE_LOGIN_ID) { + await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", { + cipherType: CipherType.Login, + }); + break; + } + if (await this.isPasswordRepromptRequired(cipher)) { await BrowserApi.tabSendMessageData(tab, "passwordReprompt", { cipherId: cipher.id, - action: COPY_PASSWORD_ID, + action: info.parentMenuItemId, }); } else { this.copyToClipboard({ text: cipher.login.password, tab: tab }); @@ -231,10 +279,17 @@ export class ContextMenuClickedHandler { break; case COPY_VERIFICATIONCODE_ID: + if (menuItemId === CREATE_LOGIN_ID) { + await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", { + cipherType: CipherType.Login, + }); + break; + } + if (await this.isPasswordRepromptRequired(cipher)) { await BrowserApi.tabSendMessageData(tab, "passwordReprompt", { cipherId: cipher.id, - action: COPY_VERIFICATIONCODE_ID, + action: info.parentMenuItemId, }); } else { this.copyToClipboard({ @@ -254,6 +309,16 @@ export class ContextMenuClickedHandler { ); } + private getCipherCreationType(menuItemId?: string): AutofillCipherTypeId | null { + return menuItemId === CREATE_IDENTITY_ID + ? CipherType.Identity + : menuItemId === CREATE_CARD_ID + ? CipherType.Card + : menuItemId === CREATE_LOGIN_ID + ? CipherType.Login + : null; + } + private async getIdentifier(tab: chrome.tabs.Tab, info: chrome.contextMenus.OnClickData) { return new Promise((resolve, reject) => { BrowserApi.sendTabsMessage( diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts index 6b599986b67..95916d2e6f1 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts @@ -60,7 +60,7 @@ describe("context-menu", () => { const createdMenu = await sut.init(); expect(createdMenu).toBeTruthy(); - expect(createSpy).toHaveBeenCalledTimes(7); + expect(createSpy).toHaveBeenCalledTimes(10); }); it("has menu enabled and has premium", async () => { @@ -70,7 +70,7 @@ describe("context-menu", () => { const createdMenu = await sut.init(); expect(createdMenu).toBeTruthy(); - expect(createSpy).toHaveBeenCalledTimes(8); + expect(createSpy).toHaveBeenCalledTimes(11); }); }); @@ -97,7 +97,7 @@ describe("context-menu", () => { }; it("is not a login cipher", async () => { - await sut.loadOptions("TEST_TITLE", "1", "", { + await sut.loadOptions("TEST_TITLE", "1", { ...createCipher(), type: CipherType.SecureNote, } as any); @@ -109,7 +109,6 @@ describe("context-menu", () => { await sut.loadOptions( "TEST_TITLE", "1", - "", createCipher({ username: "", totp: "", @@ -123,18 +122,18 @@ describe("context-menu", () => { it("create entry for each cipher piece", async () => { stateService.getCanAccessPremium.mockResolvedValue(true); - await sut.loadOptions("TEST_TITLE", "1", "", createCipher()); + await sut.loadOptions("TEST_TITLE", "1", createCipher()); // One for autofill, copy username, copy password, and copy totp code expect(createSpy).toHaveBeenCalledTimes(4); }); - it("creates noop item for no cipher", async () => { + it("creates a login/unlock item for each context menu action option when user is not authenticated", async () => { stateService.getCanAccessPremium.mockResolvedValue(true); - await sut.loadOptions("TEST_TITLE", "NOOP", ""); + await sut.loadOptions("TEST_TITLE", "NOOP"); - expect(createSpy).toHaveBeenCalledTimes(4); + expect(createSpy).toHaveBeenCalledTimes(6); }); }); }); diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.ts index b9af3dd191f..cc5cc9a5166 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.ts @@ -21,22 +21,24 @@ import { StateServiceInitOptions, } from "../../platform/background/service-factories/state-service.factory"; import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service"; - -export const ROOT_ID = "root"; - -export const AUTOFILL_ID = "autofill"; -export const COPY_USERNAME_ID = "copy-username"; -export const COPY_PASSWORD_ID = "copy-password"; -export const COPY_VERIFICATIONCODE_ID = "copy-totp"; -export const COPY_IDENTIFIER_ID = "copy-identifier"; - -const SEPARATOR_ID = "separator"; -export const GENERATE_PASSWORD_ID = "generate-password"; - -export const NOOP_COMMAND_SUFFIX = "noop"; +import { + AUTOFILL_CARD_ID, + AUTOFILL_ID, + AUTOFILL_IDENTITY_ID, + COPY_IDENTIFIER_ID, + COPY_PASSWORD_ID, + COPY_USERNAME_ID, + COPY_VERIFICATIONCODE_ID, + CREATE_CARD_ID, + CREATE_IDENTITY_ID, + CREATE_LOGIN_ID, + GENERATE_PASSWORD_ID, + NOOP_COMMAND_SUFFIX, + ROOT_ID, + SEPARATOR_ID, +} from "../constants"; export class MainContextMenuHandler { - // private initRunning = false; create: (options: chrome.contextMenus.CreateProperties) => Promise; @@ -120,7 +122,7 @@ export class MainContextMenuHandler { await create({ id: AUTOFILL_ID, parentId: ROOT_ID, - title: this.i18nService.t("autoFill"), + title: this.i18nService.t("autoFillLogin"), }); await create({ @@ -144,7 +146,25 @@ export class MainContextMenuHandler { } await create({ - id: SEPARATOR_ID, + id: SEPARATOR_ID + 1, + type: "separator", + parentId: ROOT_ID, + }); + + await create({ + id: AUTOFILL_IDENTITY_ID, + parentId: ROOT_ID, + title: this.i18nService.t("autoFillIdentity"), + }); + + await create({ + id: AUTOFILL_CARD_ID, + parentId: ROOT_ID, + title: this.i18nService.t("autoFillCard"), + }); + + await create({ + id: SEPARATOR_ID + 2, type: "separator", parentId: ROOT_ID, }); @@ -194,40 +214,52 @@ export class MainContextMenuHandler { }); } - async loadOptions(title: string, id: string, url: string, cipher?: CipherView | undefined) { - if (cipher != null && cipher.type !== CipherType.Login) { - return; - } - + async loadOptions(title: string, optionId: string, cipher?: CipherView) { try { const sanitizedTitle = MainContextMenuHandler.sanitizeContextMenuTitle(title); - const createChildItem = async (parent: string) => { - const menuItemId = `${parent}_${id}`; + const createChildItem = async (parentId: string) => { + const menuItemId = `${parentId}_${optionId}`; + return await this.create({ type: "normal", id: menuItemId, - parentId: parent, + parentId, title: sanitizedTitle, contexts: ["all"], }); }; - if (cipher == null || !Utils.isNullOrEmpty(cipher.login.password)) { + if ( + !cipher || + (cipher.type === CipherType.Login && !Utils.isNullOrEmpty(cipher.login?.password)) + ) { await createChildItem(AUTOFILL_ID); + if (cipher?.viewPassword ?? true) { await createChildItem(COPY_PASSWORD_ID); } } - if (cipher == null || !Utils.isNullOrEmpty(cipher.login.username)) { + if ( + !cipher || + (cipher.type === CipherType.Login && !Utils.isNullOrEmpty(cipher.login?.username)) + ) { await createChildItem(COPY_USERNAME_ID); } const canAccessPremium = await this.stateService.getCanAccessPremium(); - if (canAccessPremium && (cipher == null || !Utils.isNullOrEmpty(cipher.login.totp))) { + if (canAccessPremium && (!cipher || !Utils.isNullOrEmpty(cipher.login?.totp))) { await createChildItem(COPY_VERIFICATIONCODE_ID); } + + if ((!cipher || cipher.type === CipherType.Card) && optionId !== CREATE_LOGIN_ID) { + await createChildItem(AUTOFILL_CARD_ID); + } + + if ((!cipher || cipher.type === CipherType.Identity) && optionId !== CREATE_LOGIN_ID) { + await createChildItem(AUTOFILL_IDENTITY_ID); + } } catch (error) { this.logService.warning(error.message); } @@ -242,13 +274,72 @@ export class MainContextMenuHandler { const authed = await this.stateService.getIsAuthenticated(); await this.loadOptions( this.i18nService.t(authed ? "unlockVaultMenu" : "loginToVaultMenu"), - NOOP_COMMAND_SUFFIX, - "" + NOOP_COMMAND_SUFFIX ); } } - async noLogins(url: string) { - await this.loadOptions(this.i18nService.t("noMatchingLogins"), NOOP_COMMAND_SUFFIX, url); + async noCards() { + await this.create({ + id: `${AUTOFILL_CARD_ID}_NOTICE`, + enabled: false, + parentId: AUTOFILL_CARD_ID, + title: this.i18nService.t("noCards"), + type: "normal", + }); + + await this.create({ + id: `${AUTOFILL_CARD_ID}_${SEPARATOR_ID}`, + parentId: AUTOFILL_CARD_ID, + type: "separator", + }); + + await this.create({ + id: `${AUTOFILL_CARD_ID}_${CREATE_CARD_ID}`, + parentId: AUTOFILL_CARD_ID, + title: this.i18nService.t("addCardMenu"), + type: "normal", + }); + } + + async noIdentities() { + await this.create({ + id: `${AUTOFILL_IDENTITY_ID}_NOTICE`, + enabled: false, + parentId: AUTOFILL_IDENTITY_ID, + title: this.i18nService.t("noIdentities"), + type: "normal", + }); + + await this.create({ + id: `${AUTOFILL_IDENTITY_ID}_${SEPARATOR_ID}`, + parentId: AUTOFILL_IDENTITY_ID, + type: "separator", + }); + + await this.create({ + id: `${AUTOFILL_IDENTITY_ID}_${CREATE_IDENTITY_ID}`, + parentId: AUTOFILL_IDENTITY_ID, + title: this.i18nService.t("addIdentityMenu"), + type: "normal", + }); + } + + async noLogins() { + await this.create({ + id: `${AUTOFILL_ID}_NOTICE`, + enabled: false, + parentId: AUTOFILL_ID, + title: this.i18nService.t("noMatchingLogins"), + type: "normal", + }); + + await this.create({ + id: `${AUTOFILL_ID}_${SEPARATOR_ID}` + 1, + parentId: AUTOFILL_ID, + type: "separator", + }); + + await this.loadOptions(this.i18nService.t("addLoginMenu"), CREATE_LOGIN_ID); } } diff --git a/apps/browser/src/autofill/constants.ts b/apps/browser/src/autofill/constants.ts index 7f3637180b0..ef82035aef7 100644 --- a/apps/browser/src/autofill/constants.ts +++ b/apps/browser/src/autofill/constants.ts @@ -11,3 +11,19 @@ export const EVENTS = { KEYPRESS: "keypress", KEYUP: "keyup", } as const; + +/* Context Menu item Ids */ +export const AUTOFILL_CARD_ID = "autofill-card"; +export const AUTOFILL_ID = "autofill"; +export const AUTOFILL_IDENTITY_ID = "autofill-identity"; +export const COPY_IDENTIFIER_ID = "copy-identifier"; +export const COPY_PASSWORD_ID = "copy-password"; +export const COPY_USERNAME_ID = "copy-username"; +export const COPY_VERIFICATIONCODE_ID = "copy-totp"; +export const CREATE_CARD_ID = "create-card"; +export const CREATE_IDENTITY_ID = "create-identity"; +export const CREATE_LOGIN_ID = "create-login"; +export const GENERATE_PASSWORD_ID = "generate-password"; +export const NOOP_COMMAND_SUFFIX = "noop"; +export const ROOT_ID = "root"; +export const SEPARATOR_ID = "separator"; diff --git a/apps/browser/src/autofill/services/abstractions/autofill.service.ts b/apps/browser/src/autofill/services/abstractions/autofill.service.ts index ac7971768e4..bbd0b21d226 100644 --- a/apps/browser/src/autofill/services/abstractions/autofill.service.ts +++ b/apps/browser/src/autofill/services/abstractions/autofill.service.ts @@ -1,4 +1,5 @@ import { UriMatchType } from "@bitwarden/common/enums"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import AutofillField from "../../models/autofill-field"; @@ -55,5 +56,9 @@ export abstract class AutofillService { tab: chrome.tabs.Tab, fromCommand: boolean ) => Promise; - doAutoFillActiveTab: (pageDetails: PageDetail[], fromCommand: boolean) => Promise; + doAutoFillActiveTab: ( + pageDetails: PageDetail[], + fromCommand: boolean, + cipherType?: CipherType + ) => Promise; } diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 81bf107650d..6a61a781c06 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -304,21 +304,47 @@ export default class AutofillService implements AutofillServiceInterface { } /** - * Autofill the active tab with the next login item from the cache + * Autofill the active tab with the next cipher from the cache * @param {PageDetail[]} pageDetails The data scraped from the page * @param {boolean} fromCommand Whether the autofill is triggered by a keyboard shortcut (`true`) or autofill on page load (`false`) * @returns {Promise} The TOTP code of the successfully autofilled login, if any */ async doAutoFillActiveTab( pageDetails: PageDetail[], - fromCommand: boolean + fromCommand: boolean, + cipherType?: CipherType ): Promise { const tab = await this.getActiveTab(); + if (!tab || !tab.url) { return null; } - return await this.doAutoFillOnTab(pageDetails, tab, fromCommand); + if (!cipherType || cipherType === CipherType.Login) { + return await this.doAutoFillOnTab(pageDetails, tab, fromCommand); + } + + // Cipher is a non-login type + const cipher: CipherView = ( + (await this.cipherService.getAllDecryptedForUrl(tab.url, [cipherType])) || [] + ).find(({ type }) => type === cipherType); + + if (!cipher || cipher.reprompt !== CipherRepromptType.None) { + return null; + } + + return await this.doAutoFill({ + tab: tab, + cipher: cipher, + pageDetails: pageDetails, + skipLastUsed: !fromCommand, + skipUsernameOnlyFill: !fromCommand, + onlyEmptyFields: !fromCommand, + onlyVisibleFields: !fromCommand, + fillNewPassword: false, + allowUntrustedIframe: fromCommand, + allowTotpAutofill: false, + }); } /** diff --git a/apps/browser/src/autofill/types/index.ts b/apps/browser/src/autofill/types/index.ts index 8bab87709d2..8a97e397477 100644 --- a/apps/browser/src/autofill/types/index.ts +++ b/apps/browser/src/autofill/types/index.ts @@ -1,5 +1,6 @@ import { Region } from "@bitwarden/common/platform/abstractions/environment.service"; import { VaultTimeoutAction } from "@bitwarden/common/src/enums/vault-timeout-action.enum"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; export type UserSettings = { avatarColor: string | null; @@ -58,3 +59,5 @@ export type FillableFormFieldElement = HTMLInputElement | HTMLSelectElement | HT export type FormFieldElement = FillableFormFieldElement | HTMLSpanElement; export type FormElementWithAttribute = FormFieldElement & Record; + +export type AutofillCipherTypeId = CipherType.Login | CipherType.Card | CipherType.Identity; diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index dcf828ef4a0..dc7ea9a69f9 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -6,6 +6,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { SystemService } from "@bitwarden/common/platform/abstractions/system.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { AutofillService } from "../autofill/services/abstractions/autofill.service"; import { BrowserApi } from "../platform/browser/browser-api"; @@ -123,12 +124,28 @@ export default class RuntimeBackground { } break; case "openAddEditCipher": { - const addEditCipherUrl = - cipherId == null - ? "popup/index.html#/edit-cipher" - : "popup/index.html#/edit-cipher?cipherId=" + cipherId; + const isNewCipher = !cipherId; + const cipherType = msg.data?.cipherType; + const senderTab = sender.tab; + + if (!senderTab) { + break; + } + + if (isNewCipher) { + await this.browserPopoutWindowService.openCipherCreation(senderTab.windowId, { + cipherType, + senderTabId: senderTab.id, + senderTabURI: senderTab.url, + }); + } else { + await this.browserPopoutWindowService.openCipherEdit(senderTab.windowId, { + cipherId, + senderTabId: senderTab.id, + senderTabURI: senderTab.url, + }); + } - BrowserApi.openBitwardenExtensionTab(addEditCipherUrl, true); break; } case "closeTab": @@ -174,6 +191,34 @@ export default class RuntimeBackground { } break; } + case "autofill_card": { + await this.autofillService.doAutoFillActiveTab( + [ + { + frameId: sender.frameId, + tab: msg.tab, + details: msg.details, + }, + ], + false, + CipherType.Card + ); + break; + } + case "autofill_identity": { + await this.autofillService.doAutoFillActiveTab( + [ + { + frameId: sender.frameId, + tab: msg.tab, + details: msg.details, + }, + ], + false, + CipherType.Identity + ); + break; + } case "contextMenu": clearTimeout(this.autofillTimeout); this.pageDetailsToAutoFill.push({ diff --git a/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts b/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts index 0b3f55ee990..0ded45bea94 100644 --- a/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts +++ b/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts @@ -1,3 +1,5 @@ +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; + interface BrowserPopoutWindowService { openUnlockPrompt(senderWindowId: number): Promise; closeUnlockPrompt(): Promise; @@ -9,6 +11,22 @@ interface BrowserPopoutWindowService { senderTabId: number; } ): Promise; + openCipherCreation( + senderWindowId: number, + promptData: { + cipherType?: CipherType; + senderTabId: number; + senderTabURI: string; + } + ): Promise; + openCipherEdit( + senderWindowId: number, + promptData: { + cipherId: string; + senderTabId: number; + senderTabURI: string; + } + ): Promise; closePasswordRepromptPrompt(): Promise; } diff --git a/apps/browser/src/platform/popup/browser-popout-window.service.ts b/apps/browser/src/platform/popup/browser-popout-window.service.ts index ee03e3a2ec4..f5ac2f3128e 100644 --- a/apps/browser/src/platform/popup/browser-popout-window.service.ts +++ b/apps/browser/src/platform/popup/browser-popout-window.service.ts @@ -1,3 +1,5 @@ +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; + import { BrowserApi } from "../browser/browser-api"; import { BrowserPopoutWindowService as BrowserPopupWindowServiceInterface } from "./abstractions/browser-popout-window.service"; @@ -45,6 +47,50 @@ class BrowserPopoutWindowService implements BrowserPopupWindowServiceInterface { await this.openSingleActionPopout(senderWindowId, promptWindowPath, "passwordReprompt"); } + async openCipherCreation( + senderWindowId: number, + { + cipherType = CipherType.Login, + senderTabId, + senderTabURI, + }: { + cipherType?: CipherType; + senderTabId: number; + senderTabURI: string; + } + ) { + const promptWindowPath = + "popup/index.html#/edit-cipher" + + "?uilocation=popout" + + `&type=${cipherType}` + + `&senderTabId=${senderTabId}` + + `&uri=${senderTabURI}`; + + await this.openSingleActionPopout(senderWindowId, promptWindowPath, "cipherCreation"); + } + + async openCipherEdit( + senderWindowId: number, + { + cipherId, + senderTabId, + senderTabURI, + }: { + cipherId: string; + senderTabId: number; + senderTabURI: string; + } + ) { + const promptWindowPath = + "popup/index.html#/edit-cipher" + + "?uilocation=popout" + + `&cipherId=${cipherId}` + + `&senderTabId=${senderTabId}` + + `&uri=${senderTabURI}`; + + await this.openSingleActionPopout(senderWindowId, promptWindowPath, "cipherEdit"); + } + async closePasswordRepromptPrompt() { await this.closeSingleActionPopout("passwordReprompt"); } diff --git a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts index 54cb08ae893..f4e9fad5634 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts +++ b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts @@ -35,6 +35,9 @@ export class AddEditComponent extends BaseAddEditComponent { showAttachments = true; openAttachmentsInPopup: boolean; showAutoFillOnPageLoadOptions: boolean; + senderTabId?: number; + uilocation?: "popout" | "popup" | "sidebar" | "tab"; + inPopout = false; constructor( cipherService: CipherService, @@ -81,6 +84,9 @@ export class AddEditComponent extends BaseAddEditComponent { // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.queryParams.pipe(first()).subscribe(async (params) => { + this.senderTabId = parseInt(params?.senderTabId, 10) || undefined; + this.uilocation = params?.uilocation; + if (params.cipherId) { this.cipherId = params.cipherId; } @@ -128,6 +134,8 @@ export class AddEditComponent extends BaseAddEditComponent { this.openAttachmentsInPopup = this.popupUtilsService.inPopup(window); }); + this.inPopout = this.uilocation === "popout" || this.popupUtilsService.inPopout(window); + if (!this.editMode) { const tabs = await BrowserApi.tabsQuery({ windowType: "normal" }); this.currentUris = @@ -162,6 +170,11 @@ export class AddEditComponent extends BaseAddEditComponent { return true; } + if (this.senderTabId && this.inPopout) { + setTimeout(() => this.close(), 1000); + return true; + } + if (this.cloneMode) { this.router.navigate(["/tabs/vault"]); } else { @@ -194,6 +207,11 @@ export class AddEditComponent extends BaseAddEditComponent { cancel() { super.cancel(); + if (this.senderTabId && this.inPopout) { + this.close(); + return; + } + if (this.popupUtilsService.inTab(window)) { this.messagingService.send("closeTab"); return; @@ -202,6 +220,14 @@ export class AddEditComponent extends BaseAddEditComponent { this.location.back(); } + // Used for closing single-action views + close() { + BrowserApi.focusTab(this.senderTabId); + window.close(); + + return; + } + async generateUsername(): Promise { const confirmed = await super.generateUsername(); if (confirmed) {