mirror of
https://github.com/bitwarden/browser.git
synced 2024-12-21 16:18:28 +01:00
[PM-8833] Implement on page autofill menu for password generation (#11114)
This commit is contained in:
parent
9264e6775c
commit
da1e508c25
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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"],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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() {
|
||||
|
@ -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"
|
||||
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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",
|
||||
|
@ -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();
|
||||
|
@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
`;
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
require("./menu-container.scss");
|
||||
|
||||
import { AutofillInlineMenuContainer } from "./autofill-inline-menu-container";
|
||||
|
||||
(() => new AutofillInlineMenuContainer())();
|
||||
|
@ -0,0 +1,5 @@
|
||||
html,
|
||||
body {
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
export interface DomElementVisibilityService {
|
||||
isFormFieldViewable: (element: HTMLElement) => Promise<boolean>;
|
||||
isElementViewable: (element: HTMLElement) => Promise<boolean>;
|
||||
isElementHiddenByCss: (element: HTMLElement) => boolean;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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[] = [
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -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(
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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++) {
|
||||
|
@ -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];
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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
@ -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,
|
||||
|
@ -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];
|
||||
|
@ -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",
|
||||
};
|
||||
|
@ -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>';
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
@ -24,6 +24,7 @@ export const EVENTS = {
|
||||
MOUSEENTER: "mouseenter",
|
||||
MOUSELEAVE: "mouseleave",
|
||||
MOUSEUP: "mouseup",
|
||||
MOUSEOUT: "mouseout",
|
||||
SUBMIT: "submit",
|
||||
} as const;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user