1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-10-27 07:39:49 +01:00

[PM-8833] Implement on page autofill menu for password generation (#11114)

This commit is contained in:
Cesar Gonzalez 2024-10-24 13:20:00 -05:00 committed by GitHub
parent 9264e6775c
commit da1e508c25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 3848 additions and 1635 deletions

View File

@ -4599,5 +4599,158 @@
},
"authenticating": {
"message": "Authenticating"
},
"fillGeneratedPassword": {
"message": "Fill generated password",
"description": "Heading for the password generator within the inline menu"
},
"passwordRegenerated": {
"message": "Password regenerated",
"description": "Notification message for when a password has been regenerated"
},
"saveLoginToBitwarden": {
"message": "Save login to Bitwarden?",
"description": "Confirmation message for saving a login to Bitwarden"
},
"spaceCharacterDescriptor": {
"message": "Space",
"description": "Represents the space key in screen reader content as a readable word"
},
"tildeCharacterDescriptor": {
"message": "Tilde",
"description": "Represents the ~ key in screen reader content as a readable word"
},
"backtickCharacterDescriptor": {
"message": "Backtick",
"description": "Represents the ` key in screen reader content as a readable word"
},
"exclamationCharacterDescriptor": {
"message": "Exclamation mark",
"description": "Represents the ! key in screen reader content as a readable word"
},
"atSignCharacterDescriptor": {
"message": "At sign",
"description": "Represents the @ key in screen reader content as a readable word"
},
"hashSignCharacterDescriptor": {
"message": "Hash sign",
"description": "Represents the # key in screen reader content as a readable word"
},
"dollarSignCharacterDescriptor": {
"message": "Dollar sign",
"description": "Represents the $ key in screen reader content as a readable word"
},
"percentSignCharacterDescriptor": {
"message": "Percent sign",
"description": "Represents the % key in screen reader content as a readable word"
},
"caretCharacterDescriptor": {
"message": "Caret",
"description": "Represents the ^ key in screen reader content as a readable word"
},
"ampersandCharacterDescriptor": {
"message": "Ampersand",
"description": "Represents the & key in screen reader content as a readable word"
},
"asteriskCharacterDescriptor": {
"message": "Asterisk",
"description": "Represents the * key in screen reader content as a readable word"
},
"parenLeftCharacterDescriptor": {
"message": "Left parenthesis",
"description": "Represents the ( key in screen reader content as a readable word"
},
"parenRightCharacterDescriptor": {
"message": "Right parenthesis",
"description": "Represents the ) key in screen reader content as a readable word"
},
"hyphenCharacterDescriptor": {
"message": "Underscore",
"description": "Represents the _ key in screen reader content as a readable word"
},
"underscoreCharacterDescriptor": {
"message": "Hyphen",
"description": "Represents the - key in screen reader content as a readable word"
},
"plusCharacterDescriptor": {
"message": "Plus",
"description": "Represents the + key in screen reader content as a readable word"
},
"equalsCharacterDescriptor": {
"message": "Equals",
"description": "Represents the = key in screen reader content as a readable word"
},
"braceLeftCharacterDescriptor": {
"message": "Left brace",
"description": "Represents the { key in screen reader content as a readable word"
},
"braceRightCharacterDescriptor": {
"message": "Right brace",
"description": "Represents the } key in screen reader content as a readable word"
},
"bracketLeftCharacterDescriptor": {
"message": "Left bracket",
"description": "Represents the [ key in screen reader content as a readable word"
},
"bracketRightCharacterDescriptor": {
"message": "Right bracket",
"description": "Represents the ] key in screen reader content as a readable word"
},
"pipeCharacterDescriptor": {
"message": "Pipe",
"description": "Represents the | key in screen reader content as a readable word"
},
"backSlashCharacterDescriptor": {
"message": "Back slash",
"description": "Represents the back slash key in screen reader content as a readable word"
},
"colonCharacterDescriptor": {
"message": "Colon",
"description": "Represents the : key in screen reader content as a readable word"
},
"semicolonCharacterDescriptor": {
"message": "Semicolon",
"description": "Represents the ; key in screen reader content as a readable word"
},
"doubleQuoteCharacterDescriptor": {
"message": "Double quote",
"description": "Represents the double quote key in screen reader content as a readable word"
},
"singleQuoteCharacterDescriptor": {
"message": "Single quote",
"description": "Represents the ' key in screen reader content as a readable word"
},
"lessThanCharacterDescriptor": {
"message": "Less than",
"description": "Represents the < key in screen reader content as a readable word"
},
"greaterThanCharacterDescriptor": {
"message": "Greater than",
"description": "Represents the > key in screen reader content as a readable word"
},
"commaCharacterDescriptor": {
"message": "Comma",
"description": "Represents the , key in screen reader content as a readable word"
},
"periodCharacterDescriptor": {
"message": "Period",
"description": "Represents the . key in screen reader content as a readable word"
},
"questionCharacterDescriptor": {
"message": "Question mark",
"description": "Represents the ? key in screen reader content as a readable word"
},
"forwardSlashCharacterDescriptor": {
"message": "Forward slash",
"description": "Represents the / key in screen reader content as a readable word"
},
"lowercaseAriaLabel": {
"message": "Lowercase"
},
"uppercaseAriaLabel": {
"message": "Uppercase"
},
"generatedPassword": {
"message": "Generated password"
}
}

View File

