1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-09-27 04:03:00 +02:00

[PM-3455] Allow adding and autofilling Cards and Identities via Context Menu (#6050)

* PoC autofill card and identity from context menu

* PoC trigger identity and card autofills via messages

* update card and identity cipher titles in the context menu

* remove unused url argument from loadOptions

* do not show no logins message for card and identity sub-menu

* allow context menu actions to create identity or card ciphers

* open new single-action windows for cipher creation when requested from the context menu

* add context menu items for adding a login cipher when none are available to the page

* adjust titles for Card and Identity context menu items

* fix translations and add no ciphers available messages to submenus

* cleanup and update tests

* remove unrelated changes

* pass uri of context menu page to cipher creation view

* Apply suggestions from code review

Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com>

* cleanup

* handle cipher edit background messages with browserPopoutWindowService as well

* consolidate doAutoFillNonLoginActiveTab into doAutoFillActiveTab

* cleanup

---------

Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com>
This commit is contained in:
Jonathan Prusik 2023-09-29 17:20:41 -04:00 committed by GitHub
parent be8f522aac
commit a42cea8570
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 547 additions and 106 deletions

View File

@ -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"
},

View File

@ -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
);
});
});

View File

@ -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<AutofillCipherTypeId, CipherView[]> = 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);
}
}

View File

@ -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", []);
});
});
});

View File

@ -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<string>((resolve, reject) => {
BrowserApi.sendTabsMessage(

View File

@ -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);
});
});
});

View File

@ -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<void>;
@ -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,
"<all_urls>"
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);
}
}

View File

@ -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";

View File

@ -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<string | null>;
doAutoFillActiveTab: (pageDetails: PageDetail[], fromCommand: boolean) => Promise<string | null>;
doAutoFillActiveTab: (
pageDetails: PageDetail[],
fromCommand: boolean,
cipherType?: CipherType
) => Promise<string | null>;
}

View File

@ -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<string | null>} The TOTP code of the successfully autofilled login, if any
*/
async doAutoFillActiveTab(
pageDetails: PageDetail[],
fromCommand: boolean
fromCommand: boolean,
cipherType?: CipherType
): Promise<string | null> {
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,
});
}
/**

View File

@ -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<string, string | null | undefined>;
export type AutofillCipherTypeId = CipherType.Login | CipherType.Card | CipherType.Identity;

View File

@ -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({

View File

@ -1,3 +1,5 @@
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
interface BrowserPopoutWindowService {
openUnlockPrompt(senderWindowId: number): Promise<void>;
closeUnlockPrompt(): Promise<void>;
@ -9,6 +11,22 @@ interface BrowserPopoutWindowService {
senderTabId: number;
}
): Promise<void>;
openCipherCreation(
senderWindowId: number,
promptData: {
cipherType?: CipherType;
senderTabId: number;
senderTabURI: string;
}
): Promise<void>;
openCipherEdit(
senderWindowId: number,
promptData: {
cipherId: string;
senderTabId: number;
senderTabURI: string;
}
): Promise<void>;
closePasswordRepromptPrompt(): Promise<void>;
}

View File

@ -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");
}

View File

@ -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<boolean> {
const confirmed = await super.generateUsername();
if (confirmed) {