@ -2,6 +2,7 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { InlineMenuFillTypes } from "../../enums/autofill-overlay.enum";
import AutofillPageDetails from "../../models/autofill-page-details";
import { PageDetail } from "../../services/abstractions/autofill.service";
@ -32,14 +33,18 @@ export type WebsiteIconData = {
icon: string;
};
export type UpdateOverlayCiphersParams = {
updateAllCipherTypes: boolean;
refocusField: boolean;
};
export type FocusedFieldData = {
focusedFieldStyles: Partial<CSSStyleDeclaration>;
focusedFieldRects: Partial<DOMRect>;
filledByCipherType?: CipherType;
inlineMenuFillType?: InlineMenuFillTypes;
tabId?: number;
frameId?: number;
accountCreationFieldType?: string;
showInlineMenuAccountCreation?: boolean;
showPasskeys?: boolean;
};
@ -111,6 +116,12 @@ export type ToggleInlineMenuHiddenMessage = {
setTransparentInlineMenu?: boolean;
};
export type UpdateInlineMenuVisibilityMessage = {
overlayElement?: string;
isVisible?: boolean;
forceUpdate?: boolean;
};
export type OverlayBackgroundExtensionMessage = {
command: string;
portKey?: string;
@ -119,14 +130,15 @@ export type OverlayBackgroundExtensionMessage = {
details?: AutofillPageDetails;
isFieldCurrentlyFocused?: boolean;
isFieldCurrentlyFilling?: boolean;
isVisible?: boolean;
subFrameData?: SubFrameOffsetData;
focusedFieldData?: FocusedFieldData;
isOpeningFullInlineMenu?: boolean;
styles?: Partial<CSSStyleDeclaration>;
data?: LockedVaultPendingNotificationsData;
} & OverlayAddNewItemMessage &
CloseInlineMenuMessage &
ToggleInlineMenuHiddenMessage;
ToggleInlineMenuHiddenMessage &
UpdateInlineMenuVisibilityMessage;
export type OverlayPortMessage = {
[key: string]: any;
@ -188,16 +200,12 @@ export type OverlayBackgroundExtensionMessageHandlers = {
updateIsFieldCurrentlyFilling: ({ message }: BackgroundMessageParam) => void;
checkIsFieldCurrentlyFilling: () => boolean;
getAutofillInlineMenuVisibility: () => void;
openAutofillInlineMenu: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
getInlineMenuCardsVisibility: () => void;
getInlineMenuIdentitiesVisibility: () => void;
openAutofillInlineMenu: () => void;
closeAutofillInlineMenu: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
checkAutofillInlineMenuFocused: ({ sender }: BackgroundSenderParam) => void;
focusAutofillInlineMenuList: () => void;
updateAutofillInlineMenuPosition: ({
message,
sender,
}: BackgroundOnMessageHandlerParams) => Promise<void>;
getAutofillInlineMenuPosition: () => InlineMenuPosition;
updateAutofillInlineMenuElementIsVisibleStatus: ({
message,
@ -219,6 +227,7 @@ export type OverlayBackgroundExtensionMessageHandlers = {
addEditCipherSubmitted: () => void;
editedCipher: () => void;
deletedCipher: () => void;
bgSaveCipher: () => void;
fido2AbortRequest: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
};
@ -241,14 +250,16 @@ export type InlineMenuButtonPortMessageHandlers = {
export type InlineMenuListPortMessageHandlers = {
[key: string]: CallableFunction;
checkAutofillInlineMenuButtonFocused: () => void;
autofillInlineMenuBlurred: () => void;
checkAutofillInlineMenuButtonFocused: ({ port }: PortConnectionParam) => void;
autofillInlineMenuBlurred: ({ port }: PortConnectionParam) => void;
unlockVault: ({ port }: PortConnectionParam) => void;
fillAutofillInlineMenuCipher: ({ message, port }: PortOnMessageHandlerParams) => void;
addNewVaultItem: ({ message, port }: PortOnMessageHandlerParams) => void;
viewSelectedCipher: ({ message, port }: PortOnMessageHandlerParams) => void;
redirectAutofillInlineMenuFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void;
updateAutofillInlineMenuListHeight: ({ message, port }: PortOnMessageHandlerParams) => void;
refreshGeneratedPassword: () => Promise<void>;
fillGeneratedPassword: ({ port }: PortConnectionParam) => Promise<void>;
};
export interface OverlayBackground {

View File

@ -1114,8 +1114,9 @@ describe("NotificationBackground", () => {
it("skips saving the domain as a never value if the tab url does not match the queue message domain", async () => {
const tab = createChromeTabMock({ id: 2, url: "https://example.com" });
const sender = mock<chrome.runtime.MessageSender>({ tab });
const message: NotificationBackgroundExtensionMessage = { command: "bgNeverSave" };
const secondaryTab = createChromeTabMock({ id: 3, url: "https://another.com" });
const sender = mock<chrome.runtime.MessageSender>({ tab: secondaryTab });
notificationBackground["notificationQueue"] = [
mock<AddLoginQueueMessage>({
type: NotificationQueueMessageType.AddLogin,

View File

@ -173,13 +173,8 @@ export default class NotificationBackground {
}
private async doNotificationQueueCheck(tab: chrome.tabs.Tab): Promise<void> {
const tabDomain = Utils.getDomain(tab?.url);
if (!tabDomain) {
return;
}
const queueMessage = this.notificationQueue.find(
(message) => message.tab.id === tab.id && message.domain === tabDomain,
(message) => message.tab.id === tab.id && this.queueMessageIsFromTabOrigin(message, tab),
);
if (queueMessage) {
await this.sendNotificationQueueMessage(tab, queueMessage);
@ -537,8 +532,7 @@ export default class NotificationBackground {
continue;
}
const tabDomain = Utils.getDomain(tab.url);
if (tabDomain != null && tabDomain !== queueMessage.domain) {
if (!this.queueMessageIsFromTabOrigin(queueMessage, tab)) {
continue;
}
@ -685,8 +679,7 @@ export default class NotificationBackground {
continue;
}
const tabDomain = Utils.getDomain(tab.url);
if (tabDomain != null && tabDomain !== queueMessage.domain) {
if (!this.queueMessageIsFromTabOrigin(queueMessage, tab)) {
continue;
}
@ -829,4 +822,18 @@ export default class NotificationBackground {
.catch((error) => this.logService.error(error));
return true;
};
/**
* Validates whether the queue message is associated with the passed tab.
*
* @param queueMessage - The queue message to check
* @param tab - The tab to check the queue message against
*/
private queueMessageIsFromTabOrigin(
queueMessage: NotificationQueueMessageItem,
tab: chrome.tabs.Tab,
) {
const tabDomain = Utils.getDomain(tab.url);
return tabDomain === queueMessage.domain || tabDomain === Utils.getDomain(queueMessage.tab.url);
}
}

View File

@ -60,6 +60,27 @@ describe("OverlayNotificationsBackground", () => {
jest.clearAllTimers();
});
describe("feature flag behavior", () => {
let runtimeRemoveListenerSpy: jest.SpyInstance;
beforeEach(() => {
runtimeRemoveListenerSpy = jest.spyOn(chrome.runtime.onMessage, "removeListener");
});
it("removes the extension listeners if the current flag value is set to `false`", () => {
getFeatureFlagMock$.next(false);
expect(runtimeRemoveListenerSpy).toHaveBeenCalled();
});
it("ignores the feature flag change if the previous flag value is equal to the current flag value", () => {
getFeatureFlagMock$.next(false);
getFeatureFlagMock$.next(false);
expect(runtimeRemoveListenerSpy).toHaveBeenCalledTimes(1);
});
});
describe("setting up the form submission listeners", () => {
let fields: MockProxy<AutofillField>[];
let details: MockProxy<AutofillPageDetails>;
@ -180,6 +201,40 @@ describe("OverlayNotificationsBackground", () => {
await flushPromises();
});
it("ignores the store request if the sender is not within the website origins set", () => {
sendMockExtensionMessage(
{
command: "formFieldSubmitted",
uri: "example.com",
username: "username",
password: "password",
newPassword: "newPassword",
},
mock<chrome.runtime.MessageSender>({ tab: { id: 2 } }),
);
expect(
overlayNotificationsBackground["modifyLoginCipherFormData"].get(sender.tab.id),
).toBeUndefined();
});
it("ignores the store request if the form submission does not include a username, password, or newPassword", () => {
sendMockExtensionMessage(
{
command: "formFieldSubmitted",
uri: "example.com",
username: "",
password: "",
newPassword: "",
},
sender,
);
expect(
overlayNotificationsBackground["modifyLoginCipherFormData"].get(sender.tab.id),
).toBeUndefined();
});
it("stores the modified login cipher form data", async () => {
sendMockExtensionMessage(
{
@ -203,6 +258,41 @@ describe("OverlayNotificationsBackground", () => {
});
});
it("overrides previously stored modified login cipher form data with a subsequent store request", async () => {
sendMockExtensionMessage(
{
command: "formFieldSubmitted",
uri: "example.com",
username: "oldUsername",
password: "oldPassword",
newPassword: "oldNewPassword",
},
sender,
);
await flushPromises();
sendMockExtensionMessage(
{
command: "formFieldSubmitted",
uri: "example.com",
username: "username",
password: "",
newPassword: "",
},
sender,
);
await flushPromises();
expect(
overlayNotificationsBackground["modifyLoginCipherFormData"].get(sender.tab.id),
).toEqual({
uri: "example.com",
username: "username",
password: "oldPassword",
newPassword: "oldNewPassword",
});
});
it("clears the modified login cipher form data after 5 seconds", () => {
sendMockExtensionMessage(
{
@ -323,10 +413,9 @@ describe("OverlayNotificationsBackground", () => {
it("ignores requests that are not part of an active form submission", async () => {
triggerWebRequestOnCompletedEvent(
mock<chrome.webRequest.WebRequestDetails>({
mock<chrome.webRequest.WebResponseDetails>({
url: sender.url,
tabId: sender.tab.id,
method: "POST",
requestId: "123345",
}),
);
@ -348,6 +437,25 @@ describe("OverlayNotificationsBackground", () => {
await flushPromises();
triggerWebRequestOnCompletedEvent(
mock<chrome.webRequest.WebResponseDetails>({
url: sender.url,
tabId: sender.tab.id,
requestId,
}),
);
expect(notificationChangedPasswordSpy).not.toHaveBeenCalled();
expect(notificationAddLoginSpy).not.toHaveBeenCalled();
});
it("clears the notification fallback timeout if the request is completed with an invalid status code", async () => {
const clearFallbackSpy = jest.spyOn(
overlayNotificationsBackground as any,
"clearNotificationFallbackTimeout",
);
const requestId = "123345";
triggerWebRequestOnBeforeRequestEvent(
mock<chrome.webRequest.WebRequestDetails>({
url: sender.url,
tabId: sender.tab.id,
@ -355,9 +463,19 @@ describe("OverlayNotificationsBackground", () => {
requestId,
}),
);
await flushPromises();
expect(notificationChangedPasswordSpy).not.toHaveBeenCalled();
expect(notificationAddLoginSpy).not.toHaveBeenCalled();
triggerWebRequestOnCompletedEvent(
mock<chrome.webRequest.WebResponseDetails>({
url: sender.url,
tabId: sender.tab.id,
statusCode: 404,
requestId,
}),
);
await flushPromises();
expect(clearFallbackSpy).toHaveBeenCalled();
});
});
@ -402,10 +520,9 @@ describe("OverlayNotificationsBackground", () => {
);
});
triggerWebRequestOnCompletedEvent(
mock<chrome.webRequest.WebRequestDetails>({
mock<chrome.webRequest.WebResponseDetails>({
url: sender.url,
tabId: sender.tab.id,
method: "POST",
requestId,
}),
);
@ -452,10 +569,9 @@ describe("OverlayNotificationsBackground", () => {
});
triggerWebRequestOnCompletedEvent(
mock<chrome.webRequest.WebRequestDetails>({
mock<chrome.webRequest.WebResponseDetails>({
url: sender.url,
tabId: sender.tab.id,
method: "POST",
requestId,
}),
);
@ -560,14 +676,59 @@ describe("OverlayNotificationsBackground", () => {
expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(0);
});
it("clears all associated data with a tab that is entering a `loading` state", () => {
triggerTabOnUpdatedEvent(
sender.tab.id,
mock<chrome.tabs.TabChangeInfo>({ status: "loading" }),
mock<chrome.tabs.Tab>({ status: "loading" }),
);
describe("tab onUpdated", () => {
it("skips clearing the website origins if the changeInfo does not contain a `loading` status", () => {
triggerTabOnUpdatedEvent(
sender.tab.id,
mock<chrome.tabs.TabChangeInfo>({ status: "complete" }),
mock<chrome.tabs.Tab>({ status: "complete" }),
);
expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(0);
expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(1);
});
it("skips clearing the website origins if the changeInfo does not contain a url", () => {
triggerTabOnUpdatedEvent(
sender.tab.id,
mock<chrome.tabs.TabChangeInfo>({ status: "loading", url: "" }),
mock<chrome.tabs.Tab>({ status: "loading" }),
);
expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(1);
});
it("skips clearing the website origins if the tab does not contain known website origins", () => {
triggerTabOnUpdatedEvent(
199,
mock<chrome.tabs.TabChangeInfo>({ status: "loading", url: "https://example.com" }),
mock<chrome.tabs.Tab>({ status: "loading", id: 199 }),
);
expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(1);
});
it("skips clearing the website origins if the changeInfo's url is present as part of the know website origin match patterns", () => {
triggerTabOnUpdatedEvent(
sender.tab.id,
mock<chrome.tabs.TabChangeInfo>({
status: "loading",
url: "https://subdomain.example.com",
}),
mock<chrome.tabs.Tab>({ status: "loading" }),
);
expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(1);
});
it("clears all associated data with a tab that is entering a `loading` state", () => {
triggerTabOnUpdatedEvent(
sender.tab.id,
mock<chrome.tabs.TabChangeInfo>({ status: "loading" }),
mock<chrome.tabs.Tab>({ status: "loading" }),
);
expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(0);
});
});
});
});

View File

@ -333,7 +333,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
const response = (await BrowserApi.tabSendMessage(
tab,
{ command: "getFormFieldDataForNotification" },
{ command: "getInlineMenuFormFieldData" },
{ frameId },
)) as OverlayNotificationsExtensionMessage;
if (response) {
@ -471,7 +471,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
private shouldTriggerChangePasswordNotification = (
modifyLoginData: ModifyLoginCipherFormData,
) => {
return modifyLoginData.newPassword && !modifyLoginData.username;
return modifyLoginData?.newPassword && !modifyLoginData.username;
};
/**
@ -480,7 +480,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
* @param modifyLoginData - The modified login form data
*/
private shouldTriggerAddLoginNotification = (modifyLoginData: ModifyLoginCipherFormData) => {
return modifyLoginData.username && (modifyLoginData.password || modifyLoginData.newPassword);
return modifyLoginData?.username && (modifyLoginData.password || modifyLoginData.newPassword);
};
/**
@ -576,8 +576,20 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
* @param changeInfo - The change info of the tab
*/
private handleTabUpdated = (tabId: number, changeInfo: chrome.tabs.TabChangeInfo) => {
if (changeInfo.status === "loading" && this.websiteOriginsWithFields.has(tabId)) {
this.websiteOriginsWithFields.delete(tabId);
if (changeInfo.status !== "loading" || !changeInfo.url) {
return;
}
const originPatterns = this.websiteOriginsWithFields.get(tabId);
if (!originPatterns) {
return;
}
const matchPatters = generateDomainMatchPatterns(changeInfo.url);
if (matchPatters.some((pattern) => originPatterns.has(pattern))) {
return;
}
this.websiteOriginsWithFields.delete(tabId);
};
}

File diff suppressed because it is too large Load Diff

View File

@ -92,7 +92,7 @@ export default class TabsBackground {
FeatureFlag.InlineMenuPositioningImprovements,
);
const removePageDetailsStatus = new Set(["loading", "unloaded"]);
if (!!overlayImprovementsFlag && removePageDetailsStatus.has(changeInfo.status)) {
if (!overlayImprovementsFlag && removePageDetailsStatus.has(changeInfo.status)) {
this.overlayBackground.removePageDetails(tabId);
}

View File

@ -1,5 +1,4 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
import { CipherType } from "@bitwarden/common/vault/enums";
import { AutofillOverlayElementType } from "../../enums/autofill-overlay.enum";
@ -21,10 +20,10 @@ export type AutofillExtensionMessage = {
authStatus?: AuthenticationStatus;
isOpeningFullInlineMenu?: boolean;
addNewCipherType?: CipherType;
ignoreFieldFocus?: boolean;
data?: {
direction?: "previous" | "next" | "current";
forceCloseInlineMenu?: boolean;
newSettingValue?: InlineMenuVisibilitySetting;
};
};

View File

@ -4,6 +4,7 @@ import AutofillPageDetails from "../models/autofill-page-details";
import AutofillScript from "../models/autofill-script";
import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service";
import { OverlayNotificationsContentService } from "../overlay/notifications/abstractions/overlay-notifications-content.service";
import { DomElementVisibilityService } from "../services/abstractions/dom-element-visibility.service";
import { DomQueryService } from "../services/abstractions/dom-query.service";
import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service";
import {
@ -17,6 +18,7 @@ import AutofillInit from "./autofill-init";
describe("AutofillInit", () => {
let domQueryService: MockProxy<DomQueryService>;
let domElementVisibilityService: MockProxy<DomElementVisibilityService>;
let overlayNotificationsContentService: MockProxy<OverlayNotificationsContentService>;
let inlineMenuElements: MockProxy<AutofillInlineMenuContentService>;
let autofillOverlayContentService: MockProxy<AutofillOverlayContentService>;
@ -32,11 +34,13 @@ describe("AutofillInit", () => {
},
});
domQueryService = mock<DomQueryService>();
domElementVisibilityService = mock<DomElementVisibilityService>();
overlayNotificationsContentService = mock<OverlayNotificationsContentService>();
inlineMenuElements = mock<AutofillInlineMenuContentService>();
autofillOverlayContentService = mock<AutofillOverlayContentService>();
autofillInit = new AutofillInit(
domQueryService,
domElementVisibilityService,
autofillOverlayContentService,
inlineMenuElements,
overlayNotificationsContentService,

View File

@ -4,9 +4,9 @@ import AutofillPageDetails from "../models/autofill-page-details";
import { AutofillInlineMenuContentService } from "../overlay/inline-menu/abstractions/autofill-inline-menu-content.service";
import { OverlayNotificationsContentService } from "../overlay/notifications/abstractions/overlay-notifications-content.service";
import { AutofillOverlayContentService } from "../services/abstractions/autofill-overlay-content.service";
import { DomElementVisibilityService } from "../services/abstractions/dom-element-visibility.service";
import { DomQueryService } from "../services/abstractions/dom-query.service";
import { CollectAutofillContentService } from "../services/collect-autofill-content.service";
import DomElementVisibilityService from "../services/dom-element-visibility.service";
import InsertAutofillContentService from "../services/insert-autofill-content.service";
import { sendExtensionMessage } from "../utils";
@ -18,7 +18,6 @@ import {
class AutofillInit implements AutofillInitInterface {
private readonly sendExtensionMessage = sendExtensionMessage;
private readonly domElementVisibilityService: DomElementVisibilityService;
private readonly collectAutofillContentService: CollectAutofillContentService;
private readonly insertAutofillContentService: InsertAutofillContentService;
private collectPageDetailsOnLoadTimeout: number | NodeJS.Timeout | undefined;
@ -33,26 +32,25 @@ class AutofillInit implements AutofillInitInterface {
* CollectAutofillContentService and InsertAutofillContentService classes.
*
* @param domQueryService - Service used to handle DOM queries.
* @param domElementVisibilityService - Used to check if an element is viewable.
* @param autofillOverlayContentService - The autofill overlay content service, potentially undefined.
* @param autofillInlineMenuContentService - The inline menu content service, potentially undefined.
* @param overlayNotificationsContentService - The overlay notifications content service, potentially undefined.
*/
constructor(
domQueryService: DomQueryService,
domElementVisibilityService: DomElementVisibilityService,
private autofillOverlayContentService?: AutofillOverlayContentService,
private autofillInlineMenuContentService?: AutofillInlineMenuContentService,
private overlayNotificationsContentService?: OverlayNotificationsContentService,
) {
this.domElementVisibilityService = new DomElementVisibilityService(
this.autofillInlineMenuContentService,
);
this.collectAutofillContentService = new CollectAutofillContentService(
this.domElementVisibilityService,
domElementVisibilityService,
domQueryService,
this.autofillOverlayContentService,
);
this.insertAutofillContentService = new InsertAutofillContentService(
this.domElementVisibilityService,
domElementVisibilityService,
this.collectAutofillContentService,
);
}

View File

@ -1,5 +1,6 @@
import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service";
import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service";
import DomElementVisibilityService from "../services/dom-element-visibility.service";
import { DomQueryService } from "../services/dom-query.service";
import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service";
import { setupAutofillInitDisconnectAction } from "../utils";
@ -8,20 +9,25 @@ import AutofillInit from "./autofill-init";
(function (windowContext) {
if (!windowContext.bitwardenAutofillInit) {
let inlineMenuContentService: AutofillInlineMenuContentService;
if (globalThis.self === globalThis.top) {
inlineMenuContentService = new AutofillInlineMenuContentService();
}
const domQueryService = new DomQueryService();
const domElementVisibilityService = new DomElementVisibilityService(inlineMenuContentService);
const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
const autofillOverlayContentService = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
);
let inlineMenuElements: AutofillInlineMenuContentService;
if (globalThis.self === globalThis.top) {
inlineMenuElements = new AutofillInlineMenuContentService();
}
windowContext.bitwardenAutofillInit = new AutofillInit(
domQueryService,
domElementVisibilityService,
autofillOverlayContentService,
inlineMenuElements,
inlineMenuContentService,
);
setupAutofillInitDisconnectAction(windowContext);

View File

@ -1,5 +1,6 @@
import { OverlayNotificationsContentService } from "../overlay/notifications/content/overlay-notifications-content.service";
import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service";
import DomElementVisibilityService from "../services/dom-element-visibility.service";
import { DomQueryService } from "../services/dom-query.service";
import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service";
import { setupAutofillInitDisconnectAction } from "../utils";
@ -9,9 +10,11 @@ import AutofillInit from "./autofill-init";
(function (windowContext) {
if (!windowContext.bitwardenAutofillInit) {
const domQueryService = new DomQueryService();
const domElementVisibilityService = new DomElementVisibilityService();
const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
const autofillOverlayContentService = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
);
@ -22,6 +25,7 @@ import AutofillInit from "./autofill-init";
windowContext.bitwardenAutofillInit = new AutofillInit(
domQueryService,
domElementVisibilityService,
autofillOverlayContentService,
null,
overlayNotificationsContentService,

View File

@ -1,6 +1,7 @@
import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service";
import { OverlayNotificationsContentService } from "../overlay/notifications/content/overlay-notifications-content.service";
import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service";
import DomElementVisibilityService from "../services/dom-element-visibility.service";
import { DomQueryService } from "../services/dom-query.service";
import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service";
import { setupAutofillInitDisconnectAction } from "../utils";
@ -9,24 +10,27 @@ import AutofillInit from "./autofill-init";
(function (windowContext) {
if (!windowContext.bitwardenAutofillInit) {
const domQueryService = new DomQueryService();
const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
const autofillOverlayContentService = new AutofillOverlayContentService(
domQueryService,
inlineMenuFieldQualificationService,
);
let inlineMenuElements: AutofillInlineMenuContentService;
let inlineMenuContentService: AutofillInlineMenuContentService;
let overlayNotificationsContentService: OverlayNotificationsContentService;
if (globalThis.self === globalThis.top) {
inlineMenuElements = new AutofillInlineMenuContentService();
inlineMenuContentService = new AutofillInlineMenuContentService();
overlayNotificationsContentService = new OverlayNotificationsContentService();
}
const domQueryService = new DomQueryService();
const domElementVisibilityService = new DomElementVisibilityService(inlineMenuContentService);
const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
const autofillOverlayContentService = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
);
windowContext.bitwardenAutofillInit = new AutofillInit(
domQueryService,
domElementVisibilityService,
autofillOverlayContentService,
inlineMenuElements,
inlineMenuContentService,
overlayNotificationsContentService,
);
setupAutofillInitDisconnectAction(windowContext);

View File

@ -1,3 +1,4 @@
import DomElementVisibilityService from "../services/dom-element-visibility.service";
import { DomQueryService } from "../services/dom-query.service";
import { setupAutofillInitDisconnectAction } from "../utils";
@ -6,7 +7,11 @@ import AutofillInit from "./autofill-init";
(function (windowContext) {
if (!windowContext.bitwardenAutofillInit) {
const domQueryService = new DomQueryService();
windowContext.bitwardenAutofillInit = new AutofillInit(domQueryService);
const domElementVisibilityService = new DomElementVisibilityService();
windowContext.bitwardenAutofillInit = new AutofillInit(
domQueryService,
domElementVisibilityService,
);
setupAutofillInitDisconnectAction(windowContext);
windowContext.bitwardenAutofillInit.init();

View File

@ -73,6 +73,10 @@ class LegacyAutofillOverlayContentService implements LegacyAutofillOverlayConten
* Satisfy the AutofillOverlayContentService interface.
*/
messageHandlers = {} as AutofillOverlayContentExtensionMessageHandlers;
clearUserFilledFields() {
// do nothing
}
async setupOverlayListeners(
autofillFieldElement: ElementWithOpId<FormFieldElement>,
autofillFieldData: AutofillField,

View File

@ -1,3 +1,5 @@
import { CipherType } from "@bitwarden/common/vault/enums";
export const AutofillOverlayElement = {
Button: "autofill-inline-menu-button",
List: "autofill-inline-menu-list",
@ -19,4 +21,20 @@ export const RedirectFocusDirection = {
Next: "next",
} as const;
export enum InlineMenuFillType {
AccountCreationUsername = 5,
PasswordGeneration = 6,
CurrentPasswordUpdate = 7,
}
export type InlineMenuFillTypes = InlineMenuFillType | CipherType;
export const InlineMenuAccountCreationFieldType = {
Text: "text",
Email: "email",
Password: "password",
} as const;
export type InlineMenuAccountCreationFieldTypes =
(typeof InlineMenuAccountCreationFieldType)[keyof typeof InlineMenuAccountCreationFieldType];
export const MAX_SUB_FRAME_DEPTH = 8;

View File

@ -1,6 +1,8 @@
import { CipherType } from "@bitwarden/common/vault/enums";
import { AutofillFieldQualifierType } from "../enums/autofill-field.enums";
import {
InlineMenuAccountCreationFieldTypes,
InlineMenuFillTypes,
} from "../enums/autofill-overlay.enum";
/**
* Represents a single field that is collected from the page source and is potentially autofilled.
@ -107,15 +109,17 @@ export default class AutofillField {
*/
maxLength?: number | null;
dataSetValues?: string;
rel?: string | null;
checked?: boolean;
filledByCipherType?: CipherType;
showInlineMenuAccountCreation?: boolean;
inlineMenuFillType?: InlineMenuFillTypes;
showPasskeys?: boolean;
fieldQualifier?: AutofillFieldQualifierType;
accountCreationFieldType?: InlineMenuAccountCreationFieldTypes;
}

View File

@ -3,6 +3,8 @@ export type AutofillInlineMenuIframeExtensionMessage = {
styles?: Partial<CSSStyleDeclaration>;
theme?: string;
portKey?: string;
generatedPassword?: string;
refreshPassword?: boolean;
};
export type AutofillInlineMenuIframeExtensionMessageParam = {
@ -23,6 +25,9 @@ export type BackgroundPortMessageHandlers = {
}: AutofillInlineMenuIframeExtensionMessageParam) => void;
updateAutofillInlineMenuColorScheme: () => void;
fadeInAutofillInlineMenuIframe: () => void;
updateAutofillInlineMenuGeneratedPassword: ({
message,
}: AutofillInlineMenuIframeExtensionMessageParam) => void;
};
export interface AutofillInlineMenuIframeService {

View File

@ -1,25 +1,34 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { CipherType } from "@bitwarden/common/vault/enums";
import { InlineMenuCipherData } from "../../../background/abstractions/overlay.background";
import { InlineMenuFillTypes } from "../../../enums/autofill-overlay.enum";
type AutofillInlineMenuListMessage = { command: string };
export type UpdateAutofillInlineMenuListCiphersMessage = AutofillInlineMenuListMessage & {
export type UpdateAutofillInlineMenuListCiphersParams = {
ciphers: InlineMenuCipherData[];
showInlineMenuAccountCreation?: boolean;
};
export type UpdateAutofillInlineMenuListCiphersMessage = AutofillInlineMenuListMessage &
UpdateAutofillInlineMenuListCiphersParams;
export type UpdateAutofillInlineMenuGeneratedPasswordMessage = AutofillInlineMenuListMessage & {
generatedPassword: string;
};
export type InitAutofillInlineMenuListMessage = AutofillInlineMenuListMessage & {
authStatus: AuthenticationStatus;
styleSheetUrl: string;
theme: string;
translations: Record<string, string>;
ciphers?: InlineMenuCipherData[];
filledByCipherType?: CipherType;
inlineMenuFillType?: InlineMenuFillTypes;
showInlineMenuAccountCreation?: boolean;
showPasskeysLabels?: boolean;
portKey: string;
generatedPassword?: string;
showSaveLoginMenu?: boolean;
};
export type AutofillInlineMenuListWindowMessageHandlers = {
@ -31,5 +40,10 @@ export type AutofillInlineMenuListWindowMessageHandlers = {
}: {
message: UpdateAutofillInlineMenuListCiphersMessage;
}) => void;
updateAutofillInlineMenuGeneratedPassword: ({
message,
}: {
message: UpdateAutofillInlineMenuGeneratedPasswordMessage;
}) => void;
focusAutofillInlineMenuList: () => void;
};

View File

@ -3,6 +3,7 @@ import { mock, MockProxy } from "jest-mock-extended";
import AutofillInit from "../../../content/autofill-init";
import { AutofillOverlayElement } from "../../../enums/autofill-overlay.enum";
import { DomQueryService } from "../../../services/abstractions/dom-query.service";
import DomElementVisibilityService from "../../../services/dom-element-visibility.service";
import { createMutationRecordMock } from "../../../spec/autofill-mocks";
import { flushPromises, sendMockExtensionMessage } from "../../../spec/testing-utils";
import { ElementWithOpId } from "../../../types";
@ -11,6 +12,7 @@ import { AutofillInlineMenuContentService } from "./autofill-inline-menu-content
describe("AutofillInlineMenuContentService", () => {
let domQueryService: MockProxy<DomQueryService>;
let domElementVisibilityService: DomElementVisibilityService;
let autofillInlineMenuContentService: AutofillInlineMenuContentService;
let autofillInit: AutofillInit;
let sendExtensionMessageSpy: jest.SpyInstance;
@ -22,8 +24,14 @@ describe("AutofillInlineMenuContentService", () => {
globalThis.document.body.innerHTML = "";
globalThis.requestIdleCallback = jest.fn((cb, options) => setTimeout(cb, 100));
domQueryService = mock<DomQueryService>();
domElementVisibilityService = new DomElementVisibilityService();
autofillInlineMenuContentService = new AutofillInlineMenuContentService();
autofillInit = new AutofillInit(domQueryService, null, autofillInlineMenuContentService);
autofillInit = new AutofillInit(
domQueryService,
domElementVisibilityService,
null,
autofillInlineMenuContentService,
);
autofillInit.init();
observeContainerMutationsSpy = jest.spyOn(
autofillInlineMenuContentService["containerElementMutationObserver"] as any,
@ -37,6 +45,11 @@ describe("AutofillInlineMenuContentService", () => {
afterEach(() => {
jest.clearAllMocks();
Object.defineProperty(document, "activeElement", {
value: null,
writable: true,
});
});
describe("isElementInlineMenu", () => {
@ -197,6 +210,31 @@ describe("AutofillInlineMenuContentService", () => {
);
});
});
it("appends the inline menu element to a containing `dialog` element if the element is a modal", async () => {
isInlineMenuButtonVisibleSpy.mockResolvedValue(false);
const dialogElement = document.createElement("dialog");
dialogElement.setAttribute("open", "true");
jest.spyOn(dialogElement, "matches").mockReturnValue(true);
const dialogAppendSpy = jest.spyOn(dialogElement, "appendChild");
const inputElement = document.createElement("input");
dialogElement.appendChild(inputElement);
document.body.appendChild(dialogElement);
Object.defineProperty(document, "activeElement", {
value: inputElement,
writable: true,
});
sendMockExtensionMessage({
command: "appendAutofillInlineMenuToDom",
overlayElement: AutofillOverlayElement.Button,
});
await flushPromises();
expect(dialogAppendSpy).toHaveBeenCalledWith(
autofillInlineMenuContentService["buttonElement"],
);
});
});
});

View File

@ -88,7 +88,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
/**
* Removes the autofill inline menu from the page. This will initially
* unobserve the body element to ensure the mutation observer no
* unobserve the menu container to ensure the mutation observer no
* longer triggers.
*/
private closeInlineMenu = (message?: AutofillExtensionMessage) => {
@ -190,15 +190,15 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
}
/**
* Appends the inline menu element to the body element. This method will also
* observe the body element to ensure that the inline menu element is not
* Appends the inline menu element to the menu container. This method will also
* observe the menu container to ensure that the inline menu element is not
* interfered with by any DOM changes.
*
* @param element - The inline menu element to append to the body element.
* @param element - The inline menu element to append to the menu container.
*/
private appendInlineMenuElementToDom(element: HTMLElement) {
const parentDialogElement = globalThis.document.activeElement?.closest("dialog");
if (parentDialogElement && parentDialogElement.open && parentDialogElement.matches(":modal")) {
if (parentDialogElement?.open && parentDialogElement.matches(":modal")) {
this.observeContainerElement(parentDialogElement);
parentDialogElement.appendChild(element);
return;
@ -273,10 +273,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
}
/**
* Sets up mutation observers for the inline menu elements, the body element, and
* Sets up mutation observers for the inline menu elements, the menu container, and
* the document element. The mutation observers are used to remove any styles that
* are added to the inline menu elements by the website. They are also used to ensure
* that the inline menu elements are always present at the bottom of the body element.
* that the inline menu elements are always present at the bottom of the menu container.
*/
private setupMutationObserver = () => {
this.inlineMenuElementsMutationObserver = new MutationObserver(
@ -441,10 +441,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
/**
* Handles the behavior of a persistent child element that is forcing itself to
* the bottom of the body element. This method will ensure that the inline menu
* the bottom of the menu container. This method will ensure that the inline menu
* elements are not obscured by the persistent child element.
*
* @param lastChild - The last child of the body element.
* @param lastChild - The last child of the menu container.
*/
private handlePersistentLastChildOverride(lastChild: Element) {
const lastChildZIndex = parseInt((lastChild as HTMLElement).style.zIndex);
@ -460,11 +460,11 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
}
/**
* Verifies if the last child of the body element is overlaying the inline menu elements.
* This is triggered when the last child of the body is being forced by some script to
* be an element other than the inline menu elements.
* Verifies if the last child of the menu container is overlaying the inline menu elements.
* This is triggered when the last child of the menu container is being forced by some
* script to be an element other than the inline menu elements.
*
* @param lastChild - The last child of the body element.
* @param lastChild - The last child of the menu container.
*/
private verifyInlineMenuIsNotObscured = async (lastChild: Element) => {
const inlineMenuPosition: InlineMenuPosition = await this.sendExtensionMessage(
@ -495,7 +495,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
}
/**
* Clears the timeout that is used to verify that the last child of the body element
* Clears the timeout that is used to verify that the last child of the menu container
* is not overlaying the inline menu elements.
*/
private clearPersistentLastChildOverrideTimeout() {

View File

@ -3,6 +3,7 @@
exports[`AutofillInlineMenuIframeService initMenuIframe sets up the iframe's attributes 1`] = `
<iframe
allowtransparency="true"
scrolling="no"
src="chrome-extension://id/overlay/menu.html"
style="all: initial !important; position: fixed !important; display: block !important; z-index: 2147483647 !important; line-height: 0 !important; overflow: hidden !important; transition: opacity 125ms ease-out 0s !important; visibility: visible !important; clip-path: none !important; pointer-events: auto !important; margin: 0px !important; padding: 0px !important; color-scheme: normal !important; opacity: 0 !important; height: 0px;"
tabindex="-1"

View File

@ -104,9 +104,10 @@ describe("AutofillInlineMenuIframeService", () => {
expect(globalThis.setTimeout).not.toHaveBeenCalled();
});
it("announces the aria alert if the aria alert element is populated", () => {
it("announces the aria alert if the aria alert element is populated", async () => {
jest.useFakeTimers();
jest.spyOn(globalThis, "setTimeout");
sendExtensionMessageSpy.mockResolvedValue(true);
autofillInlineMenuIframeService["ariaAlertElement"] = document.createElement("div");
autofillInlineMenuIframeService["ariaAlertTimeout"] = setTimeout(jest.fn(), 2000);
@ -114,6 +115,7 @@ describe("AutofillInlineMenuIframeService", () => {
expect(globalThis.setTimeout).toHaveBeenCalled();
jest.advanceTimersByTime(2000);
await flushPromises();
expect(shadowAppendSpy).toHaveBeenCalledWith(
autofillInlineMenuIframeService["ariaAlertElement"],
@ -363,16 +365,18 @@ describe("AutofillInlineMenuIframeService", () => {
expect(autofillInlineMenuIframeService["iframe"].style.left).toBe(styles.left);
});
it("announces the opening of the iframe using an aria alert", () => {
it("announces the opening of the iframe using an aria alert", async () => {
jest.useFakeTimers();
sendExtensionMessageSpy.mockResolvedValue(true);
const styles = { top: "100px", left: "100px" };
sendPortMessage(portSpy, {
command: "updateAutofillInlineMenuPosition",
styles,
});
jest.advanceTimersByTime(2000);
await flushPromises();
expect(shadowAppendSpy).toHaveBeenCalledWith(
autofillInlineMenuIframeService["ariaAlertElement"],
);
@ -452,6 +456,19 @@ describe("AutofillInlineMenuIframeService", () => {
jest.advanceTimersByTime(10);
expect(autofillInlineMenuIframeService["iframe"].style.opacity).toBe("1");
});
it("triggers an aria alert when the password in regenerated", () => {
jest.spyOn(autofillInlineMenuIframeService as any, "createAriaAlertElement");
sendPortMessage(portSpy, {
command: "updateAutofillInlineMenuGeneratedPassword",
refreshPassword: true,
});
expect(autofillInlineMenuIframeService["createAriaAlertElement"]).toHaveBeenCalledWith(
true,
);
});
});
});

View File

@ -42,6 +42,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
title: "",
allowtransparency: "true",
tabIndex: "-1",
scrolling: "no",
};
private foreignMutationsCount = 0;
private mutationObserverIterations = 0;
@ -55,6 +56,8 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
updateAutofillInlineMenuColorScheme: () => this.updateAutofillInlineMenuColorScheme(),
triggerDelayedAutofillInlineMenuClosure: () => this.handleDelayedAutofillInlineMenuClosure(),
fadeInAutofillInlineMenuIframe: () => this.handleFadeInInlineMenuIframe(),
updateAutofillInlineMenuGeneratedPassword: ({ message }) =>
this.handleUpdateGeneratedPassword(message),
};
constructor(
@ -88,7 +91,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
this.iframe.addEventListener(EVENTS.LOAD, this.setupPortMessageListener);
if (this.ariaAlert) {
this.createAriaAlertElement(this.ariaAlert);
this.createAriaAlertElement();
}
this.shadow.appendChild(this.iframe);
@ -98,13 +101,11 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
/**
* Creates an aria alert element that is used to announce to screen readers
* when the iframe is loaded.
*
* @param ariaAlertText - Text to announce to screen readers when the iframe is loaded
*/
private createAriaAlertElement(ariaAlertText: string) {
private createAriaAlertElement(assertive = false) {
this.ariaAlertElement = globalThis.document.createElement("div");
this.ariaAlertElement.setAttribute("role", "alert");
this.ariaAlertElement.setAttribute("aria-live", "polite");
this.ariaAlertElement.setAttribute("aria-live", assertive ? "assertive" : "polite");
this.ariaAlertElement.setAttribute("aria-atomic", "true");
this.updateElementStyles(this.ariaAlertElement, {
position: "absolute",
@ -116,7 +117,6 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
opacity: "0",
pointerEvents: "none",
});
this.ariaAlertElement.textContent = ariaAlertText;
}
/**
@ -129,26 +129,42 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
this.port.onDisconnect.addListener(this.handlePortDisconnect);
this.port.onMessage.addListener(this.handlePortMessage);
this.announceAriaAlert();
this.announceAriaAlert(this.ariaAlert, 2000);
};
/**
* Announces the aria alert element to screen readers when the iframe is loaded.
*
* @param textContent - The text content to announce
* @param delay - The delay before announcing the text content
* @param triggeredByUser - Identifies whether we should present the alert regardless of field focus
*/
private announceAriaAlert() {
if (!this.ariaAlertElement) {
private announceAriaAlert(textContent: string, delay: number, triggeredByUser = false) {
if (!this.ariaAlertElement || !textContent) {
return;
}
this.ariaAlertElement.remove();
this.ariaAlertElement.textContent = textContent;
this.clearAriaAlert();
this.ariaAlertTimeout = globalThis.setTimeout(async () => {
const isFieldFocused = await this.sendExtensionMessage("checkIsFieldCurrentlyFocused");
if (isFieldFocused || triggeredByUser) {
this.shadow.appendChild(this.ariaAlertElement);
}
this.ariaAlertTimeout = null;
}, delay);
}
/**
* Clears any existing aria alert that could be announced.
*/
clearAriaAlert() {
if (this.ariaAlertTimeout) {
clearTimeout(this.ariaAlertTimeout);
this.ariaAlertTimeout = null;
}
this.ariaAlertTimeout = globalThis.setTimeout(
() => this.shadow.appendChild(this.ariaAlertElement),
2000,
);
}
/**
@ -165,6 +181,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
this.updateElementStyles(this.iframe, { opacity: "0", height: "0px" });
this.unobserveIframe();
this.clearAriaAlert();
this.port?.onMessage.removeListener(this.handlePortMessage);
this.port?.onDisconnect.removeListener(this.handlePortDisconnect);
this.port?.disconnect();
@ -267,7 +284,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
this.handleFadeInInlineMenuIframe();
}
this.announceAriaAlert();
this.announceAriaAlert(this.ariaAlert, 2000);
}
/**
@ -355,10 +372,28 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
this.delayedCloseTimeout = globalThis.setTimeout(() => {
this.updateElementStyles(this.iframe, { transition: this.fadeInOpacityTransition });
this.port?.disconnect();
this.port = null;
this.forceCloseInlineMenu();
}, 100);
}
/**
* Handles updating the generated password in the inline menu iframe. Triggers
* an aria alert if the user initiated the password regeneration.
*
* @param message - The message sent from the iframe
*/
private handleUpdateGeneratedPassword = (message: AutofillInlineMenuIframeExtensionMessage) => {
this.postMessageToIFrame(message);
if (message.refreshPassword) {
this.clearAriaAlert();
this.createAriaAlertElement(true);
this.announceAriaAlert(chrome.i18n.getMessage("passwordRegenerated"), 500, true);
}
};
/**
* Handles mutations to the iframe element. The ensures that the iframe
* element's styles are not modified by a third party source.

View File

@ -9,7 +9,7 @@ export class AutofillInlineMenuListIframe extends AutofillInlineMenuIframeElemen
AutofillOverlayPort.List,
{
height: "0px",
minWidth: "250px",
minWidth: "260px",
maxHeight: "180px",
boxShadow: "rgba(0, 0, 0, 0.1) 2px 4px 6px 0px",
borderRadius: "4px",

View File

@ -80,7 +80,9 @@ describe("AutofillInlineMenuButton", () => {
it("does not post a message to close the autofill inline menu if the button element is hovered", async () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
jest.spyOn(autofillInlineMenuButton["buttonElement"], "matches").mockReturnValue(true);
jest
.spyOn(autofillInlineMenuButton["buttonElement"], "querySelector")
.mockReturnValue(autofillInlineMenuButton["buttonElement"]);
postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused" });
await flushPromises();
@ -90,9 +92,26 @@ describe("AutofillInlineMenuButton", () => {
});
});
it("triggers a recheck of the button focus state on mouseout", async () => {
jest.spyOn(globalThis.document, "removeEventListener");
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
jest
.spyOn(autofillInlineMenuButton["buttonElement"], "querySelector")
.mockReturnValue(autofillInlineMenuButton["buttonElement"]);
postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused" });
await flushPromises();
globalThis.document.dispatchEvent(new MouseEvent("mouseout"));
expect(globalThis.document.removeEventListener).toHaveBeenCalledWith(
"mouseout",
autofillInlineMenuButton["handleMouseOutEvent"],
);
});
it("posts a message to close the autofill inline menu if the element is not focused during the focus check", async () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
jest.spyOn(autofillInlineMenuButton["buttonElement"], "matches").mockReturnValue(false);
jest.spyOn(autofillInlineMenuButton["buttonElement"], "querySelector").mockReturnValue(null);
postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused" });
await flushPromises();

View File

@ -117,10 +117,34 @@ export class AutofillInlineMenuButton extends AutofillInlineMenuPageElement {
* to the parent window indicating that the inline menu should be closed.
*/
private checkButtonFocused() {
if (globalThis.document.hasFocus() || this.buttonElement.matches(":hover")) {
if (globalThis.document.hasFocus()) {
return;
}
if (this.isButtonHovered()) {
globalThis.document.addEventListener(EVENTS.MOUSEOUT, this.handleMouseOutEvent);
return;
}
this.postMessageToParent({ command: "triggerDelayedAutofillInlineMenuClosure" });
}
/**
* Triggers a re-check of the button's focus status when the mouse leaves the button.
*/
private handleMouseOutEvent = () => {
globalThis.document.removeEventListener(EVENTS.MOUSEOUT, this.handleMouseOutEvent);
this.checkButtonFocused();
};
/**
* Identifies whether the button is currently hovered.
*/
private isButtonHovered() {
const hoveredElement = this.buttonElement?.querySelector(":hover");
return !!(
hoveredElement &&
(hoveredElement === this.buttonElement || this.buttonElement.contains(hoveredElement))
);
}
}

View File

@ -1,5 +1,54 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AutofillInlineMenuList initAutofillInlineMenuList creates the build save login item view 1`] = `
<div
class="inline-menu-list-container theme_light"
>
<div
class="save-login inline-menu-list-message"
/>
<div
class="inline-menu-list-button-container"
>
<button
aria-label=""
class="add-new-item-button inline-menu-list-button inline-menu-list-action"
id="new-item-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="17"
width="17"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
clip-rule="evenodd"
d="M9.607 7.15h5.35c.322 0 .627.133.847.362a1.213 1.213 0 0 1 .002 1.68c-.221.23-.527.363-.85.363H9.607v5.652c0 .312-.12.613-.336.839a1.176 1.176 0 0 1-1.696.003 1.21 1.21 0 0 1-.34-.842V9.555H1.888a1.173 1.173 0 0 1-.847-.361A1.193 1.193 0 0 1 .7 8.352a1.219 1.219 0 0 1 .336-.838 1.175 1.175 0 0 1 .85-.364h5.349V1.635c0-.31.118-.611.336-.84A1.176 1.176 0 0 1 9.268.795c.222.228.34.533.34.841V7.15Z"
fill="#175DDC"
fill-rule="evenodd"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M.421.421h16v16h-16z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</div>
`;
exports[`AutofillInlineMenuList initAutofillInlineMenuList the inline menu with an empty list of ciphers creates the views for the no results inline menu that does not have a fill by cipher type 1`] = `
<div
class="inline-menu-list-container theme_light"
@ -2177,7 +2226,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the locked inline men
class="locked-inline-menu inline-menu-list-message"
id="locked-inline-menu-description"
>
unlockYourAccount
unlockYourAccountToViewAutofillSuggestions
</div>
<div
class="inline-menu-list-button-container"
@ -2220,3 +2269,188 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the locked inline men
</div>
</div>
`;
exports[`AutofillInlineMenuList initAutofillInlineMenuList the password generator view creates the views for the password generator 1`] = `
<div
class="password-generator-container"
>
<div
class="password-generator-actions"
>
<button
aria-label=""
class="fill-generated-password-button inline-menu-list-action"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="25"
viewBox="0 0 24 25"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M21.803 3.035a7.453 7.453 0 0 0-2.427-1.567 7.763 7.763 0 0 0-2.877-.551c-.988 0-1.967.187-2.878.55a7.455 7.455 0 0 0-2.427 1.568A7.193 7.193 0 0 0 9.283 6.23a6.936 6.936 0 0 0-.023 3.675.556.556 0 0 1-.16.549L.656 18.61a.77.77 0 0 0-.233.468l-.415 3.756a.722.722 0 0 0 .04.354.773.773 0 0 0 .203.3.85.85 0 0 0 .697.201l5.141-.855a.832.832 0 0 0 .461-.241.757.757 0 0 0 .211-.458l.108-1.162a.554.554 0 0 1 .17-.35.62.62 0 0 1 .365-.167l1.2-.105a.832.832 0 0 0 .503-.23.756.756 0 0 0 .23-.482l.124-1.326a.361.361 0 0 1 .111-.23.4.4 0 0 1 .24-.108l1.381-.113a.815.815 0 0 0 .501-.225l2.473-2.386a.506.506 0 0 1 .48-.126 7.904 7.904 0 0 0 1.912.235 7.68 7.68 0 0 0 2.846-.539 7.344 7.344 0 0 0 2.402-1.546C23.213 11.905 24 10.069 24 8.155c0-1.914-.787-3.752-2.194-5.122l-.003.002Zm-10.81 7.148a5.496 5.496 0 0 1-.25-3.208 5.677 5.677 0 0 1 1.6-2.835 5.828 5.828 0 0 1 1.902-1.233 6.075 6.075 0 0 1 4.515 0 5.829 5.829 0 0 1 1.902 1.233c1.107 1.073 1.726 2.514 1.726 4.016 0 1.501-.62 2.943-1.726 4.016a5.925 5.925 0 0 1-2.93 1.537 6.135 6.135 0 0 1-3.339-.245.844.844 0 0 0-.85.182l-2.498 2.409a1.124 1.124 0 0 1-.682.308l-1.687.142a.839.839 0 0 0-.503.23.754.754 0 0 0-.23.482l-.105 1.13a.594.594 0 0 1-.181.374.653.653 0 0 1-.39.178l-1.171.1a.832.832 0 0 0-.503.23.755.755 0 0 0-.23.483l-.122 1.313a.474.474 0 0 1-.13.287.518.518 0 0 1-.288.151l-2.66.439a.36.36 0 0 1-.286-.084.314.314 0 0 1-.102-.266l.182-1.758a.724.724 0 0 1 .222-.449l8.636-8.333a.778.778 0 0 0 .215-.39.756.756 0 0 0-.036-.439h-.001Zm6.976-1.226c-.474 0-.938-.134-1.332-.384a2.31 2.31 0 0 1-.884-1.022 2.17 2.17 0 0 1-.137-1.317c.093-.442.321-.848.657-1.166a2.441 2.441 0 0 1 1.228-.624 2.516 2.516 0 0 1 1.386.13 2.37 2.37 0 0 1 1.077.84c.263.374.404.814.404 1.265 0 .605-.253 1.184-.703 1.611-.45.428-1.06.667-1.696.667Zm0-3.56c-.266 0-.527.075-.75.216-.221.14-.394.34-.496.575a1.22 1.22 0 0 0-.077.74c.053.249.18.477.37.657.189.18.43.3.691.35.262.05.533.025.78-.072.246-.097.457-.261.606-.472a1.235 1.235 0 0 0-.168-1.619 1.369 1.369 0 0 0-.954-.376v.002l-.002-.001Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .308h24v24H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
<div
class="password-generator-content"
id="password-generator-content"
>
<div
class="password-generator-heading"
/>
<div
aria-label=": g e n e r a t e d P a s s w o r d 1 "
class="colorized-password"
>
<div
class="password-letter"
>
g
</div>
<div
class="password-letter"
>
e
</div>
<div
class="password-letter"
>
n
</div>
<div
class="password-letter"
>
e
</div>
<div
class="password-letter"
>
r
</div>
<div
class="password-letter"
>
a
</div>
<div
class="password-letter"
>
t
</div>
<div
class="password-letter"
>
e
</div>
<div
class="password-letter"
>
d
</div>
<div
class="password-letter"
>
P
</div>
<div
class="password-letter"
>
a
</div>
<div
class="password-letter"
>
s
</div>
<div
class="password-letter"
>
s
</div>
<div
class="password-letter"
>
w
</div>
<div
class="password-letter"
>
o
</div>
<div
class="password-letter"
>
r
</div>
<div
class="password-letter"
>
d
</div>
<div
class="password-special"
>
!
</div>
<div
class="password-number"
>
1
</div>
</div>
</div>
</button>
<button
aria-label=""
class="refresh-generated-password-button inline-menu-list-action"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="21"
viewBox="0 0 20 21"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M18.383 11.37a.678.678 0 0 0-.496.086.65.65 0 0 0-.291.402 7.457 7.457 0 0 1-2.451 3.912 7.754 7.754 0 0 1-4.328 1.78 7.761 7.761 0 0 1-4.554-.901 7.502 7.502 0 0 1-3.167-3.318c-.025-.064.03-.159.165-.14l1.039.417a.687.687 0 0 0 .51.005.662.662 0 0 0 .365-.346.62.62 0 0 0-.142-.694.64.64 0 0 0-.214-.136l-2.656-1.061a.686.686 0 0 0-.854.31L.065 14.139a.621.621 0 0 0 .31.847.69.69 0 0 0 .639-.033.653.653 0 0 0 .247-.261l.4-.792a.167.167 0 0 1 .124-.077.173.173 0 0 1 .075.01.16.16 0 0 1 .063.04 8.813 8.813 0 0 0 3.29 3.627 9.109 9.109 0 0 0 4.764 1.358c.312 0 .632-.015.961-.044a9.223 9.223 0 0 0 5.065-2.116 8.871 8.871 0 0 0 2.89-4.578.628.628 0 0 0-.274-.656.655.655 0 0 0-.236-.095v.001Zm1.25-5.735a.693.693 0 0 0-.64.033.659.659 0 0 0-.247.262l-.4.79a.166.166 0 0 1-.261.028 8.809 8.809 0 0 0-3.29-3.63 9.113 9.113 0 0 0-4.764-1.36c-.311 0-.631.014-.961.045A9.224 9.224 0 0 0 4.004 3.92a8.863 8.863 0 0 0-2.89 4.58.622.622 0 0 0 .276.658.657.657 0 0 0 .237.094c.17.036.349.005.496-.086a.65.65 0 0 0 .29-.402 7.452 7.452 0 0 1 2.452-3.911 7.764 7.764 0 0 1 4.328-1.781 7.761 7.761 0 0 1 4.553.902 7.508 7.508 0 0 1 3.168 3.317c.023.063-.03.16-.165.138l-1.042-.42a.688.688 0 0 0-.509-.004.666.666 0 0 0-.367.345.622.622 0 0 0 .357.83l2.65 1.06c.156.064.33.067.489.01a.665.665 0 0 0 .365-.318l1.243-2.454a.622.622 0 0 0-.302-.843Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .421h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</div>
`;

View File

@ -13,6 +13,7 @@ import { flushPromises, postWindowMessage } from "../../../../spec/testing-utils
import { AutofillInlineMenuList } from "./autofill-inline-menu-list";
describe("AutofillInlineMenuList", () => {
const generatedPassword = "generatedPassword!1";
globalThis.customElements.define("autofill-inline-menu-list", AutofillInlineMenuList);
global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
@ -83,7 +84,7 @@ describe("AutofillInlineMenuList", () => {
createInitAutofillInlineMenuListMessageMock({
authStatus: AuthenticationStatus.Unlocked,
ciphers: [],
filledByCipherType: CipherType.Card,
inlineMenuFillType: CipherType.Card,
portKey,
}),
);
@ -96,7 +97,7 @@ describe("AutofillInlineMenuList", () => {
createInitAutofillInlineMenuListMessageMock({
authStatus: AuthenticationStatus.Unlocked,
ciphers: [],
filledByCipherType: CipherType.Identity,
inlineMenuFillType: CipherType.Identity,
portKey,
}),
);
@ -109,7 +110,7 @@ describe("AutofillInlineMenuList", () => {
createInitAutofillInlineMenuListMessageMock({
authStatus: AuthenticationStatus.Unlocked,
ciphers: [],
filledByCipherType: undefined,
inlineMenuFillType: undefined,
portKey,
}),
);
@ -142,7 +143,7 @@ describe("AutofillInlineMenuList", () => {
it("creates the views for a list of card ciphers", () => {
postWindowMessage(
createInitAutofillInlineMenuListMessageMock({
filledByCipherType: CipherType.Card,
inlineMenuFillType: CipherType.Card,
ciphers: [
createAutofillOverlayCipherDataMock(1, {
type: CipherType.Card,
@ -172,7 +173,7 @@ describe("AutofillInlineMenuList", () => {
it("creates the views for a list of identity ciphers", () => {
postWindowMessage(
createInitAutofillInlineMenuListMessageMock({
filledByCipherType: CipherType.Card,
inlineMenuFillType: CipherType.Card,
ciphers: [
createAutofillOverlayCipherDataMock(1, {
type: CipherType.Identity,
@ -228,6 +229,7 @@ describe("AutofillInlineMenuList", () => {
describe("fill cipher button event listeners", () => {
beforeEach(() => {
postWindowMessage(createInitAutofillInlineMenuListMessageMock({ portKey }));
jest.spyOn(autofillInlineMenuList as any, "isListHovered").mockReturnValue(true);
});
describe("filling a cipher", () => {
@ -473,7 +475,7 @@ describe("AutofillInlineMenuList", () => {
beforeEach(async () => {
postWindowMessage(
createInitAutofillInlineMenuListMessageMock({
filledByCipherType: CipherType.Login,
inlineMenuFillType: CipherType.Login,
showInlineMenuAccountCreation: true,
portKey,
ciphers: [
@ -718,6 +720,171 @@ describe("AutofillInlineMenuList", () => {
});
});
});
describe("the password generator view", () => {
it("creates the views for the password generator", async () => {
postWindowMessage(
createInitAutofillInlineMenuListMessageMock({
generatedPassword,
}),
);
await flushPromises();
expect(autofillInlineMenuList["passwordGeneratorContainer"]).toMatchSnapshot();
});
describe("fill generated password button event listeners", () => {
beforeEach(async () => {
postWindowMessage(
createInitAutofillInlineMenuListMessageMock({ generatedPassword, portKey }),
);
await flushPromises();
});
it("triggers a fill of the generated password on click", () => {
const fillGeneratedPasswordButton = autofillInlineMenuList[
"passwordGeneratorContainer"
].querySelector(".fill-generated-password-button");
fillGeneratedPasswordButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "fillGeneratedPassword", portKey },
"*",
);
});
describe("keyup events on the fill generated password button", () => {
it("skips acting on keyup events that have the shiftKey pressed in combination", () => {
const fillGeneratedPasswordButton = autofillInlineMenuList[
"passwordGeneratorContainer"
].querySelector(".fill-generated-password-button");
fillGeneratedPasswordButton.dispatchEvent(
new KeyboardEvent("keyup", { code: "Space", shiftKey: true }),
);
expect(globalThis.parent.postMessage).not.toHaveBeenCalledWith(
{ command: "fillGeneratedPassword", portKey },
"*",
);
});
it("triggers a fill of the generated password on keyup of the `Space` key", () => {
const fillGeneratedPasswordButton = autofillInlineMenuList[
"passwordGeneratorContainer"
].querySelector(".fill-generated-password-button");
fillGeneratedPasswordButton.dispatchEvent(
new KeyboardEvent("keyup", { code: "Space" }),
);
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "fillGeneratedPassword", portKey },
"*",
);
});
it("focuses the refresh generated password button on `ArrowRight`", () => {
const fillGeneratedPasswordButton = autofillInlineMenuList[
"passwordGeneratorContainer"
].querySelector(".fill-generated-password-button");
const refreshGeneratedPasswordButton = autofillInlineMenuList[
"passwordGeneratorContainer"
].querySelector(".refresh-generated-password-button");
jest.spyOn(refreshGeneratedPasswordButton as HTMLElement, "focus");
fillGeneratedPasswordButton.dispatchEvent(
new KeyboardEvent("keyup", { code: "ArrowRight" }),
);
expect((refreshGeneratedPasswordButton as HTMLElement).focus).toBeCalled();
});
});
});
describe("refresh generated password button event listeners", () => {
beforeEach(async () => {
postWindowMessage(
createInitAutofillInlineMenuListMessageMock({ generatedPassword, portKey }),
);
await flushPromises();
});
it("triggers a refresh of the generated password on click", () => {
const refreshGeneratedPasswordButton = autofillInlineMenuList[
"passwordGeneratorContainer"
].querySelector(".refresh-generated-password-button");
refreshGeneratedPasswordButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "refreshGeneratedPassword", portKey },
"*",
);
});
describe("keyup events on the refresh generated password button", () => {
it("skips acting on keyup events that have the shiftKey pressed in combination", () => {
const refreshGeneratedPasswordButton = autofillInlineMenuList[
"passwordGeneratorContainer"
].querySelector(".refresh-generated-password-button");
refreshGeneratedPasswordButton.dispatchEvent(
new KeyboardEvent("keyup", { code: "Space", shiftKey: true }),
);
expect(globalThis.parent.postMessage).not.toHaveBeenCalledWith(
{ command: "refreshGeneratedPassword", portKey },
"*",
);
});
it("triggers a refresh of the generated password on press of the `Space` key", () => {
const refreshGeneratedPasswordButton = autofillInlineMenuList[
"passwordGeneratorContainer"
].querySelector(".refresh-generated-password-button");
refreshGeneratedPasswordButton.dispatchEvent(
new KeyboardEvent("keyup", { code: "Space" }),
);
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "refreshGeneratedPassword", portKey },
"*",
);
});
it("focuses the fill generated password button on `ArrowLeft`", () => {
const fillGeneratedPasswordButton = autofillInlineMenuList[
"passwordGeneratorContainer"
].querySelector(".fill-generated-password-button");
const refreshGeneratedPasswordButton = autofillInlineMenuList[
"passwordGeneratorContainer"
].querySelector(".refresh-generated-password-button");
jest.spyOn(fillGeneratedPasswordButton as HTMLElement, "focus");
refreshGeneratedPasswordButton.dispatchEvent(
new KeyboardEvent("keyup", { code: "ArrowLeft" }),
);
expect((fillGeneratedPasswordButton as HTMLElement).focus).toBeCalled();
});
});
});
});
it("creates the build save login item view", async () => {
postWindowMessage(
createInitAutofillInlineMenuListMessageMock({
showSaveLoginMenu: true,
generatedPassword,
}),
);
await flushPromises();
expect(autofillInlineMenuList["inlineMenuListContainer"]).toMatchSnapshot();
});
});
describe("global event listener handlers", () => {
@ -736,19 +903,35 @@ describe("AutofillInlineMenuList", () => {
it("does not post a `checkAutofillInlineMenuButtonFocused` message if the inline menu list is currently hovered", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
jest
.spyOn(autofillInlineMenuList["inlineMenuListContainer"], "matches")
.mockReturnValue(true);
.spyOn(autofillInlineMenuList["inlineMenuListContainer"], "querySelector")
.mockReturnValue(autofillInlineMenuList["inlineMenuListContainer"]);
postWindowMessage({ command: "checkAutofillInlineMenuListFocused" });
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
});
it("triggers a recheck of the list focus state on mouseout", async () => {
jest.spyOn(globalThis.document, "removeEventListener");
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
jest
.spyOn(autofillInlineMenuList["inlineMenuListContainer"], "querySelector")
.mockReturnValue(autofillInlineMenuList["inlineMenuListContainer"]);
postWindowMessage({ command: "checkAutofillInlineMenuListFocused" });
await flushPromises();
globalThis.document.dispatchEvent(new MouseEvent("mouseout"));
expect(globalThis.document.removeEventListener).toHaveBeenCalledWith(
"mouseout",
autofillInlineMenuList["handleMouseOutEvent"],
);
});
it("posts a `checkAutofillInlineMenuButtonFocused` message to the parent if the inline menu is not currently focused", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
jest
.spyOn(autofillInlineMenuList["inlineMenuListContainer"], "matches")
.mockReturnValue(false);
.spyOn(autofillInlineMenuList["inlineMenuListContainer"], "querySelector")
.mockReturnValue(null);
postWindowMessage({ command: "checkAutofillInlineMenuListFocused" });
@ -767,6 +950,109 @@ describe("AutofillInlineMenuList", () => {
expect(updateCiphersSpy).toHaveBeenCalled();
});
describe("updating the password generator view", () => {
let buildPasswordGeneratorSpy: jest.SpyInstance;
let buildColorizedPasswordElementSpy: jest.SpyInstance;
beforeEach(() => {
buildPasswordGeneratorSpy = jest.spyOn(
autofillInlineMenuList as any,
"buildPasswordGenerator",
);
buildColorizedPasswordElementSpy = jest.spyOn(
autofillInlineMenuList as any,
"buildColorizedPasswordElement",
);
});
it("skips updating the password generator if the user is not authed", async () => {
postWindowMessage(
createInitAutofillInlineMenuListMessageMock({
authStatus: AuthenticationStatus.Locked,
}),
);
await flushPromises();
postWindowMessage({
command: "updateAutofillInlineMenuGeneratedPassword",
generatedPassword,
});
expect(buildColorizedPasswordElementSpy).not.toHaveBeenCalled();
});
it("skips update the password generator if the message does not contain a password", async () => {
postWindowMessage(createInitAutofillInlineMenuListMessageMock());
await flushPromises();
postWindowMessage({ command: "updateAutofillInlineMenuGeneratedPassword" });
expect(buildColorizedPasswordElementSpy).not.toHaveBeenCalled();
});
it("builds the password generator if the colorized password element is not present", async () => {
postWindowMessage(createInitAutofillInlineMenuListMessageMock());
await flushPromises();
postWindowMessage({
command: "updateAutofillInlineMenuGeneratedPassword",
generatedPassword,
});
expect(buildPasswordGeneratorSpy).toHaveBeenCalled();
});
it("replaces the colorized password element if it is present", async () => {
postWindowMessage(
createInitAutofillInlineMenuListMessageMock({
generatedPassword,
}),
);
await flushPromises();
postWindowMessage({
command: "updateAutofillInlineMenuGeneratedPassword",
generatedPassword,
});
expect(buildPasswordGeneratorSpy).toHaveBeenCalledTimes(1);
expect(buildColorizedPasswordElementSpy).toHaveBeenCalledTimes(2);
});
});
describe("displaying the save login view", () => {
let buildSaveLoginInlineMenuListSpy: jest.SpyInstance;
beforeEach(() => {
buildSaveLoginInlineMenuListSpy = jest.spyOn(
autofillInlineMenuList as any,
"buildSaveLoginInlineMenuList",
);
});
it("skips displaying the save login item view if the user is not authenticated", async () => {
postWindowMessage(
createInitAutofillInlineMenuListMessageMock({
authStatus: AuthenticationStatus.Locked,
}),
);
await flushPromises();
postWindowMessage({ command: "showSaveLoginInlineMenuList" });
expect(buildSaveLoginInlineMenuListSpy).not.toHaveBeenCalled();
});
it("builds the save login item view", async () => {
postWindowMessage(createInitAutofillInlineMenuListMessageMock());
await flushPromises();
postWindowMessage({ command: "showSaveLoginInlineMenuList" });
expect(buildSaveLoginInlineMenuListSpy).toHaveBeenCalled();
});
});
describe("directing user focus into the inline menu list", () => {
it("sets ARIA attributes that define the list as a `dialog` to screen reader users", () => {
postWindowMessage(

View File

@ -1,29 +1,36 @@
import "@webcomponents/custom-elements";
import "lit/polyfill-support.js";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EVENTS, UPDATE_PASSKEYS_HEADINGS_ON_SCROLL } from "@bitwarden/common/autofill/constants";
import { CipherType } from "@bitwarden/common/vault/enums";
import { InlineMenuCipherData } from "../../../../background/abstractions/overlay.background";
import { buildSvgDomElement, throttle } from "../../../../utils";
import { InlineMenuFillTypes } from "../../../../enums/autofill-overlay.enum";
import { buildSvgDomElement, specialCharacterToKeyMap, throttle } from "../../../../utils";
import {
creditCardIcon,
globeIcon,
idCardIcon,
lockIcon,
passkeyIcon,
plusIcon,
viewCipherIcon,
passkeyIcon,
keyIcon,
refreshIcon,
spinnerIcon,
} from "../../../../utils/svg-icons";
import {
AutofillInlineMenuListWindowMessageHandlers,
InitAutofillInlineMenuListMessage,
UpdateAutofillInlineMenuGeneratedPasswordMessage,
UpdateAutofillInlineMenuListCiphersParams,
} from "../../abstractions/autofill-inline-menu-list";
import { AutofillInlineMenuPageElement } from "../shared/autofill-inline-menu-page-element";
export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
private inlineMenuListContainer: HTMLDivElement;
private passwordGeneratorContainer: HTMLDivElement;
private resizeObserver: ResizeObserver;
private eventHandlersMemo: { [key: string]: EventListener } = {};
private ciphers: InlineMenuCipherData[] = [];
@ -31,7 +38,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
private cipherListScrollIsDebounced = false;
private cipherListScrollDebounceTimeout: number | NodeJS.Timeout;
private currentCipherIndex = 0;
private filledByCipherType: CipherType;
private inlineMenuFillType: InlineMenuFillTypes;
private showInlineMenuAccountCreation: boolean;
private showPasskeysLabels: boolean;
private newItemButtonElement: HTMLButtonElement;
@ -42,14 +49,17 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
private lastPasskeysListItemHeight: number;
private ciphersListHeight: number;
private isPasskeyAuthInProgress = false;
private authStatus: AuthenticationStatus;
private readonly showCiphersPerPage = 6;
private readonly headingBorderClass = "inline-menu-list-heading--bordered";
private readonly inlineMenuListWindowMessageHandlers: AutofillInlineMenuListWindowMessageHandlers =
{
initAutofillInlineMenuList: ({ message }) => this.initAutofillInlineMenuList(message),
checkAutofillInlineMenuListFocused: () => this.checkInlineMenuListFocused(),
updateAutofillInlineMenuListCiphers: ({ message }) =>
this.updateListItems(message.ciphers, message.showInlineMenuAccountCreation),
updateAutofillInlineMenuListCiphers: ({ message }) => this.updateListItems(message),
updateAutofillInlineMenuGeneratedPassword: ({ message }) =>
this.handleUpdateAutofillInlineMenuGeneratedPassword(message),
showSaveLoginInlineMenuList: () => this.handleShowSaveLoginInlineMenuList(),
focusAutofillInlineMenuList: () => this.focusInlineMenuList(),
};
@ -63,27 +73,22 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* Initializes the inline menu list and updates the list items with the passed ciphers.
* If the auth status is not `Unlocked`, the locked inline menu is built.
*
* @param translations - The translations to use for the inline menu list.
* @param styleSheetUrl - The URL of the stylesheet to use for the inline menu list.
* @param theme - The theme to use for the inline menu list.
* @param authStatus - The current authentication status.
* @param ciphers - The ciphers to display in the inline menu list.
* @param portKey - Background generated key that allows the port to communicate with the background.
* @param filledByCipherType - The type of cipher that fills the current field.
* @param showInlineMenuAccountCreation - Whether identity ciphers are shown on login fields.
* @param showPasskeysLabels - Whether passkeys labels are shown in the inline menu list.
* @param message - The message containing the data to initialize the inline menu list.
*/
private async initAutofillInlineMenuList({
translations,
styleSheetUrl,
theme,
authStatus,
ciphers,
portKey,
filledByCipherType,
showInlineMenuAccountCreation,
showPasskeysLabels,
}: InitAutofillInlineMenuListMessage) {
private async initAutofillInlineMenuList(message: InitAutofillInlineMenuListMessage) {
const {
translations,
styleSheetUrl,
theme,
authStatus,
ciphers,
portKey,
inlineMenuFillType,
showInlineMenuAccountCreation,
showPasskeysLabels,
generatedPassword,
showSaveLoginMenu,
} = message;
const linkElement = await this.initAutofillInlineMenuPage(
"list",
styleSheetUrl,
@ -91,7 +96,8 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
portKey,
);
this.filledByCipherType = filledByCipherType;
this.authStatus = authStatus;
this.inlineMenuFillType = inlineMenuFillType;
this.showPasskeysLabels = showPasskeysLabels;
const themeClass = `theme_${theme}`;
@ -103,12 +109,25 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
this.shadowDom.append(linkElement, this.inlineMenuListContainer);
if (authStatus === AuthenticationStatus.Unlocked) {
this.updateListItems(ciphers, showInlineMenuAccountCreation);
if (authStatus !== AuthenticationStatus.Unlocked) {
this.buildLockedInlineMenu();
return;
}
this.buildLockedInlineMenu();
if (showSaveLoginMenu) {
this.buildSaveLoginInlineMenuList();
return;
}
if (generatedPassword) {
this.buildPasswordGenerator(generatedPassword);
return;
}
this.updateListItems({
ciphers,
showInlineMenuAccountCreation,
});
}
/**
@ -119,7 +138,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
const lockedInlineMenu = globalThis.document.createElement("div");
lockedInlineMenu.id = "locked-inline-menu-description";
lockedInlineMenu.classList.add("locked-inline-menu", "inline-menu-list-message");
lockedInlineMenu.textContent = this.getTranslation("unlockYourAccount");
lockedInlineMenu.textContent = this.getTranslation(
"unlockYourAccountToViewAutofillSuggestions",
);
const unlockButtonElement = globalThis.document.createElement("button");
unlockButtonElement.id = "unlock-button";
@ -139,6 +160,30 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
this.inlineMenuListContainer.append(lockedInlineMenu, inlineMenuListButtonContainer);
}
/**
* Builds the inline menu list as a prompt that asks the user if they'd like to save the login data.
*/
private buildSaveLoginInlineMenuList() {
const saveLoginMessage = globalThis.document.createElement("div");
saveLoginMessage.classList.add("save-login", "inline-menu-list-message");
saveLoginMessage.textContent = this.getTranslation("saveLoginToBitwarden");
const newItemButton = this.buildNewItemButton(true);
this.showInlineMenuAccountCreation = true;
this.inlineMenuListContainer.append(saveLoginMessage, newItemButton);
}
/**
* Handles the show save login inline menu list message that is triggered from the background script.
*/
private handleShowSaveLoginInlineMenuList() {
if (this.authStatus === AuthenticationStatus.Unlocked) {
this.resetInlineMenuContainer();
this.buildSaveLoginInlineMenuList();
}
}
/**
* Handles the click event for the unlock button.
* Sends a message to the parent window to unlock the vault.
@ -147,6 +192,224 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
this.postMessageToParent({ command: "unlockVault" });
};
/**
* Builds the password generator within the inline menu.
*
* @param generatedPassword - The generated password to display.
*/
private buildPasswordGenerator(generatedPassword: string) {
this.passwordGeneratorContainer = globalThis.document.createElement("div");
this.passwordGeneratorContainer.classList.add("password-generator-container");
const passwordGeneratorActions = globalThis.document.createElement("div");
passwordGeneratorActions.classList.add("password-generator-actions");
const fillGeneratedPasswordButton = globalThis.document.createElement("button");
fillGeneratedPasswordButton.tabIndex = -1;
fillGeneratedPasswordButton.classList.add(
"fill-generated-password-button",
"inline-menu-list-action",
);
fillGeneratedPasswordButton.setAttribute(
"aria-label",
this.getTranslation("fillGeneratedPassword"),
);
const passwordGeneratorHeading = globalThis.document.createElement("div");
passwordGeneratorHeading.classList.add("password-generator-heading");
passwordGeneratorHeading.textContent = this.getTranslation("fillGeneratedPassword");
const passwordGeneratorContent = globalThis.document.createElement("div");
passwordGeneratorContent.id = "password-generator-content";
passwordGeneratorContent.classList.add("password-generator-content");
passwordGeneratorContent.append(
passwordGeneratorHeading,
this.buildColorizedPasswordElement(generatedPassword),
);
fillGeneratedPasswordButton.append(buildSvgDomElement(keyIcon), passwordGeneratorContent);
fillGeneratedPasswordButton.addEventListener(
EVENTS.CLICK,
this.handleFillGeneratedPasswordClick,
);
fillGeneratedPasswordButton.addEventListener(
EVENTS.KEYUP,
this.handleFillGeneratedPasswordKeyUp,
);
const refreshGeneratedPasswordButton = globalThis.document.createElement("button");
refreshGeneratedPasswordButton.tabIndex = -1;
refreshGeneratedPasswordButton.classList.add(
"refresh-generated-password-button",
"inline-menu-list-action",
);
refreshGeneratedPasswordButton.setAttribute(
"aria-label",
this.getTranslation("regeneratePassword"),
);
refreshGeneratedPasswordButton.appendChild(buildSvgDomElement(refreshIcon));
refreshGeneratedPasswordButton.addEventListener(
EVENTS.CLICK,
this.handleRefreshGeneratedPasswordClick,
);
refreshGeneratedPasswordButton.addEventListener(
EVENTS.KEYUP,
this.handleRefreshGeneratedPasswordKeyUp,
);
passwordGeneratorActions.append(fillGeneratedPasswordButton, refreshGeneratedPasswordButton);
this.passwordGeneratorContainer.appendChild(passwordGeneratorActions);
this.inlineMenuListContainer.appendChild(this.passwordGeneratorContainer);
}
/**
* Builds the colorized password content element.
*
* @param password - The password to display.
*/
private buildColorizedPasswordElement(password: string) {
let ariaDescription = `${this.getTranslation("generatedPassword")}: `;
const passwordContainer = globalThis.document.createElement("div");
passwordContainer.classList.add("colorized-password");
const appendPasswordCharacter = (character: string, type: string) => {
const characterElement = globalThis.document.createElement("div");
characterElement.classList.add(`password-${type}`);
characterElement.textContent = character;
passwordContainer.appendChild(characterElement);
};
const passwordArray = Array.from(password);
for (let i = 0; i < passwordArray.length; i++) {
const character = passwordArray[i];
if (character.match(/\W/)) {
appendPasswordCharacter(character, "special");
ariaDescription += `${this.getTranslation(specialCharacterToKeyMap[character])} `;
continue;
}
if (character.match(/\d/)) {
appendPasswordCharacter(character, "number");
ariaDescription += `${character} `;
continue;
}
appendPasswordCharacter(character, "letter");
ariaDescription +=
character === character.toLowerCase()
? `${this.getTranslation("lowercaseAriaLabel")} ${character} `
: `${this.getTranslation("uppercaseAriaLabel")} ${character} `;
}
passwordContainer.setAttribute("aria-label", ariaDescription);
return passwordContainer;
}
/**
* Handles the click event for the fill generated password button. Triggers
* a message to the background script to fill the generated password.
*/
private handleFillGeneratedPasswordClick = () => {
this.postMessageToParent({ command: "fillGeneratedPassword" });
};
/**
* Handles the keyup event for the fill generated password button.
*
* @param event - The keyup event.
*/
private handleFillGeneratedPasswordKeyUp = (event: KeyboardEvent) => {
if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) {
return;
}
if (event.code === "Space") {
this.handleFillGeneratedPasswordClick();
return;
}
if (
event.code === "ArrowRight" &&
event.target instanceof HTMLElement &&
event.target.nextElementSibling
) {
(event.target.nextElementSibling as HTMLElement).focus();
event.target.parentElement.classList.add("remove-outline");
return;
}
};
/**
* Handles the click event of the password regenerator button.
*
* @param event - The click event.
*/
private handleRefreshGeneratedPasswordClick = (event?: MouseEvent) => {
if (event) {
(event.target as HTMLElement)
.closest(".password-generator-actions")
?.classList.add("remove-outline");
}
this.postMessageToParent({ command: "refreshGeneratedPassword" });
};
/**
* Handles the keyup event for the password regenerator button.
*
* @param event - The keyup event.
*/
private handleRefreshGeneratedPasswordKeyUp = (event: KeyboardEvent) => {
if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) {
return;
}
if (event.code === "Space") {
this.handleRefreshGeneratedPasswordClick();
return;
}
if (
event.code === "ArrowLeft" &&
event.target instanceof HTMLElement &&
event.target.previousElementSibling
) {
(event.target.previousElementSibling as HTMLElement).focus();
event.target.parentElement.classList.remove("remove-outline");
return;
}
};
/**
* Updates the generated password content element with the passed generated password.
*
* @param message - The message containing the generated password.
*/
private handleUpdateAutofillInlineMenuGeneratedPassword(
message: UpdateAutofillInlineMenuGeneratedPasswordMessage,
) {
if (this.authStatus !== AuthenticationStatus.Unlocked || !message.generatedPassword) {
return;
}
const passwordGeneratorContentElement = this.inlineMenuListContainer.querySelector(
"#password-generator-content",
);
const colorizedPasswordElement =
passwordGeneratorContentElement?.querySelector(".colorized-password");
if (!colorizedPasswordElement) {
this.resetInlineMenuContainer();
this.buildPasswordGenerator(message.generatedPassword);
return;
}
colorizedPasswordElement.replaceWith(
this.buildColorizedPasswordElement(message.generatedPassword),
);
}
/**
* Updates the list items with the passed ciphers.
* If no ciphers are passed, the no results inline menu is built.
@ -154,10 +417,10 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* @param ciphers - The ciphers to display in the inline menu list.
* @param showInlineMenuAccountCreation - Whether identity ciphers are shown on login fields.
*/
private updateListItems(
ciphers: InlineMenuCipherData[],
showInlineMenuAccountCreation?: boolean,
) {
private updateListItems({
ciphers,
showInlineMenuAccountCreation,
}: UpdateAutofillInlineMenuListCiphersParams) {
if (this.isPasskeyAuthInProgress) {
return;
}
@ -221,7 +484,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
/**
* Builds a "New Item" button and returns the container of that button.
*/
private buildNewItemButton() {
private buildNewItemButton(showLogin = false) {
this.newItemButtonElement = globalThis.document.createElement("button");
this.newItemButtonElement.tabIndex = -1;
this.newItemButtonElement.id = "new-item-button";
@ -230,8 +493,8 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
"inline-menu-list-button",
"inline-menu-list-action",
);
this.newItemButtonElement.textContent = this.getNewItemButtonText();
this.newItemButtonElement.setAttribute("aria-label", this.getNewItemAriaLabel());
this.newItemButtonElement.textContent = this.getNewItemButtonText(showLogin);
this.newItemButtonElement.setAttribute("aria-label", this.getNewItemAriaLabel(showLogin));
this.newItemButtonElement.prepend(buildSvgDomElement(plusIcon));
this.newItemButtonElement.addEventListener(EVENTS.CLICK, this.handeNewItemButtonClick);
@ -241,8 +504,8 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
/**
* Gets the new item text for the button based on the cipher type the focused field is filled by.
*/
private getNewItemButtonText() {
if (this.isFilledByLoginCipher() || this.showInlineMenuAccountCreation) {
private getNewItemButtonText(showLogin: boolean) {
if (this.isFilledByLoginCipher() || this.showInlineMenuAccountCreation || showLogin) {
return this.getTranslation("newLogin");
}
@ -260,17 +523,17 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
/**
* Gets the aria label for the new item button based on the cipher type the focused field is filled by.
*/
private getNewItemAriaLabel() {
if (this.isFilledByLoginCipher() || this.showInlineMenuAccountCreation) {
return this.getTranslation("addNewLoginItem");
private getNewItemAriaLabel(showLogin: boolean) {
if (this.isFilledByLoginCipher() || this.showInlineMenuAccountCreation || showLogin) {
return this.getTranslation("addNewLoginItemAria");
}
if (this.isFilledByCardCipher()) {
return this.getTranslation("addNewCardItem");
return this.getTranslation("addNewCardItemAria");
}
if (this.isFilledByIdentityCipher()) {
return this.getTranslation("addNewIdentityItem");
return this.getTranslation("addNewIdentityItemAria");
}
return this.getTranslation("addNewVaultItem");
@ -294,7 +557,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* Sends a message to the parent window to add a new vault item.
*/
private handeNewItemButtonClick = () => {
let addNewCipherType = this.filledByCipherType;
let addNewCipherType = this.inlineMenuFillType;
if (this.showInlineMenuAccountCreation) {
addNewCipherType = CipherType.Login;
@ -560,7 +823,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
"aria-label",
`${
cipher.login?.passkey
? this.getTranslation("logInWithPasskey")
? this.getTranslation("logInWithPasskeyAriaLabel")
: this.getTranslation("fillCredentialsFor")
} ${cipher.name}`,
);
@ -589,7 +852,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
if (username) {
fillCipherElement.setAttribute(
"aria-description",
`${this.getTranslation("username")}: ${username}`,
`${this.getTranslation("username")?.toLowerCase()}: ${username}`,
);
}
return;
@ -980,13 +1243,38 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* If not focused, will check if the button element is focused.
*/
private checkInlineMenuListFocused() {
if (globalThis.document.hasFocus() || this.inlineMenuListContainer.matches(":hover")) {
if (globalThis.document.hasFocus()) {
return;
}
if (this.isListHovered()) {
globalThis.document.addEventListener(EVENTS.MOUSEOUT, this.handleMouseOutEvent);
return;
}
this.postMessageToParent({ command: "checkAutofillInlineMenuButtonFocused" });
}
/**
* Triggers a re-check of the list's focus status when the mouse leaves the list.
*/
private handleMouseOutEvent = () => {
globalThis.document.removeEventListener(EVENTS.MOUSEOUT, this.handleMouseOutEvent);
this.checkInlineMenuListFocused();
};
/**
* Validates whether the inline menu list iframe is currently hovered.
*/
private isListHovered = () => {
const hoveredElement = this.inlineMenuListContainer?.querySelector(":hover");
return !!(
hoveredElement &&
(hoveredElement === this.inlineMenuListContainer ||
this.inlineMenuListContainer.contains(hoveredElement))
);
};
/**
* Focuses the inline menu list iframe. The element that receives focus is
* determined by the presence of the unlock button, new item button, or
@ -1157,21 +1445,21 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* Identifies if the current focused field is filled by a login cipher.
*/
private isFilledByLoginCipher = () => {
return this.filledByCipherType === CipherType.Login;
return this.inlineMenuFillType === CipherType.Login;
};
/**
* Identifies if the current focused field is filled by a card cipher.
*/
private isFilledByCardCipher = () => {
return this.filledByCipherType === CipherType.Card;
return this.inlineMenuFillType === CipherType.Card;
};
/**
* Identifies if the current focused field is filled by an identity cipher.
*/
private isFilledByIdentityCipher = () => {
return this.filledByCipherType === CipherType.Identity;
return this.inlineMenuFillType === CipherType.Identity;
};
/**

View File

@ -5,10 +5,14 @@
* {
box-sizing: border-box;
}
html,
body {
overflow: hidden;
pointer-events: none;
}
html {
font-size: 10px;
overflow: hidden;
}
body {
@ -24,6 +28,10 @@ body {
}
}
body * {
pointer-events: auto;
}
.inline-menu-list-message {
font-size: 1.4rem;
line-height: 1.5;
@ -34,7 +42,8 @@ body {
color: themed("textColor");
}
&.no-items {
&.no-items,
&.save-login {
font-size: 1.6rem;
}
}
@ -228,7 +237,7 @@ body {
padding: 0.7rem 0.3rem 0.7rem 0.7rem;
border-radius: 0.4rem;
&:focus-within:not(.remove-outline) {
&:has(:focus-visible):not(.remove-outline) {
outline-width: 0.2rem;
outline-style: solid;
@ -428,3 +437,136 @@ body {
}
}
}
// Password generator styles
.password-generator-container {
padding: 0.2rem;
}
.password-generator-actions {
display: flex;
align-content: flex-start;
align-items: center;
justify-content: flex-start;
padding: 0.8rem 0.4rem 1.1rem 0.65rem;
border-radius: 0.4rem;
transition: background-color 0.2s ease-in-out;
&:hover {
@include themify($themes) {
background: themed("backgroundOffsetColor");
}
}
&:has(:focus-visible):not(.remove-outline) {
outline-width: 0.2rem;
outline-style: solid;
@include themify($themes) {
outline-color: themed("focusOutlineColor");
}
}
.inline-menu-list-action {
padding: 0;
margin: 0;
border: none;
background-color: transparent;
cursor: pointer;
@include themify($themes) {
color: themed("textColor");
}
svg {
path {
@include themify($themes) {
fill: themed("primaryColor") !important;
}
}
}
}
.fill-generated-password-button {
display: flex;
align-items: center;
align-content: center;
justify-content: flex-start;
width: calc(100% - 4rem);
outline: none;
padding-right: 0.2rem;
svg {
flex-shrink: 0;
width: 3.2rem;
margin-top: 0.2rem;
margin-right: 0.65rem;
}
}
.refresh-generated-password-button {
flex-shrink: 0;
width: 4rem;
height: 4rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.4rem;
&:focus:focus-visible {
outline-width: 0.2rem;
outline-style: solid;
@include themify($themes) {
outline-color: themed("focusOutlineColor");
}
}
svg {
margin-top: 0.2rem;
path {
@include themify($themes) {
fill: themed("primaryColor") !important;
}
}
}
}
.password-generator-content {
text-align: left;
}
.password-generator-heading {
font-size: 1.4rem;
line-height: 1.6;
margin-bottom: 0.1rem;
font-family: $font-family-sans-serif;
}
.colorized-password {
font-size: 1.2rem;
line-height: 1.3;
font-family: $font-family-source-code-pro;
letter-spacing: 0.05rem;
font-weight: 400;
display: flex;
align-content: center;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
width: 100%;
.password-special {
@include themify($themes) {
color: themed("passwordSpecialColor") !important;
}
}
.password-number {
@include themify($themes) {
color: themed("passwordNumberColor") !important;
}
}
}
}

View File

@ -1,3 +1,5 @@
require("./menu-container.scss");
import { AutofillInlineMenuContainer } from "./autofill-inline-menu-container";
(() => new AutofillInlineMenuContainer())();

View File

@ -0,0 +1,5 @@
html,
body {
overflow: hidden;
pointer-events: none;
}

View File

@ -2,6 +2,7 @@ import { mock, MockProxy } from "jest-mock-extended";
import AutofillInit from "../../../content/autofill-init";
import { DomQueryService } from "../../../services/abstractions/dom-query.service";
import DomElementVisibilityService from "../../../services/dom-element-visibility.service";
import { flushPromises, sendMockExtensionMessage } from "../../../spec/testing-utils";
import { NotificationTypeData } from "../abstractions/overlay-notifications-content.service";
@ -10,15 +11,18 @@ import { OverlayNotificationsContentService } from "./overlay-notifications-cont
describe("OverlayNotificationsContentService", () => {
let overlayNotificationsContentService: OverlayNotificationsContentService;
let domQueryService: MockProxy<DomQueryService>;
let domElementVisibilityService: DomElementVisibilityService;
let autofillInit: AutofillInit;
let bodyAppendChildSpy: jest.SpyInstance;
beforeEach(() => {
jest.useFakeTimers();
domQueryService = mock<DomQueryService>();
domElementVisibilityService = new DomElementVisibilityService();
overlayNotificationsContentService = new OverlayNotificationsContentService();
autofillInit = new AutofillInit(
domQueryService,
domElementVisibilityService,
null,
null,
overlayNotificationsContentService,

View File

@ -1,22 +1,14 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { SubFrameOffsetData } from "../../background/abstractions/overlay.background";
import { AutofillExtensionMessageParam } from "../../content/abstractions/autofill-init";
import AutofillField from "../../models/autofill-field";
import AutofillPageDetails from "../../models/autofill-page-details";
import { ElementWithOpId, FormFieldElement } from "../../types";
export type OpenAutofillInlineMenuOptions = {
isFocusingFieldElement?: boolean;
isOpeningFullInlineMenu?: boolean;
authStatus?: AuthenticationStatus;
};
export type SubFrameDataFromWindowMessage = SubFrameOffsetData & {
subFrameDepth: number;
};
export type NotificationFormFieldData = {
export type InlineMenuFormFieldData = {
uri: string;
username: string;
password: string;
@ -25,21 +17,22 @@ export type NotificationFormFieldData = {
export type AutofillOverlayContentExtensionMessageHandlers = {
[key: string]: CallableFunction;
openAutofillInlineMenu: ({ message }: AutofillExtensionMessageParam) => void;
addNewVaultItemFromOverlay: ({ message }: AutofillExtensionMessageParam) => void;
blurMostRecentlyFocusedField: () => void;
focusMostRecentlyFocusedField: () => void;
blurMostRecentlyFocusedField: () => Promise<void>;
unsetMostRecentlyFocusedField: () => void;
checkIsMostRecentlyFocusedFieldWithinViewport: () => Promise<boolean>;
bgUnlockPopoutOpened: () => void;
bgVaultItemRepromptPopoutOpened: () => void;
bgUnlockPopoutOpened: () => Promise<void>;
bgVaultItemRepromptPopoutOpened: () => Promise<void>;
redirectAutofillInlineMenuFocusOut: ({ message }: AutofillExtensionMessageParam) => void;
updateAutofillInlineMenuVisibility: ({ message }: AutofillExtensionMessageParam) => void;
getSubFrameOffsets: ({ message }: AutofillExtensionMessageParam) => Promise<SubFrameOffsetData>;
getSubFrameOffsetsFromWindowMessage: ({ message }: AutofillExtensionMessageParam) => void;
checkMostRecentlyFocusedFieldHasValue: () => boolean;
setupRebuildSubFrameOffsetsListeners: () => void;
destroyAutofillInlineMenuListeners: () => void;
getFormFieldDataForNotification: () => Promise<NotificationFormFieldData>;
getInlineMenuFormFieldData: ({
message,
}: AutofillExtensionMessageParam) => Promise<InlineMenuFormFieldData>;
};
export interface AutofillOverlayContentService {
@ -52,5 +45,6 @@ export interface AutofillOverlayContentService {
pageDetails: AutofillPageDetails,
): Promise<void>;
blurMostRecentlyFocusedField(isClosingInlineMenu?: boolean): void;
clearUserFilledFields(): void;
destroy(): void;
}

View File

@ -1,4 +1,4 @@
export interface DomElementVisibilityService {
isFormFieldViewable: (element: HTMLElement) => Promise<boolean>;
isElementViewable: (element: HTMLElement) => Promise<boolean>;
isElementHiddenByCss: (element: HTMLElement) => boolean;
}

View File

@ -14,6 +14,7 @@ export type SubmitButtonKeywordsMap = WeakMap<HTMLElement, string>;
export interface InlineMenuFieldQualificationService {
isUsernameField(field: AutofillField): boolean;
isCurrentPasswordField(field: AutofillField): boolean;
isUpdateCurrentPasswordField(field: AutofillField): boolean;
isNewPasswordField(field: AutofillField): boolean;
isEmailField(field: AutofillField): boolean;
isFieldForLoginForm(field: AutofillField, pageDetails: AutofillPageDetails): boolean;

View File

@ -29,8 +29,6 @@ export class AutoFillConstants {
static readonly TotpFieldNames: string[] = [
"totp",
"2fa",
"mfa",
"totpcode",
"2facode",
"approvals_code",
@ -44,11 +42,11 @@ export class AutoFillConstants {
"twofactor",
"twofa",
"twofactorcode",
"verificationCode",
"verificationcode",
"verification code",
];
static readonly AmbiguousTotpFieldNames: string[] = ["code", "pin", "otc", "otp"];
static readonly AmbiguousTotpFieldNames: string[] = ["code", "pin", "otc", "otp", "2fa", "mfa"];
static readonly SearchFieldNames: string[] = ["search", "query", "find", "go"];
@ -373,6 +371,7 @@ export class IdentityAutoFillConstants {
"label-left",
"label-top",
"data-recurly",
"accountCreationFieldType",
];
static readonly FullNameFieldNames: string[] = ["name", "full-name", "your-name"];
@ -875,7 +874,7 @@ export const SubmitLoginButtonNames: string[] = [
"submit",
"continue",
"next",
"go",
"verify",
];
export const SubmitChangePasswordButtonNames: string[] = [

View File

@ -1,12 +1,12 @@
import { mock } from "jest-mock-extended";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AutofillOverlayVisibility, EVENTS } from "@bitwarden/common/autofill/constants";
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { CipherType } from "@bitwarden/common/vault/enums";
import AutofillInit from "../content/autofill-init";
import {
AutofillOverlayElement,
InlineMenuFillType,
MAX_SUB_FRAME_DEPTH,
RedirectFocusDirection,
} from "../enums/autofill-overlay.enum";
@ -24,6 +24,7 @@ import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../
import { AutoFillConstants } from "./autofill-constants";
import { AutofillOverlayContentService } from "./autofill-overlay-content.service";
import DomElementVisibilityService from "./dom-element-visibility.service";
import { DomQueryService } from "./dom-query.service";
import { InlineMenuFieldQualificationService } from "./inline-menu-field-qualification.service";
@ -31,6 +32,7 @@ const defaultWindowReadyState = document.readyState;
const defaultDocumentVisibilityState = document.visibilityState;
describe("AutofillOverlayContentService", () => {
let domQueryService: DomQueryService;
let domElementVisibilityService: DomElementVisibilityService;
let autofillInit: AutofillInit;
let inlineMenuFieldQualificationService: InlineMenuFieldQualificationService;
let autofillOverlayContentService: AutofillOverlayContentService;
@ -38,15 +40,23 @@ describe("AutofillOverlayContentService", () => {
const sendResponseSpy = jest.fn();
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
beforeEach(() => {
beforeEach(async () => {
inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
domQueryService = new DomQueryService();
domElementVisibilityService = new DomElementVisibilityService();
autofillOverlayContentService = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
);
autofillInit = new AutofillInit(domQueryService, autofillOverlayContentService);
autofillInit = new AutofillInit(
domQueryService,
domElementVisibilityService,
autofillOverlayContentService,
);
autofillInit.init();
autofillOverlayContentService["showInlineMenuCards"] = true;
autofillOverlayContentService["showInlineMenuIdentities"] = true;
sendExtensionMessageSpy = jest
.spyOn(autofillOverlayContentService as any, "sendExtensionMessage")
.mockResolvedValue(undefined);
@ -122,14 +132,17 @@ describe("AutofillOverlayContentService", () => {
});
it("sets up a focus out listener for the window", () => {
const handleFormFieldBlurEventSpy = jest.spyOn(
const handleWindowFocusOutEventSpy = jest.spyOn(
autofillOverlayContentService as any,
"handleFormFieldBlurEvent",
"handleWindowFocusOutEvent",
);
autofillOverlayContentService.init();
expect(window.addEventListener).toHaveBeenCalledWith("focusout", handleFormFieldBlurEventSpy);
expect(window.addEventListener).toHaveBeenCalledWith(
"focusout",
handleWindowFocusOutEventSpy,
);
});
});
@ -225,39 +238,6 @@ describe("AutofillOverlayContentService", () => {
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled();
});
describe("identifies the overlay visibility setting", () => {
it("defaults the overlay visibility setting to `OnFieldFocus` if a value is not set", async () => {
sendExtensionMessageSpy.mockResolvedValueOnce(undefined);
autofillOverlayContentService["inlineMenuVisibility"] = undefined;
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("getAutofillInlineMenuVisibility");
expect(autofillOverlayContentService["inlineMenuVisibility"]).toEqual(
AutofillOverlayVisibility.OnFieldFocus,
);
});
it("sets the overlay visibility setting to the value returned from the background script", async () => {
sendExtensionMessageSpy.mockResolvedValueOnce(AutofillOverlayVisibility.OnFieldFocus);
autofillOverlayContentService["inlineMenuVisibility"] = undefined;
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
expect(autofillOverlayContentService["inlineMenuVisibility"]).toEqual(
AutofillOverlayVisibility.OnFieldFocus,
);
});
});
describe("sets up form field element listeners", () => {
it("removes all cached event listeners from the form field element", async () => {
jest.spyOn(autofillFieldElement, "removeEventListener");
@ -377,14 +357,7 @@ describe("AutofillOverlayContentService", () => {
it("opens the overlay list and focuses it after a delay if it is not visible when the `ArrowDown` key is pressed", async () => {
jest.useFakeTimers();
const updateMostRecentlyFocusedFieldSpy = jest.spyOn(
autofillOverlayContentService as any,
"updateMostRecentlyFocusedField",
);
const openAutofillOverlaySpy = jest.spyOn(
autofillOverlayContentService as any,
"openInlineMenu",
);
autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement;
jest
.spyOn(autofillOverlayContentService as any, "isInlineMenuListVisible")
.mockResolvedValue(false);
@ -392,8 +365,7 @@ describe("AutofillOverlayContentService", () => {
autofillFieldElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowDown" }));
await flushPromises();
expect(updateMostRecentlyFocusedFieldSpy).toHaveBeenCalledWith(autofillFieldElement);
expect(openAutofillOverlaySpy).toHaveBeenCalledWith({
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("openAutofillInlineMenu", {
isOpeningFullInlineMenu: true,
});
expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith("focusAutofillInlineMenuList");
@ -441,13 +413,11 @@ describe("AutofillOverlayContentService", () => {
const randomElement = document.createElement(
"input",
) as ElementWithOpId<FillableFormFieldElement>;
jest.spyOn(autofillOverlayContentService as any, "qualifyUserFilledLoginField");
jest.spyOn(autofillOverlayContentService as any, "qualifyUserFilledField");
autofillOverlayContentService["storeModifiedFormElement"](randomElement);
expect(
autofillOverlayContentService["qualifyUserFilledLoginField"],
).not.toHaveBeenCalled();
expect(autofillOverlayContentService["qualifyUserFilledField"]).not.toHaveBeenCalled();
});
it("sets the field as the most recently focused form field element", async () => {
@ -499,8 +469,7 @@ describe("AutofillOverlayContentService", () => {
);
});
it("removes the overlay if the form field element has a value and the user is not authed", async () => {
jest.spyOn(autofillOverlayContentService as any, "isUserAuthed").mockReturnValue(false);
it("Closes the inline menu list and does not re-open the inline menu if the field has a value", async () => {
(autofillFieldElement as HTMLInputElement).value = "test";
await autofillOverlayContentService.setupOverlayListeners(
@ -515,16 +484,10 @@ describe("AutofillOverlayContentService", () => {
overlayElement: AutofillOverlayElement.List,
forceCloseInlineMenu: true,
});
expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith("openAutofillInlineMenu");
});
it("removes the overlay if the form field element has a value and the overlay ciphers are populated", async () => {
jest.spyOn(autofillOverlayContentService as any, "isUserAuthed").mockReturnValue(true);
jest
.spyOn(autofillOverlayContentService as any, "isInlineMenuCiphersPopulated")
.mockResolvedValue(true);
(autofillFieldElement as HTMLInputElement).value = "test";
it("opens the inline menu if the field does not have a value", async () => {
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
@ -533,60 +496,7 @@ describe("AutofillOverlayContentService", () => {
autofillFieldElement.dispatchEvent(new Event("input"));
await flushPromises();
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
overlayElement: AutofillOverlayElement.List,
forceCloseInlineMenu: true,
});
});
it("opens the autofill inline menu if the form field is empty", async () => {
jest.spyOn(autofillOverlayContentService as any, "openInlineMenu");
(autofillFieldElement as HTMLInputElement).value = "";
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
autofillFieldElement.dispatchEvent(new Event("input"));
await flushPromises();
expect(autofillOverlayContentService["openInlineMenu"]).toHaveBeenCalled();
});
it("opens the autofill inline menu if the form field is empty and the user is authed", async () => {
jest.spyOn(autofillOverlayContentService as any, "isUserAuthed").mockReturnValue(true);
jest.spyOn(autofillOverlayContentService as any, "openInlineMenu");
(autofillFieldElement as HTMLInputElement).value = "";
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
autofillFieldElement.dispatchEvent(new Event("input"));
await flushPromises();
expect(autofillOverlayContentService["openInlineMenu"]).toHaveBeenCalled();
});
it("opens the autofill inline menu if the form field is empty and the overlay ciphers are not populated", async () => {
jest.spyOn(autofillOverlayContentService as any, "isUserAuthed").mockReturnValue(false);
jest
.spyOn(autofillOverlayContentService as any, "isInlineMenuCiphersPopulated")
.mockResolvedValue(false);
jest.spyOn(autofillOverlayContentService as any, "openInlineMenu");
(autofillFieldElement as HTMLInputElement).value = "";
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
autofillFieldElement.dispatchEvent(new Event("input"));
await flushPromises();
expect(autofillOverlayContentService["openInlineMenu"]).toHaveBeenCalled();
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("openAutofillInlineMenu");
});
describe("input changes on a field filled by a card cipher", () => {
@ -605,7 +515,7 @@ describe("AutofillOverlayContentService", () => {
elementNumber: 3,
autoCompleteType: "cc-number",
type: "text",
filledByCipherType: CipherType.Card,
inlineMenuFillType: CipherType.Card,
viewable: true,
});
selectFieldElement = document.createElement(
@ -617,7 +527,7 @@ describe("AutofillOverlayContentService", () => {
elementNumber: 4,
autoCompleteType: "cc-type",
type: "select",
filledByCipherType: CipherType.Card,
inlineMenuFillType: CipherType.Card,
viewable: true,
});
pageDetailsMock.fields = [inputFieldData, selectFieldData];
@ -625,7 +535,6 @@ describe("AutofillOverlayContentService", () => {
it("only stores the element if the form field is a select element", async () => {
jest.spyOn(autofillOverlayContentService as any, "storeModifiedFormElement");
jest.spyOn(autofillOverlayContentService as any, "hideInlineMenuListOnFilledField");
await autofillOverlayContentService.setupOverlayListeners(
selectFieldElement,
@ -638,9 +547,10 @@ describe("AutofillOverlayContentService", () => {
expect(autofillOverlayContentService["storeModifiedFormElement"]).toHaveBeenCalledWith(
selectFieldElement,
);
expect(
autofillOverlayContentService["hideInlineMenuListOnFilledField"],
).not.toHaveBeenCalled();
expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith(
"openAutofillInlineMenu",
expect.any(Object),
);
});
it("stores cardholder name fields", async () => {
@ -752,7 +662,7 @@ describe("AutofillOverlayContentService", () => {
elementNumber: 3,
autoCompleteType: "given-name",
type: "text",
filledByCipherType: CipherType.Identity,
inlineMenuFillType: CipherType.Identity,
viewable: true,
});
pageDetailsMock.fields = [inputFieldData];
@ -1026,6 +936,70 @@ describe("AutofillOverlayContentService", () => {
);
});
});
describe("input changes on a field for an account creation form", () => {
const inputFieldData = createAutofillFieldMock({
form: "validFormId",
autoCompleteType: "username",
type: "text",
});
const passwordFieldData = createAutofillFieldMock({
type: "password",
autoCompleteType: "new-password",
form: "validFormId",
placeholder: "new password",
});
const confirmPasswordFieldData = createAutofillFieldMock({
type: "password",
autoCompleteType: "new-password",
form: "validFormId",
placeholder: "confirm password",
});
beforeEach(() => {
jest
.spyOn(inlineMenuFieldQualificationService, "isFieldForLoginForm")
.mockReturnValue(false);
});
it("stores fields account username fields", async () => {
const inputFieldElement = document.createElement(
"input",
) as ElementWithOpId<FillableFormFieldElement>;
pageDetailsMock.fields = [inputFieldData, passwordFieldData, confirmPasswordFieldData];
await autofillOverlayContentService.setupOverlayListeners(
inputFieldElement,
inputFieldData,
pageDetailsMock,
);
inputFieldElement.dispatchEvent(new Event("input"));
expect(autofillOverlayContentService["userFilledFields"].username).toEqual(
inputFieldElement,
);
});
it("stores new password fields", async () => {
const inputFieldElement = document.createElement(
"input",
) as ElementWithOpId<FillableFormFieldElement>;
pageDetailsMock.fields = [inputFieldData, passwordFieldData, confirmPasswordFieldData];
await autofillOverlayContentService.setupOverlayListeners(
inputFieldElement,
passwordFieldData,
pageDetailsMock,
);
inputFieldElement.dispatchEvent(new Event("input"));
expect(autofillOverlayContentService["userFilledFields"].newPassword).toEqual(
inputFieldElement,
);
});
});
});
describe("form field click event listener", () => {
@ -1088,8 +1062,6 @@ describe("AutofillOverlayContentService", () => {
it("skips triggering the handler logic if autofill is currently filling", async () => {
isFieldCurrentlyFillingSpy.mockResolvedValue(true);
autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement;
autofillOverlayContentService["inlineMenuVisibility"] =
AutofillOverlayVisibility.OnFieldFocus;
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
@ -1102,6 +1074,22 @@ describe("AutofillOverlayContentService", () => {
expect(updateMostRecentlyFocusedFieldSpy).not.toHaveBeenCalled();
});
it("triggers a re-collection of page details when the field is focused if a dom change has occurred", async () => {
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
autofillOverlayContentService.pageDetailsUpdateRequired = true;
autofillFieldElement.dispatchEvent(new Event("focus"));
await flushPromises();
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("bgCollectPageDetails", {
sender: "autofillOverlayContentService",
});
});
it("closes the inline menu if the focused element is a select element", async () => {
const selectFieldElement = document.createElement(
"select",
@ -1138,48 +1126,7 @@ describe("AutofillOverlayContentService", () => {
);
});
it("removes the overlay list if the autofill visibility is set to onClick", async () => {
autofillOverlayContentService["inlineMenuVisibility"] =
AutofillOverlayVisibility.OnButtonClick;
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
autofillFieldElement.dispatchEvent(new Event("focus"));
await flushPromises();
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
overlayElement: AutofillOverlayElement.List,
forceCloseInlineMenu: true,
});
});
it("removes the overlay list if the form element has a value and the focused field is newly focused", async () => {
autofillOverlayContentService["mostRecentlyFocusedField"] = document.createElement(
"input",
) as ElementWithOpId<HTMLInputElement>;
(autofillFieldElement as HTMLInputElement).value = "test";
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
autofillFieldElement.dispatchEvent(new Event("focus"));
await flushPromises();
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
overlayElement: AutofillOverlayElement.List,
forceCloseInlineMenu: true,
});
});
it("opens the autofill inline menu if the form element has no value", async () => {
(autofillFieldElement as HTMLInputElement).value = "";
autofillOverlayContentService["inlineMenuVisibility"] =
AutofillOverlayVisibility.OnFieldFocus;
it("opens the autofill inline menu ", async () => {
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
@ -1191,44 +1138,6 @@ describe("AutofillOverlayContentService", () => {
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("openAutofillInlineMenu");
});
it("opens the autofill inline menu if the overlay ciphers are not populated and the user is authed", async () => {
(autofillFieldElement as HTMLInputElement).value = "";
autofillOverlayContentService["inlineMenuVisibility"] =
AutofillOverlayVisibility.OnFieldFocus;
jest.spyOn(autofillOverlayContentService as any, "isUserAuthed").mockReturnValue(true);
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
autofillFieldElement.dispatchEvent(new Event("focus"));
await flushPromises();
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("openAutofillInlineMenu");
});
it("updates the overlay button position if the focus event is not opening the overlay", async () => {
autofillOverlayContentService["inlineMenuVisibility"] =
AutofillOverlayVisibility.OnFieldFocus;
(autofillFieldElement as HTMLInputElement).value = "test";
jest
.spyOn(autofillOverlayContentService as any, "isInlineMenuCiphersPopulated")
.mockReturnValue(true);
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
autofillFieldElement.dispatchEvent(new Event("focus"));
await flushPromises();
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateAutofillInlineMenuPosition", {
overlayElement: AutofillOverlayElement.Button,
});
});
});
describe("hidden form field focus event", () => {
@ -1273,7 +1182,7 @@ describe("AutofillOverlayContentService", () => {
autofillFieldData,
pageDetailsMock,
);
autofillOverlayContentService["formFieldElements"].delete(autofillFieldElement);
autofillOverlayContentService["hiddenFormFieldElements"].delete(autofillFieldElement);
autofillFieldElement.dispatchEvent(new Event("focus"));
@ -1415,10 +1324,10 @@ describe("AutofillOverlayContentService", () => {
elementNumber: 3,
autoCompleteType: "username",
placeholder: "new username",
type: "text",
type: "email",
viewable: true,
});
const passwordAccountFieldData = createAutofillFieldMock({
const newPasswordFieldData = createAutofillFieldMock({
opid: "create-account-password-field",
form: "validFormId",
elementNumber: 4,
@ -1429,13 +1338,13 @@ describe("AutofillOverlayContentService", () => {
});
beforeEach(() => {
pageDetailsMock.fields = [inputAccountFieldData, passwordAccountFieldData];
pageDetailsMock.fields = [inputAccountFieldData, newPasswordFieldData];
jest
.spyOn(inlineMenuFieldQualificationService, "isFieldForLoginForm")
.mockReturnValue(false);
});
it("sets up the field listeners on the field", async () => {
it("sets up the field listeners on a username account creation field", async () => {
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
inputAccountFieldData,
@ -1464,8 +1373,46 @@ describe("AutofillOverlayContentService", () => {
expect.any(Function),
);
expect(autofillFieldElement.removeEventListener).toHaveBeenCalled();
expect(inputAccountFieldData.filledByCipherType).toEqual(CipherType.Identity);
expect(inputAccountFieldData.showInlineMenuAccountCreation).toEqual(true);
expect(inputAccountFieldData.inlineMenuFillType).toEqual(
InlineMenuFillType.AccountCreationUsername,
);
});
it("sets up field a current password field within an update password form", async () => {
const currentPasswordFieldData = createAutofillFieldMock({
opid: "current-password-field",
form: "validFormId",
elementNumber: 5,
autoCompleteType: "current-password",
placeholder: "current password",
type: "password",
viewable: true,
});
const confirmNewPasswordFieldData = createAutofillFieldMock({
opid: "confirm-new-password-field",
form: "validFormId",
elementNumber: 6,
autoCompleteType: "new-password",
placeholder: "confirm new password",
type: "password",
viewable: true,
});
pageDetailsMock.fields = [
currentPasswordFieldData,
newPasswordFieldData,
confirmNewPasswordFieldData,
];
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
currentPasswordFieldData,
pageDetailsMock,
);
await flushPromises();
expect(currentPasswordFieldData.inlineMenuFillType).toEqual(
InlineMenuFillType.CurrentPasswordUpdate,
);
});
});
});
@ -1519,11 +1466,13 @@ describe("AutofillOverlayContentService", () => {
});
it("sends a `formFieldSubmitted` message to the background on interaction of a generic input element", async () => {
domElementVisibilityService.isElementViewable = jest.fn().mockReturnValue(true);
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
await flushPromises();
genericSubmitElement.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
@ -1532,6 +1481,59 @@ describe("AutofillOverlayContentService", () => {
);
});
});
describe("triggering submission trough interaction of a button element", () => {
let buttonElement: HTMLButtonElement;
beforeEach(() => {
buttonElement = document.createElement("button");
buttonElement.textContent = "Login In";
buttonElement.type = "button";
form.appendChild(buttonElement);
});
it("sends a `formFieldSubmitted` message to the background on interaction of a button element", async () => {
domElementVisibilityService.isElementViewable = jest.fn().mockReturnValue(true);
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
await flushPromises();
buttonElement.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
"formFieldSubmitted",
expect.any(Object),
);
});
});
describe("triggering submission through interaction of an anchor element", () => {
let anchorElement: HTMLAnchorElement;
beforeEach(() => {
anchorElement = document.createElement("a");
anchorElement.textContent = "Login In";
form.appendChild(anchorElement);
});
it("sends a `formFieldSubmitted` message to the background on interaction of an anchor element", async () => {
domElementVisibilityService.isElementViewable = jest.fn().mockReturnValue(true);
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
await flushPromises();
anchorElement.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
"formFieldSubmitted",
expect.any(Object),
);
});
});
});
describe("listeners set up on a fields without a form", () => {
@ -1596,12 +1598,14 @@ describe("AutofillOverlayContentService", () => {
});
it("triggers submission through interaction of a submit button", async () => {
domElementVisibilityService.isElementViewable = jest.fn().mockReturnValue(true);
const submitButton = document.querySelector("button");
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
await flushPromises();
submitButton.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
@ -1611,6 +1615,7 @@ describe("AutofillOverlayContentService", () => {
});
it("captures submit buttons when the field is structured within a shadow DOM", async () => {
domElementVisibilityService.isElementViewable = jest.fn().mockReturnValue(true);
document.body.innerHTML = `<div id="form-div">
<div id="shadow-root"></div>
<button id="button-el">Change Password</button>
@ -1641,6 +1646,7 @@ describe("AutofillOverlayContentService", () => {
autofillFieldData,
pageDetailsMock,
);
await flushPromises();
buttonElement.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
@ -1687,34 +1693,6 @@ describe("AutofillOverlayContentService", () => {
autofillFieldElement,
);
});
it("sets the most recently focused field to the passed form field element if the value is not set", async () => {
autofillOverlayContentService["mostRecentlyFocusedField"] = undefined;
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
expect(autofillOverlayContentService["mostRecentlyFocusedField"]).toEqual(
autofillFieldElement,
);
});
});
describe("focusMostRecentlyFocusedField", () => {
it("focuses the most recently focused overlay field", () => {
const mostRecentlyFocusedField = document.createElement(
"input",
) as ElementWithOpId<HTMLInputElement>;
autofillOverlayContentService["mostRecentlyFocusedField"] = mostRecentlyFocusedField;
jest.spyOn(mostRecentlyFocusedField, "focus");
autofillOverlayContentService["focusMostRecentlyFocusedField"]();
expect(mostRecentlyFocusedField.focus).toHaveBeenCalled();
});
});
describe("handleOverlayRepositionEvent", () => {
@ -1758,144 +1736,6 @@ describe("AutofillOverlayContentService", () => {
});
describe("extension onMessage handlers", () => {
describe("openAutofillInlineMenu message handler", () => {
let autofillFieldElement: ElementWithOpId<FormFieldElement>;
beforeEach(() => {
document.body.innerHTML = `
<form id="validFormId">
<input type="text" id="username-field" placeholder="username" />
<input type="password" id="password-field" placeholder="password" />
</form>
`;
autofillFieldElement = document.getElementById(
"username-field",
) as ElementWithOpId<FormFieldElement>;
autofillFieldElement.opid = "op-1";
autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement;
});
it("skips opening the overlay if a field has not been recently focused", () => {
autofillOverlayContentService["mostRecentlyFocusedField"] = undefined;
sendMockExtensionMessage({ command: "openAutofillInlineMenu" });
expect(sendExtensionMessageSpy).not.toHaveBeenCalled();
});
it("focuses the most recent overlay field if the field is not focused", () => {
jest.spyOn(autofillFieldElement, "getRootNode").mockReturnValue(document);
Object.defineProperty(document, "activeElement", {
value: document.createElement("div"),
writable: true,
});
const focusMostRecentOverlayFieldSpy = jest.spyOn(
autofillOverlayContentService as any,
"focusMostRecentlyFocusedField",
);
sendMockExtensionMessage({
command: "openAutofillInlineMenu",
isFocusingFieldElement: true,
});
expect(focusMostRecentOverlayFieldSpy).toHaveBeenCalled();
});
it("skips focusing the most recent overlay field if the field is already focused", () => {
jest.spyOn(autofillFieldElement, "getRootNode").mockReturnValue(document);
Object.defineProperty(document, "activeElement", {
value: autofillFieldElement,
writable: true,
});
const focusMostRecentOverlayFieldSpy = jest.spyOn(
autofillOverlayContentService as any,
"focusMostRecentlyFocusedField",
);
sendMockExtensionMessage({
command: "openAutofillInlineMenu",
isFocusingFieldElement: true,
});
expect(focusMostRecentOverlayFieldSpy).not.toHaveBeenCalled();
});
it("stores the user's auth status", () => {
autofillOverlayContentService["authStatus"] = undefined;
sendMockExtensionMessage({
command: "openAutofillInlineMenu",
authStatus: AuthenticationStatus.Unlocked,
});
expect(autofillOverlayContentService["authStatus"]).toEqual(AuthenticationStatus.Unlocked);
});
it("opens both autofill inline menu elements", () => {
autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement;
sendMockExtensionMessage({ command: "openAutofillInlineMenu" });
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateAutofillInlineMenuPosition", {
overlayElement: AutofillOverlayElement.Button,
});
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateAutofillInlineMenuPosition", {
overlayElement: AutofillOverlayElement.List,
});
});
it("opens the autofill inline menu button only if overlay visibility is set for onButtonClick", () => {
autofillOverlayContentService["inlineMenuVisibility"] =
AutofillOverlayVisibility.OnButtonClick;
sendMockExtensionMessage({
command: "openAutofillInlineMenu",
isOpeningFullInlineMenu: false,
});
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateAutofillInlineMenuPosition", {
overlayElement: AutofillOverlayElement.Button,
});
expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith(
"updateAutofillInlineMenuPosition",
{
overlayElement: AutofillOverlayElement.List,
},
);
});
it("overrides the onButtonClick visibility setting to open both overlay elements", () => {
autofillOverlayContentService["inlineMenuVisibility"] =
AutofillOverlayVisibility.OnButtonClick;
sendMockExtensionMessage({
command: "openAutofillInlineMenu",
isOpeningFullInlineMenu: true,
});
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateAutofillInlineMenuPosition", {
overlayElement: AutofillOverlayElement.Button,
});
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateAutofillInlineMenuPosition", {
overlayElement: AutofillOverlayElement.List,
});
});
it("sends an extension message requesting an re-collection of page details if they need to update", () => {
jest.spyOn(autofillOverlayContentService as any, "sendExtensionMessage");
autofillOverlayContentService.pageDetailsUpdateRequired = true;
autofillOverlayContentService["openInlineMenu"]();
sendMockExtensionMessage({ command: "openAutofillInlineMenu" });
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("bgCollectPageDetails", {
sender: "autofillOverlayContentService",
});
});
});
describe("addNewVaultItemFromOverlay message handler", () => {
it("skips sending the message if the overlay list is not visible", async () => {
jest
@ -2052,7 +1892,7 @@ describe("AutofillOverlayContentService", () => {
autofillOverlayContentService["focusedFieldData"] = {
focusedFieldStyles: { paddingRight: "10", paddingLeft: "10" },
focusedFieldRects: { width: 10, height: 10, top: 10, left: 10 },
filledByCipherType: CipherType.Login,
inlineMenuFillType: CipherType.Login,
};
});
@ -2070,6 +1910,20 @@ describe("AutofillOverlayContentService", () => {
});
});
describe("focusMostRecentlyFocusedField message handler", () => {
it("focuses the most recently focused field", async () => {
autofillOverlayContentService["mostRecentlyFocusedField"] =
mock<ElementWithOpId<FormFieldElement>>();
sendMockExtensionMessage({
command: "focusMostRecentlyFocusedField",
});
await flushPromises();
expect(autofillOverlayContentService["mostRecentlyFocusedField"].focus).toHaveBeenCalled();
});
});
describe("messages that trigger a blur of the most recently focused field", () => {
const messages = [
"blurMostRecentlyFocusedField",
@ -2088,7 +1942,9 @@ describe("AutofillOverlayContentService", () => {
expect(autofillOverlayContentService["mostRecentlyFocusedField"].blur).toHaveBeenCalled();
if (isClosingInlineMenu) {
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu");
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
forceCloseInlineMenu: true,
});
}
});
});
@ -2234,19 +2090,6 @@ describe("AutofillOverlayContentService", () => {
});
});
describe("updateAutofillInlineMenuVisibility message handler", () => {
it("updates the inlineMenuVisibility property", () => {
sendMockExtensionMessage({
command: "updateAutofillInlineMenuVisibility",
data: { newSettingValue: AutofillOverlayVisibility.OnButtonClick },
});
expect(autofillOverlayContentService["inlineMenuVisibility"]).toEqual(
AutofillOverlayVisibility.OnButtonClick,
);
});
});
describe("getSubFrameOffsets message handler", () => {
const iframeSource = "https://example.com/";
const originalLocation = globalThis.location;
@ -2578,14 +2421,14 @@ describe("AutofillOverlayContentService", () => {
});
});
describe("getFormFieldDataForNotification message handler", () => {
describe("getInlineMenuFormFieldData message handler", () => {
it("returns early if a field is currently focused", async () => {
jest
.spyOn(autofillOverlayContentService as any, "isFieldCurrentlyFocused")
.mockReturnValue(true);
sendMockExtensionMessage(
{ command: "getFormFieldDataForNotification" },
{ command: "getInlineMenuFormFieldData" },
mock<chrome.runtime.MessageSender>(),
sendResponseSpy,
);
@ -2596,7 +2439,7 @@ describe("AutofillOverlayContentService", () => {
it("returns the form field data for a notification", async () => {
sendMockExtensionMessage(
{ command: "getFormFieldDataForNotification" },
{ command: "getInlineMenuFormFieldData" },
mock<chrome.runtime.MessageSender>(),
sendResponseSpy,
);

View File

@ -2,15 +2,12 @@ import "@webcomponents/custom-elements";
import "lit/polyfill-support.js";
import { FocusableElement, tabbable } from "tabbable";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import {
EVENTS,
AutofillOverlayVisibility,
AUTOFILL_OVERLAY_HANDLE_REPOSITION,
AUTOFILL_TRIGGER_FORM_FIELD_SUBMIT,
AUTOFILL_OVERLAY_HANDLE_SCROLL,
} from "@bitwarden/common/autofill/constants";
import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
import { CipherType } from "@bitwarden/common/vault/enums";
import {
@ -24,6 +21,8 @@ import { AutofillExtensionMessage } from "../content/abstractions/autofill-init"
import { AutofillFieldQualifier, AutofillFieldQualifierType } from "../enums/autofill-field.enums";
import {
AutofillOverlayElement,
InlineMenuAccountCreationFieldType,
InlineMenuFillType,
MAX_SUB_FRAME_DEPTH,
RedirectFocusDirection,
} from "../enums/autofill-overlay.enum";
@ -31,9 +30,12 @@ import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details";
import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types";
import {
currentlyInSandboxedIframe,
debounce,
elementIsFillableFormField,
elementIsSelectElement,
getAttributeBoolean,
nodeIsAnchorElement,
nodeIsButtonElement,
nodeIsTypeSubmitElement,
sendExtensionMessage,
@ -43,17 +45,16 @@ import {
import {
AutofillOverlayContentExtensionMessageHandlers,
AutofillOverlayContentService as AutofillOverlayContentServiceInterface,
NotificationFormFieldData,
OpenAutofillInlineMenuOptions,
InlineMenuFormFieldData,
SubFrameDataFromWindowMessage,
} from "./abstractions/autofill-overlay-content.service";
import { DomElementVisibilityService } from "./abstractions/dom-element-visibility.service";
import { DomQueryService } from "./abstractions/dom-query.service";
import { InlineMenuFieldQualificationService } from "./abstractions/inline-menu-field-qualifications.service";
import { AutoFillConstants } from "./autofill-constants";
export class AutofillOverlayContentService implements AutofillOverlayContentServiceInterface {
pageDetailsUpdateRequired = false;
inlineMenuVisibility: InlineMenuVisibilitySetting;
private showInlineMenuIdentities: boolean;
private showInlineMenuCards: boolean;
private readonly findTabs = tabbable;
@ -66,7 +67,6 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
private fieldsWithSubmitElements: WeakMap<FillableFormFieldElement, HTMLElement> = new WeakMap();
private ignoredFieldTypes: Set<string> = new Set(AutoFillConstants.ExcludedInlineMenuTypes);
private userFilledFields: Record<string, FillableFormFieldElement> = {};
private authStatus: AuthenticationStatus;
private focusableElements: FocusableElement[] = [];
private mostRecentlyFocusedField: ElementWithOpId<FormFieldElement>;
private focusedFieldData: FocusedFieldData;
@ -74,8 +74,8 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
private focusInlineMenuListTimeout: number | NodeJS.Timeout;
private eventHandlersMemo: { [key: string]: EventListener } = {};
private readonly extensionMessageHandlers: AutofillOverlayContentExtensionMessageHandlers = {
openAutofillInlineMenu: ({ message }) => this.openInlineMenu(message),
addNewVaultItemFromOverlay: ({ message }) => this.addNewVaultItem(message),
focusMostRecentlyFocusedField: () => this.focusMostRecentlyFocusedField(),
blurMostRecentlyFocusedField: () => this.blurMostRecentlyFocusedField(),
unsetMostRecentlyFocusedField: () => this.unsetMostRecentlyFocusedField(),
checkIsMostRecentlyFocusedFieldWithinViewport: () =>
@ -84,20 +84,25 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
bgVaultItemRepromptPopoutOpened: () => this.blurMostRecentlyFocusedField(true),
redirectAutofillInlineMenuFocusOut: ({ message }) =>
this.redirectInlineMenuFocusOut(message?.data?.direction),
updateAutofillInlineMenuVisibility: ({ message }) => this.updateInlineMenuVisibility(message),
getSubFrameOffsets: ({ message }) => this.getSubFrameOffsets(message),
getSubFrameOffsetsFromWindowMessage: ({ message }) =>
this.getSubFrameOffsetsFromWindowMessage(message),
checkMostRecentlyFocusedFieldHasValue: () => this.mostRecentlyFocusedFieldHasValue(),
setupRebuildSubFrameOffsetsListeners: () => this.setupRebuildSubFrameOffsetsListeners(),
destroyAutofillInlineMenuListeners: () => this.destroy(),
getFormFieldDataForNotification: () => this.handleGetFormFieldDataForNotificationMessage(),
getInlineMenuFormFieldData: ({ message }) =>
this.handleGetInlineMenuFormFieldDataMessage(message),
};
private readonly loginFieldQualifiers: Record<string, CallableFunction> = {
[AutofillFieldQualifier.username]: this.inlineMenuFieldQualificationService.isUsernameField,
[AutofillFieldQualifier.password]:
this.inlineMenuFieldQualificationService.isCurrentPasswordField,
};
private readonly accountCreationFieldQualifiers: Record<string, CallableFunction> = {
[AutofillFieldQualifier.username]: this.inlineMenuFieldQualificationService.isUsernameField,
[AutofillFieldQualifier.newPassword]:
this.inlineMenuFieldQualificationService.isNewPasswordField,
};
private readonly cardFieldQualifiers: Record<string, CallableFunction> = {
[AutofillFieldQualifier.cardholderName]:
this.inlineMenuFieldQualificationService.isFieldForCardholderName,
@ -144,12 +149,11 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
this.inlineMenuFieldQualificationService.isFieldForIdentityEmail,
[AutofillFieldQualifier.identityUsername]:
this.inlineMenuFieldQualificationService.isFieldForIdentityUsername,
[AutofillFieldQualifier.newPassword]:
this.inlineMenuFieldQualificationService.isNewPasswordField,
};
constructor(
private domQueryService: DomQueryService,
private domElementVisibilityService: DomElementVisibilityService,
private inlineMenuFieldQualificationService: InlineMenuFieldQualificationService,
) {}
@ -158,6 +162,9 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
* The observers will be instantiated on DOMContentLoaded if the page is current loading.
*/
init() {
void this.getInlineMenuCardsVisibility();
void this.getInlineMenuIdentitiesVisibility();
if (globalThis.document.readyState === "loading") {
globalThis.document.addEventListener(EVENTS.DOMCONTENTLOADED, this.setupGlobalEventListeners);
return;
@ -187,19 +194,8 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
autofillFieldData: AutofillField,
pageDetails: AutofillPageDetails,
) {
if (!this.inlineMenuVisibility) {
await this.getInlineMenuVisibility();
}
if (this.showInlineMenuCards == null) {
await this.getInlineMenuCardsVisibility();
}
if (this.showInlineMenuIdentities == null) {
await this.getInlineMenuIdentitiesVisibility();
}
if (
currentlyInSandboxedIframe() ||
this.formFieldElements.has(formFieldElement) ||
this.isIgnoredField(autofillFieldData, pageDetails)
) {
@ -213,76 +209,33 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
await this.setupOverlayListenersOnQualifiedField(formFieldElement, autofillFieldData);
}
/**
* Handles opening the autofill inline menu. Will conditionally open
* the inline menu based on the current inline menu visibility setting.
* Allows you to optionally focus the field element when opening the inline menu.
* Will also optionally ignore the inline menu visibility setting and open the
*
* @param options - Options for opening the autofill inline menu.
*/
openInlineMenu(options: OpenAutofillInlineMenuOptions = {}) {
const { isFocusingFieldElement, isOpeningFullInlineMenu, authStatus } = options;
if (!this.mostRecentlyFocusedField) {
return;
}
if (this.pageDetailsUpdateRequired) {
void this.sendExtensionMessage("bgCollectPageDetails", {
sender: "autofillOverlayContentService",
});
this.pageDetailsUpdateRequired = false;
}
if (isFocusingFieldElement && !this.recentlyFocusedFieldIsCurrentlyFocused()) {
this.focusMostRecentlyFocusedField();
}
if (typeof authStatus !== "undefined") {
this.authStatus = authStatus;
}
if (
this.inlineMenuVisibility === AutofillOverlayVisibility.OnButtonClick &&
!isOpeningFullInlineMenu
) {
this.updateInlineMenuButtonPosition();
return;
}
this.updateInlineMenuElementsPosition();
}
/**
* Focuses the most recently focused field element.
*/
focusMostRecentlyFocusedField() {
this.mostRecentlyFocusedField?.focus();
}
/**
* Removes focus from the most recently focused field element.
*/
blurMostRecentlyFocusedField(isClosingInlineMenu: boolean = false) {
async blurMostRecentlyFocusedField(isClosingInlineMenu: boolean = false) {
this.mostRecentlyFocusedField?.blur();
if (isClosingInlineMenu) {
void this.sendExtensionMessage("closeAutofillInlineMenu");
await this.sendExtensionMessage("closeAutofillInlineMenu", { forceCloseInlineMenu: true });
}
}
/**
* Sets the most recently focused field within the current frame to a `null` value.
* Clears all cached user filled fields.
*/
unsetMostRecentlyFocusedField() {
this.mostRecentlyFocusedField = null;
clearUserFilledFields() {
Object.keys(this.userFilledFields).forEach((key) => {
if (this.userFilledFields[key]) {
delete this.userFilledFields[key];
}
});
}
/**
* Formats any found user filled fields for a login cipher and sends a message
* to the background script to add a new cipher.
*/
async addNewVaultItem({ addNewCipherType }: AutofillExtensionMessage) {
private async addNewVaultItem({ addNewCipherType }: AutofillExtensionMessage) {
const command = "autofillOverlayAddNewVaultItem";
const password =
this.userFilledFields["newPassword"]?.value || this.userFilledFields["password"]?.value;
@ -295,7 +248,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
hostname: globalThis.document.location.hostname,
};
void this.sendExtensionMessage(command, { addNewCipherType, login });
await this.sendExtensionMessage(command, { addNewCipherType, login });
return;
}
@ -310,7 +263,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
cvv: this.userFilledFields["cardCvv"]?.value || "",
};
void this.sendExtensionMessage(command, { addNewCipherType, card });
await this.sendExtensionMessage(command, { addNewCipherType, card });
return;
}
@ -335,10 +288,24 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
username: this.userFilledFields["identityUsername"]?.value || "",
};
void this.sendExtensionMessage(command, { addNewCipherType, identity });
await this.sendExtensionMessage(command, { addNewCipherType, identity });
}
}
/**
* Focuses the most recently focused field element.
*/
private focusMostRecentlyFocusedField() {
this.mostRecentlyFocusedField?.focus();
}
/**
* Sets the most recently focused field within the current frame to a `null` value.
*/
private unsetMostRecentlyFocusedField() {
this.mostRecentlyFocusedField = null;
}
/**
* Redirects the keyboard focus out of the inline menu, selecting the element that is
* either previous or next in the tab order. If the direction is current, the most
@ -436,23 +403,23 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
* @param formFieldElement - The form field element to set up the submit button listeners for.
* @param autofillFieldData - Autofill field data captured from the form field element.
*/
private setupFormSubmissionEventListeners(
private async setupFormSubmissionEventListeners(
formFieldElement: ElementWithOpId<FormFieldElement>,
autofillFieldData: AutofillField,
) {
if (
!elementIsFillableFormField(formFieldElement) ||
autofillFieldData.filledByCipherType === CipherType.Card
autofillFieldData.inlineMenuFillType === CipherType.Card
) {
return;
}
if (autofillFieldData.form) {
this.setupSubmitListenerOnFieldWithForms(formFieldElement);
await this.setupSubmitListenerOnFieldWithForms(formFieldElement);
return;
}
this.setupSubmitListenerOnFormlessField(formFieldElement);
await this.setupSubmitListenerOnFormlessField(formFieldElement);
}
/**
@ -462,13 +429,20 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
*
* @param formFieldElement - The form field element to set up the submit listener for.
*/
private setupSubmitListenerOnFieldWithForms(formFieldElement: FillableFormFieldElement) {
private async setupSubmitListenerOnFieldWithForms(formFieldElement: FillableFormFieldElement) {
const formElement = formFieldElement.form;
if (formElement && !this.formElements.has(formElement)) {
this.formElements.add(formElement);
formElement.addEventListener(EVENTS.SUBMIT, this.handleFormFieldSubmitEvent);
const closesSubmitButton = this.findSubmitButton(formElement);
const closesSubmitButton = await this.findSubmitButton(formElement);
// If we cannot find a submit button within the form, check for a submit button outside the form.
if (!closesSubmitButton) {
await this.setupSubmitListenerOnFormlessField(formFieldElement);
return;
}
this.setupSubmitButtonEventListeners(closesSubmitButton);
}
}
@ -479,9 +453,9 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
*
* @param formFieldElement - The form field element to set up the submit listener for.
*/
private setupSubmitListenerOnFormlessField(formFieldElement: FillableFormFieldElement) {
private async setupSubmitListenerOnFormlessField(formFieldElement: FillableFormFieldElement) {
if (formFieldElement && !this.fieldsWithSubmitElements.has(formFieldElement)) {
const closesSubmitButton = this.findClosestFormlessSubmitButton(formFieldElement);
const closesSubmitButton = await this.findClosestFormlessSubmitButton(formFieldElement);
this.setupSubmitButtonEventListeners(closesSubmitButton);
}
}
@ -491,13 +465,13 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
*
* @param formFieldElement - The form field element to find the closest formless submit button for.
*/
private findClosestFormlessSubmitButton(
private async findClosestFormlessSubmitButton(
formFieldElement: FillableFormFieldElement,
): HTMLElement | null {
): Promise<HTMLElement | null> {
let currentElement: HTMLElement = formFieldElement;
while (currentElement && currentElement.tagName !== "HTML") {
const submitButton = this.findSubmitButton(currentElement);
const submitButton = await this.findSubmitButton(currentElement);
if (submitButton) {
this.formFieldElements.forEach((_, element) => {
if (currentElement.contains(element)) {
@ -525,8 +499,8 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
*
* @param element - The element to find the submit button within.
*/
private findSubmitButton(element: HTMLElement): HTMLElement | null {
const genericSubmitElement = this.querySubmitButtonElement(
private async findSubmitButton(element: HTMLElement): Promise<HTMLElement | null> {
const genericSubmitElement = await this.querySubmitButtonElement(
element,
"[type='submit']",
(node: Node) => nodeIsTypeSubmitElement(node),
@ -535,7 +509,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
return genericSubmitElement;
}
const submitButtonElement = this.querySubmitButtonElement(
const submitButtonElement = await this.querySubmitButtonElement(
element,
"button, [type='button']",
(node: Node) => nodeIsButtonElement(node),
@ -543,6 +517,14 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
if (submitButtonElement) {
return submitButtonElement;
}
// If the submit button is not a traditional button element, check for an anchor element that contains submission keywords.
const submitAnchorElement = await this.querySubmitButtonElement(element, "a", (node: Node) =>
nodeIsAnchorElement(node),
);
if (submitAnchorElement) {
return submitAnchorElement;
}
}
/**
@ -552,7 +534,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
* @param selector - The selector to use to query the element for a submit button.
* @param treeWalkerFilter - The tree walker filter to use when querying the element.
*/
private querySubmitButtonElement(
private async querySubmitButtonElement(
element: HTMLElement,
selector: string,
treeWalkerFilter: CallableFunction,
@ -564,7 +546,10 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
);
for (let index = 0; index < submitButtonElements.length; index++) {
const submitElement = submitButtonElements[index];
if (this.isElementSubmitButton(submitElement)) {
if (
this.isElementSubmitButton(submitElement) &&
(await this.domElementVisibilityService.isElementViewable(submitElement))
) {
return submitElement;
}
}
@ -624,26 +609,27 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
* Handles the repositioning of the autofill overlay when the form is submitted.
*/
private handleFormFieldSubmitEvent = () => {
void this.sendExtensionMessage("formFieldSubmitted", this.getFormFieldDataForNotification());
void this.sendExtensionMessage("formFieldSubmitted", this.getFormFieldData());
};
/**
* Handles capturing the form field data for a notification message. Is triggered from the
* background script when a POST request is encountered. Will not trigger this behavior
* in the case where the user is still typing in the field.
* Handles capturing the form field data for a notification message. Will not trigger this behavior
* in the case where the user is still typing in the field unless the focus is ignored.
*/
private handleGetFormFieldDataForNotificationMessage = async () => {
if (await this.isFieldCurrentlyFocused()) {
private handleGetInlineMenuFormFieldDataMessage = async ({
ignoreFieldFocus,
}: AutofillExtensionMessage) => {
if (!ignoreFieldFocus && (await this.isFieldCurrentlyFocused())) {
return;
}
return this.getFormFieldDataForNotification();
return this.getFormFieldData();
};
/**
* Returns the form field data used for add login and change password notifications.
*/
private getFormFieldDataForNotification = (): NotificationFormFieldData => {
private getFormFieldData = (): InlineMenuFormFieldData => {
return {
uri: globalThis.document.URL,
username: this.userFilledFields["username"]?.value || "",
@ -681,9 +667,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
* is currently focused.
*/
private handleFormFieldBlurEvent = () => {
void this.sendExtensionMessage("updateIsFieldCurrentlyFocused", {
isFieldCurrentlyFocused: false,
});
void this.updateIsFieldCurrentlyFocused(false);
void this.sendExtensionMessage("checkAutofillInlineMenuFocused");
};
@ -726,7 +710,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
if (this.mostRecentlyFocusedField && !(await this.isInlineMenuListVisible())) {
this.clearFocusInlineMenuListTimeout();
await this.updateMostRecentlyFocusedField(this.mostRecentlyFocusedField);
this.openInlineMenu({ isOpeningFullInlineMenu: true });
await this.sendExtensionMessage("openAutofillInlineMenu", { isOpeningFullInlineMenu: true });
this.focusInlineMenuListTimeout = globalThis.setTimeout(
() => this.sendExtensionMessage("focusAutofillInlineMenuList"),
125,
@ -744,7 +728,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
*/
private handleFormFieldInputEvent = (formFieldElement: ElementWithOpId<FormFieldElement>) => {
return this.useEventHandlersMemo(
() => this.triggerFormFieldInput(formFieldElement),
debounce(() => this.triggerFormFieldInput(formFieldElement), 100, true),
this.getFormFieldHandlerMemoIndex(formFieldElement, EVENTS.INPUT),
);
};
@ -766,15 +750,14 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
return;
}
if (await this.hideInlineMenuListOnFilledField(formFieldElement)) {
void this.sendExtensionMessage("closeAutofillInlineMenu", {
overlayElement: AutofillOverlayElement.List,
forceCloseInlineMenu: true,
});
return;
}
await this.sendExtensionMessage("closeAutofillInlineMenu", {
overlayElement: AutofillOverlayElement.List,
forceCloseInlineMenu: true,
});
this.openInlineMenu();
if (!formFieldElement?.value) {
await this.sendExtensionMessage("openAutofillInlineMenu");
}
}
/**
@ -796,15 +779,20 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
}
if (!autofillFieldData.fieldQualifier) {
switch (autofillFieldData.filledByCipherType) {
switch (autofillFieldData.inlineMenuFillType) {
case CipherType.Login:
this.qualifyUserFilledLoginField(autofillFieldData);
case InlineMenuFillType.CurrentPasswordUpdate:
this.qualifyUserFilledField(autofillFieldData, this.loginFieldQualifiers);
break;
case InlineMenuFillType.AccountCreationUsername:
case InlineMenuFillType.PasswordGeneration:
this.qualifyUserFilledField(autofillFieldData, this.accountCreationFieldQualifiers);
break;
case CipherType.Card:
this.qualifyUserFilledCardField(autofillFieldData);
this.qualifyUserFilledField(autofillFieldData, this.cardFieldQualifiers);
break;
case CipherType.Identity:
this.qualifyUserFilledIdentityField(autofillFieldData);
this.qualifyUserFilledField(autofillFieldData, this.identityFieldQualifiers);
break;
}
}
@ -813,52 +801,22 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
}
/**
* Handles qualifying the user field login field to be used when adding a new vault item.
* Handles qualification of the user filled field based on the field qualifiers provided.
*
* @param autofillFieldData - Autofill field data captured from the form field element.
* @param qualifiers - The field qualifiers to use when qualifying the user filled field.
*/
private qualifyUserFilledLoginField(autofillFieldData: AutofillField) {
for (const [fieldQualifier, fieldQualifierFunction] of Object.entries(
this.loginFieldQualifiers,
)) {
private qualifyUserFilledField = (
autofillFieldData: AutofillField,
qualifiers: Record<string, CallableFunction>,
) => {
for (const [fieldQualifier, fieldQualifierFunction] of Object.entries(qualifiers)) {
if (fieldQualifierFunction(autofillFieldData)) {
autofillFieldData.fieldQualifier = fieldQualifier as AutofillFieldQualifierType;
return;
}
}
}
/**
* Handles qualifying the user field card field to be used when adding a new vault item.
*
* @param autofillFieldData - Autofill field data captured from the form field element.
*/
private qualifyUserFilledCardField(autofillFieldData: AutofillField) {
for (const [fieldQualifier, fieldQualifierFunction] of Object.entries(
this.cardFieldQualifiers,
)) {
if (fieldQualifierFunction(autofillFieldData)) {
autofillFieldData.fieldQualifier = fieldQualifier as AutofillFieldQualifierType;
return;
}
}
}
/**
* Handles qualifying the user field identity field to be used when adding a new vault item.
*
* @param autofillFieldData - Autofill field data captured from the form field element.
*/
private qualifyUserFilledIdentityField(autofillFieldData: AutofillField) {
for (const [fieldQualifier, fieldQualifierFunction] of Object.entries(
this.identityFieldQualifiers,
)) {
if (fieldQualifierFunction(autofillFieldData)) {
autofillFieldData.fieldQualifier = fieldQualifier as AutofillFieldQualifierType;
return;
}
}
}
};
/**
* Stores the qualified user filled filed to allow for referencing its value when adding a new vault item.
@ -936,6 +894,13 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
return;
}
if (this.pageDetailsUpdateRequired) {
await this.sendExtensionMessage("bgCollectPageDetails", {
sender: "autofillOverlayContentService",
});
this.pageDetailsUpdateRequired = false;
}
if (elementIsSelectElement(formFieldElement)) {
await this.sendExtensionMessage("closeAutofillInlineMenu", {
forceCloseInlineMenu: true,
@ -943,75 +908,19 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
return;
}
await this.sendExtensionMessage("updateIsFieldCurrentlyFocused", {
isFieldCurrentlyFocused: true,
});
const initiallyFocusedField = this.mostRecentlyFocusedField;
await this.updateIsFieldCurrentlyFocused(true);
await this.updateMostRecentlyFocusedField(formFieldElement);
const hideInlineMenuListOnFilledField = await this.hideInlineMenuListOnFilledField(
formFieldElement as FillableFormFieldElement,
);
if (
this.inlineMenuVisibility === AutofillOverlayVisibility.OnButtonClick ||
(initiallyFocusedField !== this.mostRecentlyFocusedField && hideInlineMenuListOnFilledField)
) {
await this.sendExtensionMessage("closeAutofillInlineMenu", {
overlayElement: AutofillOverlayElement.List,
forceCloseInlineMenu: true,
});
}
if (hideInlineMenuListOnFilledField) {
this.updateInlineMenuButtonPosition();
return;
}
void this.sendExtensionMessage("openAutofillInlineMenu");
await this.sendExtensionMessage("openAutofillInlineMenu");
}
/**
* Validates whether the user is currently authenticated.
* Triggers an update in the background script focused status of the form field element.
*
* @param isFieldCurrentlyFocused - The focused status of the form field element.
*/
private isUserAuthed() {
return this.authStatus === AuthenticationStatus.Unlocked;
}
/**
* Validates that the most recently focused field is currently
* focused within the root node relative to the field.
*/
private recentlyFocusedFieldIsCurrentlyFocused() {
return (
this.getRootNodeActiveElement(this.mostRecentlyFocusedField) === this.mostRecentlyFocusedField
);
}
/**
* Updates the position of both the inline menu button and list.
*/
private updateInlineMenuElementsPosition() {
this.updateInlineMenuButtonPosition();
this.updateInlineMenuListPosition();
}
/**
* Updates the position of the inline menu button.
*/
private updateInlineMenuButtonPosition() {
void this.sendExtensionMessage("updateAutofillInlineMenuPosition", {
overlayElement: AutofillOverlayElement.Button,
});
}
/**
* Updates the position of the inline menu list.
*/
private updateInlineMenuListPosition() {
void this.sendExtensionMessage("updateAutofillInlineMenuPosition", {
overlayElement: AutofillOverlayElement.List,
});
}
private updateIsFieldCurrentlyFocused = async (isFieldCurrentlyFocused: boolean) => {
await this.sendExtensionMessage("updateIsFieldCurrentlyFocused", { isFieldCurrentlyFocused });
};
/**
* Updates the data used to position the inline menu elements in relation
@ -1036,31 +945,12 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
await this.getMostRecentlyFocusedFieldRects(formFieldElement);
const autofillFieldData = this.formFieldElements.get(formFieldElement);
let accountCreationFieldType = null;
if (
// user setting allows display of identities in inline menu
this.showInlineMenuIdentities &&
// `showInlineMenuAccountCreation` has been set or field is filled by Login cipher
(autofillFieldData?.showInlineMenuAccountCreation ||
autofillFieldData?.filledByCipherType === CipherType.Login) &&
// field is a username field, which is relevant to both Identity and Login ciphers
this.inlineMenuFieldQualificationService.isUsernameField(autofillFieldData)
) {
accountCreationFieldType = this.inlineMenuFieldQualificationService.isEmailField(
autofillFieldData,
)
? "email"
: autofillFieldData.type;
}
this.focusedFieldData = {
focusedFieldStyles: { paddingRight, paddingLeft },
focusedFieldRects: { width, height, top, left },
filledByCipherType: autofillFieldData?.filledByCipherType,
showInlineMenuAccountCreation: autofillFieldData?.showInlineMenuAccountCreation,
inlineMenuFillType: autofillFieldData?.inlineMenuFillType,
showPasskeys: !!autofillFieldData?.showPasskeys,
accountCreationFieldType,
accountCreationFieldType: autofillFieldData?.accountCreationFieldType,
};
await this.sendExtensionMessage("updateFocusedFieldData", {
@ -1141,8 +1031,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
if (
this.inlineMenuFieldQualificationService.isFieldForLoginForm(autofillFieldData, pageDetails)
) {
autofillFieldData.filledByCipherType = CipherType.Login;
autofillFieldData.showPasskeys = autofillFieldData.autoCompleteType.includes("webauthn");
void this.setQualifiedLoginFillType(autofillFieldData);
return false;
}
@ -1153,7 +1042,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
pageDetails,
)
) {
autofillFieldData.filledByCipherType = CipherType.Card;
autofillFieldData.inlineMenuFillType = CipherType.Card;
return false;
}
@ -1163,8 +1052,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
pageDetails,
)
) {
autofillFieldData.filledByCipherType = CipherType.Identity;
autofillFieldData.showInlineMenuAccountCreation = true;
this.setQualifiedAccountCreationFillType(autofillFieldData);
return false;
}
@ -1175,13 +1063,71 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
pageDetails,
)
) {
autofillFieldData.filledByCipherType = CipherType.Identity;
autofillFieldData.inlineMenuFillType = CipherType.Identity;
return false;
}
return true;
}
/**
* Sets the autofill field data that indicates this field is part of a login form
*
* @param autofillFieldData - Autofill field data captured from the form field element.
*/
private async setQualifiedLoginFillType(autofillFieldData: AutofillField) {
autofillFieldData.inlineMenuFillType = CipherType.Login;
autofillFieldData.showPasskeys = autofillFieldData.autoCompleteType.includes("webauthn");
this.qualifyAccountCreationFieldType(autofillFieldData);
}
/**
* Sets the autofill field data that indicates this field is part of an account creation or update form.
*
* @param autofillFieldData - Autofill field data captured from the form field element.
*/
private setQualifiedAccountCreationFillType(autofillFieldData: AutofillField) {
if (this.inlineMenuFieldQualificationService.isNewPasswordField(autofillFieldData)) {
autofillFieldData.inlineMenuFillType = InlineMenuFillType.PasswordGeneration;
this.qualifyAccountCreationFieldType(autofillFieldData);
return;
}
if (this.inlineMenuFieldQualificationService.isUpdateCurrentPasswordField(autofillFieldData)) {
autofillFieldData.inlineMenuFillType = InlineMenuFillType.CurrentPasswordUpdate;
return;
}
if (this.inlineMenuFieldQualificationService.isUsernameField(autofillFieldData)) {
autofillFieldData.inlineMenuFillType = InlineMenuFillType.AccountCreationUsername;
this.qualifyAccountCreationFieldType(autofillFieldData);
}
}
/**
* Sets the account creation field type for the autofill field data based on the field's attributes.
*
* @param autofillFieldData - Autofill field data captured from the form field element.
*/
private qualifyAccountCreationFieldType(autofillFieldData: AutofillField) {
if (!this.inlineMenuFieldQualificationService.isUsernameField(autofillFieldData)) {
autofillFieldData.accountCreationFieldType = InlineMenuAccountCreationFieldType.Password;
return;
}
if (!this.showInlineMenuIdentities) {
return;
}
if (this.inlineMenuFieldQualificationService.isEmailField(autofillFieldData)) {
autofillFieldData.accountCreationFieldType = InlineMenuAccountCreationFieldType.Email;
return;
}
autofillFieldData.accountCreationFieldType = InlineMenuAccountCreationFieldType.Text;
}
/**
* Validates whether a field is considered to be "hidden" based on the field's attributes.
* If the field is hidden, a fallback listener will be set up to ensure that the
@ -1287,12 +1233,12 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
) {
this.formFieldElements.set(formFieldElement, autofillFieldData);
if (!this.mostRecentlyFocusedField) {
await this.updateMostRecentlyFocusedField(formFieldElement);
if (elementIsFillableFormField(formFieldElement) && !!formFieldElement.value) {
this.storeModifiedFormElement(formFieldElement);
}
this.setupFormFieldElementEventListeners(formFieldElement);
this.setupFormSubmissionEventListeners(formFieldElement, autofillFieldData);
await this.setupFormSubmissionEventListeners(formFieldElement, autofillFieldData);
if (
globalThis.document.hasFocus() &&
@ -1302,16 +1248,6 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
}
}
/**
* Queries the background script for the autofill inline menu visibility setting.
* If the setting is not found, a default value of OnFieldFocus will be used
* @private
*/
private async getInlineMenuVisibility() {
const inlineMenuVisibility = await this.sendExtensionMessage("getAutofillInlineMenuVisibility");
this.inlineMenuVisibility = inlineMenuVisibility || AutofillOverlayVisibility.OnFieldFocus;
}
/**
* Queries the background script for the autofill inline menu's Cards visibility setting.
* If the setting is not found, a default value of true will be used
@ -1336,20 +1272,6 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
this.showInlineMenuIdentities = inlineMenuIdentitiesVisibility ?? true;
}
/**
* Returns a value that indicates if we should hide the inline menu list due to a filled field.
*
* @param formFieldElement - The form field element that triggered the focus event.
*/
private async hideInlineMenuListOnFilledField(
formFieldElement?: FillableFormFieldElement,
): Promise<boolean> {
return (
formFieldElement?.value &&
((await this.isInlineMenuCiphersPopulated()) || !this.isUserAuthed())
);
}
/**
* Indicates whether the most recently focused field has a value.
*/
@ -1357,19 +1279,6 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
return Boolean((this.mostRecentlyFocusedField as FillableFormFieldElement)?.value);
}
/**
* Updates the local reference to the inline menu visibility setting.
*
* @param data - The data object from the extension message.
*/
private updateInlineMenuVisibility({ data }: AutofillExtensionMessage) {
const newSettingValue = data?.newSettingValue;
if (!isNaN(newSettingValue)) {
this.inlineMenuVisibility = newSettingValue;
}
}
/**
* Checks if a field is currently filling within an frame in the tab.
*/
@ -1398,13 +1307,6 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
return (await this.sendExtensionMessage("checkIsFieldCurrentlyFocused")) === true;
}
/**
* Checks if the current tab contains ciphers that can be used to populate the inline menu.
*/
private async isInlineMenuCiphersPopulated() {
return (await this.sendExtensionMessage("checkIsInlineMenuCiphersPopulated")) === true;
}
/**
* Gets the root node of the passed element and returns the active element within that root node.
*
@ -1611,7 +1513,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
private setupGlobalEventListeners = () => {
globalThis.addEventListener(EVENTS.MESSAGE, this.handleWindowMessageEvent);
globalThis.document.addEventListener(EVENTS.VISIBILITYCHANGE, this.handleVisibilityChangeEvent);
globalThis.addEventListener(EVENTS.FOCUSOUT, this.handleFormFieldBlurEvent);
globalThis.addEventListener(EVENTS.FOCUSOUT, this.handleWindowFocusOutEvent);
this.setOverlayRepositionEventListeners();
};
@ -1627,19 +1529,36 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
}
};
/**
* Handles the window focus out event, triggering a focus check on the
* inline menu if the document has focus and a closure of the inline
* menu if it does not have focus.
*/
private handleWindowFocusOutEvent = () => {
if (document.hasFocus()) {
this.handleFormFieldBlurEvent();
return;
}
void this.sendExtensionMessage("closeAutofillInlineMenu", {
forceCloseInlineMenu: true,
});
};
/**
* Handles the visibility change event. This method will remove the
* autofill overlay if the document is not visible.
*/
private handleVisibilityChangeEvent = () => {
if (!this.mostRecentlyFocusedField || globalThis.document.visibilityState === "visible") {
return;
if (globalThis.document.visibilityState === "hidden") {
void this.sendExtensionMessage("closeAutofillInlineMenu", {
forceCloseInlineMenu: true,
});
}
this.unsetMostRecentlyFocusedField();
void this.sendExtensionMessage("closeAutofillInlineMenu", {
forceCloseInlineMenu: true,
});
if (this.mostRecentlyFocusedField) {
this.unsetMostRecentlyFocusedField();
}
};
/**
@ -1811,11 +1730,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
formFieldElement.removeEventListener(EVENTS.KEYUP, this.handleFormFieldKeyupEvent);
this.formFieldElements.delete(formFieldElement);
});
Object.keys(this.userFilledFields).forEach((key) => {
if (this.userFilledFields[key]) {
delete this.userFilledFields[key];
}
});
this.clearUserFilledFields();
this.userFilledFields = null;
globalThis.removeEventListener(EVENTS.MESSAGE, this.handleWindowMessageEvent);
globalThis.document.removeEventListener(

View File

@ -289,41 +289,6 @@ describe("AutofillService", () => {
expect(BrowserApi.tabSendMessageData).not.toHaveBeenCalled();
});
describe("updates the inline menu visibility setting", () => {
it("when changing the inline menu from on focus of field to on button click", async () => {
inlineMenuVisibilityMock$.next(AutofillOverlayVisibility.OnButtonClick);
await flushPromises();
expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith(
tab1,
"updateAutofillInlineMenuVisibility",
{ newSettingValue: AutofillOverlayVisibility.OnButtonClick },
);
expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith(
tab2,
"updateAutofillInlineMenuVisibility",
{ newSettingValue: AutofillOverlayVisibility.OnButtonClick },
);
});
it("when changing the inline menu from button click to field focus", async () => {
inlineMenuVisibilityMock$.next(AutofillOverlayVisibility.OnButtonClick);
inlineMenuVisibilityMock$.next(AutofillOverlayVisibility.OnFieldFocus);
await flushPromises();
expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith(
tab1,
"updateAutofillInlineMenuVisibility",
{ newSettingValue: AutofillOverlayVisibility.OnFieldFocus },
);
expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith(
tab2,
"updateAutofillInlineMenuVisibility",
{ newSettingValue: AutofillOverlayVisibility.OnFieldFocus },
);
});
});
describe("reloads the autofill scripts", () => {
it("when changing the inline menu from a disabled setting to an enabled setting", async () => {
inlineMenuVisibilityMock$.next(AutofillOverlayVisibility.Off);
@ -3292,10 +3257,6 @@ describe("AutofillService", () => {
);
expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(excludedField);
expect(AutofillService["isExcludedFieldType"]).toHaveBeenCalledWith(
excludedField,
AutoFillConstants.ExcludedAutofillTypes,
);
expect(AutofillService["isFieldMatch"]).not.toHaveBeenCalled();
expect(value.script).toStrictEqual([]);
});
@ -4725,8 +4686,6 @@ describe("AutofillService", () => {
const result = AutofillService["fieldIsFuzzyMatch"](field, ["some-value"]);
expect(AutofillService.hasValue).toHaveBeenCalledTimes(7);
expect(AutofillService["fuzzyMatch"]).not.toHaveBeenCalled();
expect(result).toBe(false);
});

View File

@ -1882,7 +1882,10 @@ export default class AutofillService implements AutofillServiceInterface {
*/
private excludeFieldFromIdentityFill(field: AutofillField): boolean {
return (
AutofillService.isExcludedFieldType(field, AutoFillConstants.ExcludedAutofillTypes) ||
AutofillService.isExcludedFieldType(field, [
"password",
...AutoFillConstants.ExcludedAutofillTypes,
]) ||
AutoFillConstants.ExcludedIdentityAutocompleteTypes.has(field.autoCompleteType) ||
!field.viewable
);
@ -2887,6 +2890,12 @@ export default class AutofillService implements AutofillServiceInterface {
) {
return true;
}
if (
AutofillService.hasValue(field.dataSetValues) &&
this.fuzzyMatch(names, field.dataSetValues)
) {
return true;
}
return false;
}
@ -3062,13 +3071,12 @@ export default class AutofillService implements AutofillServiceInterface {
*
* @param oldSettingValue - The previous setting value
* @param newSettingValue - The current setting value
* @param cipherType - The cipher type of the changed inline menu setting
*/
private async handleInlineMenuVisibilitySettingsChange(
oldSettingValue: InlineMenuVisibilitySetting | boolean,
newSettingValue: InlineMenuVisibilitySetting | boolean,
) {
if (oldSettingValue === undefined || oldSettingValue === newSettingValue) {
if (oldSettingValue == null || oldSettingValue === newSettingValue) {
return;
}
@ -3076,18 +3084,11 @@ export default class AutofillService implements AutofillServiceInterface {
typeof oldSettingValue === "boolean" || typeof newSettingValue === "boolean";
const inlineMenuPreviouslyDisabled = oldSettingValue === AutofillOverlayVisibility.Off;
const inlineMenuCurrentlyDisabled = newSettingValue === AutofillOverlayVisibility.Off;
if (
!isInlineMenuVisibilitySubSetting &&
!inlineMenuPreviouslyDisabled &&
!inlineMenuCurrentlyDisabled
) {
const tabs = await BrowserApi.tabsQuery({});
tabs.forEach((tab) =>
BrowserApi.tabSendMessageData(tab, "updateAutofillInlineMenuVisibility", {
newSettingValue,
}),
);
return;
}

View File

@ -43,6 +43,7 @@ describe("CollectAutofillContentService", () => {
const domQueryService = new DomQueryService();
const autofillOverlayContentService = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
);
let collectAutofillContentService: CollectAutofillContentService;
@ -262,8 +263,8 @@ describe("CollectAutofillContentService", () => {
collectAutofillContentService["autofillFieldElements"] = new Map([
[fieldElement, autofillField],
]);
const isFormFieldViewableSpy = jest
.spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable")
const isElementViewableSpy = jest
.spyOn(collectAutofillContentService["domElementVisibilityService"], "isElementViewable")
.mockResolvedValue(true);
const setupAutofillOverlayListenerOnFieldSpy = jest.spyOn(
collectAutofillContentService["autofillOverlayContentService"],
@ -273,7 +274,7 @@ describe("CollectAutofillContentService", () => {
await collectAutofillContentService.getPageDetails();
expect(autofillField.viewable).toBe(true);
expect(isFormFieldViewableSpy).toHaveBeenCalledWith(fieldElement);
expect(isElementViewableSpy).toHaveBeenCalledWith(fieldElement);
expect(setupAutofillOverlayListenerOnFieldSpy).toHaveBeenCalled();
});
@ -301,7 +302,7 @@ describe("CollectAutofillContentService", () => {
jest.spyOn(collectAutofillContentService as any, "buildAutofillFormsData");
jest.spyOn(collectAutofillContentService as any, "buildAutofillFieldsData");
jest
.spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable")
.spyOn(collectAutofillContentService["domElementVisibilityService"], "isElementViewable")
.mockResolvedValue(true);
const pageDetails = await collectAutofillContentService.getPageDetails();
@ -353,6 +354,7 @@ describe("CollectAutofillContentService", () => {
"aria-disabled": false,
"aria-haspopup": false,
"data-stripe": null,
dataSetValues: "",
},
{
opid: "__1",
@ -385,6 +387,7 @@ describe("CollectAutofillContentService", () => {
"aria-disabled": false,
"aria-haspopup": false,
"data-stripe": null,
dataSetValues: "",
},
],
collectedTimestamp: expect.any(Number),
@ -561,7 +564,7 @@ describe("CollectAutofillContentService", () => {
jest.spyOn(collectAutofillContentService as any, "getAutofillFieldElements");
jest.spyOn(collectAutofillContentService as any, "buildAutofillFieldItem");
jest
.spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable")
.spyOn(collectAutofillContentService["domElementVisibilityService"], "isElementViewable")
.mockResolvedValue(true);
const { formFieldElements } =
@ -609,6 +612,7 @@ describe("CollectAutofillContentService", () => {
type: "text",
value: "",
viewable: true,
dataSetValues: "",
},
{
"aria-disabled": false,
@ -641,6 +645,7 @@ describe("CollectAutofillContentService", () => {
type: "password",
value: "",
viewable: true,
dataSetValues: "",
},
]);
});
@ -929,7 +934,7 @@ describe("CollectAutofillContentService", () => {
collectAutofillContentService["autofillFieldElements"].set(usernameInput, existingFieldData);
jest.spyOn(collectAutofillContentService as any, "getAutofillFieldMaxLength");
jest
.spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable")
.spyOn(collectAutofillContentService["domElementVisibilityService"], "isElementViewable")
.mockResolvedValue(true);
jest.spyOn(collectAutofillContentService as any, "getPropertyOrAttribute");
jest.spyOn(collectAutofillContentService as any, "getElementValue");
@ -941,7 +946,7 @@ describe("CollectAutofillContentService", () => {
expect(collectAutofillContentService["getAutofillFieldMaxLength"]).not.toHaveBeenCalled();
expect(
collectAutofillContentService["domElementVisibilityService"].isFormFieldViewable,
collectAutofillContentService["domElementVisibilityService"].isElementViewable,
).not.toHaveBeenCalled();
expect(collectAutofillContentService["getPropertyOrAttribute"]).not.toHaveBeenCalled();
expect(collectAutofillContentService["getElementValue"]).not.toHaveBeenCalled();
@ -962,7 +967,7 @@ describe("CollectAutofillContentService", () => {
) as ElementWithOpId<FormFieldElement>;
jest.spyOn(collectAutofillContentService as any, "getAutofillFieldMaxLength");
jest
.spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable")
.spyOn(collectAutofillContentService["domElementVisibilityService"], "isElementViewable")
.mockResolvedValue(true);
jest.spyOn(collectAutofillContentService as any, "getPropertyOrAttribute");
jest.spyOn(collectAutofillContentService as any, "getElementValue");
@ -976,7 +981,7 @@ describe("CollectAutofillContentService", () => {
spanElement,
);
expect(
collectAutofillContentService["domElementVisibilityService"].isFormFieldViewable,
collectAutofillContentService["domElementVisibilityService"].isElementViewable,
).toHaveBeenCalledWith(spanElement);
expect(collectAutofillContentService["getPropertyOrAttribute"]).toHaveBeenNthCalledWith(
1,
@ -1020,6 +1025,7 @@ describe("CollectAutofillContentService", () => {
tagName: spanElement.tagName.toLowerCase(),
title: spanElementTitle,
viewable: true,
dataSetValues: "",
});
});
@ -1070,7 +1076,7 @@ describe("CollectAutofillContentService", () => {
) as ElementWithOpId<FillableFormFieldElement>;
jest.spyOn(collectAutofillContentService as any, "getAutofillFieldMaxLength");
jest
.spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable")
.spyOn(collectAutofillContentService["domElementVisibilityService"], "isElementViewable")
.mockResolvedValue(true);
jest.spyOn(collectAutofillContentService as any, "getPropertyOrAttribute");
jest.spyOn(collectAutofillContentService as any, "getElementValue");
@ -1111,6 +1117,7 @@ describe("CollectAutofillContentService", () => {
type: usernameField.type,
value: usernameField.value,
viewable: true,
dataSetValues: "label: username-data-label, stripe: data-stripe, ",
});
});
@ -1155,7 +1162,7 @@ describe("CollectAutofillContentService", () => {
) as ElementWithOpId<FillableFormFieldElement>;
jest.spyOn(collectAutofillContentService as any, "getAutofillFieldMaxLength");
jest
.spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable")
.spyOn(collectAutofillContentService["domElementVisibilityService"], "isElementViewable")
.mockResolvedValue(true);
jest.spyOn(collectAutofillContentService as any, "getPropertyOrAttribute");
jest.spyOn(collectAutofillContentService as any, "getElementValue");
@ -1189,6 +1196,7 @@ describe("CollectAutofillContentService", () => {
type: hiddenField.type,
value: hiddenField.value,
viewable: true,
dataSetValues: "stripe: data-stripe, ",
});
});
});
@ -2499,13 +2507,13 @@ describe("CollectAutofillContentService", () => {
});
describe("handleFormElementIntersection", () => {
let isFormFieldViewableSpy: jest.SpyInstance;
let isElementViewableSpy: jest.SpyInstance;
let setupAutofillOverlayListenerOnFieldSpy: jest.SpyInstance;
beforeEach(() => {
isFormFieldViewableSpy = jest.spyOn(
isElementViewableSpy = jest.spyOn(
collectAutofillContentService["domElementVisibilityService"],
"isFormFieldViewable",
"isElementViewable",
);
setupAutofillOverlayListenerOnFieldSpy = jest.spyOn(
collectAutofillContentService["autofillOverlayContentService"],
@ -2524,7 +2532,7 @@ describe("CollectAutofillContentService", () => {
await collectAutofillContentService["handleFormElementIntersection"](entries);
expect(isFormFieldViewableSpy).not.toHaveBeenCalled();
expect(isElementViewableSpy).not.toHaveBeenCalled();
expect(setupAutofillOverlayListenerOnFieldSpy).not.toHaveBeenCalled();
});
@ -2535,11 +2543,11 @@ describe("CollectAutofillContentService", () => {
{ target: formFieldElement, isIntersecting: true },
] as unknown as IntersectionObserverEntry[];
collectAutofillContentService["autofillFieldElements"].set(formFieldElement, autofillField);
isFormFieldViewableSpy.mockReturnValueOnce(false);
isElementViewableSpy.mockReturnValueOnce(false);
await collectAutofillContentService["handleFormElementIntersection"](entries);
expect(isFormFieldViewableSpy).toHaveBeenCalledWith(formFieldElement);
expect(isElementViewableSpy).toHaveBeenCalledWith(formFieldElement);
expect(setupAutofillOverlayListenerOnFieldSpy).not.toHaveBeenCalled();
});
@ -2548,12 +2556,12 @@ describe("CollectAutofillContentService", () => {
const entries = [
{ target: formFieldElement, isIntersecting: true },
] as unknown as IntersectionObserverEntry[];
isFormFieldViewableSpy.mockReturnValueOnce(true);
isElementViewableSpy.mockReturnValueOnce(true);
collectAutofillContentService["intersectionObserver"] = mockIntersectionObserver;
await collectAutofillContentService["handleFormElementIntersection"](entries);
expect(isFormFieldViewableSpy).not.toHaveBeenCalled();
expect(isElementViewableSpy).not.toHaveBeenCalled();
expect(setupAutofillOverlayListenerOnFieldSpy).not.toHaveBeenCalled();
});
@ -2563,13 +2571,13 @@ describe("CollectAutofillContentService", () => {
const entries = [
{ target: formFieldElement, isIntersecting: true },
] as unknown as IntersectionObserverEntry[];
isFormFieldViewableSpy.mockReturnValueOnce(true);
isElementViewableSpy.mockReturnValueOnce(true);
collectAutofillContentService["autofillFieldElements"].set(formFieldElement, autofillField);
collectAutofillContentService["intersectionObserver"] = mockIntersectionObserver;
await collectAutofillContentService["handleFormElementIntersection"](entries);
expect(isFormFieldViewableSpy).toHaveBeenCalledWith(formFieldElement);
expect(isElementViewableSpy).toHaveBeenCalledWith(formFieldElement);
expect(setupAutofillOverlayListenerOnFieldSpy).toHaveBeenCalledWith(
formFieldElement,
autofillField,

View File

@ -196,7 +196,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
private updateCachedAutofillFieldVisibility() {
this.autofillFieldElements.forEach(async (autofillField, element) => {
const previouslyViewable = autofillField.viewable;
autofillField.viewable = await this.domElementVisibilityService.isFormFieldViewable(element);
autofillField.viewable = await this.domElementVisibilityService.isElementViewable(element);
if (!previouslyViewable && autofillField.viewable) {
this.setupOverlayOnField(element, autofillField);
@ -360,13 +360,14 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
opid: element.opid,
elementNumber: index,
maxLength: this.getAutofillFieldMaxLength(element),
viewable: await this.domElementVisibilityService.isFormFieldViewable(element),
viewable: await this.domElementVisibilityService.isElementViewable(element),
htmlID: this.getPropertyOrAttribute(element, "id"),
htmlName: this.getPropertyOrAttribute(element, "name"),
htmlClass: this.getPropertyOrAttribute(element, "class"),
tabindex: this.getPropertyOrAttribute(element, "tabindex"),
title: this.getPropertyOrAttribute(element, "title"),
tagName: this.getAttributeLowerCase(element, "tagName"),
dataSetValues: this.getDataSetValues(element),
};
if (!autofillFieldBase.viewable) {
@ -800,6 +801,21 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
return elementValue;
}
/**
* Captures the `data-*` attribute metadata to help with validating the autofill data.
*
* @param element - The form field element to capture the `data-*` attribute metadata from
*/
private getDataSetValues(element: ElementWithOpId<FormFieldElement>): string {
let datasetValues = "";
const dataset = element.dataset;
for (const key in dataset) {
datasetValues += `${key}: ${dataset[key]}, `;
}
return datasetValues;
}
/**
* Get the options from a select element and return them as an array
* of arrays indicating the select element option text and value.
@ -945,6 +961,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
this.domRecentlyMutated = true;
if (this.autofillOverlayContentService) {
this.autofillOverlayContentService.pageDetailsUpdateRequired = true;
this.autofillOverlayContentService.clearUserFilledFields();
void this.sendExtensionMessage("closeAutofillInlineMenu", { forceCloseInlineMenu: true });
}
this.noFieldsFound = false;
@ -1315,8 +1332,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
continue;
}
const isViewable =
await this.domElementVisibilityService.isFormFieldViewable(formFieldElement);
const isViewable = await this.domElementVisibilityService.isElementViewable(formFieldElement);
if (!isViewable) {
continue;
}

View File

@ -37,7 +37,7 @@ describe("DomElementVisibilityService", () => {
document.body.innerHTML = "";
});
describe("isFormFieldViewable", () => {
describe("isElementViewable", () => {
it("returns false if the element is outside viewport bounds", async () => {
const usernameElement = document.querySelector("input[name='username']") as FormFieldElement;
jest.spyOn(usernameElement, "getBoundingClientRect");
@ -47,10 +47,10 @@ describe("DomElementVisibilityService", () => {
jest.spyOn(domElementVisibilityService, "isElementHiddenByCss");
jest.spyOn(domElementVisibilityService as any, "formFieldIsNotHiddenBehindAnotherElement");
const isFormFieldViewable =
await domElementVisibilityService.isFormFieldViewable(usernameElement);
const isElementViewable =
await domElementVisibilityService.isElementViewable(usernameElement);
expect(isFormFieldViewable).toEqual(false);
expect(isElementViewable).toEqual(false);
expect(usernameElement.getBoundingClientRect).toHaveBeenCalled();
expect(domElementVisibilityService["isElementOutsideViewportBounds"]).toHaveBeenCalledWith(
usernameElement,
@ -71,10 +71,10 @@ describe("DomElementVisibilityService", () => {
jest.spyOn(domElementVisibilityService, "isElementHiddenByCss").mockReturnValueOnce(true);
jest.spyOn(domElementVisibilityService as any, "formFieldIsNotHiddenBehindAnotherElement");
const isFormFieldViewable =
await domElementVisibilityService.isFormFieldViewable(usernameElement);
const isElementViewable =
await domElementVisibilityService.isElementViewable(usernameElement);
expect(isFormFieldViewable).toEqual(false);
expect(isElementViewable).toEqual(false);
expect(usernameElement.getBoundingClientRect).toHaveBeenCalled();
expect(domElementVisibilityService["isElementOutsideViewportBounds"]).toHaveBeenCalledWith(
usernameElement,
@ -99,10 +99,10 @@ describe("DomElementVisibilityService", () => {
.spyOn(domElementVisibilityService as any, "formFieldIsNotHiddenBehindAnotherElement")
.mockReturnValueOnce(false);
const isFormFieldViewable =
await domElementVisibilityService.isFormFieldViewable(usernameElement);
const isElementViewable =
await domElementVisibilityService.isElementViewable(usernameElement);
expect(isFormFieldViewable).toEqual(false);
expect(isElementViewable).toEqual(false);
expect(usernameElement.getBoundingClientRect).toHaveBeenCalled();
expect(domElementVisibilityService["isElementOutsideViewportBounds"]).toHaveBeenCalledWith(
usernameElement,
@ -127,10 +127,10 @@ describe("DomElementVisibilityService", () => {
.spyOn(domElementVisibilityService as any, "formFieldIsNotHiddenBehindAnotherElement")
.mockReturnValueOnce(true);
const isFormFieldViewable =
await domElementVisibilityService.isFormFieldViewable(usernameElement);
const isElementViewable =
await domElementVisibilityService.isElementViewable(usernameElement);
expect(isFormFieldViewable).toEqual(true);
expect(isElementViewable).toEqual(true);
expect(usernameElement.getBoundingClientRect).toHaveBeenCalled();
expect(domElementVisibilityService["isElementOutsideViewportBounds"]).toHaveBeenCalledWith(
usernameElement,

View File

@ -6,15 +6,14 @@ import { DomElementVisibilityService as DomElementVisibilityServiceInterface } f
class DomElementVisibilityService implements DomElementVisibilityServiceInterface {
private cachedComputedStyle: CSSStyleDeclaration | null = null;
constructor(private inlineMenuElements?: AutofillInlineMenuContentService) {}
constructor(private inlineMenuContentService?: AutofillInlineMenuContentService) {}
/**
* Checks if a form field is viewable. This is done by checking if the element is within the
* Checks if an element is viewable. This is done by checking if the element is within the
* viewport bounds, not hidden by CSS, and not hidden behind another element.
* @param {FormFieldElement} element
* @returns {Promise<boolean>}
* @param element
*/
async isFormFieldViewable(element: FormFieldElement): Promise<boolean> {
async isElementViewable(element: HTMLElement): Promise<boolean> {
const elementBoundingClientRect = element.getBoundingClientRect();
if (
this.isElementOutsideViewportBounds(element, elementBoundingClientRect) ||
@ -190,7 +189,7 @@ class DomElementVisibilityService implements DomElementVisibilityServiceInterfac
return true;
}
if (this.inlineMenuElements?.isElementInlineMenu(elementAtCenterPoint as HTMLElement)) {
if (this.inlineMenuContentService?.isElementInlineMenu(elementAtCenterPoint as HTMLElement)) {
return true;
}

View File

@ -192,6 +192,10 @@ export class DomQueryService implements DomQueryServiceInterface {
root: Document | ShadowRoot | Element,
returnSingleShadowRoot = false,
): ShadowRoot[] {
if (!root) {
return [];
}
const shadowRoots: ShadowRoot[] = [];
const potentialShadowRoots = root.querySelectorAll(":defined");
for (let index = 0; index < potentialShadowRoots.length; index++) {

View File

@ -408,7 +408,7 @@ describe("InlineMenuFieldQualificationService", () => {
autoCompleteType: "new-password",
htmlID: "user-password",
htmlName: "user-password",
placeholder: "user-password",
placeholder: "new password",
});
pageDetails.fields = [field, passwordField];

View File

@ -35,9 +35,31 @@ export class InlineMenuFieldQualificationService
private autofillFieldKeywordsMap: AutofillKeywordsMap = new WeakMap();
private submitButtonKeywordsMap: SubmitButtonKeywordsMap = new WeakMap();
private autocompleteDisabledValues = new Set(["off", "false"]);
private newFieldKeywords = new Set(["new", "change", "neue", "ändern"]);
private accountCreationFieldKeywords = [
...new Set(["register", "registration", "create", "confirm", ...this.newFieldKeywords]),
"register",
"registration",
"create password",
"create a password",
"create an account",
"create account password",
"create user password",
"confirm password",
"confirm account password",
"confirm user password",
"new user",
"new email",
"new e-mail",
"new password",
"new-password",
"neuer benutzer",
"neues passwort",
"neue e-mail",
];
private updatePasswordFieldKeywords = [
"update password",
"change password",
"current password",
"kennwort ändern",
];
private creditCardFieldKeywords = [
...new Set([
@ -145,8 +167,7 @@ export class InlineMenuFieldQualificationService
return this.isFieldForLoginFormFallback(field);
}
const isTotpField = this.isTotpField(field);
if (isTotpField) {
if (this.isTotpField(field)) {
return false;
}
@ -176,12 +197,6 @@ export class InlineMenuFieldQualificationService
return true;
}
// If the field contains any keywords indicating this is for a "new" or "changed" credit card
// field, we should assume that the field is not going to be autofilled.
if (this.keywordsFoundInFieldData(field, [...this.newFieldKeywords])) {
return false;
}
const parentForm = pageDetails.forms[field.form];
// If the field does not have a parent form
@ -229,7 +244,10 @@ export class InlineMenuFieldQualificationService
* @param pageDetails - The details of the page that the field is on.
*/
isFieldForAccountCreationForm(field: AutofillField, pageDetails: AutofillPageDetails): boolean {
if (this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet)) {
if (
this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet) ||
this.isTotpField(field)
) {
return false;
}
@ -286,10 +304,22 @@ export class InlineMenuFieldQualificationService
field: AutofillField,
pageDetails: AutofillPageDetails,
): boolean {
const parentForm = pageDetails.forms[field.form];
// If the provided field is set with an autocomplete value of "current-password", we should assume that
// the page developer intends for this field to be interpreted as a password field for a login form.
if (this.fieldContainsAutocompleteValues(field, this.currentPasswordAutocompleteValue)) {
return true;
if (!parentForm) {
return (
pageDetails.fields.filter(this.isNewPasswordField).filter((f) => f.viewable).length === 0
);
}
return (
pageDetails.fields
.filter(this.isNewPasswordField)
.filter((f) => f.viewable && f.form === field.form).length === 0
);
}
const usernameFieldsInPageDetails = pageDetails.fields.filter(this.isUsernameField);
@ -303,7 +333,6 @@ export class InlineMenuFieldQualificationService
// If the field is not structured within a form, we need to identify if the field is present on
// a page with multiple password fields. If that isn't the case, we can assume this is a login form field.
const parentForm = pageDetails.forms[field.form];
if (!parentForm) {
// If no parent form is found, and multiple password fields are present, we should assume that
// the passed field belongs to a user account creation form.
@ -368,7 +397,7 @@ export class InlineMenuFieldQualificationService
// If any keywords in the field's data indicates that this is a field for a "new" or "changed"
// username, we should assume that this field is not for a login form.
if (this.keywordsFoundInFieldData(field, [...this.newFieldKeywords])) {
if (this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords)) {
return false;
}
@ -416,12 +445,18 @@ export class InlineMenuFieldQualificationService
return false;
}
// If the form that contains the field has more than one visible field, we should assume
// that the field is part of an account creation form.
// If the form that contains a single field, we should assume that it is part
// of a multistep login form.
const fieldsWithinForm = pageDetails.fields.filter(
(pageDetailsField) => pageDetailsField.form === field.form,
);
return fieldsWithinForm.length === 1;
if (fieldsWithinForm.length === 1) {
return true;
}
// If multiple fields exist within the form, we should check if a single visible field exists.
// If so, we should assume that the field is part of a login form.
return fieldsWithinForm.filter((field) => field.viewable).length === 1;
}
// If a single password field exists within the page details, and that password field is part of
@ -439,8 +474,7 @@ export class InlineMenuFieldQualificationService
return false;
}
// If no visible fields are found on the page, but we have a single password
// field we should assume that the field is part of a login form.
// If we have a single password field we should assume that the field is part of a login form.
if (passwordFieldsInPageDetails.length === 1) {
return true;
}
@ -814,7 +848,8 @@ export class InlineMenuFieldQualificationService
isUsernameField = (field: AutofillField): boolean => {
if (
!this.usernameFieldTypes.has(field.type) ||
this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet)
this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet) ||
this.fieldHasDisqualifyingAttributeValue(field)
) {
return false;
}
@ -854,6 +889,22 @@ export class InlineMenuFieldQualificationService
return this.isPasswordField(field);
};
/**
* Validates the provided field as a current password field for an update password form.
*
* @param field - The field to validate
*/
isUpdateCurrentPasswordField = (field: AutofillField): boolean => {
if (this.fieldContainsAutocompleteValues(field, this.newPasswordAutoCompleteValue)) {
return false;
}
return (
this.isPasswordField(field) &&
this.keywordsFoundInFieldData(field, this.updatePasswordFieldKeywords)
);
};
/**
* Validates the provided field as a new password field.
*
@ -1084,6 +1135,7 @@ export class InlineMenuFieldQualificationService
autofillFieldData.title,
autofillFieldData.placeholder,
autofillFieldData.autoCompleteType,
autofillFieldData.dataSetValues,
autofillFieldData["label-data"],
autofillFieldData["label-aria"],
autofillFieldData["label-left"],
@ -1101,7 +1153,7 @@ export class InlineMenuFieldQualificationService
keywordEl = keywordEl.replace(/-/g, "");
// Split the keyword by non-alphanumeric characters to get the keywords without treating a space as a separator.
keywordEl.split(/[^\p{L}\d]+/gu).forEach((keyword) => {
keywordEl.split(/[^\p{L}\d]+/gu).forEach((keyword: string) => {
if (keyword) {
keywordsSet.add(keyword);
}
@ -1111,7 +1163,7 @@ export class InlineMenuFieldQualificationService
keywordEl
.replace(/\s/g, "")
.split(/[^\p{L}\d]+/gu)
.forEach((keyword) => {
.forEach((keyword: string) => {
if (keyword) {
keywordsSet.add(keyword);
}

View File

@ -74,6 +74,7 @@ describe("InsertAutofillContentService", () => {
const domElementVisibilityService = new DomElementVisibilityService();
const autofillOverlayContentService = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
);
const collectAutofillContentService = new CollectAutofillContentService(
@ -122,16 +123,25 @@ describe("InsertAutofillContentService", () => {
});
describe("fillForm", () => {
afterEach(() => {
Object.defineProperty(globalThis, "window", {
value: { frameElement: null },
writable: true,
});
Object.defineProperty(globalThis, "frameElement", {
value: { hasAttribute: jest.fn(() => false) },
writable: true,
});
});
it("returns early if the passed fill script does not have a script property", async () => {
fillScript.script = [];
jest.spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe");
jest.spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill");
jest.spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill");
jest.spyOn(insertAutofillContentService as any, "runFillScriptAction");
await insertAutofillContentService.fillForm(fillScript);
expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).not.toHaveBeenCalled();
expect(
insertAutofillContentService["userCancelledInsecureUrlAutofill"],
).not.toHaveBeenCalled();
@ -142,16 +152,16 @@ describe("InsertAutofillContentService", () => {
});
it("returns early if the script is filling within a sand boxed iframe", async () => {
jest
.spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe")
.mockReturnValue(true);
Object.defineProperty(globalThis, "frameElement", {
value: { hasAttribute: jest.fn(() => true) },
writable: true,
});
jest.spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill");
jest.spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill");
jest.spyOn(insertAutofillContentService as any, "runFillScriptAction");
await insertAutofillContentService.fillForm(fillScript);
expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).toHaveBeenCalled();
expect(
insertAutofillContentService["userCancelledInsecureUrlAutofill"],
).not.toHaveBeenCalled();
@ -162,9 +172,6 @@ describe("InsertAutofillContentService", () => {
});
it("returns early if the autofill is occurring on an insecure url and the user cancels the autofill", async () => {
jest
.spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe")
.mockReturnValue(false);
jest
.spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill")
.mockReturnValue(true);
@ -173,7 +180,6 @@ describe("InsertAutofillContentService", () => {
await insertAutofillContentService.fillForm(fillScript);
expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).toHaveBeenCalled();
expect(insertAutofillContentService["userCancelledInsecureUrlAutofill"]).toHaveBeenCalled();
expect(
insertAutofillContentService["userCancelledUntrustedIframeAutofill"],
@ -182,9 +188,6 @@ describe("InsertAutofillContentService", () => {
});
it("returns early if the iframe is untrusted and the user cancelled the autofill", async () => {
jest
.spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe")
.mockReturnValue(false);
jest
.spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill")
.mockReturnValue(false);
@ -195,7 +198,6 @@ describe("InsertAutofillContentService", () => {
await insertAutofillContentService.fillForm(fillScript);
expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).toHaveBeenCalled();
expect(insertAutofillContentService["userCancelledInsecureUrlAutofill"]).toHaveBeenCalled();
expect(
insertAutofillContentService["userCancelledUntrustedIframeAutofill"],
@ -204,9 +206,6 @@ describe("InsertAutofillContentService", () => {
});
it("runs the fill script action for all scripts found within the fill script", async () => {
jest
.spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe")
.mockReturnValue(false);
jest
.spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill")
.mockReturnValue(false);
@ -217,7 +216,6 @@ describe("InsertAutofillContentService", () => {
await insertAutofillContentService.fillForm(fillScript);
expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).toHaveBeenCalled();
expect(insertAutofillContentService["userCancelledInsecureUrlAutofill"]).toHaveBeenCalled();
expect(
insertAutofillContentService["userCancelledUntrustedIframeAutofill"],
@ -244,41 +242,6 @@ describe("InsertAutofillContentService", () => {
});
});
describe("fillingWithinSandboxedIframe", () => {
afterEach(() => {
Object.defineProperty(globalThis, "window", {
value: { frameElement: null },
writable: true,
});
});
it("returns false if the `self.origin` value is not null", () => {
const result = insertAutofillContentService["fillingWithinSandboxedIframe"]();
expect(result).toBe(false);
expect(self.origin).not.toBeNull();
});
it("returns true if the frameElement has a sandbox attribute", () => {
Object.defineProperty(globalThis, "frameElement", {
value: { hasAttribute: jest.fn(() => true) },
writable: true,
});
const result = insertAutofillContentService["fillingWithinSandboxedIframe"]();
expect(result).toBe(true);
});
it("returns true if the window location hostname is empty", () => {
setMockWindowLocation({ protocol: "http:", hostname: "" });
const result = insertAutofillContentService["fillingWithinSandboxedIframe"]();
expect(result).toBe(true);
});
});
describe("userCancelledInsecureUrlAutofill", () => {
const currentHostname = "bitwarden.com";

View File

@ -3,15 +3,16 @@ import { EVENTS, TYPE_CHECK } from "@bitwarden/common/autofill/constants";
import AutofillScript, { AutofillInsertActions, FillScript } from "../models/autofill-script";
import { FormFieldElement } from "../types";
import {
currentlyInSandboxedIframe,
elementIsFillableFormField,
elementIsInputElement,
elementIsSelectElement,
elementIsTextAreaElement,
} from "../utils";
import { DomElementVisibilityService } from "./abstractions/dom-element-visibility.service";
import { InsertAutofillContentService as InsertAutofillContentServiceInterface } from "./abstractions/insert-autofill-content.service";
import { CollectAutofillContentService } from "./collect-autofill-content.service";
import DomElementVisibilityService from "./dom-element-visibility.service";
class InsertAutofillContentService implements InsertAutofillContentServiceInterface {
private readonly autofillInsertActions: AutofillInsertActions = {
@ -39,7 +40,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
async fillForm(fillScript: AutofillScript) {
if (
!fillScript.script?.length ||
this.fillingWithinSandboxedIframe() ||
currentlyInSandboxedIframe() ||
this.userCancelledInsecureUrlAutofill(fillScript.savedUrls) ||
this.userCancelledUntrustedIframeAutofill(fillScript)
) {
@ -50,20 +51,6 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
await Promise.all(fillActionPromises);
}
/**
* Identifies if the execution of this script is happening
* within a sandboxed iframe.
* @returns {boolean}
* @private
*/
private fillingWithinSandboxedIframe() {
return (
String(self.origin).toLowerCase() === "null" ||
globalThis.frameElement?.hasAttribute("sandbox") ||
globalThis.location.hostname === ""
);
}
/**
* Checks if the autofill is occurring on a page that can be considered secure. If the page is not secure,
* the user is prompted to confirm that they want to autofill on the page.

View File

@ -3,6 +3,7 @@
$dark-icon-themes: "theme_dark", "theme_solarizedDark", "theme_nord";
$font-family-sans-serif: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
$font-family-source-code-pro: "Source Code Pro", monospace;
$font-size-base: 14px;
$text-color: #212529;
$muted-text-color: #6c747c;
@ -11,6 +12,8 @@ $border-color-dark: #ddd;
$border-radius: 3px;
$focus-outline-color: #1252a3;
$muted-blue: #5a6d91;
$password-special-color: #b80017;
$password-number-color: #1452c1;
$brand-primary: #175ddc;
@ -47,6 +50,8 @@ $themes: (
successColor: $success-color-light,
errorColor: $error-color-light,
passkeysAuthenticating: $muted-blue,
passwordSpecialColor: $password-special-color,
passwordNumberColor: $password-number-color,
),
dark: (
textColor: #ffffff,
@ -63,6 +68,8 @@ $themes: (
successColor: $success-color-dark,
errorColor: $error-color-dark,
passkeysAuthenticating: #bac0ce,
passwordSpecialColor: #ff8d85,
passwordNumberColor: #6f9df1,
),
nord: (
textColor: $nord5,
@ -78,6 +85,8 @@ $themes: (
focusOutlineColor: lighten($focus-outline-color, 25%),
successColor: $success-color-dark,
passkeysAuthenticating: $nord4,
passwordSpecialColor: $nord12,
passwordNumberColor: $nord8,
),
solarizedDark: (
textColor: $solarizedDarkBase2,
@ -94,6 +103,8 @@ $themes: (
focusOutlineColor: lighten($focus-outline-color, 15%),
successColor: $success-color-dark,
passkeysAuthenticating: $solarizedDarkBase2,
passwordSpecialColor: #b58900,
passwordNumberColor: $solarizedDarkCyan,
),
);

File diff suppressed because one or more lines are too long

View File

@ -156,11 +156,9 @@ export function createAutofillScriptMock(
const overlayPagesTranslations = {
locale: "en",
buttonPageTitle: "buttonPageTitle",
listPageTitle: "listPageTitle",
opensInANewWindow: "opensInANewWindow",
toggleBitwardenVaultOverlay: "toggleBitwardenVaultOverlay",
unlockYourAccount: "unlockYourAccount",
unlockYourAccountToViewAutofillSuggestions: "unlockYourAccountToViewAutofillSuggestions",
unlockAccount: "unlockAccount",
fillCredentialsFor: "fillCredentialsFor",
username: "username",
@ -215,7 +213,7 @@ export function createInitAutofillInlineMenuListMessageMock(
theme: ThemeType.Light,
authStatus: AuthenticationStatus.Unlocked,
portKey: "portKey",
filledByCipherType: CipherType.Login,
inlineMenuFillType: CipherType.Login,
ciphers: [
createAutofillOverlayCipherDataMock(1, {
icon: {
@ -264,7 +262,7 @@ export function createFocusedFieldDataMock(
paddingRight: "6px",
paddingLeft: "6px",
},
filledByCipherType: CipherType.Login,
inlineMenuFillType: CipherType.Login,
tabId: 1,
frameId: 2,
...customFields,

View File

@ -165,7 +165,7 @@ export function triggerWebRequestOnBeforeRedirectEvent(
});
}
export function triggerWebRequestOnCompletedEvent(details: chrome.webRequest.WebRequestDetails) {
export function triggerWebRequestOnCompletedEvent(details: chrome.webRequest.WebResponseDetails) {
(chrome.webRequest.onCompleted.addListener as unknown as jest.SpyInstance).mock.calls.forEach(
(call) => {
const callback = call[0];

View File

@ -323,6 +323,10 @@ export function nodeIsButtonElement(node: Node): node is HTMLButtonElement {
);
}
export function nodeIsAnchorElement(node: Node): node is HTMLAnchorElement {
return nodeIsElement(node) && elementIsInstanceOf<HTMLAnchorElement>(node, "a");
}
/**
* Returns a boolean representing the attribute value of an element.
*
@ -378,12 +382,26 @@ export function throttle(callback: (_args: any) => any, limit: number) {
*
* @param callback - The callback function to debounce.
* @param delay - The time in milliseconds to debounce the callback.
* @param immediate - Determines whether the callback should run immediately.
*/
export function debounce(callback: (_args: any) => any, delay: number) {
export function debounce(callback: (_args: any) => any, delay: number, immediate?: boolean) {
let timeout: NodeJS.Timeout;
return function (...args: unknown[]) {
globalThis.clearTimeout(timeout);
timeout = globalThis.setTimeout(() => callback.apply(this, args), delay);
const callImmediately = !!immediate && !timeout;
if (timeout) {
globalThis.clearTimeout(timeout);
}
timeout = globalThis.setTimeout(() => {
timeout = null;
if (!callImmediately) {
callback.apply(this, args);
}
}, delay);
if (callImmediately) {
callback.apply(this, args);
}
};
}
@ -473,3 +491,54 @@ export function generateDomainMatchPatterns(url: string): string[] {
export function isInvalidResponseStatusCode(statusCode: number) {
return statusCode < 200 || statusCode >= 300;
}
/**
* Determines if the current context is within a sandboxed iframe.
*/
export function currentlyInSandboxedIframe(): boolean {
return (
String(self.origin).toLowerCase() === "null" ||
globalThis.frameElement?.hasAttribute("sandbox") ||
globalThis.location.hostname === ""
);
}
/**
* This object allows us to map a special character to a key name. The key name is used
* in gathering the i18n translation of the written version of the special character.
*/
export const specialCharacterToKeyMap: Record<string, string> = {
" ": "spaceCharacterDescriptor",
"~": "tildeCharacterDescriptor",
"`": "backtickCharacterDescriptor",
"!": "exclamationCharacterDescriptor",
"@": "atSignCharacterDescriptor",
"#": "hashSignCharacterDescriptor",
$: "dollarSignCharacterDescriptor",
"%": "percentSignCharacterDescriptor",
"^": "caretCharacterDescriptor",
"&": "ampersandCharacterDescriptor",
"*": "asteriskCharacterDescriptor",
"(": "parenLeftCharacterDescriptor",
")": "parenRightCharacterDescriptor",
"-": "hyphenCharacterDescriptor",
_: "underscoreCharacterDescriptor",
"+": "plusCharacterDescriptor",
"=": "equalsCharacterDescriptor",
"{": "braceLeftCharacterDescriptor",
"}": "braceRightCharacterDescriptor",
"[": "bracketLeftCharacterDescriptor",
"]": "bracketRightCharacterDescriptor",
"|": "pipeCharacterDescriptor",
"\\": "backSlashCharacterDescriptor",
":": "colonCharacterDescriptor",
";": "semicolonCharacterDescriptor",
'"': "doubleQuoteCharacterDescriptor",
"'": "singleQuoteCharacterDescriptor",
"<": "lessThanCharacterDescriptor",
">": "greaterThanCharacterDescriptor",
",": "commaCharacterDescriptor",
".": "periodCharacterDescriptor",
"?": "questionCharacterDescriptor",
"/": "forwardSlashCharacterDescriptor",
};

View File

@ -30,3 +30,9 @@ export const circleCheckIcon =
export const spinnerIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><g clip-path="url(#a)"><path fill="#5A6D91" d="M4.869 15.015a.588.588 0 1 0 0-1.177.588.588 0 0 0 0 1.177ZM8.252 16a.588.588 0 1 0 0-1.176.588.588 0 0 0 0 1.176Zm3.683-.911a.589.589 0 1 0 0-1.177.589.589 0 0 0 0 1.177ZM2.43 12.882a.693.693 0 1 0 0-1.387.693.693 0 0 0 0 1.387ZM1.318 9.738a.82.82 0 1 0 0-1.64.82.82 0 0 0 0 1.64Zm.69-3.578a.968.968 0 1 0 0-1.937.968.968 0 0 0 0 1.937ZM4.81 3.337a1.175 1.175 0 1 0 0-2.35 1.175 1.175 0 0 0 0 2.35Zm4.597-.676a1.33 1.33 0 1 0 0-2.661 1.33 1.33 0 0 0 0 2.66Zm4.543 2.954a1.553 1.553 0 1 0 0-3.105 1.553 1.553 0 0 0 0 3.105Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>';
export const keyIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="25" viewBox="0 0 24 25" fill="none"><g clip-path="url(#a)"><path fill="#175DDC" d="M21.803 3.035a7.453 7.453 0 0 0-2.427-1.567 7.763 7.763 0 0 0-2.877-.551c-.988 0-1.967.187-2.878.55a7.455 7.455 0 0 0-2.427 1.568A7.193 7.193 0 0 0 9.283 6.23a6.936 6.936 0 0 0-.023 3.675.556.556 0 0 1-.16.549L.656 18.61a.77.77 0 0 0-.233.468l-.415 3.756a.722.722 0 0 0 .04.354.773.773 0 0 0 .203.3.85.85 0 0 0 .697.201l5.141-.855a.832.832 0 0 0 .461-.241.757.757 0 0 0 .211-.458l.108-1.162a.554.554 0 0 1 .17-.35.62.62 0 0 1 .365-.167l1.2-.105a.832.832 0 0 0 .503-.23.756.756 0 0 0 .23-.482l.124-1.326a.361.361 0 0 1 .111-.23.4.4 0 0 1 .24-.108l1.381-.113a.815.815 0 0 0 .501-.225l2.473-2.386a.506.506 0 0 1 .48-.126 7.904 7.904 0 0 0 1.912.235 7.68 7.68 0 0 0 2.846-.539 7.344 7.344 0 0 0 2.402-1.546C23.213 11.905 24 10.069 24 8.155c0-1.914-.787-3.752-2.194-5.122l-.003.002Zm-10.81 7.148a5.496 5.496 0 0 1-.25-3.208 5.677 5.677 0 0 1 1.6-2.835 5.828 5.828 0 0 1 1.902-1.233 6.075 6.075 0 0 1 4.515 0 5.829 5.829 0 0 1 1.902 1.233c1.107 1.073 1.726 2.514 1.726 4.016 0 1.501-.62 2.943-1.726 4.016a5.925 5.925 0 0 1-2.93 1.537 6.135 6.135 0 0 1-3.339-.245.844.844 0 0 0-.85.182l-2.498 2.409a1.124 1.124 0 0 1-.682.308l-1.687.142a.839.839 0 0 0-.503.23.754.754 0 0 0-.23.482l-.105 1.13a.594.594 0 0 1-.181.374.653.653 0 0 1-.39.178l-1.171.1a.832.832 0 0 0-.503.23.755.755 0 0 0-.23.483l-.122 1.313a.474.474 0 0 1-.13.287.518.518 0 0 1-.288.151l-2.66.439a.36.36 0 0 1-.286-.084.314.314 0 0 1-.102-.266l.182-1.758a.724.724 0 0 1 .222-.449l8.636-8.333a.778.778 0 0 0 .215-.39.756.756 0 0 0-.036-.439h-.001Zm6.976-1.226c-.474 0-.938-.134-1.332-.384a2.31 2.31 0 0 1-.884-1.022 2.17 2.17 0 0 1-.137-1.317c.093-.442.321-.848.657-1.166a2.441 2.441 0 0 1 1.228-.624 2.516 2.516 0 0 1 1.386.13 2.37 2.37 0 0 1 1.077.84c.263.374.404.814.404 1.265 0 .605-.253 1.184-.703 1.611-.45.428-1.06.667-1.696.667Zm0-3.56c-.266 0-.527.075-.75.216-.221.14-.394.34-.496.575a1.22 1.22 0 0 0-.077.74c.053.249.18.477.37.657.189.18.43.3.691.35.262.05.533.025.78-.072.246-.097.457-.261.606-.472a1.235 1.235 0 0 0-.168-1.619 1.369 1.369 0 0 0-.954-.376v.002l-.002-.001Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 .308h24v24H0z"/></clipPath></defs></svg>';
export const refreshIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="21" viewBox="0 0 20 21" fill="none"><g clip-path="url(#a)"><path fill="#175DDC" d="M18.383 11.37a.678.678 0 0 0-.496.086.65.65 0 0 0-.291.402 7.457 7.457 0 0 1-2.451 3.912 7.754 7.754 0 0 1-4.328 1.78 7.761 7.761 0 0 1-4.554-.901 7.502 7.502 0 0 1-3.167-3.318c-.025-.064.03-.159.165-.14l1.039.417a.687.687 0 0 0 .51.005.662.662 0 0 0 .365-.346.62.62 0 0 0-.142-.694.64.64 0 0 0-.214-.136l-2.656-1.061a.686.686 0 0 0-.854.31L.065 14.139a.621.621 0 0 0 .31.847.69.69 0 0 0 .639-.033.653.653 0 0 0 .247-.261l.4-.792a.167.167 0 0 1 .124-.077.173.173 0 0 1 .075.01.16.16 0 0 1 .063.04 8.813 8.813 0 0 0 3.29 3.627 9.109 9.109 0 0 0 4.764 1.358c.312 0 .632-.015.961-.044a9.223 9.223 0 0 0 5.065-2.116 8.871 8.871 0 0 0 2.89-4.578.628.628 0 0 0-.274-.656.655.655 0 0 0-.236-.095v.001Zm1.25-5.735a.693.693 0 0 0-.64.033.659.659 0 0 0-.247.262l-.4.79a.166.166 0 0 1-.261.028 8.809 8.809 0 0 0-3.29-3.63 9.113 9.113 0 0 0-4.764-1.36c-.311 0-.631.014-.961.045A9.224 9.224 0 0 0 4.004 3.92a8.863 8.863 0 0 0-2.89 4.58.622.622 0 0 0 .276.658.657.657 0 0 0 .237.094c.17.036.349.005.496-.086a.65.65 0 0 0 .29-.402 7.452 7.452 0 0 1 2.452-3.911 7.764 7.764 0 0 1 4.328-1.781 7.761 7.761 0 0 1 4.553.902 7.508 7.508 0 0 1 3.168 3.317c.023.063-.03.16-.165.138l-1.042-.42a.688.688 0 0 0-.509-.004.666.666 0 0 0-.367.345.622.622 0 0 0 .357.83l2.65 1.06c.156.064.33.067.489.01a.665.665 0 0 0 .365-.318l1.243-2.454a.622.622 0 0 0-.302-.843Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 .421h20v19.773H0z"/></clipPath></defs></svg>';

View File

@ -3,7 +3,6 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ExtensionCommand, ExtensionCommandType } from "@bitwarden/common/autofill/constants";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { openUnlockPopout } from "../auth/popup/utils/auth-popout-window";
import { LockedVaultPendingNotificationsData } from "../autofill/background/abstractions/notification.background";
@ -17,10 +16,10 @@ export default class CommandsBackground {
constructor(
private main: MainBackground,
private passwordGenerationService: PasswordGenerationServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
private vaultTimeoutService: VaultTimeoutService,
private authService: AuthService,
private generatePasswordToClipboard: () => Promise<void>,
) {
this.isSafari = this.platformUtilsService.isSafari();
this.isVivaldi = this.platformUtilsService.isVivaldi();
@ -77,13 +76,6 @@ export default class CommandsBackground {
}
}
private async generatePasswordToClipboard() {
const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {};
const password = await this.passwordGenerationService.generatePassword(options);
this.platformUtilsService.copyToClipboard(password);
await this.passwordGenerationService.addHistory(password);
}
private async triggerAutofillCommand(
tab?: chrome.tabs.Tab,
commandSender?: ExtensionCommandType,

View File

@ -233,6 +233,7 @@ import { Fido2Background } from "../autofill/fido2/background/fido2.background";
import { BrowserFido2UserInterfaceService } from "../autofill/fido2/services/browser-fido2-user-interface.service";
import { AutofillService as AutofillServiceAbstraction } from "../autofill/services/abstractions/autofill.service";
import AutofillService from "../autofill/services/autofill.service";
import { InlineMenuFieldQualificationService } from "../autofill/services/inline-menu-field-qualification.service";
import { SafariApp } from "../browser/safariApp";
import { BackgroundBrowserBiometricsService } from "../key-management/biometrics/background-browser-biometrics.service";
import { BrowserKeyService } from "../key-management/browser-key.service";
@ -1153,10 +1154,10 @@ export default class MainBackground {
);
this.commandsBackground = new CommandsBackground(
this,
this.passwordGenerationService,
this.platformUtilsService,
this.vaultTimeoutService,
this.authService,
() => this.generatePasswordToClipboard(),
);
this.notificationBackground = new NotificationBackground(
this.autofillService,
@ -1201,14 +1202,7 @@ export default class MainBackground {
const contextMenuClickedHandler = new ContextMenuClickedHandler(
(options) => this.platformUtilsService.copyToClipboard(options.text),
async (_tab) => {
const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {};
const password = await this.passwordGenerationService.generatePassword(options);
this.platformUtilsService.copyToClipboard(password);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.passwordGenerationService.addHistory(password);
},
async () => this.generatePasswordToClipboard(),
async (tab, cipher) => {
this.loginToAutoFill = cipher;
if (tab == null) {
@ -1665,6 +1659,7 @@ export default class MainBackground {
this.themeStateService,
);
} else {
const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
this.overlayBackground = new OverlayBackground(
this.logService,
this.cipherService,
@ -1677,7 +1672,10 @@ export default class MainBackground {
this.platformUtilsService,
this.vaultSettingsService,
this.fido2ActiveRequestManager,
inlineMenuFieldQualificationService,
this.themeStateService,
() => this.generatePassword(),
(password) => this.addPasswordToHistory(password),
);
}
@ -1690,4 +1688,19 @@ export default class MainBackground {
await this.overlayBackground.init();
await this.tabsBackground.init();
}
generatePassword = async (): Promise<string> => {
const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {};
return await this.passwordGenerationService.generatePassword(options);
};
generatePasswordToClipboard = async () => {
const password = await this.generatePassword();
this.platformUtilsService.copyToClipboard(password);
await this.addPasswordToHistory(password);
};
addPasswordToHistory = async (password: string) => {
await this.passwordGenerationService.addHistory(password);
};
}

View File

@ -24,6 +24,7 @@ export const EVENTS = {
MOUSEENTER: "mouseenter",
MOUSELEAVE: "mouseleave",
MOUSEUP: "mouseup",
MOUSEOUT: "mouseout",
SUBMIT: "submit",
} as const;