mirror of
https://github.com/bitwarden/browser.git
synced 2024-12-22 16:29:09 +01:00
[PM-10669] Fix inconsistencies with notification bar when saving or updating login credentials (#10617)
* [PM-10669] Notification bar appears inconsistently after login * [PM-10669] Notification bar appears inconsistently after login * [PM-10669] Migrating work from POC branch into feature branch * [PM-10669] Incorporating styles for select element * [PM-10669] Incorporating styles for select element * [PM-10669] Fixing notification bar lifespan const * [PM-10669] Incorporating logic that conditionally loads specific bootstrap autofill feature files * [PM-10669] Incorporating logic to more smoothly handle transitioning between pages within the notification bar0 * [PM-10669] Incorporating logic to more smoothly handle transitioning between pages within the notification bar0 * [PM-10669] Incorporating logic to more smoothly handle transitioning between pages within the notification bar0 * [PM-10669] Incorporating a circle checkmark icon within the success message of the notification bar * [PM-10669] Fixing an issue where the notification bar can potentially load in between loading states for a tab * [PM-10669] Fixing an issue where the notification bar can potentially load in between loading states for a tab * [PM-10669] Fixing an issue where the notification bar can potentially load in between loading states for a tab * [PM-10669] Fixing an issue where the notification bar can potentially load in between loading states for a tab * [PM-10669] Fixing how we handle keyup events on the submit button * [PM-10669] Fixing how we handle keyup events on the submit button * [PM-10669] Fixing jest tests within notification bar * [PM-10669] Adding a jest tests to validate behavior within AutofillInit * [PM-10669] Adding a jest tests to validate behavior within AutofillInit * [PM-11170] Addressing test coverage within CollectAutofillContentService * [PM-11170] Addressing test coverage within CollectAutofillContentService * [PM-10669] Refactoring implementation * [PM-10669] Adding documentation to the methods incorporated within the AutofillOverlayContentService * [PM-10669] Incorporating jest tests for the AutofillOverlayContentService * [PM-10669] Migrating logic associated with the DomQuerySevice away from the CollectAutofillContentService * [PM-10669] Fixing required references to DomQueryService within the implementation * [PM-10669] Holding off on re-incorporating the userTreeWalkerStrategyFlag * [PM-10669] Incorporating jest tests for DomQueryService * [PM-10669] Adding jest test to validate changes within AutofillService * [PM-10669] Adding jest tests to validate changes within AutofillOverlayContentService * [PM-10669] Adding documentation to the OverlayNotificationsBackground class * [PM-10669] Adding documentation to the OverlayNotificationsBackground class * [PM-10669] Incorporating jest tests to validate the OverlayNotificationsBackground class * [PM-10669] Incorporating jest tests to validate the OverlayNotificationsBackground class * [PM-10669] Incorporating jest tests to validate the OverlayNotificationsBackground class * [PM-10669] Incorporating jest tests to validate the OverlayNotificationsBackground class * [PM-10669] Incorporating jest tests to validate the OverlayNotificationsBackground class * [PM-10669] Refactoring OverlayNotificationsContentService and incorporating logic that triggers a fade out of the notification bar on success of a saved password * [PM-10669] Refactoring OverlayNotificationsContentService and incorporating logic that triggers a fade out of the notification bar on success of a saved password * [PM-10669] Refactoring OverlayNotificationsContentService and incorporating logic that triggers a fade out of the notification bar on success of a saved password * [PM-10669] Finalizing jest tests for OverlayNotificationsContentService * [PM-10669] Finalizing jest tests for OverlayNotificationsContentService * [PM-10669] Adding new copy for the password saved/updated event in the notification bar * [PM-10669] Fixing visual presentation of sucesss message * [PM-10669] Fixing visual presentation of sucesss message * [PM-10418] Incorporating fallback for when we cannot capture the form button effectively * [PM-10669] Incorporating fixes for form submission button not being captured * [PM-10669] Incorporating a guard to ensure that an AJAX submission captures form data after the user has entered their credentials * [PM-10669] Incorporating a field qualification rule to ensure that we capture forms that are non-viewable on load * [PM-10669] Incorporating a document readyState listener to ensure that we populate the notification bar once the document body is loaded * [PM-10669] Incorporating a match pattern for subdomains of a main domain when filtering out web requests * [PM-10669] Incorporating a match pattern for subdomains of a main domain when filtering out web requests * [PM-10669] Incorporating a redundant methodology to capture `GET` requests that trigger after a form submisson * [PM-10669] Incorporating a redundant methodology to capture `GET` requests that trigger after a form submisson * [PM-10669] Adding jest tests to validate changes within OverlayNotificationsBackground * [PM-10669] Adjusting timeout for modified login credentials to ensure user can enter data on form * [PM-10669] Refining how we handle re-capturing user credentails on before request to better handle multi-part forms * [PM-10669] Refining how we handle re-capturing user credentails on before request to better handle multi-part forms * [PM-10669] Adjusting jest tests to ensure code coverage * [PM-10669] Fixing issues with Safari * [PM-10669] Fixing an invalid qualification rule * [PM-10669] Ensuring that we capture input changes correctly when a field is going from a hidden to non-hidden state * [PM-10669] Fixing jest tests within overlay content service * [PM-10669] Fixing jest tests within overlay content service * [PM-10669] Adding a jest test to validate changes to overlay content service
This commit is contained in:
parent
9041a4cd4c
commit
5b4e4d8f1a
@ -3645,10 +3645,18 @@
|
||||
"message": "Credentials saved successfully!",
|
||||
"description": "Notification message for when saving credentials has succeeded."
|
||||
},
|
||||
"passwordSaved": {
|
||||
"message": "Password saved!",
|
||||
"description": "Notification message for when saving credentials has succeeded."
|
||||
},
|
||||
"updateCipherAttemptSuccess": {
|
||||
"message": "Credentials updated successfully!",
|
||||
"description": "Notification message for when updating credentials has succeeded."
|
||||
},
|
||||
"passwordUpdated": {
|
||||
"message": "Password updated!",
|
||||
"description": "Notification message for when updating credentials has succeeded."
|
||||
},
|
||||
"saveCipherAttemptFailed": {
|
||||
"message": "Error saving credentials. Check console for details.",
|
||||
"description": "Notification message for when saving credentials has failed."
|
||||
|
@ -9,6 +9,7 @@ interface NotificationQueueMessage {
|
||||
type: NotificationQueueMessageTypes;
|
||||
domain: string;
|
||||
tab: chrome.tabs.Tab;
|
||||
launchTimestamp: number;
|
||||
expires: Date;
|
||||
wasVaultLocked: boolean;
|
||||
}
|
||||
@ -88,10 +89,9 @@ type NotificationBackgroundExtensionMessage = {
|
||||
tab?: chrome.tabs.Tab;
|
||||
sender?: string;
|
||||
notificationType?: string;
|
||||
fadeOutNotification?: boolean;
|
||||
};
|
||||
|
||||
type SaveOrUpdateCipherResult = undefined | { error: string };
|
||||
|
||||
type BackgroundMessageParam = { message: NotificationBackgroundExtensionMessage };
|
||||
type BackgroundSenderParam = { sender: chrome.runtime.MessageSender };
|
||||
type BackgroundOnMessageHandlerParams = BackgroundMessageParam & BackgroundSenderParam;
|
||||
@ -100,7 +100,7 @@ type NotificationBackgroundExtensionMessageHandlers = {
|
||||
[key: string]: CallableFunction;
|
||||
unlockCompleted: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
bgGetFolderData: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<FolderView[]>;
|
||||
bgCloseNotificationBar: ({ sender }: BackgroundSenderParam) => Promise<void>;
|
||||
bgCloseNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
bgAdjustNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
bgAddLogin: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
bgChangedPassword: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
@ -129,7 +129,6 @@ export {
|
||||
ChangePasswordMessageData,
|
||||
UnlockVaultMessageData,
|
||||
AddLoginMessageData,
|
||||
SaveOrUpdateCipherResult,
|
||||
NotificationBackgroundExtensionMessage,
|
||||
NotificationBackgroundExtensionMessageHandlers,
|
||||
};
|
||||
|
@ -0,0 +1,52 @@
|
||||
import AutofillPageDetails from "../../models/autofill-page-details";
|
||||
|
||||
export type NotificationTypeData = {
|
||||
isVaultLocked?: boolean;
|
||||
theme?: string;
|
||||
removeIndividualVault?: boolean;
|
||||
importType?: string;
|
||||
launchTimestamp?: number;
|
||||
};
|
||||
|
||||
export type WebsiteOriginsWithFields = Map<chrome.tabs.Tab["id"], Set<string>>;
|
||||
|
||||
export type ActiveFormSubmissionRequests = Set<chrome.webRequest.ResourceRequest["requestId"]>;
|
||||
|
||||
export type ModifyLoginCipherFormData = {
|
||||
uri: string;
|
||||
username: string;
|
||||
password: string;
|
||||
newPassword: string;
|
||||
};
|
||||
|
||||
export type ModifyLoginCipherFormDataForTab = Map<
|
||||
chrome.tabs.Tab["id"],
|
||||
{ uri: string; username: string; password: string; newPassword: string }
|
||||
>;
|
||||
|
||||
export type OverlayNotificationsExtensionMessage = {
|
||||
command: string;
|
||||
uri?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
newPassword?: string;
|
||||
details?: AutofillPageDetails;
|
||||
};
|
||||
|
||||
type OverlayNotificationsMessageParams = { message: OverlayNotificationsExtensionMessage };
|
||||
type OverlayNotificationSenderParams = { sender: chrome.runtime.MessageSender };
|
||||
type OverlayNotificationsMessageHandlersParams = OverlayNotificationsMessageParams &
|
||||
OverlayNotificationSenderParams;
|
||||
|
||||
export type OverlayNotificationsExtensionMessageHandlers = {
|
||||
[key: string]: ({ message, sender }: OverlayNotificationsMessageHandlersParams) => any;
|
||||
formFieldSubmitted: ({ message, sender }: OverlayNotificationsMessageHandlersParams) => void;
|
||||
collectPageDetailsResponse: ({
|
||||
message,
|
||||
sender,
|
||||
}: OverlayNotificationsMessageHandlersParams) => Promise<void>;
|
||||
};
|
||||
|
||||
export interface OverlayNotificationsBackground {
|
||||
init(): void;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
|
||||
@ -48,7 +48,8 @@ describe("NotificationBackground", () => {
|
||||
let notificationBackground: NotificationBackground;
|
||||
const autofillService = mock<AutofillService>();
|
||||
const cipherService = mock<CipherService>();
|
||||
const authService = mock<AuthService>();
|
||||
let activeAccountStatusMock$: BehaviorSubject<AuthenticationStatus>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
const policyService = mock<PolicyService>();
|
||||
const folderService = mock<FolderService>();
|
||||
const userNotificationSettingsService = mock<UserNotificationSettingsService>();
|
||||
@ -60,6 +61,9 @@ describe("NotificationBackground", () => {
|
||||
const accountService = mock<AccountService>();
|
||||
|
||||
beforeEach(() => {
|
||||
activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Locked);
|
||||
authService = mock<AuthService>();
|
||||
authService.activeAccountStatus$ = activeAccountStatusMock$;
|
||||
notificationBackground = new NotificationBackground(
|
||||
autofillService,
|
||||
cipherService,
|
||||
@ -91,6 +95,7 @@ describe("NotificationBackground", () => {
|
||||
tab: createChromeTabMock(),
|
||||
expires: new Date(),
|
||||
wasVaultLocked: false,
|
||||
launchTimestamp: 0,
|
||||
};
|
||||
const cipherView = notificationBackground["convertAddLoginQueueMessageToCipherView"](message);
|
||||
|
||||
@ -126,6 +131,7 @@ describe("NotificationBackground", () => {
|
||||
tab: createChromeTabMock(),
|
||||
expires: new Date(),
|
||||
wasVaultLocked: false,
|
||||
launchTimestamp: 0,
|
||||
};
|
||||
const cipherView = notificationBackground["convertAddLoginQueueMessageToCipherView"](
|
||||
message,
|
||||
@ -222,6 +228,7 @@ describe("NotificationBackground", () => {
|
||||
expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith(
|
||||
sender.tab,
|
||||
"closeNotificationBar",
|
||||
{ fadeOutNotification: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -249,7 +256,6 @@ describe("NotificationBackground", () => {
|
||||
describe("bgAddLogin message handler", () => {
|
||||
let tab: chrome.tabs.Tab;
|
||||
let sender: chrome.runtime.MessageSender;
|
||||
let getAuthStatusSpy: jest.SpyInstance;
|
||||
let getEnableAddedLoginPromptSpy: jest.SpyInstance;
|
||||
let getEnableChangedPasswordPromptSpy: jest.SpyInstance;
|
||||
let pushAddLoginToQueueSpy: jest.SpyInstance;
|
||||
@ -259,7 +265,6 @@ describe("NotificationBackground", () => {
|
||||
beforeEach(() => {
|
||||
tab = createChromeTabMock();
|
||||
sender = mock<chrome.runtime.MessageSender>({ tab });
|
||||
getAuthStatusSpy = jest.spyOn(authService, "getAuthStatus");
|
||||
getEnableAddedLoginPromptSpy = jest.spyOn(
|
||||
notificationBackground as any,
|
||||
"getEnableAddedLoginPrompt",
|
||||
@ -281,12 +286,11 @@ describe("NotificationBackground", () => {
|
||||
command: "bgAddLogin",
|
||||
login: { username: "test", password: "password", url: "https://example.com" },
|
||||
};
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.LoggedOut);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.LoggedOut);
|
||||
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(getAuthStatusSpy).toHaveBeenCalled();
|
||||
expect(getEnableAddedLoginPromptSpy).not.toHaveBeenCalled();
|
||||
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -296,12 +300,11 @@ describe("NotificationBackground", () => {
|
||||
command: "bgAddLogin",
|
||||
login: { username: "test", password: "password", url: "" },
|
||||
};
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Locked);
|
||||
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(getAuthStatusSpy).toHaveBeenCalled();
|
||||
expect(getEnableAddedLoginPromptSpy).not.toHaveBeenCalled();
|
||||
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -311,13 +314,12 @@ describe("NotificationBackground", () => {
|
||||
command: "bgAddLogin",
|
||||
login: { username: "test", password: "password", url: "https://example.com" },
|
||||
};
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Locked);
|
||||
getEnableAddedLoginPromptSpy.mockReturnValueOnce(false);
|
||||
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(getAuthStatusSpy).toHaveBeenCalled();
|
||||
expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled();
|
||||
expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled();
|
||||
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
|
||||
@ -329,14 +331,13 @@ describe("NotificationBackground", () => {
|
||||
command: "bgAddLogin",
|
||||
login: { username: "test", password: "password", url: "https://example.com" },
|
||||
};
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
|
||||
getEnableAddedLoginPromptSpy.mockReturnValueOnce(false);
|
||||
getAllDecryptedForUrlSpy.mockResolvedValueOnce([]);
|
||||
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(getAuthStatusSpy).toHaveBeenCalled();
|
||||
expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled();
|
||||
expect(getAllDecryptedForUrlSpy).toHaveBeenCalled();
|
||||
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
|
||||
@ -348,7 +349,7 @@ describe("NotificationBackground", () => {
|
||||
command: "bgAddLogin",
|
||||
login: { username: "test", password: "password", url: "https://example.com" },
|
||||
};
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
|
||||
getEnableAddedLoginPromptSpy.mockReturnValueOnce(true);
|
||||
getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false);
|
||||
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
|
||||
@ -358,7 +359,6 @@ describe("NotificationBackground", () => {
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(getAuthStatusSpy).toHaveBeenCalled();
|
||||
expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled();
|
||||
expect(getAllDecryptedForUrlSpy).toHaveBeenCalled();
|
||||
expect(getEnableChangedPasswordPromptSpy).toHaveBeenCalled();
|
||||
@ -371,7 +371,7 @@ describe("NotificationBackground", () => {
|
||||
command: "bgAddLogin",
|
||||
login: { username: "test", password: "password", url: "https://example.com" },
|
||||
};
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
|
||||
getEnableAddedLoginPromptSpy.mockReturnValueOnce(true);
|
||||
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
|
||||
mock<CipherView>({ login: { username: "test", password: "password" } }),
|
||||
@ -380,7 +380,6 @@ describe("NotificationBackground", () => {
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(getAuthStatusSpy).toHaveBeenCalled();
|
||||
expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled();
|
||||
expect(getAllDecryptedForUrlSpy).toHaveBeenCalled();
|
||||
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
|
||||
@ -390,13 +389,12 @@ describe("NotificationBackground", () => {
|
||||
it("adds the login to the queue if the user has a locked account", async () => {
|
||||
const login = { username: "test", password: "password", url: "https://example.com" };
|
||||
const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login };
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Locked);
|
||||
getEnableAddedLoginPromptSpy.mockReturnValueOnce(true);
|
||||
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(getAuthStatusSpy).toHaveBeenCalled();
|
||||
expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith("example.com", login, sender.tab, true);
|
||||
});
|
||||
|
||||
@ -407,7 +405,7 @@ describe("NotificationBackground", () => {
|
||||
url: "https://example.com",
|
||||
} as any;
|
||||
const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login };
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
|
||||
getEnableAddedLoginPromptSpy.mockReturnValueOnce(true);
|
||||
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
|
||||
mock<CipherView>({ login: { username: "anotherTestUsername", password: "password" } }),
|
||||
@ -416,14 +414,13 @@ describe("NotificationBackground", () => {
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(getAuthStatusSpy).toHaveBeenCalled();
|
||||
expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith("example.com", login, sender.tab);
|
||||
});
|
||||
|
||||
it("adds a change password message to the queue if the user has changed an existing cipher's password", async () => {
|
||||
const login = { username: "tEsT", password: "password", url: "https://example.com" };
|
||||
const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login };
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
|
||||
getEnableAddedLoginPromptSpy.mockResolvedValueOnce(true);
|
||||
getEnableChangedPasswordPromptSpy.mockResolvedValueOnce(true);
|
||||
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
|
||||
@ -448,14 +445,12 @@ describe("NotificationBackground", () => {
|
||||
describe("bgChangedPassword message handler", () => {
|
||||
let tab: chrome.tabs.Tab;
|
||||
let sender: chrome.runtime.MessageSender;
|
||||
let getAuthStatusSpy: jest.SpyInstance;
|
||||
let pushChangePasswordToQueueSpy: jest.SpyInstance;
|
||||
let getAllDecryptedForUrlSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
tab = createChromeTabMock();
|
||||
sender = mock<chrome.runtime.MessageSender>({ tab });
|
||||
getAuthStatusSpy = jest.spyOn(authService, "getAuthStatus");
|
||||
pushChangePasswordToQueueSpy = jest.spyOn(
|
||||
notificationBackground as any,
|
||||
"pushChangePasswordToQueue",
|
||||
@ -484,12 +479,11 @@ describe("NotificationBackground", () => {
|
||||
url: "https://example.com",
|
||||
},
|
||||
};
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Locked);
|
||||
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(getAuthStatusSpy).toHaveBeenCalled();
|
||||
expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith(
|
||||
null,
|
||||
"example.com",
|
||||
@ -508,7 +502,7 @@ describe("NotificationBackground", () => {
|
||||
url: "https://example.com",
|
||||
},
|
||||
};
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
|
||||
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
|
||||
mock<CipherView>({ login: { username: "test", password: "password" } }),
|
||||
]);
|
||||
@ -516,7 +510,6 @@ describe("NotificationBackground", () => {
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(getAuthStatusSpy).toHaveBeenCalled();
|
||||
expect(getAllDecryptedForUrlSpy).toHaveBeenCalled();
|
||||
expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -530,7 +523,7 @@ describe("NotificationBackground", () => {
|
||||
url: "https://example.com",
|
||||
},
|
||||
};
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
|
||||
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
|
||||
mock<CipherView>({ login: { username: "test", password: "password" } }),
|
||||
mock<CipherView>({ login: { username: "test2", password: "password" } }),
|
||||
@ -539,7 +532,6 @@ describe("NotificationBackground", () => {
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(getAuthStatusSpy).toHaveBeenCalled();
|
||||
expect(getAllDecryptedForUrlSpy).toHaveBeenCalled();
|
||||
expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -553,7 +545,7 @@ describe("NotificationBackground", () => {
|
||||
url: "https://example.com",
|
||||
},
|
||||
};
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
|
||||
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
|
||||
mock<CipherView>({
|
||||
id: "cipher-id",
|
||||
@ -580,7 +572,7 @@ describe("NotificationBackground", () => {
|
||||
url: "https://example.com",
|
||||
},
|
||||
};
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
|
||||
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
|
||||
mock<CipherView>({ login: { username: "test", password: "password" } }),
|
||||
mock<CipherView>({ login: { username: "test2", password: "password" } }),
|
||||
@ -589,7 +581,6 @@ describe("NotificationBackground", () => {
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(getAuthStatusSpy).toHaveBeenCalled();
|
||||
expect(getAllDecryptedForUrlSpy).toHaveBeenCalled();
|
||||
expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -602,7 +593,7 @@ describe("NotificationBackground", () => {
|
||||
url: "https://example.com",
|
||||
},
|
||||
};
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
|
||||
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
|
||||
mock<CipherView>({
|
||||
id: "cipher-id",
|
||||
@ -658,12 +649,10 @@ describe("NotificationBackground", () => {
|
||||
});
|
||||
|
||||
describe("bgSaveCipher message handler", () => {
|
||||
let getAuthStatusSpy: jest.SpyInstance;
|
||||
let tabSendMessageDataSpy: jest.SpyInstance;
|
||||
let openUnlockPopoutSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
getAuthStatusSpy = jest.spyOn(authService, "getAuthStatus");
|
||||
tabSendMessageDataSpy = jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation();
|
||||
openUnlockPopoutSpy = jest
|
||||
.spyOn(notificationBackground as any, "openUnlockPopout")
|
||||
@ -677,12 +666,11 @@ describe("NotificationBackground", () => {
|
||||
edit: false,
|
||||
folder: "folder-id",
|
||||
};
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Locked);
|
||||
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(getAuthStatusSpy).toHaveBeenCalled();
|
||||
expect(tabSendMessageDataSpy).toHaveBeenCalledWith(
|
||||
sender.tab,
|
||||
"addToLockedVaultPendingNotifications",
|
||||
@ -716,7 +704,7 @@ describe("NotificationBackground", () => {
|
||||
let cipherEncryptSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
|
||||
getDecryptedCipherByIdSpy = jest.spyOn(
|
||||
notificationBackground as any,
|
||||
"getDecryptedCipherById",
|
||||
@ -1214,11 +1202,9 @@ describe("NotificationBackground", () => {
|
||||
});
|
||||
|
||||
describe("bgUnlockPopoutOpened message handler", () => {
|
||||
let getAuthStatusSpy: jest.SpyInstance;
|
||||
let pushUnlockVaultToQueueSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
getAuthStatusSpy = jest.spyOn(authService, "getAuthStatus");
|
||||
pushUnlockVaultToQueueSpy = jest.spyOn(
|
||||
notificationBackground as any,
|
||||
"pushUnlockVaultToQueue",
|
||||
@ -1236,7 +1222,6 @@ describe("NotificationBackground", () => {
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(getAuthStatusSpy).not.toHaveBeenCalled();
|
||||
expect(pushUnlockVaultToQueueSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@ -1246,12 +1231,11 @@ describe("NotificationBackground", () => {
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgUnlockPopoutOpened",
|
||||
};
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.LoggedOut);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.LoggedOut);
|
||||
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(getAuthStatusSpy).toHaveBeenCalled();
|
||||
expect(pushUnlockVaultToQueueSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@ -1261,7 +1245,7 @@ describe("NotificationBackground", () => {
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgUnlockPopoutOpened",
|
||||
};
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Locked);
|
||||
notificationBackground["notificationQueue"] = [mock<AddLoginQueueMessage>()];
|
||||
|
||||
sendMockExtensionMessage(message, sender);
|
||||
@ -1276,7 +1260,7 @@ describe("NotificationBackground", () => {
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgUnlockPopoutOpened",
|
||||
};
|
||||
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked);
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Locked);
|
||||
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
@ -44,6 +44,7 @@ import {
|
||||
NotificationBackgroundExtensionMessage,
|
||||
NotificationBackgroundExtensionMessageHandlers,
|
||||
} from "./abstractions/notification.background";
|
||||
import { NotificationTypeData } from "./abstractions/overlay-notifications.background";
|
||||
import { OverlayBackgroundExtensionMessage } from "./abstractions/overlay.background";
|
||||
|
||||
export default class NotificationBackground {
|
||||
@ -58,7 +59,8 @@ export default class NotificationBackground {
|
||||
private readonly extensionMessageHandlers: NotificationBackgroundExtensionMessageHandlers = {
|
||||
unlockCompleted: ({ message, sender }) => this.handleUnlockCompleted(message, sender),
|
||||
bgGetFolderData: () => this.getFolderData(),
|
||||
bgCloseNotificationBar: ({ sender }) => this.handleCloseNotificationBarMessage(sender),
|
||||
bgCloseNotificationBar: ({ message, sender }) =>
|
||||
this.handleCloseNotificationBarMessage(message, sender),
|
||||
bgAdjustNotificationBar: ({ message, sender }) =>
|
||||
this.handleAdjustNotificationBarMessage(message, sender),
|
||||
bgAddLogin: ({ message, sender }) => this.addLogin(message, sender),
|
||||
@ -132,6 +134,10 @@ export default class NotificationBackground {
|
||||
return await firstValueFrom(this.configService.serverConfig$);
|
||||
}
|
||||
|
||||
private async getAuthStatus() {
|
||||
return await firstValueFrom(this.authService.activeAccountStatus$);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the notification queue for any messages that need to be sent to the
|
||||
* specified tab. If no tab is specified, the current tab will be used.
|
||||
@ -186,9 +192,10 @@ export default class NotificationBackground {
|
||||
) {
|
||||
const notificationType = notificationQueueMessage.type;
|
||||
|
||||
const typeData: Record<string, any> = {
|
||||
const typeData: NotificationTypeData = {
|
||||
isVaultLocked: notificationQueueMessage.wasVaultLocked,
|
||||
theme: await firstValueFrom(this.themeStateService.selectedTheme$),
|
||||
launchTimestamp: notificationQueueMessage.launchTimestamp,
|
||||
};
|
||||
|
||||
switch (notificationType) {
|
||||
@ -230,11 +237,11 @@ export default class NotificationBackground {
|
||||
* @param message - The message to add to the queue
|
||||
* @param sender - The contextual sender of the message
|
||||
*/
|
||||
private async addLogin(
|
||||
async addLogin(
|
||||
message: NotificationBackgroundExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
) {
|
||||
const authStatus = await this.authService.getAuthStatus();
|
||||
const authStatus = await this.getAuthStatus();
|
||||
if (authStatus === AuthenticationStatus.LoggedOut) {
|
||||
return;
|
||||
}
|
||||
@ -289,6 +296,7 @@ export default class NotificationBackground {
|
||||
) {
|
||||
// remove any old messages for this tab
|
||||
this.removeTabFromNotificationQueue(tab);
|
||||
const launchTimestamp = new Date().getTime();
|
||||
const message: AddLoginQueueMessage = {
|
||||
type: NotificationQueueMessageType.AddLogin,
|
||||
username: loginInfo.username,
|
||||
@ -296,7 +304,8 @@ export default class NotificationBackground {
|
||||
domain: loginDomain,
|
||||
uri: loginInfo.url,
|
||||
tab: tab,
|
||||
expires: new Date(new Date().getTime() + NOTIFICATION_BAR_LIFESPAN_MS),
|
||||
launchTimestamp,
|
||||
expires: new Date(launchTimestamp + NOTIFICATION_BAR_LIFESPAN_MS),
|
||||
wasVaultLocked: isVaultLocked,
|
||||
};
|
||||
this.notificationQueue.push(message);
|
||||
@ -310,7 +319,7 @@ export default class NotificationBackground {
|
||||
* @param message - The message to add to the queue
|
||||
* @param sender - The contextual sender of the message
|
||||
*/
|
||||
private async changedPassword(
|
||||
async changedPassword(
|
||||
message: NotificationBackgroundExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
) {
|
||||
@ -320,7 +329,7 @@ export default class NotificationBackground {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((await this.authService.getAuthStatus()) < AuthenticationStatus.Unlocked) {
|
||||
if ((await this.getAuthStatus()) < AuthenticationStatus.Unlocked) {
|
||||
await this.pushChangePasswordToQueue(
|
||||
null,
|
||||
loginDomain,
|
||||
@ -380,7 +389,7 @@ export default class NotificationBackground {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentAuthStatus = await this.authService.getAuthStatus();
|
||||
const currentAuthStatus = await this.getAuthStatus();
|
||||
if (currentAuthStatus !== AuthenticationStatus.Locked || this.notificationQueue.length) {
|
||||
return;
|
||||
}
|
||||
@ -399,7 +408,7 @@ export default class NotificationBackground {
|
||||
* @param importType - The type of import that is being requested
|
||||
*/
|
||||
async requestFilelessImport(tab: chrome.tabs.Tab, importType: string) {
|
||||
const currentAuthStatus = await this.authService.getAuthStatus();
|
||||
const currentAuthStatus = await this.getAuthStatus();
|
||||
if (currentAuthStatus !== AuthenticationStatus.Unlocked || this.notificationQueue.length) {
|
||||
return;
|
||||
}
|
||||
@ -419,13 +428,15 @@ export default class NotificationBackground {
|
||||
) {
|
||||
// remove any old messages for this tab
|
||||
this.removeTabFromNotificationQueue(tab);
|
||||
const launchTimestamp = new Date().getTime();
|
||||
const message: AddChangePasswordQueueMessage = {
|
||||
type: NotificationQueueMessageType.ChangePassword,
|
||||
cipherId: cipherId,
|
||||
newPassword: newPassword,
|
||||
domain: loginDomain,
|
||||
tab: tab,
|
||||
expires: new Date(new Date().getTime() + NOTIFICATION_BAR_LIFESPAN_MS),
|
||||
launchTimestamp,
|
||||
expires: new Date(launchTimestamp + NOTIFICATION_BAR_LIFESPAN_MS),
|
||||
wasVaultLocked: isVaultLocked,
|
||||
};
|
||||
this.notificationQueue.push(message);
|
||||
@ -434,11 +445,13 @@ export default class NotificationBackground {
|
||||
|
||||
private async pushUnlockVaultToQueue(loginDomain: string, tab: chrome.tabs.Tab) {
|
||||
this.removeTabFromNotificationQueue(tab);
|
||||
const launchTimestamp = new Date().getTime();
|
||||
const message: AddUnlockVaultQueueMessage = {
|
||||
type: NotificationQueueMessageType.UnlockVault,
|
||||
domain: loginDomain,
|
||||
tab: tab,
|
||||
expires: new Date(new Date().getTime() + 0.5 * 60000), // 30 seconds
|
||||
launchTimestamp,
|
||||
expires: new Date(launchTimestamp + 0.5 * 60000), // 30 seconds
|
||||
wasVaultLocked: true,
|
||||
};
|
||||
await this.sendNotificationQueueMessage(tab, message);
|
||||
@ -459,11 +472,13 @@ export default class NotificationBackground {
|
||||
importType?: string,
|
||||
) {
|
||||
this.removeTabFromNotificationQueue(tab);
|
||||
const launchTimestamp = new Date().getTime();
|
||||
const message: AddRequestFilelessImportQueueMessage = {
|
||||
type: NotificationQueueMessageType.RequestFilelessImport,
|
||||
domain: loginDomain,
|
||||
tab,
|
||||
expires: new Date(new Date().getTime() + 0.5 * 60000), // 30 seconds
|
||||
launchTimestamp,
|
||||
expires: new Date(launchTimestamp + 0.5 * 60000), // 30 seconds
|
||||
wasVaultLocked: false,
|
||||
importType,
|
||||
};
|
||||
@ -484,7 +499,7 @@ export default class NotificationBackground {
|
||||
message: NotificationBackgroundExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
) {
|
||||
if ((await this.authService.getAuthStatus()) < AuthenticationStatus.Unlocked) {
|
||||
if ((await this.getAuthStatus()) < AuthenticationStatus.Unlocked) {
|
||||
await BrowserApi.tabSendMessageData(sender.tab, "addToLockedVaultPendingNotifications", {
|
||||
commandToRetry: {
|
||||
message: {
|
||||
@ -736,10 +751,16 @@ export default class NotificationBackground {
|
||||
* Sends a message back to the sender tab which
|
||||
* triggers closure of the notification bar.
|
||||
*
|
||||
* @param message - The extension message
|
||||
* @param sender - The contextual sender of the message
|
||||
*/
|
||||
private async handleCloseNotificationBarMessage(sender: chrome.runtime.MessageSender) {
|
||||
await BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar");
|
||||
private async handleCloseNotificationBarMessage(
|
||||
message: NotificationBackgroundExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
) {
|
||||
await BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar", {
|
||||
fadeOutNotification: !!message.fadeOutNotification,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -0,0 +1,548 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { EnvironmentServerConfigData } from "@bitwarden/common/platform/models/data/server-config.data";
|
||||
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
import AutofillField from "../models/autofill-field";
|
||||
import AutofillPageDetails from "../models/autofill-page-details";
|
||||
import {
|
||||
flushPromises,
|
||||
sendMockExtensionMessage,
|
||||
triggerTabOnRemovedEvent,
|
||||
triggerTabOnUpdatedEvent,
|
||||
triggerWebNavigationOnCompletedEvent,
|
||||
triggerWebRequestOnBeforeRequestEvent,
|
||||
triggerWebRequestOnCompletedEvent,
|
||||
} from "../spec/testing-utils";
|
||||
|
||||
import NotificationBackground from "./notification.background";
|
||||
import { OverlayNotificationsBackground } from "./overlay-notifications.background";
|
||||
|
||||
describe("OverlayNotificationsBackground", () => {
|
||||
let logService: MockProxy<LogService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let notificationBackground: NotificationBackground;
|
||||
let getEnableChangedPasswordPromptSpy: jest.SpyInstance;
|
||||
let getEnableAddedLoginPromptSpy: jest.SpyInstance;
|
||||
let overlayNotificationsBackground: OverlayNotificationsBackground;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
logService = mock<LogService>();
|
||||
configService = mock<ConfigService>();
|
||||
notificationBackground = mock<NotificationBackground>();
|
||||
getEnableChangedPasswordPromptSpy = jest
|
||||
.spyOn(notificationBackground, "getEnableChangedPasswordPrompt")
|
||||
.mockResolvedValue(true);
|
||||
getEnableAddedLoginPromptSpy = jest
|
||||
.spyOn(notificationBackground, "getEnableAddedLoginPrompt")
|
||||
.mockResolvedValue(true);
|
||||
overlayNotificationsBackground = new OverlayNotificationsBackground(
|
||||
logService,
|
||||
configService,
|
||||
notificationBackground,
|
||||
);
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
await overlayNotificationsBackground.init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
|
||||
describe("setting up the form submission listeners", () => {
|
||||
let fields: MockProxy<AutofillField>[];
|
||||
let details: MockProxy<AutofillPageDetails>;
|
||||
|
||||
beforeEach(() => {
|
||||
fields = [mock<AutofillField>(), mock<AutofillField>(), mock<AutofillField>()];
|
||||
details = mock<AutofillPageDetails>({ fields });
|
||||
});
|
||||
|
||||
describe("skipping setting up the web request listeners", () => {
|
||||
it("skips setting up listeners when the notification bar is disabled", async () => {
|
||||
getEnableChangedPasswordPromptSpy.mockResolvedValue(false);
|
||||
getEnableAddedLoginPromptSpy.mockResolvedValue(false);
|
||||
|
||||
sendMockExtensionMessage({
|
||||
command: "collectPageDetailsResponse",
|
||||
details,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(chrome.webRequest.onCompleted.addListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("when the sender is from an excluded domain", () => {
|
||||
const senderHost = "example.com";
|
||||
const senderUrl = `https://${senderHost}`;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(notificationBackground, "getExcludedDomains").mockResolvedValue({
|
||||
[senderHost]: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips setting up listeners when the sender is the user's vault", async () => {
|
||||
const vault = "https://vault.bitwarden.com";
|
||||
const sender = mock<chrome.runtime.MessageSender>({ origin: vault });
|
||||
jest
|
||||
.spyOn(notificationBackground, "getActiveUserServerConfig")
|
||||
.mockResolvedValue(
|
||||
mock<ServerConfig>({ environment: mock<EnvironmentServerConfigData>({ vault }) }),
|
||||
);
|
||||
|
||||
sendMockExtensionMessage({ command: "collectPageDetailsResponse", details }, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(chrome.webRequest.onCompleted.addListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips setting up listeners when the sender is an excluded domain", async () => {
|
||||
const sender = mock<chrome.runtime.MessageSender>({ origin: senderUrl });
|
||||
|
||||
sendMockExtensionMessage({ command: "collectPageDetailsResponse", details }, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(chrome.webRequest.onCompleted.addListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips setting up listeners when the sender contains a malformed origin", async () => {
|
||||
const senderOrigin = "-_-!..exampwle.com";
|
||||
const sender = mock<chrome.runtime.MessageSender>({ origin: senderOrigin });
|
||||
|
||||
sendMockExtensionMessage({ command: "collectPageDetailsResponse", details }, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(chrome.webRequest.onCompleted.addListener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("skips setting up listeners when the sender tab does not contain page details fields", async () => {
|
||||
const sender = mock<chrome.runtime.MessageSender>({ tab: { id: 1 } });
|
||||
details.fields = [];
|
||||
|
||||
sendMockExtensionMessage({ command: "collectPageDetailsResponse", details }, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(chrome.webRequest.onCompleted.addListener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("sets up the web request listeners", async () => {
|
||||
const sender = mock<chrome.runtime.MessageSender>({
|
||||
tab: { id: 1 },
|
||||
url: "example.com",
|
||||
});
|
||||
|
||||
sendMockExtensionMessage({ command: "collectPageDetailsResponse", details }, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(chrome.webRequest.onCompleted.addListener).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips setting up duplicate listeners when the website origin has been previously encountered with fields", async () => {
|
||||
const sender = mock<chrome.runtime.MessageSender>({
|
||||
tab: { id: 1 },
|
||||
url: "example.com",
|
||||
});
|
||||
|
||||
sendMockExtensionMessage({ command: "collectPageDetailsResponse", details }, sender);
|
||||
await flushPromises();
|
||||
sendMockExtensionMessage({ command: "collectPageDetailsResponse", details }, sender);
|
||||
await flushPromises();
|
||||
sendMockExtensionMessage({ command: "collectPageDetailsResponse", details }, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(chrome.webRequest.onCompleted.addListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storing the modified login form data", () => {
|
||||
const sender = mock<chrome.runtime.MessageSender>({ tab: { id: 1 } });
|
||||
|
||||
it("stores the modified login cipher form data", async () => {
|
||||
sendMockExtensionMessage(
|
||||
{
|
||||
command: "formFieldSubmitted",
|
||||
uri: "example.com",
|
||||
username: "username",
|
||||
password: "password",
|
||||
newPassword: "newPassword",
|
||||
},
|
||||
sender,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
overlayNotificationsBackground["modifyLoginCipherFormData"].get(sender.tab.id),
|
||||
).toEqual({
|
||||
uri: "example.com",
|
||||
username: "username",
|
||||
password: "password",
|
||||
newPassword: "newPassword",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the modified login cipher form data after 5 seconds", () => {
|
||||
sendMockExtensionMessage(
|
||||
{
|
||||
command: "formFieldSubmitted",
|
||||
uri: "example.com",
|
||||
username: "username",
|
||||
password: "password",
|
||||
newPassword: "newPassword",
|
||||
},
|
||||
sender,
|
||||
);
|
||||
|
||||
jest.advanceTimersByTime(CLEAR_NOTIFICATION_LOGIN_DATA_DURATION);
|
||||
|
||||
expect(overlayNotificationsBackground["modifyLoginCipherFormData"].size).toBe(0);
|
||||
});
|
||||
|
||||
it("attempts to store the modified login cipher form data within the onBeforeRequest listener when the data is not captured through a submit button click event", async () => {
|
||||
const pageDetails = mock<AutofillPageDetails>({ fields: [mock<AutofillField>()] });
|
||||
const tab = mock<chrome.tabs.Tab>({ id: sender.tab.id });
|
||||
jest.spyOn(BrowserApi, "getTab").mockResolvedValueOnce(tab);
|
||||
const response = {
|
||||
command: "formFieldSubmitted",
|
||||
uri: "example.com",
|
||||
username: "username",
|
||||
password: "password",
|
||||
newPassword: "newPassword",
|
||||
};
|
||||
jest.spyOn(BrowserApi, "tabSendMessage").mockResolvedValueOnce(response);
|
||||
sendMockExtensionMessage(
|
||||
{ command: "collectPageDetailsResponse", details: pageDetails },
|
||||
sender,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
triggerWebRequestOnBeforeRequestEvent(
|
||||
mock<chrome.webRequest.WebRequestDetails>({
|
||||
url: "https://example.com",
|
||||
tabId: sender.tab.id,
|
||||
method: "POST",
|
||||
requestId: "123345",
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
overlayNotificationsBackground["modifyLoginCipherFormData"].get(sender.tab.id),
|
||||
).toEqual({
|
||||
uri: "example.com",
|
||||
username: "username",
|
||||
password: "password",
|
||||
newPassword: "newPassword",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("web request listeners", () => {
|
||||
let sender: MockProxy<chrome.runtime.MessageSender>;
|
||||
const pageDetails = mock<AutofillPageDetails>({ fields: [mock<AutofillField>()] });
|
||||
let notificationChangedPasswordSpy: jest.SpyInstance;
|
||||
let notificationAddLoginSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
sender = mock<chrome.runtime.MessageSender>({
|
||||
tab: { id: 1 },
|
||||
url: "https://example.com",
|
||||
});
|
||||
notificationChangedPasswordSpy = jest.spyOn(notificationBackground, "changedPassword");
|
||||
notificationAddLoginSpy = jest.spyOn(notificationBackground, "addLogin");
|
||||
|
||||
sendMockExtensionMessage(
|
||||
{ command: "collectPageDetailsResponse", details: pageDetails },
|
||||
sender,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
describe("ignored web requests", () => {
|
||||
it("ignores requests from urls that do not start with a valid protocol", async () => {
|
||||
sender.url = "chrome-extension://extension-id";
|
||||
|
||||
triggerWebRequestOnBeforeRequestEvent(
|
||||
mock<chrome.webRequest.WebRequestDetails>({
|
||||
url: sender.url,
|
||||
tabId: sender.tab.id,
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(overlayNotificationsBackground["activeFormSubmissionRequests"].size).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores requests from urls that do not have a valid tabId", async () => {
|
||||
sender.tab = mock<chrome.tabs.Tab>({ id: -1 });
|
||||
|
||||
triggerWebRequestOnBeforeRequestEvent(
|
||||
mock<chrome.webRequest.WebRequestDetails>({
|
||||
url: sender.url,
|
||||
tabId: sender.tab.id,
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(overlayNotificationsBackground["activeFormSubmissionRequests"].size).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores requests from urls that do not have a valid request method", async () => {
|
||||
triggerWebRequestOnBeforeRequestEvent(
|
||||
mock<chrome.webRequest.WebRequestDetails>({
|
||||
url: sender.url,
|
||||
tabId: sender.tab.id,
|
||||
method: "GET",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(overlayNotificationsBackground["activeFormSubmissionRequests"].size).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores requests that are not part of an active form submission", async () => {
|
||||
triggerWebRequestOnCompletedEvent(
|
||||
mock<chrome.webRequest.WebRequestDetails>({
|
||||
url: sender.url,
|
||||
tabId: sender.tab.id,
|
||||
method: "POST",
|
||||
requestId: "123345",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(notificationChangedPasswordSpy).not.toHaveBeenCalled();
|
||||
expect(notificationAddLoginSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores requests for tabs that do not contain stored login data", async () => {
|
||||
const requestId = "123345";
|
||||
triggerWebRequestOnBeforeRequestEvent(
|
||||
mock<chrome.webRequest.WebRequestDetails>({
|
||||
url: sender.url,
|
||||
tabId: sender.tab.id,
|
||||
method: "POST",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
triggerWebRequestOnCompletedEvent(
|
||||
mock<chrome.webRequest.WebRequestDetails>({
|
||||
url: sender.url,
|
||||
tabId: sender.tab.id,
|
||||
method: "POST",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(notificationChangedPasswordSpy).not.toHaveBeenCalled();
|
||||
expect(notificationAddLoginSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("web requests that trigger notifications", () => {
|
||||
const requestId = "123345";
|
||||
|
||||
beforeEach(async () => {
|
||||
sendMockExtensionMessage(
|
||||
{
|
||||
command: "formFieldSubmitted",
|
||||
uri: "example.com",
|
||||
username: "username",
|
||||
password: "password",
|
||||
newPassword: "newPassword",
|
||||
},
|
||||
sender,
|
||||
);
|
||||
triggerWebRequestOnBeforeRequestEvent(
|
||||
mock<chrome.webRequest.WebRequestDetails>({
|
||||
url: sender.url,
|
||||
tabId: sender.tab.id,
|
||||
method: "POST",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("waits for the tab's navigation to complete using the web navigation API before initializing the notification", async () => {
|
||||
chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => {
|
||||
callback(
|
||||
mock<chrome.tabs.Tab>({
|
||||
status: "loading",
|
||||
url: sender.url,
|
||||
}),
|
||||
);
|
||||
});
|
||||
triggerWebRequestOnCompletedEvent(
|
||||
mock<chrome.webRequest.WebRequestDetails>({
|
||||
url: sender.url,
|
||||
tabId: sender.tab.id,
|
||||
method: "POST",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => {
|
||||
callback(
|
||||
mock<chrome.tabs.Tab>({
|
||||
status: "complete",
|
||||
url: sender.url,
|
||||
}),
|
||||
);
|
||||
});
|
||||
triggerWebNavigationOnCompletedEvent(
|
||||
mock<chrome.webNavigation.WebNavigationFramedCallbackDetails>({
|
||||
tabId: sender.tab.id,
|
||||
url: sender.url,
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(notificationAddLoginSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("initializes the notification immediately when the tab's navigation is complete", async () => {
|
||||
sendMockExtensionMessage(
|
||||
{
|
||||
command: "formFieldSubmitted",
|
||||
uri: "example.com",
|
||||
username: "username",
|
||||
password: "password",
|
||||
newPassword: "newPassword",
|
||||
},
|
||||
sender,
|
||||
);
|
||||
await flushPromises();
|
||||
chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => {
|
||||
callback(
|
||||
mock<chrome.tabs.Tab>({
|
||||
status: "complete",
|
||||
url: sender.url,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
triggerWebRequestOnCompletedEvent(
|
||||
mock<chrome.webRequest.WebRequestDetails>({
|
||||
url: sender.url,
|
||||
tabId: sender.tab.id,
|
||||
method: "POST",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(notificationAddLoginSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers the notification on the beforeRequest listener when a post-submission redirection is encountered", async () => {
|
||||
sender.tab = mock<chrome.tabs.Tab>({ id: 4 });
|
||||
sendMockExtensionMessage(
|
||||
{
|
||||
command: "formFieldSubmitted",
|
||||
uri: "example.com",
|
||||
username: "",
|
||||
password: "password",
|
||||
newPassword: "newPassword",
|
||||
},
|
||||
sender,
|
||||
);
|
||||
await flushPromises();
|
||||
chrome.tabs.get = jest.fn().mockImplementation((tabId, callback) => {
|
||||
callback(
|
||||
mock<chrome.tabs.Tab>({
|
||||
status: "complete",
|
||||
url: sender.url,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
triggerWebRequestOnBeforeRequestEvent(
|
||||
mock<chrome.webRequest.WebRequestDetails>({
|
||||
url: sender.url,
|
||||
tabId: sender.tab.id,
|
||||
method: "POST",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
triggerWebRequestOnBeforeRequestEvent(
|
||||
mock<chrome.webRequest.WebRequestDetails>({
|
||||
url: "https://example.com/redirect",
|
||||
tabId: sender.tab.id,
|
||||
method: "GET",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(notificationChangedPasswordSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("tab listeners", () => {
|
||||
let sender: MockProxy<chrome.runtime.MessageSender>;
|
||||
const pageDetails = mock<AutofillPageDetails>({ fields: [mock<AutofillField>()] });
|
||||
const requestId = "123345";
|
||||
|
||||
beforeEach(async () => {
|
||||
sender = mock<chrome.runtime.MessageSender>({
|
||||
tab: { id: 1 },
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
sendMockExtensionMessage(
|
||||
{ command: "collectPageDetailsResponse", details: pageDetails },
|
||||
sender,
|
||||
);
|
||||
await flushPromises();
|
||||
triggerWebRequestOnBeforeRequestEvent(
|
||||
mock<chrome.webRequest.WebRequestDetails>({
|
||||
url: sender.url,
|
||||
tabId: sender.tab.id,
|
||||
method: "POST",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
sendMockExtensionMessage(
|
||||
{
|
||||
command: "formFieldSubmitted",
|
||||
uri: "example.com",
|
||||
username: "username",
|
||||
password: "password",
|
||||
newPassword: "newPassword",
|
||||
},
|
||||
sender,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("clears all associated data with a removed tab", () => {
|
||||
triggerTabOnRemovedEvent(sender.tab.id, mock<chrome.tabs.TabRemoveInfo>());
|
||||
|
||||
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" }),
|
||||
);
|
||||
|
||||
expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,557 @@
|
||||
import { Subject, switchMap, timer } from "rxjs";
|
||||
|
||||
import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
import {
|
||||
ActiveFormSubmissionRequests,
|
||||
ModifyLoginCipherFormData,
|
||||
ModifyLoginCipherFormDataForTab,
|
||||
OverlayNotificationsBackground as OverlayNotificationsBackgroundInterface,
|
||||
OverlayNotificationsExtensionMessage,
|
||||
OverlayNotificationsExtensionMessageHandlers,
|
||||
WebsiteOriginsWithFields,
|
||||
} from "./abstractions/overlay-notifications.background";
|
||||
import NotificationBackground from "./notification.background";
|
||||
|
||||
export class OverlayNotificationsBackground implements OverlayNotificationsBackgroundInterface {
|
||||
private websiteOriginsWithFields: WebsiteOriginsWithFields = new Map();
|
||||
private activeFormSubmissionRequests: ActiveFormSubmissionRequests = new Set();
|
||||
private modifyLoginCipherFormData: ModifyLoginCipherFormDataForTab = new Map();
|
||||
private clearLoginCipherFormDataSubject: Subject<void> = new Subject();
|
||||
private readonly formSubmissionRequestMethods: Set<string> = new Set(["POST", "PUT", "PATCH"]);
|
||||
private readonly extensionMessageHandlers: OverlayNotificationsExtensionMessageHandlers = {
|
||||
formFieldSubmitted: ({ message, sender }) => this.storeModifiedLoginFormData(message, sender),
|
||||
collectPageDetailsResponse: ({ message, sender }) =>
|
||||
this.handleCollectPageDetailsResponse(message, sender),
|
||||
};
|
||||
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
private configService: ConfigService,
|
||||
private notificationBackground: NotificationBackground,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Initialize the overlay notifications background service.
|
||||
*/
|
||||
async init() {
|
||||
const featureFlagActive = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.NotificationBarAddLoginImprovements,
|
||||
);
|
||||
if (!featureFlagActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setupExtensionListeners();
|
||||
this.clearLoginCipherFormDataSubject
|
||||
.pipe(switchMap(() => timer(CLEAR_NOTIFICATION_LOGIN_DATA_DURATION)))
|
||||
.subscribe(() => this.modifyLoginCipherFormData.clear());
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the response from the content script with the page details. Triggers an initialization
|
||||
* of the add login or change password notification if the conditions are met.
|
||||
*
|
||||
* @param message - The message from the content script
|
||||
* @param sender - The sender of the message
|
||||
*/
|
||||
private async handleCollectPageDetailsResponse(
|
||||
message: OverlayNotificationsExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
) {
|
||||
if (await this.shouldInitAddLoginOrChangePasswordNotification(message, sender)) {
|
||||
this.websiteOriginsWithFields.set(sender.tab.id, this.getSenderUrlMatchPatterns(sender));
|
||||
this.setupWebRequestsListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the add login or change password notification should be initialized. This depends
|
||||
* on whether the user has enabled the notification, the sender is not from an excluded domain, the
|
||||
* tab's page details contains fillable fields, and the website origin has not been previously stored.
|
||||
*
|
||||
* @param message - The message from the content script
|
||||
* @param sender - The sender of the message
|
||||
*/
|
||||
private async shouldInitAddLoginOrChangePasswordNotification(
|
||||
message: OverlayNotificationsExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
) {
|
||||
return (
|
||||
(await this.isAddLoginOrChangePasswordNotificationEnabled()) &&
|
||||
!(await this.isSenderFromExcludedDomain(sender)) &&
|
||||
message.details?.fields?.length > 0 &&
|
||||
!this.websiteOriginsWithFields.has(sender.tab.id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the add login or change password notification is enabled.
|
||||
* This is based on the user's settings for the notification.
|
||||
*/
|
||||
private async isAddLoginOrChangePasswordNotificationEnabled() {
|
||||
return (
|
||||
(await this.notificationBackground.getEnableChangedPasswordPrompt()) ||
|
||||
(await this.notificationBackground.getEnableAddedLoginPrompt())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the match patterns for the sender's URL. This is used to filter out
|
||||
* the web requests that are not from the sender's tab.
|
||||
*
|
||||
* @param sender - The sender of the message
|
||||
*/
|
||||
private getSenderUrlMatchPatterns(sender: chrome.runtime.MessageSender) {
|
||||
return new Set([
|
||||
...this.generateMatchPatterns(sender.url),
|
||||
...this.generateMatchPatterns(sender.tab.url),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the origin and subdomain match patterns for the URL.
|
||||
*
|
||||
* @param url - The URL of the tab
|
||||
*/
|
||||
private generateMatchPatterns(url: string): string[] {
|
||||
try {
|
||||
if (!url.startsWith("http")) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
|
||||
const originMatchPattern = `${new URL(url).origin}/*`;
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
const splitHost = parsedUrl.hostname.split(".");
|
||||
const domain = splitHost.slice(-2).join(".");
|
||||
const subDomainMatchPattern = `${parsedUrl.protocol}//*.${domain}/*`;
|
||||
|
||||
return [originMatchPattern, subDomainMatchPattern];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the login form data that was modified by the user in the content script. This data is
|
||||
* used to trigger the add login or change password notification when the form is submitted.
|
||||
*
|
||||
* @param message - The message from the content script
|
||||
* @param sender - The sender of the message
|
||||
*/
|
||||
private storeModifiedLoginFormData = (
|
||||
message: OverlayNotificationsExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
) => {
|
||||
const { uri, username, password, newPassword } = message;
|
||||
if (!username && !password && !newPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearLoginCipherFormDataSubject.next();
|
||||
const formData = { uri, username, password, newPassword };
|
||||
|
||||
const existingModifyLoginData = this.modifyLoginCipherFormData.get(sender.tab.id);
|
||||
if (existingModifyLoginData) {
|
||||
formData.username = formData.username || existingModifyLoginData.username;
|
||||
formData.password = formData.password || existingModifyLoginData.password;
|
||||
formData.newPassword = formData.newPassword || existingModifyLoginData.newPassword;
|
||||
}
|
||||
|
||||
this.modifyLoginCipherFormData.set(sender.tab.id, formData);
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if the sender of the message is from an excluded domain. This is used to prevent the
|
||||
* add login or change password notification from being triggered on the user's vault domain or
|
||||
* other excluded domains.
|
||||
*
|
||||
* @param sender - The sender of the message
|
||||
*/
|
||||
private async isSenderFromExcludedDomain(sender: chrome.runtime.MessageSender): Promise<boolean> {
|
||||
try {
|
||||
const senderOrigin = sender.origin;
|
||||
const serverConfig = await this.notificationBackground.getActiveUserServerConfig();
|
||||
const activeUserVault = serverConfig?.environment?.vault;
|
||||
if (activeUserVault === senderOrigin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const excludedDomains = await this.notificationBackground.getExcludedDomains();
|
||||
if (!excludedDomains) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const senderDomain = new URL(senderOrigin).hostname;
|
||||
return excludedDomains[senderDomain] !== undefined;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes and resets the onBeforeRequest and onCompleted listeners for web requests. This ensures
|
||||
* that we are only listening for form submission requests on the tabs that have fillable form fields.
|
||||
*/
|
||||
private setupWebRequestsListeners() {
|
||||
chrome.webRequest.onBeforeRequest.removeListener(this.handleOnBeforeRequestEvent);
|
||||
chrome.webRequest.onCompleted.removeListener(this.handleOnCompletedRequestEvent);
|
||||
if (this.websiteOriginsWithFields.size) {
|
||||
const requestFilter: chrome.webRequest.RequestFilter = this.generateRequestFilter();
|
||||
chrome.webRequest.onBeforeRequest.addListener(this.handleOnBeforeRequestEvent, requestFilter);
|
||||
chrome.webRequest.onCompleted.addListener(this.handleOnCompletedRequestEvent, requestFilter);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the request filter for the web requests. This is used to filter out the web requests
|
||||
* that are not from the tabs that have fillable form fields.
|
||||
*/
|
||||
private generateRequestFilter(): chrome.webRequest.RequestFilter {
|
||||
const websiteOrigins = Array.from(this.websiteOriginsWithFields.values());
|
||||
const urls: string[] = [];
|
||||
websiteOrigins.forEach((origins) => urls.push(...origins));
|
||||
return {
|
||||
urls,
|
||||
types: ["main_frame", "sub_frame", "xmlhttprequest"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the onBeforeRequest event for web requests. This is used to ensures that the following
|
||||
* onCompleted event is only triggered for form submission requests.
|
||||
*
|
||||
* @param details - The details of the web request
|
||||
*/
|
||||
private handleOnBeforeRequestEvent = (details: chrome.webRequest.WebRequestDetails) => {
|
||||
if (this.isPostSubmissionFormRedirection(details)) {
|
||||
this.setupNotificationInitTrigger(
|
||||
details.tabId,
|
||||
details.requestId,
|
||||
this.modifyLoginCipherFormData.get(details.tabId),
|
||||
).catch((error) => this.logService.error(error));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isValidFormSubmissionRequest(details)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { requestId, tabId, frameId } = details;
|
||||
this.activeFormSubmissionRequests.add(requestId);
|
||||
|
||||
if (this.notificationDataIncompleteOnBeforeRequest(tabId)) {
|
||||
this.getFormFieldDataFromTab(tabId, frameId).catch((error) => this.logService.error(error));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Captures the modified login form data if the tab contains incomplete data. This is used as
|
||||
* a redundancy to ensure that the modified login form data is captured in cases where the form
|
||||
* is split into multiple parts.
|
||||
*
|
||||
* @param tabId - The id of the tab
|
||||
*/
|
||||
private notificationDataIncompleteOnBeforeRequest = (tabId: number) => {
|
||||
const modifyLoginData = this.modifyLoginCipherFormData.get(tabId);
|
||||
return (
|
||||
!modifyLoginData ||
|
||||
!this.shouldTriggerAddLoginNotification(modifyLoginData) ||
|
||||
!this.shouldTriggerChangePasswordNotification(modifyLoginData)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines whether the request is happening after a form submission. This is identified by a GET
|
||||
* request that is triggered after a form submission POST request from the same request id. If
|
||||
* this is the case, and the modified login form data is available, the add login or change password
|
||||
* notification is triggered.
|
||||
*
|
||||
* @param details - The details of the web request
|
||||
*/
|
||||
private isPostSubmissionFormRedirection = (details: chrome.webRequest.WebRequestDetails) => {
|
||||
return (
|
||||
details.method?.toUpperCase() === "GET" &&
|
||||
this.activeFormSubmissionRequests.has(details.requestId) &&
|
||||
this.modifyLoginCipherFormData.has(details.tabId)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if the web request is a valid form submission request. A valid web request
|
||||
* is a POST, PUT, or PATCH request that is not from an invalid host.
|
||||
*
|
||||
* @param details - The details of the web request
|
||||
*/
|
||||
private isValidFormSubmissionRequest = (details: chrome.webRequest.WebRequestDetails) => {
|
||||
return (
|
||||
!this.requestHostIsInvalid(details) &&
|
||||
this.formSubmissionRequestMethods.has(details.method?.toUpperCase())
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the form field data from the tab. This is used to get the modified login form data
|
||||
* in cases where the submit button is not clicked, but the form is submitted through other means.
|
||||
*
|
||||
* @param tabId - The senders tab id
|
||||
* @param frameId - The frame where the form is located
|
||||
*/
|
||||
private getFormFieldDataFromTab = async (tabId: number, frameId: number) => {
|
||||
const tab = await BrowserApi.getTab(tabId);
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = (await BrowserApi.tabSendMessage(
|
||||
tab,
|
||||
{ command: "getFormFieldDataForNotification" },
|
||||
{ frameId },
|
||||
)) as OverlayNotificationsExtensionMessage;
|
||||
if (response) {
|
||||
this.storeModifiedLoginFormData(response, { tab });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the onCompleted event for web requests. This is used to trigger the add login or change
|
||||
* password notification when a form submission request is completed.
|
||||
*
|
||||
* @param details - The details of the web response
|
||||
*/
|
||||
private handleOnCompletedRequestEvent = async (details: chrome.webRequest.WebResponseDetails) => {
|
||||
if (
|
||||
this.requestHostIsInvalid(details) ||
|
||||
this.isInvalidStatusCode(details.statusCode) ||
|
||||
!this.activeFormSubmissionRequests.has(details.requestId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modifyLoginData = this.modifyLoginCipherFormData.get(details.tabId);
|
||||
if (!modifyLoginData) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setupNotificationInitTrigger(details.tabId, details.requestId, modifyLoginData).catch(
|
||||
(error) => this.logService.error(error),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets up the initialization trigger for the add login or change password notification. This is used
|
||||
* to ensure that the notification is triggered after the tab has finished loading.
|
||||
*
|
||||
* @param tabId - The id of the tab
|
||||
* @param requestId - The request id of the web request
|
||||
* @param modifyLoginData - The modified login form data
|
||||
*/
|
||||
private setupNotificationInitTrigger = async (
|
||||
tabId: number,
|
||||
requestId: string,
|
||||
modifyLoginData: ModifyLoginCipherFormData,
|
||||
) => {
|
||||
const tab = await BrowserApi.getTab(tabId);
|
||||
if (tab.status !== "complete") {
|
||||
await this.delayNotificationInitUntilTabIsComplete(tabId, requestId, modifyLoginData);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.triggerNotificationInit(requestId, modifyLoginData, tab);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delays the initialization of the add login or change password notification
|
||||
* until the tab is complete. This is used to ensure that the notification is
|
||||
* triggered after the tab has finished loading.
|
||||
*
|
||||
* @param tabId - The id of the tab
|
||||
* @param requestId - The request id of the web request
|
||||
* @param modifyLoginData - The modified login form data
|
||||
*/
|
||||
private delayNotificationInitUntilTabIsComplete = async (
|
||||
tabId: chrome.webRequest.ResourceRequest["tabId"],
|
||||
requestId: chrome.webRequest.ResourceRequest["requestId"],
|
||||
modifyLoginData: ModifyLoginCipherFormData,
|
||||
) => {
|
||||
const handleWebNavigationOnCompleted = async () => {
|
||||
chrome.webNavigation.onCompleted.removeListener(handleWebNavigationOnCompleted);
|
||||
const tab = await BrowserApi.getTab(tabId);
|
||||
await this.triggerNotificationInit(requestId, modifyLoginData, tab);
|
||||
};
|
||||
chrome.webNavigation.onCompleted.addListener(handleWebNavigationOnCompleted);
|
||||
};
|
||||
|
||||
/**
|
||||
* Initializes the add login or change password notification based on the modified login form data
|
||||
* and the tab details. This will trigger the notification to be displayed to the user.
|
||||
*
|
||||
* @param requestId - The details of the web response
|
||||
* @param modifyLoginData - The modified login form data
|
||||
* @param tab - The tab details
|
||||
*/
|
||||
private triggerNotificationInit = async (
|
||||
requestId: chrome.webRequest.ResourceRequest["requestId"],
|
||||
modifyLoginData: ModifyLoginCipherFormData,
|
||||
tab: chrome.tabs.Tab,
|
||||
) => {
|
||||
if (this.shouldTriggerChangePasswordNotification(modifyLoginData)) {
|
||||
// These notifications are temporarily setup as "messages" to the notification background.
|
||||
// This will be structured differently in a future refactor.
|
||||
await this.notificationBackground.changedPassword(
|
||||
{
|
||||
command: "bgChangedPassword",
|
||||
data: {
|
||||
url: modifyLoginData.uri,
|
||||
currentPassword: modifyLoginData.password,
|
||||
newPassword: modifyLoginData.newPassword,
|
||||
},
|
||||
},
|
||||
{ tab },
|
||||
);
|
||||
this.clearCompletedWebRequest(requestId, tab);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.shouldTriggerAddLoginNotification(modifyLoginData)) {
|
||||
await this.notificationBackground.addLogin(
|
||||
{
|
||||
command: "bgAddLogin",
|
||||
login: {
|
||||
url: modifyLoginData.uri,
|
||||
username: modifyLoginData.username,
|
||||
password: modifyLoginData.password || modifyLoginData.newPassword,
|
||||
},
|
||||
},
|
||||
{ tab },
|
||||
);
|
||||
this.clearCompletedWebRequest(requestId, tab);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if the change password notification should be triggered.
|
||||
*
|
||||
* @param modifyLoginData - The modified login form data
|
||||
*/
|
||||
private shouldTriggerChangePasswordNotification = (
|
||||
modifyLoginData: ModifyLoginCipherFormData,
|
||||
) => {
|
||||
return modifyLoginData.newPassword && !modifyLoginData.username;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if the add login notification should be triggered.
|
||||
*
|
||||
* @param modifyLoginData - The modified login form data
|
||||
*/
|
||||
private shouldTriggerAddLoginNotification = (modifyLoginData: ModifyLoginCipherFormData) => {
|
||||
return modifyLoginData.username && (modifyLoginData.password || modifyLoginData.newPassword);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears the completed web request and removes the modified login form data for the tab.
|
||||
*
|
||||
* @param requestId - The request id of the web request
|
||||
* @param tab - The tab details
|
||||
*/
|
||||
private clearCompletedWebRequest = (
|
||||
requestId: chrome.webRequest.ResourceRequest["requestId"],
|
||||
tab: chrome.tabs.Tab,
|
||||
) => {
|
||||
this.activeFormSubmissionRequests.delete(requestId);
|
||||
this.modifyLoginCipherFormData.delete(tab.id);
|
||||
this.websiteOriginsWithFields.delete(tab.id);
|
||||
this.setupWebRequestsListeners();
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if the status code of the web response is invalid. An invalid status code is
|
||||
* any status code that is not in the 200-299 range.
|
||||
*
|
||||
* @param statusCode - The status code of the web response
|
||||
*/
|
||||
private isInvalidStatusCode = (statusCode: number) => {
|
||||
return statusCode < 200 || statusCode >= 300;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if the host of the web request is invalid. An invalid host is any host that does not
|
||||
* start with "http" or a tab id that is less than 0.
|
||||
*
|
||||
* @param details - The details of the web request
|
||||
*/
|
||||
private requestHostIsInvalid = (details: chrome.webRequest.ResourceRequest) => {
|
||||
return !details.url?.startsWith("http") || details.tabId < 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets up the listeners for the extension messages and the tab events.
|
||||
*/
|
||||
private setupExtensionListeners() {
|
||||
BrowserApi.messageListener("overlay-notifications", this.handleExtensionMessage);
|
||||
chrome.tabs.onRemoved.addListener(this.handleTabRemoved);
|
||||
chrome.tabs.onUpdated.addListener(this.handleTabUpdated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles messages that are sent to the extension background.
|
||||
*
|
||||
* @param message - The message from the content script
|
||||
* @param sender - The sender of the message
|
||||
* @param sendResponse - The response to send back to the content script
|
||||
*/
|
||||
private handleExtensionMessage = (
|
||||
message: OverlayNotificationsExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response?: any) => void,
|
||||
) => {
|
||||
const handler: CallableFunction = this.extensionMessageHandlers[message.command];
|
||||
if (!handler) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messageResponse = handler({ message, sender });
|
||||
if (typeof messageResponse === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
Promise.resolve(messageResponse)
|
||||
.then((response) => sendResponse(response))
|
||||
.catch((error) => this.logService.error(error));
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the removal of a tab. This is used to remove the modified login form data for the tab.
|
||||
*
|
||||
* @param tabId - The id of the tab that was removed
|
||||
*/
|
||||
private handleTabRemoved = (tabId: number) => {
|
||||
this.modifyLoginCipherFormData.delete(tabId);
|
||||
if (this.websiteOriginsWithFields.has(tabId)) {
|
||||
this.websiteOriginsWithFields.delete(tabId);
|
||||
this.setupWebRequestsListeners();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the update of a tab. This is used to remove the modified
|
||||
* login form data for the tab when the tab is loading.
|
||||
*
|
||||
* @param tabId - The id of the tab that was updated
|
||||
* @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);
|
||||
}
|
||||
};
|
||||
}
|
@ -12,6 +12,16 @@ let pageDetailsMock: AutofillPageDetails;
|
||||
let fillScriptMock: AutofillScript;
|
||||
let autofillFieldElementByOpidMock: FormFieldElement;
|
||||
|
||||
jest.mock("../services/dom-query.service", () => {
|
||||
const module = jest.requireActual("../services/dom-query.service");
|
||||
return {
|
||||
DomQueryService: class extends module.DomQueryService {
|
||||
deepQueryElements<T>(element: HTMLElement, queryString: string): T[] {
|
||||
return Array.from(element.querySelectorAll(queryString)) as T[];
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
jest.mock("../services/collect-autofill-content.service", () => {
|
||||
const module = jest.requireActual("../services/collect-autofill-content.service");
|
||||
return {
|
||||
@ -20,10 +30,6 @@ jest.mock("../services/collect-autofill-content.service", () => {
|
||||
return pageDetailsMock;
|
||||
}
|
||||
|
||||
deepQueryElements<T>(element: HTMLElement, queryString: string): T[] {
|
||||
return Array.from(element.querySelectorAll(queryString)) as T[];
|
||||
}
|
||||
|
||||
getAutofillFieldElementByOpid(opid: string) {
|
||||
const mockedEl = autofillFieldElementByOpidMock;
|
||||
if (mockedEl) {
|
||||
|
@ -4,13 +4,16 @@ import AutofillPageDetails from "../models/autofill-page-details";
|
||||
import AutofillScript from "../models/autofill-script";
|
||||
import { CollectAutofillContentService } from "../services/collect-autofill-content.service";
|
||||
import DomElementVisibilityService from "../services/dom-element-visibility.service";
|
||||
import { DomQueryService } from "../services/dom-query.service";
|
||||
import InsertAutofillContentService from "../services/insert-autofill-content.service";
|
||||
import { elementIsInputElement, nodeIsFormElement, sendExtensionMessage } from "../utils";
|
||||
|
||||
(function (globalContext) {
|
||||
const domQueryService = new DomQueryService();
|
||||
const domElementVisibilityService = new DomElementVisibilityService();
|
||||
const collectAutofillContentService = new CollectAutofillContentService(
|
||||
domElementVisibilityService,
|
||||
domQueryService,
|
||||
);
|
||||
const insertAutofillContentService = new InsertAutofillContentService(
|
||||
domElementVisibilityService,
|
||||
@ -191,7 +194,7 @@ import { elementIsInputElement, nodeIsFormElement, sendExtensionMessage } from "
|
||||
element: HTMLElement,
|
||||
lastFieldIsPasswordInput = false,
|
||||
): boolean {
|
||||
const genericSubmitElement = collectAutofillContentService.deepQueryElements<HTMLButtonElement>(
|
||||
const genericSubmitElement = domQueryService.deepQueryElements<HTMLButtonElement>(
|
||||
element,
|
||||
"[type='submit']",
|
||||
);
|
||||
@ -200,10 +203,7 @@ import { elementIsInputElement, nodeIsFormElement, sendExtensionMessage } from "
|
||||
return true;
|
||||
}
|
||||
|
||||
const buttons = collectAutofillContentService.deepQueryElements<HTMLButtonElement>(
|
||||
element,
|
||||
"button",
|
||||
);
|
||||
const buttons = domQueryService.deepQueryElements<HTMLButtonElement>(element, "button");
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
if (isLoginButton(buttons[i])) {
|
||||
clickSubmitElement(buttons[i], lastFieldIsPasswordInput);
|
||||
@ -274,7 +274,7 @@ import { elementIsInputElement, nodeIsFormElement, sendExtensionMessage } from "
|
||||
*/
|
||||
function getAutofillFormElements(): HTMLFormElement[] {
|
||||
const formElements: HTMLFormElement[] = [];
|
||||
collectAutofillContentService.queryAllTreeWalkerNodes(
|
||||
domQueryService.queryAllTreeWalkerNodes(
|
||||
globalContext.document.documentElement,
|
||||
(node: Node) => {
|
||||
if (nodeIsFormElement(node)) {
|
||||
|
@ -3,6 +3,8 @@ import { mock, MockProxy } from "jest-mock-extended";
|
||||
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 { DomQueryService } from "../services/abstractions/dom-query.service";
|
||||
import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service";
|
||||
import {
|
||||
flushPromises,
|
||||
@ -14,6 +16,8 @@ import { AutofillExtensionMessage } from "./abstractions/autofill-init";
|
||||
import AutofillInit from "./autofill-init";
|
||||
|
||||
describe("AutofillInit", () => {
|
||||
let domQueryService: MockProxy<DomQueryService>;
|
||||
let overlayNotificationsContentService: MockProxy<OverlayNotificationsContentService>;
|
||||
let inlineMenuElements: MockProxy<AutofillInlineMenuContentService>;
|
||||
let autofillOverlayContentService: MockProxy<AutofillOverlayContentService>;
|
||||
let autofillInit: AutofillInit;
|
||||
@ -27,9 +31,16 @@ describe("AutofillInit", () => {
|
||||
addListener: jest.fn(),
|
||||
},
|
||||
});
|
||||
domQueryService = mock<DomQueryService>();
|
||||
overlayNotificationsContentService = mock<OverlayNotificationsContentService>();
|
||||
inlineMenuElements = mock<AutofillInlineMenuContentService>();
|
||||
autofillOverlayContentService = mock<AutofillOverlayContentService>();
|
||||
autofillInit = new AutofillInit(autofillOverlayContentService, inlineMenuElements);
|
||||
autofillInit = new AutofillInit(
|
||||
domQueryService,
|
||||
autofillOverlayContentService,
|
||||
inlineMenuElements,
|
||||
overlayNotificationsContentService,
|
||||
);
|
||||
sendExtensionMessageSpy = jest
|
||||
.spyOn(autofillInit as any, "sendExtensionMessage")
|
||||
.mockImplementation();
|
||||
@ -171,6 +182,16 @@ describe("AutofillInit", () => {
|
||||
expect(inlineMenuElements.messageHandlers.messageHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers extension message handlers from the OverlayNotificationsContentService", () => {
|
||||
overlayNotificationsContentService.messageHandlers.messageHandler = jest.fn();
|
||||
|
||||
sendMockExtensionMessage({ command: "messageHandler" }, sender, sendResponse);
|
||||
|
||||
expect(
|
||||
overlayNotificationsContentService.messageHandlers.messageHandler,
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("collectPageDetails", () => {
|
||||
it("sends the collected page details for autofill using a background script message", async () => {
|
||||
const pageDetails: AutofillPageDetails = {
|
||||
|
@ -2,7 +2,9 @@ import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||
|
||||
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 { 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";
|
||||
@ -16,8 +18,6 @@ import {
|
||||
|
||||
class AutofillInit implements AutofillInitInterface {
|
||||
private readonly sendExtensionMessage = sendExtensionMessage;
|
||||
private readonly autofillOverlayContentService: AutofillOverlayContentService | undefined;
|
||||
private readonly autofillInlineMenuContentService: AutofillInlineMenuContentService | undefined;
|
||||
private readonly domElementVisibilityService: DomElementVisibilityService;
|
||||
private readonly collectAutofillContentService: CollectAutofillContentService;
|
||||
private readonly insertAutofillContentService: InsertAutofillContentService;
|
||||
@ -32,20 +32,23 @@ class AutofillInit implements AutofillInitInterface {
|
||||
* AutofillInit constructor. Initializes the DomElementVisibilityService,
|
||||
* CollectAutofillContentService and InsertAutofillContentService classes.
|
||||
*
|
||||
* @param domQueryService - Service used to handle DOM queries.
|
||||
* @param autofillOverlayContentService - The autofill overlay content service, potentially undefined.
|
||||
* @param inlineMenuElements - The inline menu elements, potentially undefined.
|
||||
* @param autofillInlineMenuContentService - The inline menu content service, potentially undefined.
|
||||
* @param overlayNotificationsContentService - The overlay notifications content service, potentially undefined.
|
||||
*/
|
||||
constructor(
|
||||
autofillOverlayContentService?: AutofillOverlayContentService,
|
||||
inlineMenuElements?: AutofillInlineMenuContentService,
|
||||
private domQueryService: DomQueryService,
|
||||
private autofillOverlayContentService?: AutofillOverlayContentService,
|
||||
private autofillInlineMenuContentService?: AutofillInlineMenuContentService,
|
||||
private overlayNotificationsContentService?: OverlayNotificationsContentService,
|
||||
) {
|
||||
this.autofillOverlayContentService = autofillOverlayContentService;
|
||||
this.autofillInlineMenuContentService = inlineMenuElements;
|
||||
this.domElementVisibilityService = new DomElementVisibilityService(
|
||||
this.autofillInlineMenuContentService,
|
||||
);
|
||||
this.collectAutofillContentService = new CollectAutofillContentService(
|
||||
this.domElementVisibilityService,
|
||||
domQueryService,
|
||||
this.autofillOverlayContentService,
|
||||
);
|
||||
this.insertAutofillContentService = new InsertAutofillContentService(
|
||||
@ -204,6 +207,10 @@ class AutofillInit implements AutofillInitInterface {
|
||||
return this.autofillInlineMenuContentService.messageHandlers[command];
|
||||
}
|
||||
|
||||
if (this.overlayNotificationsContentService?.messageHandlers?.[command]) {
|
||||
return this.overlayNotificationsContentService.messageHandlers[command];
|
||||
}
|
||||
|
||||
return this.extensionMessageHandlers[command];
|
||||
}
|
||||
|
||||
@ -217,6 +224,7 @@ class AutofillInit implements AutofillInitInterface {
|
||||
this.collectAutofillContentService.destroy();
|
||||
this.autofillOverlayContentService?.destroy();
|
||||
this.autofillInlineMenuContentService?.destroy();
|
||||
this.overlayNotificationsContentService?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,30 @@
|
||||
import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service";
|
||||
import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service";
|
||||
import { DomQueryService } from "../services/dom-query.service";
|
||||
import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service";
|
||||
import { setupAutofillInitDisconnectAction } from "../utils";
|
||||
|
||||
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;
|
||||
if (globalThis.self === globalThis.top) {
|
||||
inlineMenuElements = new AutofillInlineMenuContentService();
|
||||
}
|
||||
windowContext.bitwardenAutofillInit = new AutofillInit(
|
||||
domQueryService,
|
||||
autofillOverlayContentService,
|
||||
inlineMenuElements,
|
||||
);
|
||||
setupAutofillInitDisconnectAction(windowContext);
|
||||
|
||||
windowContext.bitwardenAutofillInit.init();
|
||||
}
|
||||
})(window);
|
@ -0,0 +1,33 @@
|
||||
import { OverlayNotificationsContentService } from "../overlay/notifications/content/overlay-notifications-content.service";
|
||||
import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service";
|
||||
import { DomQueryService } from "../services/dom-query.service";
|
||||
import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service";
|
||||
import { setupAutofillInitDisconnectAction } from "../utils";
|
||||
|
||||
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 overlayNotificationsContentService: OverlayNotificationsContentService;
|
||||
if (globalThis.self === globalThis.top) {
|
||||
overlayNotificationsContentService = new OverlayNotificationsContentService();
|
||||
}
|
||||
|
||||
windowContext.bitwardenAutofillInit = new AutofillInit(
|
||||
domQueryService,
|
||||
autofillOverlayContentService,
|
||||
null,
|
||||
overlayNotificationsContentService,
|
||||
);
|
||||
setupAutofillInitDisconnectAction(windowContext);
|
||||
|
||||
windowContext.bitwardenAutofillInit.init();
|
||||
}
|
||||
})(window);
|
@ -1,5 +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 { DomQueryService } from "../services/dom-query.service";
|
||||
import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service";
|
||||
import { setupAutofillInitDisconnectAction } from "../utils";
|
||||
|
||||
@ -7,17 +9,25 @@ 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 overlayNotificationsContentService: OverlayNotificationsContentService;
|
||||
if (globalThis.self === globalThis.top) {
|
||||
inlineMenuElements = new AutofillInlineMenuContentService();
|
||||
overlayNotificationsContentService = new OverlayNotificationsContentService();
|
||||
}
|
||||
|
||||
windowContext.bitwardenAutofillInit = new AutofillInit(
|
||||
domQueryService,
|
||||
autofillOverlayContentService,
|
||||
inlineMenuElements,
|
||||
overlayNotificationsContentService,
|
||||
);
|
||||
setupAutofillInitDisconnectAction(windowContext);
|
||||
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { DomQueryService } from "../services/dom-query.service";
|
||||
import { setupAutofillInitDisconnectAction } from "../utils";
|
||||
|
||||
import AutofillInit from "./autofill-init";
|
||||
|
||||
(function (windowContext) {
|
||||
if (!windowContext.bitwardenAutofillInit) {
|
||||
windowContext.bitwardenAutofillInit = new AutofillInit();
|
||||
const domQueryService = new DomQueryService();
|
||||
windowContext.bitwardenAutofillInit = new AutofillInit(domQueryService);
|
||||
setupAutofillInitDisconnectAction(windowContext);
|
||||
|
||||
windowContext.bitwardenAutofillInit.init();
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
import AutofillField from "../models/autofill-field";
|
||||
import { WatchedForm } from "../models/watched-form";
|
||||
import { NotificationBarIframeInitData } from "../notification/abstractions/notification-bar";
|
||||
import { NotificationTypeData } from "../overlay/notifications/abstractions/overlay-notifications-content.service";
|
||||
import { FormData } from "../services/abstractions/autofill.service";
|
||||
import { sendExtensionMessage, setupExtensionDisconnectAction } from "../utils";
|
||||
|
||||
@ -832,7 +833,7 @@ async function loadNotificationBar() {
|
||||
// End Form Detection and Submission Handling
|
||||
|
||||
// Notification Bar Functions (open, close, height adjustment, etc.)
|
||||
function closeExistingAndOpenBar(type: string, typeData: any) {
|
||||
function closeExistingAndOpenBar(type: string, typeData: NotificationTypeData) {
|
||||
const notificationBarInitData: NotificationBarIframeInitData = {
|
||||
type,
|
||||
isVaultLocked: typeData.isVaultLocked,
|
||||
|
@ -2,6 +2,7 @@ import { AutofillInit } from "../../content/abstractions/autofill-init";
|
||||
import AutofillPageDetails from "../../models/autofill-page-details";
|
||||
import { CollectAutofillContentService } from "../../services/collect-autofill-content.service";
|
||||
import DomElementVisibilityService from "../../services/dom-element-visibility.service";
|
||||
import { DomQueryService } from "../../services/dom-query.service";
|
||||
import InsertAutofillContentService from "../../services/insert-autofill-content.service";
|
||||
import { sendExtensionMessage } from "../../utils";
|
||||
import { LegacyAutofillOverlayContentService } from "../services/abstractions/autofill-overlay-content.service";
|
||||
@ -40,8 +41,10 @@ class LegacyAutofillInit implements AutofillInit {
|
||||
constructor(autofillOverlayContentService?: LegacyAutofillOverlayContentService) {
|
||||
this.autofillOverlayContentService = autofillOverlayContentService;
|
||||
this.domElementVisibilityService = new DomElementVisibilityService();
|
||||
const domQueryService = new DomQueryService();
|
||||
this.collectAutofillContentService = new CollectAutofillContentService(
|
||||
this.domElementVisibilityService,
|
||||
domQueryService,
|
||||
this.autofillOverlayContentService,
|
||||
);
|
||||
this.insertAutofillContentService = new InsertAutofillContentService(
|
||||
|
@ -73,7 +73,7 @@ class LegacyAutofillOverlayContentService implements LegacyAutofillOverlayConten
|
||||
* Satisfy the AutofillOverlayContentService interface.
|
||||
*/
|
||||
messageHandlers = {} as AutofillOverlayContentExtensionMessageHandlers;
|
||||
async setupInlineMenu(
|
||||
async setupOverlayListeners(
|
||||
autofillFieldElement: ElementWithOpId<FormFieldElement>,
|
||||
autofillFieldData: AutofillField,
|
||||
pageDetails: AutofillPageDetails,
|
||||
|
@ -1,5 +1,6 @@
|
||||
export const AutofillFieldQualifier = {
|
||||
password: "password",
|
||||
newPassword: "newPassword",
|
||||
username: "username",
|
||||
cardholderName: "cardholderName",
|
||||
cardNumber: "cardNumber",
|
||||
|
@ -4,6 +4,8 @@ type NotificationBarIframeInitData = {
|
||||
theme?: string;
|
||||
removeIndividualVault?: boolean;
|
||||
importType?: string;
|
||||
applyRedesign?: boolean;
|
||||
launchTimestamp?: number;
|
||||
};
|
||||
|
||||
type NotificationBarWindowMessage = {
|
||||
|
@ -13,16 +13,11 @@
|
||||
</a>
|
||||
</div>
|
||||
<div id="content"></div>
|
||||
<div>
|
||||
<div class="notification-close">
|
||||
<button type="button" class="neutral" id="close-button">
|
||||
<svg
|
||||
id="close"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
xmlns:v="https://vecta.io/nano"
|
||||
>
|
||||
<svg id="close" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none">
|
||||
<path
|
||||
d="M497.72 429.63l-169-174.82L498.11 82.24c6.956-7.168 6.956-18.85 0-26.018L449.934 6.31C446.585 2.859 442.076 1 437.31 1s-9.275 1.991-12.624 5.31l-168.62 172.04L87.196 6.45c-3.349-3.451-7.858-5.31-12.624-5.31s-9.275 1.991-12.624 5.31L13.9 56.362c-6.956 7.168-6.956 18.85 0 26.018l169.39 172.57L14.42 429.64c-3.349 3.451-5.281 8.097-5.281 13.009s1.803 9.558 5.281 13.009l48.176 49.912c3.478 3.584 7.987 5.442 12.624 5.442 4.508 0 9.146-1.726 12.624-5.442l168.23-174.16 168.36 174.03c3.478 3.584 7.986 5.442 12.624 5.442 4.509 0 9.146-1.726 12.624-5.442l48.176-49.912c3.349-3.451 5.281-8.097 5.281-13.009a19.32 19.32 0 0 0-5.41-12.876z"
|
||||
d="M14.431 13.57 8.865 8.173a.388.388 0 0 1 0-.559l5.498-5.33a.388.388 0 0 0-.005-.553.415.415 0 0 0-.572-.006l-5.498 5.33a.416.416 0 0 1-.577 0L2.196 1.72a.403.403 0 0 0-.29-.12.422.422 0 0 0-.292.115.395.395 0 0 0-.12.283.386.386 0 0 0 .125.28l5.515 5.338a.388.388 0 0 1 0 .559L1.56 13.568a.397.397 0 0 0-.12.28c0 .105.044.205.12.28a.416.416 0 0 0 .578-.001l5.574-5.395a.416.416 0 0 1 .577 0l5.567 5.395a.422.422 0 0 0 .582.005.398.398 0 0 0 .12-.282.387.387 0 0 0-.125-.281Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
@ -32,8 +27,8 @@
|
||||
|
||||
<template id="template-add">
|
||||
<div class="inner-wrapper">
|
||||
<div id="add-text"></div>
|
||||
<div class="add-change-cipher-buttons">
|
||||
<div id="add-text" class="notification-body"></div>
|
||||
<div class="add-change-cipher-buttons notification-actions">
|
||||
<button type="button" id="never-save" class="link"></button>
|
||||
<select id="select-folder"></select>
|
||||
<button type="button" id="add-edit" class="secondary"></button>
|
||||
@ -44,8 +39,8 @@
|
||||
|
||||
<template id="template-change">
|
||||
<div class="inner-wrapper">
|
||||
<div id="change-text"></div>
|
||||
<div class="add-change-cipher-buttons">
|
||||
<div id="change-text" class="notification-body"></div>
|
||||
<div class="add-change-cipher-buttons notification-actions">
|
||||
<button type="button" id="change-edit" class="secondary"></button>
|
||||
<button type="button" id="change-save" class="primary"></button>
|
||||
</div>
|
||||
@ -54,8 +49,8 @@
|
||||
|
||||
<template id="template-unlock">
|
||||
<div class="inner-wrapper">
|
||||
<div id="unlock-text"></div>
|
||||
<div>
|
||||
<div id="unlock-text" class="notification-body"></div>
|
||||
<div class="notification-actions">
|
||||
<button type="button" id="unlock-vault" class="primary"></button>
|
||||
</div>
|
||||
</div>
|
||||
@ -63,8 +58,8 @@
|
||||
|
||||
<template id="template-fileless-import">
|
||||
<div class="inner-wrapper">
|
||||
<div id="fileless-import-text"></div>
|
||||
<div id="fileless-import-buttons">
|
||||
<div id="fileless-import-text" class="notification-body"></div>
|
||||
<div id="fileless-import-buttons" class="notification-actions">
|
||||
<button type="button" id="cancel-fileless-import" class="secondary"></button>
|
||||
<button type="button" id="start-fileless-import" class="primary"></button>
|
||||
</div>
|
||||
|
@ -68,8 +68,8 @@ img {
|
||||
|
||||
#close {
|
||||
display: block;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
> path {
|
||||
@include themify($themes) {
|
||||
@ -158,9 +158,24 @@ button {
|
||||
}
|
||||
|
||||
.success-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
|
||||
@include themify($themes) {
|
||||
color: themed("successColor");
|
||||
}
|
||||
|
||||
svg {
|
||||
margin-right: 8px;
|
||||
|
||||
path {
|
||||
@include themify($themes) {
|
||||
fill: themed("successColor");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
@ -180,3 +195,158 @@ button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-bar-redesign {
|
||||
button {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.outer-wrapper {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
border-top: 1px solid transparent;
|
||||
border-left: 1px solid transparent;
|
||||
border-right: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
|
||||
@include themify($themes) {
|
||||
border-top-color: themed("borderColor");
|
||||
border-left-color: themed("borderColor");
|
||||
border-right-color: themed("borderColor");
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notification-close {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
|
||||
#close-button {
|
||||
display: flex;
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#content .inner-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.notification-body {
|
||||
width: 100%;
|
||||
padding: 4px 38px 24px 42px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.notification-actions {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
align-content: stretch;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
|
||||
#never-save {
|
||||
margin-right: auto;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
#select-folder {
|
||||
width: 125px;
|
||||
margin-right: 6px;
|
||||
font-size: 12px;
|
||||
appearance: none;
|
||||
background-size: 16px;
|
||||
background-position: center right 4px;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
@include themify($themes) {
|
||||
color: themed("mutedTextColor");
|
||||
border-color: themed("mutedTextColor");
|
||||
}
|
||||
|
||||
&:not([disabled]) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.primary,
|
||||
.secondary {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
margin-right: 6px;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
&.success-message,
|
||||
&.error-message {
|
||||
padding: 4px 36px 6px 42px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.success-event,
|
||||
.error-event {
|
||||
.notification-body {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.theme_light {
|
||||
.notification-bar-redesign #content .inner-wrapper {
|
||||
#select-folder {
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHhtbG5zOnhsaW5rPSdodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rJyB3aWR0aD0nMTYnIGhlaWdodD0nMTYnIGZpbGw9J25vbmUnPjxwYXRoIHN0cm9rZT0nIzIxMjUyOScgZD0nbTUgNiAzIDMgMy0zJy8+PC9zdmc+");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.theme_dark {
|
||||
.notification-bar-redesign #content .inner-wrapper {
|
||||
#select-folder {
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxNicgaGVpZ2h0PScxNicgZmlsbD0nbm9uZSc+PHBhdGggc3Ryb2tlPScjZmZmZmZmJyBkPSdtNSA2IDMgMyAzLTMnLz48L3N2Zz4=");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.theme_solarizedDark {
|
||||
.notification-bar-redesign #content .inner-wrapper {
|
||||
#select-folder {
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxNicgaGVpZ2h0PScxNicgZmlsbD0nbm9uZSc+PHBhdGggc3Ryb2tlPScjZWVlOGQ1JyBkPSdtNSA2IDMgMyAzLTMnLz48L3N2Zz4=");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.theme_nord {
|
||||
.notification-bar-redesign #content .inner-wrapper {
|
||||
#select-folder {
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxNicgaGVpZ2h0PScxNicgZmlsbD0nbm9uZSc+PHBhdGggc3Ryb2tlPScjRTVFOUYwJyBkPSdtNSA2IDMgMyAzLTMnLz48L3N2Zz4=");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,8 @@ import type { FolderView } from "@bitwarden/common/vault/models/view/folder.view
|
||||
|
||||
import { FilelessImportPort, FilelessImportType } from "../../tools/enums/fileless-import.enums";
|
||||
import { AdjustNotificationBarMessageData } from "../background/abstractions/notification.background";
|
||||
import { buildSvgDomElement } from "../utils";
|
||||
import { circleCheckIcon } from "../utils/svg-icons";
|
||||
|
||||
import {
|
||||
NotificationBarWindowMessageHandlers,
|
||||
@ -212,20 +214,24 @@ function handleSaveCipherAttemptCompletedMessage(message: NotificationBarWindowM
|
||||
notificationBarOuterWrapper.classList.add("error-event");
|
||||
});
|
||||
|
||||
adjustHeight();
|
||||
logService.error(`Error encountered when saving credentials: ${message.error}`);
|
||||
return;
|
||||
}
|
||||
const messageName =
|
||||
notificationBarIframeInitData.type === "add"
|
||||
? "saveCipherAttemptSuccess"
|
||||
: "updateCipherAttemptSuccess";
|
||||
notificationBarIframeInitData.type === "add" ? "passwordSaved" : "passwordUpdated";
|
||||
|
||||
addSaveButtonContainers.forEach((element) => {
|
||||
element.textContent = chrome.i18n.getMessage(messageName);
|
||||
element.prepend(buildSvgDomElement(circleCheckIcon));
|
||||
element.classList.add("success-message");
|
||||
notificationBarOuterWrapper.classList.add("success-event");
|
||||
});
|
||||
setTimeout(() => sendPlatformMessage({ command: "bgCloseNotificationBar" }), 1250);
|
||||
adjustHeight();
|
||||
globalThis.setTimeout(
|
||||
() => sendPlatformMessage({ command: "bgCloseNotificationBar", fadeOutNotification: true }),
|
||||
3000,
|
||||
);
|
||||
}
|
||||
|
||||
function handleTypeUnlock() {
|
||||
@ -276,14 +282,17 @@ function handleTypeFilelessImport() {
|
||||
|
||||
if (msg.command === "filelessImportCompleted") {
|
||||
filelessImportButtons.textContent = chrome.i18n.getMessage("dataSuccessfullyImported");
|
||||
filelessImportButtons.prepend(buildSvgDomElement(circleCheckIcon));
|
||||
filelessImportButtons.classList.add("success-message");
|
||||
notificationBarOuterWrapper.classList.add("success-event");
|
||||
adjustHeight();
|
||||
return;
|
||||
}
|
||||
|
||||
filelessImportButtons.textContent = chrome.i18n.getMessage("dataImportFailed");
|
||||
filelessImportButtons.classList.add("error-message");
|
||||
notificationBarOuterWrapper.classList.add("error-event");
|
||||
adjustHeight();
|
||||
logService.error(`Error Encountered During Import: ${msg.importErrorMessage}`);
|
||||
};
|
||||
port.onMessage.addListener(handlePortMessage);
|
||||
@ -390,6 +399,10 @@ function setNotificationBarTheme() {
|
||||
}
|
||||
|
||||
document.documentElement.classList.add(`theme_${theme}`);
|
||||
|
||||
if (notificationBarIframeInitData.applyRedesign) {
|
||||
document.body.classList.add("notification-bar-redesign");
|
||||
}
|
||||
}
|
||||
|
||||
function postMessageToParent(message: NotificationBarWindowMessage) {
|
||||
|
@ -1,5 +1,8 @@
|
||||
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 { createMutationRecordMock } from "../../../spec/autofill-mocks";
|
||||
import { flushPromises, sendMockExtensionMessage } from "../../../spec/testing-utils";
|
||||
import { ElementWithOpId } from "../../../types";
|
||||
@ -7,6 +10,7 @@ import { ElementWithOpId } from "../../../types";
|
||||
import { AutofillInlineMenuContentService } from "./autofill-inline-menu-content.service";
|
||||
|
||||
describe("AutofillInlineMenuContentService", () => {
|
||||
let domQueryService: MockProxy<DomQueryService>;
|
||||
let autofillInlineMenuContentService: AutofillInlineMenuContentService;
|
||||
let autofillInit: AutofillInit;
|
||||
let sendExtensionMessageSpy: jest.SpyInstance;
|
||||
@ -17,8 +21,9 @@ describe("AutofillInlineMenuContentService", () => {
|
||||
beforeEach(() => {
|
||||
globalThis.document.body.innerHTML = "";
|
||||
globalThis.requestIdleCallback = jest.fn((cb, options) => setTimeout(cb, 100));
|
||||
domQueryService = mock<DomQueryService>();
|
||||
autofillInlineMenuContentService = new AutofillInlineMenuContentService();
|
||||
autofillInit = new AutofillInit(null, autofillInlineMenuContentService);
|
||||
autofillInit = new AutofillInit(domQueryService, null, autofillInlineMenuContentService);
|
||||
autofillInit.init();
|
||||
observeBodyMutationsSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService["bodyElementMutationObserver"] as any,
|
||||
|
@ -0,0 +1,40 @@
|
||||
export type NotificationTypeData = {
|
||||
isVaultLocked?: boolean;
|
||||
theme?: string;
|
||||
removeIndividualVault?: boolean;
|
||||
importType?: string;
|
||||
launchTimestamp?: number;
|
||||
};
|
||||
|
||||
export type NotificationsExtensionMessage = {
|
||||
command: string;
|
||||
data?: {
|
||||
type?: string;
|
||||
typeData?: NotificationTypeData;
|
||||
height?: number;
|
||||
error?: string;
|
||||
fadeOutNotification?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type OverlayNotificationsExtensionMessageParam = {
|
||||
message: NotificationsExtensionMessage;
|
||||
};
|
||||
type OverlayNotificationsExtensionSenderParam = {
|
||||
sender: chrome.runtime.MessageSender;
|
||||
};
|
||||
export type OverlayNotificationsExtensionMessageParams = OverlayNotificationsExtensionMessageParam &
|
||||
OverlayNotificationsExtensionSenderParam;
|
||||
|
||||
export type OverlayNotificationsExtensionMessageHandlers = {
|
||||
[key: string]: ({ message, sender }: OverlayNotificationsExtensionMessageParams) => any;
|
||||
openNotificationBar: ({ message }: OverlayNotificationsExtensionMessageParam) => void;
|
||||
closeNotificationBar: ({ message }: OverlayNotificationsExtensionMessageParam) => void;
|
||||
adjustNotificationBar: ({ message }: OverlayNotificationsExtensionMessageParam) => void;
|
||||
saveCipherAttemptCompleted: ({ message }: OverlayNotificationsExtensionMessageParam) => void;
|
||||
};
|
||||
|
||||
export interface OverlayNotificationsContentService {
|
||||
messageHandlers: OverlayNotificationsExtensionMessageHandlers;
|
||||
destroy: () => void;
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`OverlayNotificationsContentService opening the notification bar creates the notification bar elements and appends them to the body 1`] = `
|
||||
<div
|
||||
id="bit-notification-bar"
|
||||
style="height: 82px !important; width: 430px !important; max-width: calc(100% - 20px) !important; min-height: initial !important; top: 10px !important; right: 10px !important; padding: 0px !important; position: fixed !important; z-index: 2147483647 !important; visibility: visible !important; border-radius: 4px !important; background-color: transparent !important; overflow: hidden !important; transition: box-shadow 0.15s ease !important; transition-delay: 0.15s;"
|
||||
>
|
||||
<iframe
|
||||
id="bit-notification-bar-iframe"
|
||||
src="chrome-extension://id/notification/bar.html"
|
||||
style="width: 100% !important; height: 100% !important; border: 0px !important; display: block !important; position: relative !important; transition: transform 0.15s ease-out, opacity 0.15s ease !important; border-radius: 4px !important; transform: translateX(0) !important; opacity: 0;"
|
||||
/>
|
||||
</div>
|
||||
`;
|
@ -0,0 +1,264 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import AutofillInit from "../../../content/autofill-init";
|
||||
import { DomQueryService } from "../../../services/abstractions/dom-query.service";
|
||||
import { flushPromises, sendMockExtensionMessage } from "../../../spec/testing-utils";
|
||||
import { NotificationTypeData } from "../abstractions/overlay-notifications-content.service";
|
||||
|
||||
import { OverlayNotificationsContentService } from "./overlay-notifications-content.service";
|
||||
|
||||
describe("OverlayNotificationsContentService", () => {
|
||||
let overlayNotificationsContentService: OverlayNotificationsContentService;
|
||||
let domQueryService: MockProxy<DomQueryService>;
|
||||
let autofillInit: AutofillInit;
|
||||
let bodyAppendChildSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
domQueryService = mock<DomQueryService>();
|
||||
overlayNotificationsContentService = new OverlayNotificationsContentService();
|
||||
autofillInit = new AutofillInit(
|
||||
domQueryService,
|
||||
null,
|
||||
null,
|
||||
overlayNotificationsContentService,
|
||||
);
|
||||
autofillInit.init();
|
||||
bodyAppendChildSpy = jest.spyOn(globalThis.document.body, "appendChild");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
|
||||
describe("opening the notification bar", () => {
|
||||
it("skips opening the notification bar if the init data is not present in the message", async () => {
|
||||
sendMockExtensionMessage({ command: "openNotificationBar" });
|
||||
await flushPromises();
|
||||
|
||||
expect(bodyAppendChildSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the notification bar if the notification bar type has changed", async () => {
|
||||
overlayNotificationsContentService["currentNotificationBarType"] = "add";
|
||||
const closeNotificationBarSpy = jest.spyOn(
|
||||
overlayNotificationsContentService as any,
|
||||
"closeNotificationBar",
|
||||
);
|
||||
|
||||
sendMockExtensionMessage({
|
||||
command: "openNotificationBar",
|
||||
data: {
|
||||
type: "change",
|
||||
typeData: mock<NotificationTypeData>(),
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(closeNotificationBarSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates the notification bar elements and appends them to the body", async () => {
|
||||
sendMockExtensionMessage({
|
||||
command: "openNotificationBar",
|
||||
data: {
|
||||
type: "change",
|
||||
typeData: mock<NotificationTypeData>(),
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(overlayNotificationsContentService["notificationBarElement"]).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("sets up a slide in animation when the notification is fresh", async () => {
|
||||
sendMockExtensionMessage({
|
||||
command: "openNotificationBar",
|
||||
data: {
|
||||
type: "change",
|
||||
typeData: mock<NotificationTypeData>({
|
||||
launchTimestamp: Date.now(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
overlayNotificationsContentService["notificationBarIframeElement"].style.transform,
|
||||
).toBe("translateX(100%)");
|
||||
});
|
||||
|
||||
it("triggers the iframe animation on load of the element", async () => {
|
||||
sendMockExtensionMessage({
|
||||
command: "openNotificationBar",
|
||||
data: {
|
||||
type: "change",
|
||||
typeData: mock<NotificationTypeData>(),
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
overlayNotificationsContentService["notificationBarIframeElement"].dispatchEvent(
|
||||
new Event("load"),
|
||||
);
|
||||
|
||||
expect(
|
||||
overlayNotificationsContentService["notificationBarIframeElement"].style.transform,
|
||||
).toBe("translateX(0)");
|
||||
});
|
||||
|
||||
it("sends an initialization message to the notification bar iframe", async () => {
|
||||
sendMockExtensionMessage({
|
||||
command: "openNotificationBar",
|
||||
data: {
|
||||
type: "change",
|
||||
typeData: mock<NotificationTypeData>(),
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
const postMessageSpy = jest.spyOn(
|
||||
overlayNotificationsContentService["notificationBarIframeElement"].contentWindow,
|
||||
"postMessage",
|
||||
);
|
||||
|
||||
globalThis.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: { command: "someOtherMessage" },
|
||||
}),
|
||||
);
|
||||
globalThis.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: { command: "initNotificationBar" },
|
||||
source: overlayNotificationsContentService["notificationBarIframeElement"].contentWindow,
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(postMessageSpy).toHaveBeenCalledTimes(1);
|
||||
expect(postMessageSpy).toHaveBeenCalledWith(
|
||||
{
|
||||
command: "initNotificationBar",
|
||||
initData: expect.any(Object),
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("closing the notification bar", () => {
|
||||
beforeEach(async () => {
|
||||
sendMockExtensionMessage({
|
||||
command: "openNotificationBar",
|
||||
data: {
|
||||
type: "change",
|
||||
typeData: mock<NotificationTypeData>(),
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("triggers a fadeout of the notification bar", () => {
|
||||
sendMockExtensionMessage({
|
||||
command: "closeNotificationBar",
|
||||
data: { fadeOutNotification: true },
|
||||
});
|
||||
|
||||
expect(overlayNotificationsContentService["notificationBarIframeElement"].style.opacity).toBe(
|
||||
"0",
|
||||
);
|
||||
|
||||
jest.advanceTimersByTime(150);
|
||||
|
||||
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith(
|
||||
{ command: "bgRemoveTabFromNotificationQueue" },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("closes the notification bar without a fadeout", () => {
|
||||
jest.spyOn(globalThis, "setTimeout");
|
||||
sendMockExtensionMessage({
|
||||
command: "closeNotificationBar",
|
||||
data: { fadeOutNotification: false },
|
||||
});
|
||||
|
||||
expect(globalThis.setTimeout).not.toHaveBeenCalled();
|
||||
expect(overlayNotificationsContentService["notificationBarIframeElement"]).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("adjusting the notification bar's height", () => {
|
||||
beforeEach(async () => {
|
||||
sendMockExtensionMessage({
|
||||
command: "openNotificationBar",
|
||||
data: {
|
||||
type: "change",
|
||||
typeData: mock<NotificationTypeData>(),
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("adjusts the height of the notification bar", () => {
|
||||
sendMockExtensionMessage({
|
||||
command: "adjustNotificationBar",
|
||||
data: { height: 1000 },
|
||||
});
|
||||
|
||||
expect(overlayNotificationsContentService["notificationBarElement"].style.height).toBe(
|
||||
"1000px",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when a save cipher attempt is completed", () => {
|
||||
beforeEach(async () => {
|
||||
sendMockExtensionMessage({
|
||||
command: "openNotificationBar",
|
||||
data: {
|
||||
type: "change",
|
||||
typeData: mock<NotificationTypeData>(),
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("sends a message to the notification bar iframe indicating that the save attempt completed", () => {
|
||||
jest.spyOn(
|
||||
overlayNotificationsContentService["notificationBarIframeElement"].contentWindow,
|
||||
"postMessage",
|
||||
);
|
||||
|
||||
sendMockExtensionMessage({
|
||||
command: "saveCipherAttemptCompleted",
|
||||
data: { error: "" },
|
||||
});
|
||||
|
||||
expect(
|
||||
overlayNotificationsContentService["notificationBarIframeElement"].contentWindow
|
||||
.postMessage,
|
||||
).toHaveBeenCalledWith({ command: "saveCipherAttemptCompleted", error: "" }, "*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("destroy", () => {
|
||||
beforeEach(async () => {
|
||||
sendMockExtensionMessage({
|
||||
command: "openNotificationBar",
|
||||
data: {
|
||||
type: "change",
|
||||
typeData: mock<NotificationTypeData>(),
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("triggers a closure of the notification bar", () => {
|
||||
overlayNotificationsContentService.destroy();
|
||||
|
||||
expect(overlayNotificationsContentService["notificationBarElement"]).toBeNull();
|
||||
expect(overlayNotificationsContentService["notificationBarIframeElement"]).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,281 @@
|
||||
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||
|
||||
import { NotificationBarIframeInitData } from "../../../notification/abstractions/notification-bar";
|
||||
import { sendExtensionMessage, setElementStyles } from "../../../utils";
|
||||
import {
|
||||
NotificationsExtensionMessage,
|
||||
OverlayNotificationsContentService as OverlayNotificationsContentServiceInterface,
|
||||
OverlayNotificationsExtensionMessageHandlers,
|
||||
} from "../abstractions/overlay-notifications-content.service";
|
||||
|
||||
export class OverlayNotificationsContentService
|
||||
implements OverlayNotificationsContentServiceInterface
|
||||
{
|
||||
private notificationBarElement: HTMLElement | null = null;
|
||||
private notificationBarIframeElement: HTMLIFrameElement | null = null;
|
||||
private currentNotificationBarType: string | null = null;
|
||||
private removeTabFromNotificationQueueTypes = new Set(["add", "change"]);
|
||||
private notificationBarElementStyles: Partial<CSSStyleDeclaration> = {
|
||||
height: "82px",
|
||||
width: "430px",
|
||||
maxWidth: "calc(100% - 20px)",
|
||||
minHeight: "initial",
|
||||
top: "10px",
|
||||
right: "10px",
|
||||
padding: "0",
|
||||
position: "fixed",
|
||||
zIndex: "2147483647",
|
||||
visibility: "visible",
|
||||
borderRadius: "4px",
|
||||
border: "none",
|
||||
backgroundColor: "transparent",
|
||||
overflow: "hidden",
|
||||
transition: "box-shadow 0.15s ease",
|
||||
transitionDelay: "0.15s",
|
||||
};
|
||||
private notificationBarIframeElementStyles: Partial<CSSStyleDeclaration> = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "0",
|
||||
display: "block",
|
||||
position: "relative",
|
||||
transition: "transform 0.15s ease-out, opacity 0.15s ease",
|
||||
borderRadius: "4px",
|
||||
};
|
||||
private readonly extensionMessageHandlers: OverlayNotificationsExtensionMessageHandlers = {
|
||||
openNotificationBar: ({ message }) => this.handleOpenNotificationBarMessage(message),
|
||||
closeNotificationBar: ({ message }) => this.handleCloseNotificationBarMessage(message),
|
||||
adjustNotificationBar: ({ message }) => this.handleAdjustNotificationBarHeightMessage(message),
|
||||
saveCipherAttemptCompleted: ({ message }) =>
|
||||
this.handleSaveCipherAttemptCompletedMessage(message),
|
||||
};
|
||||
|
||||
constructor() {
|
||||
void sendExtensionMessage("checkNotificationQueue");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the message handlers for the content script.
|
||||
*/
|
||||
get messageHandlers() {
|
||||
return this.extensionMessageHandlers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the notification bar with the provided init data. Will trigger a closure
|
||||
* of the notification bar if the type of the notification bar changes.
|
||||
*
|
||||
* @param message - The message containing the initialization data for the notification bar.
|
||||
*/
|
||||
private handleOpenNotificationBarMessage(message: NotificationsExtensionMessage) {
|
||||
if (!message.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, typeData } = message.data;
|
||||
if (this.currentNotificationBarType && type !== this.currentNotificationBarType) {
|
||||
this.closeNotificationBar();
|
||||
}
|
||||
const initData = {
|
||||
type,
|
||||
isVaultLocked: typeData.isVaultLocked,
|
||||
theme: typeData.theme,
|
||||
removeIndividualVault: typeData.removeIndividualVault,
|
||||
importType: typeData.importType,
|
||||
applyRedesign: true,
|
||||
launchTimestamp: typeData.launchTimestamp,
|
||||
};
|
||||
|
||||
if (globalThis.document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => this.openNotificationBar(initData));
|
||||
return;
|
||||
}
|
||||
|
||||
this.openNotificationBar(initData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the notification bar. If the message contains a flag to fade out the notification,
|
||||
* the notification bar will fade out before being removed from the DOM.
|
||||
*
|
||||
* @param message - The message containing the data for closing the notification bar.
|
||||
*/
|
||||
private handleCloseNotificationBarMessage(message: NotificationsExtensionMessage) {
|
||||
if (message.data?.fadeOutNotification) {
|
||||
setElementStyles(this.notificationBarIframeElement, { opacity: "0" }, true);
|
||||
globalThis.setTimeout(() => this.closeNotificationBar(true), 150);
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeNotificationBar(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts the height of the notification bar.
|
||||
*
|
||||
* @param message - The message containing the height of the notification bar.
|
||||
*/
|
||||
private handleAdjustNotificationBarHeightMessage(message: NotificationsExtensionMessage) {
|
||||
if (this.notificationBarElement && message.data?.height) {
|
||||
this.notificationBarElement.style.height = `${message.data.height}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the message for when a save cipher attempt has completed. This triggers an update
|
||||
* to the presentation of the notification bar, facilitating a visual indication of the save
|
||||
* attempt's success or failure.
|
||||
*
|
||||
* @param message
|
||||
* @private
|
||||
*/
|
||||
private handleSaveCipherAttemptCompletedMessage(message: NotificationsExtensionMessage) {
|
||||
this.sendMessageToNotificationBarIframe({
|
||||
command: "saveCipherAttemptCompleted",
|
||||
error: message.data?.error,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the notification bar with the given initialization data.
|
||||
*
|
||||
* @param initData
|
||||
* @private
|
||||
*/
|
||||
private openNotificationBar(initData: NotificationBarIframeInitData) {
|
||||
if (!this.notificationBarElement && !this.notificationBarIframeElement) {
|
||||
this.createNotificationBarIframeElement(initData);
|
||||
this.createNotificationBarElement();
|
||||
|
||||
this.setupInitNotificationBarMessageListener(initData);
|
||||
globalThis.document.body.appendChild(this.notificationBarElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the iframe element for the notification bar.
|
||||
*
|
||||
* @param initData - The initialization data for the notification bar.
|
||||
*/
|
||||
private createNotificationBarIframeElement(initData: NotificationBarIframeInitData) {
|
||||
const isNotificationFresh =
|
||||
initData.launchTimestamp && Date.now() - initData.launchTimestamp < 250;
|
||||
|
||||
this.currentNotificationBarType = initData.type;
|
||||
this.notificationBarIframeElement = globalThis.document.createElement("iframe");
|
||||
this.notificationBarIframeElement.id = "bit-notification-bar-iframe";
|
||||
this.notificationBarIframeElement.src = chrome.runtime.getURL("notification/bar.html");
|
||||
setElementStyles(
|
||||
this.notificationBarIframeElement,
|
||||
{
|
||||
...this.notificationBarIframeElementStyles,
|
||||
transform: isNotificationFresh ? "translateX(100%)" : "translateX(0)",
|
||||
opacity: isNotificationFresh ? "1" : "0",
|
||||
},
|
||||
true,
|
||||
);
|
||||
this.notificationBarIframeElement.addEventListener(
|
||||
EVENTS.LOAD,
|
||||
this.handleNotificationBarIframeOnLoad,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the load event for the notification bar iframe.
|
||||
* This will animate the notification bar into view.
|
||||
*/
|
||||
private handleNotificationBarIframeOnLoad = () => {
|
||||
setElementStyles(
|
||||
this.notificationBarIframeElement,
|
||||
{ transform: "translateX(0)", opacity: "1" },
|
||||
true,
|
||||
);
|
||||
setElementStyles(this.notificationBarElement, { boxShadow: "2px 4px 6px 0px #0000001A" }, true);
|
||||
this.notificationBarIframeElement.removeEventListener(
|
||||
EVENTS.LOAD,
|
||||
this.handleNotificationBarIframeOnLoad,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the container for the notification bar iframe.
|
||||
*/
|
||||
private createNotificationBarElement() {
|
||||
if (this.notificationBarIframeElement) {
|
||||
this.notificationBarElement = globalThis.document.createElement("div");
|
||||
this.notificationBarElement.id = "bit-notification-bar";
|
||||
setElementStyles(this.notificationBarElement, this.notificationBarElementStyles, true);
|
||||
this.notificationBarElement.appendChild(this.notificationBarIframeElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the message listener for the initialization of the notification bar.
|
||||
* This will send the initialization data to the notification bar iframe.
|
||||
*
|
||||
* @param initData - The initialization data for the notification bar.
|
||||
*/
|
||||
private setupInitNotificationBarMessageListener(initData: NotificationBarIframeInitData) {
|
||||
const handleInitNotificationBarMessage = (event: MessageEvent) => {
|
||||
const { source, data } = event;
|
||||
if (
|
||||
source !== this.notificationBarIframeElement.contentWindow ||
|
||||
data?.command !== "initNotificationBar"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendMessageToNotificationBarIframe({ command: "initNotificationBar", initData });
|
||||
globalThis.removeEventListener("message", handleInitNotificationBarMessage);
|
||||
};
|
||||
|
||||
if (this.notificationBarIframeElement) {
|
||||
globalThis.addEventListener("message", handleInitNotificationBarMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the notification bar. Will trigger a removal of the notification bar
|
||||
* from the background queue if the notification bar was closed by the user.
|
||||
*
|
||||
* @param closedByUserAction - Whether the notification bar was closed by the user.
|
||||
*/
|
||||
private closeNotificationBar(closedByUserAction: boolean = false) {
|
||||
if (!this.notificationBarElement && !this.notificationBarIframeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.notificationBarIframeElement.remove();
|
||||
this.notificationBarIframeElement = null;
|
||||
|
||||
this.notificationBarElement.remove();
|
||||
this.notificationBarElement = null;
|
||||
|
||||
if (
|
||||
closedByUserAction &&
|
||||
this.removeTabFromNotificationQueueTypes.has(this.currentNotificationBarType)
|
||||
) {
|
||||
void sendExtensionMessage("bgRemoveTabFromNotificationQueue");
|
||||
}
|
||||
|
||||
this.currentNotificationBarType = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to the notification bar iframe.
|
||||
*
|
||||
* @param message - The message to send to the notification bar iframe.
|
||||
*/
|
||||
private sendMessageToNotificationBarIframe(message: Record<string, any>) {
|
||||
if (this.notificationBarIframeElement) {
|
||||
this.notificationBarIframeElement.contentWindow.postMessage(message, "*");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the notification bar.
|
||||
*/
|
||||
destroy() {
|
||||
this.closeNotificationBar(true);
|
||||
}
|
||||
}
|
@ -16,6 +16,13 @@ export type SubFrameDataFromWindowMessage = SubFrameOffsetData & {
|
||||
subFrameDepth: number;
|
||||
};
|
||||
|
||||
export type NotificationFormFieldData = {
|
||||
uri: string;
|
||||
username: string;
|
||||
password: string;
|
||||
newPassword: string;
|
||||
};
|
||||
|
||||
export type AutofillOverlayContentExtensionMessageHandlers = {
|
||||
[key: string]: CallableFunction;
|
||||
openAutofillInlineMenu: ({ message }: AutofillExtensionMessageParam) => void;
|
||||
@ -32,13 +39,14 @@ export type AutofillOverlayContentExtensionMessageHandlers = {
|
||||
checkMostRecentlyFocusedFieldHasValue: () => boolean;
|
||||
setupRebuildSubFrameOffsetsListeners: () => void;
|
||||
destroyAutofillInlineMenuListeners: () => void;
|
||||
getFormFieldDataForNotification: () => Promise<NotificationFormFieldData>;
|
||||
};
|
||||
|
||||
export interface AutofillOverlayContentService {
|
||||
pageDetailsUpdateRequired: boolean;
|
||||
messageHandlers: AutofillOverlayContentExtensionMessageHandlers;
|
||||
init(): void;
|
||||
setupInlineMenu(
|
||||
setupOverlayListeners(
|
||||
autofillFieldElement: ElementWithOpId<FormFieldElement>,
|
||||
autofillFieldData: AutofillField,
|
||||
pageDetails: AutofillPageDetails,
|
||||
|
@ -18,16 +18,6 @@ interface CollectAutofillContentService {
|
||||
autofillFormElements: AutofillFormElements;
|
||||
getPageDetails(): Promise<AutofillPageDetails>;
|
||||
getAutofillFieldElementByOpid(opid: string): HTMLElement | null;
|
||||
deepQueryElements<T>(
|
||||
root: Document | ShadowRoot | Element,
|
||||
selector: string,
|
||||
isObservingShadowRoot?: boolean,
|
||||
): T[];
|
||||
queryAllTreeWalkerNodes(
|
||||
rootNode: Node,
|
||||
filterCallback: CallableFunction,
|
||||
isObservingShadowRoot?: boolean,
|
||||
): Node[];
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,12 @@
|
||||
export interface DomQueryService {
|
||||
deepQueryElements<T>(
|
||||
root: Document | ShadowRoot | Element,
|
||||
queryString: string,
|
||||
mutationObserver?: MutationObserver,
|
||||
): T[];
|
||||
queryAllTreeWalkerNodes(
|
||||
rootNode: Node,
|
||||
filterCallback: CallableFunction,
|
||||
mutationObserver?: MutationObserver,
|
||||
): Node[];
|
||||
}
|
@ -9,6 +9,8 @@ export type AutofillKeywordsMap = WeakMap<
|
||||
}
|
||||
>;
|
||||
|
||||
export type SubmitButtonKeywordsMap = WeakMap<HTMLElement, string>;
|
||||
|
||||
export interface InlineMenuFieldQualificationService {
|
||||
isUsernameField(field: AutofillField): boolean;
|
||||
isNewPasswordField(field: AutofillField): boolean;
|
||||
@ -39,4 +41,6 @@ export interface InlineMenuFieldQualificationService {
|
||||
isFieldForIdentityPhone(field: AutofillField): boolean;
|
||||
isFieldForIdentityEmail(field: AutofillField): boolean;
|
||||
isFieldForIdentityUsername(field: AutofillField): boolean;
|
||||
isElementLoginSubmitButton(element: Element): boolean;
|
||||
isElementChangePasswordSubmitButton(element: Element): boolean;
|
||||
}
|
||||
|
@ -811,3 +811,21 @@ export class IdentityAutoFillConstants {
|
||||
saskatchewan: "SK",
|
||||
};
|
||||
}
|
||||
|
||||
export const SubmitLoginButtonNames: string[] = [
|
||||
"login",
|
||||
"signin",
|
||||
"submit",
|
||||
"continue",
|
||||
"next",
|
||||
"go",
|
||||
];
|
||||
|
||||
export const SubmitChangePasswordButtonNames: string[] = [
|
||||
"change",
|
||||
"save",
|
||||
"savepassword",
|
||||
"updatepassword",
|
||||
"changepassword",
|
||||
"resetpassword",
|
||||
];
|
||||
|
@ -14,28 +14,38 @@ import AutofillField from "../models/autofill-field";
|
||||
import AutofillForm from "../models/autofill-form";
|
||||
import AutofillPageDetails from "../models/autofill-page-details";
|
||||
import { createAutofillFieldMock } from "../spec/autofill-mocks";
|
||||
import { flushPromises, postWindowMessage, sendMockExtensionMessage } from "../spec/testing-utils";
|
||||
import {
|
||||
flushPromises,
|
||||
mockQuerySelectorAllDefinedCall,
|
||||
postWindowMessage,
|
||||
sendMockExtensionMessage,
|
||||
} from "../spec/testing-utils";
|
||||
import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types";
|
||||
|
||||
import { AutoFillConstants } from "./autofill-constants";
|
||||
import { AutofillOverlayContentService } from "./autofill-overlay-content.service";
|
||||
import { DomQueryService } from "./dom-query.service";
|
||||
import { InlineMenuFieldQualificationService } from "./inline-menu-field-qualification.service";
|
||||
|
||||
const defaultWindowReadyState = document.readyState;
|
||||
const defaultDocumentVisibilityState = document.visibilityState;
|
||||
describe("AutofillOverlayContentService", () => {
|
||||
let domQueryService: DomQueryService;
|
||||
let autofillInit: AutofillInit;
|
||||
let inlineMenuFieldQualificationService: InlineMenuFieldQualificationService;
|
||||
let autofillOverlayContentService: AutofillOverlayContentService;
|
||||
let sendExtensionMessageSpy: jest.SpyInstance;
|
||||
const sendResponseSpy = jest.fn();
|
||||
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
|
||||
|
||||
beforeEach(() => {
|
||||
inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
|
||||
domQueryService = new DomQueryService();
|
||||
autofillOverlayContentService = new AutofillOverlayContentService(
|
||||
domQueryService,
|
||||
inlineMenuFieldQualificationService,
|
||||
);
|
||||
autofillInit = new AutofillInit(autofillOverlayContentService);
|
||||
autofillInit = new AutofillInit(domQueryService, autofillOverlayContentService);
|
||||
autofillInit.init();
|
||||
sendExtensionMessageSpy = jest
|
||||
.spyOn(autofillOverlayContentService as any, "sendExtensionMessage")
|
||||
@ -66,6 +76,10 @@ describe("AutofillOverlayContentService", () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mockQuerySelectorAll.mockRestore();
|
||||
});
|
||||
|
||||
describe("init", () => {
|
||||
let setupGlobalEventListenersSpy: jest.SpyInstance;
|
||||
|
||||
@ -171,7 +185,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
AutoFillConstants.ExcludedInlineMenuTypes.forEach(async (excludedType) => {
|
||||
autofillFieldData.type = excludedType;
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -186,7 +200,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
autofillFieldData.htmlID = "another-type-of-field";
|
||||
autofillFieldData.placeholder = "another-type-of-field";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -202,7 +216,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
autofillFieldData,
|
||||
);
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -216,7 +230,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
sendExtensionMessageSpy.mockResolvedValueOnce(undefined);
|
||||
autofillOverlayContentService["inlineMenuVisibility"] = undefined;
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -232,7 +246,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
sendExtensionMessageSpy.mockResolvedValueOnce(AutofillOverlayVisibility.OnFieldFocus);
|
||||
autofillOverlayContentService["inlineMenuVisibility"] = undefined;
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -256,7 +270,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
"op-1-username-field-focus-handler": focusHandler,
|
||||
};
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -270,15 +284,20 @@ describe("AutofillOverlayContentService", () => {
|
||||
expect(autofillFieldElement.removeEventListener).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"input",
|
||||
inputHandler,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(autofillFieldElement.removeEventListener).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"input",
|
||||
inputHandler,
|
||||
);
|
||||
expect(autofillFieldElement.removeEventListener).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
"click",
|
||||
clickHandler,
|
||||
);
|
||||
expect(autofillFieldElement.removeEventListener).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
5,
|
||||
"focus",
|
||||
focusHandler,
|
||||
);
|
||||
@ -286,7 +305,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
|
||||
describe("form field blur event listener", () => {
|
||||
beforeEach(async () => {
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -310,7 +329,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
|
||||
describe("form field keyup event listener", () => {
|
||||
beforeEach(async () => {
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -407,7 +426,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
) as ElementWithOpId<HTMLSpanElement>;
|
||||
jest.spyOn(autofillOverlayContentService as any, "storeModifiedFormElement");
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
spanAutofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -435,7 +454,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
autofillOverlayContentService["mostRecentlyFocusedField"] =
|
||||
mock<ElementWithOpId<FormFieldElement>>();
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -450,7 +469,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
});
|
||||
|
||||
it("stores the field as a user filled field if the form field data indicates that it is for a username", async () => {
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -468,7 +487,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
) as ElementWithOpId<FormFieldElement>;
|
||||
autofillFieldData.type = "password";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
passwordFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -484,7 +503,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
jest.spyOn(autofillOverlayContentService as any, "isUserAuthed").mockReturnValue(false);
|
||||
(autofillFieldElement as HTMLInputElement).value = "test";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -506,7 +525,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
|
||||
(autofillFieldElement as HTMLInputElement).value = "test";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -524,7 +543,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
jest.spyOn(autofillOverlayContentService as any, "openInlineMenu");
|
||||
(autofillFieldElement as HTMLInputElement).value = "";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -540,7 +559,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
jest.spyOn(autofillOverlayContentService as any, "openInlineMenu");
|
||||
(autofillFieldElement as HTMLInputElement).value = "";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -559,7 +578,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
jest.spyOn(autofillOverlayContentService as any, "openInlineMenu");
|
||||
(autofillFieldElement as HTMLInputElement).value = "";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -608,7 +627,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
jest.spyOn(autofillOverlayContentService as any, "storeModifiedFormElement");
|
||||
jest.spyOn(autofillOverlayContentService as any, "hideInlineMenuListOnFilledField");
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
selectFieldElement,
|
||||
selectFieldData,
|
||||
pageDetailsMock,
|
||||
@ -627,7 +646,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores cardholder name fields", async () => {
|
||||
inputFieldData.autoCompleteType = "cc-name";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -641,7 +660,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
});
|
||||
|
||||
it("stores card number fields", async () => {
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -657,7 +676,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores card expiration month fields", async () => {
|
||||
inputFieldData.autoCompleteType = "cc-exp-month";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -673,7 +692,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores card expiration year fields", async () => {
|
||||
inputFieldData.autoCompleteType = "cc-exp-year";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -689,7 +708,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores card expiration date fields", async () => {
|
||||
inputFieldData.autoCompleteType = "cc-exp";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -705,7 +724,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores card cvv fields", async () => {
|
||||
inputFieldData.autoCompleteType = "cc-csc";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -742,7 +761,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores identity title fields", async () => {
|
||||
inputFieldData.autoCompleteType = "honorific-prefix";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -758,7 +777,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores first name fields", async () => {
|
||||
inputFieldData.autoCompleteType = "given-name";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -774,7 +793,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores identity middle name fields", async () => {
|
||||
inputFieldData.autoCompleteType = "additional-name";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -790,7 +809,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores identity last name fields", async () => {
|
||||
inputFieldData.autoCompleteType = "family-name";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -806,7 +825,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores identity full name fields", async () => {
|
||||
inputFieldData.autoCompleteType = "name";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -822,7 +841,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores identity address1 fields", async () => {
|
||||
inputFieldData.autoCompleteType = "address-line1";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -838,7 +857,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores identity address2 fields", async () => {
|
||||
inputFieldData.autoCompleteType = "address-line2";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -854,7 +873,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores identity address3 fields", async () => {
|
||||
inputFieldData.autoCompleteType = "address-line3";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -870,7 +889,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores identity city fields", async () => {
|
||||
inputFieldData.autoCompleteType = "address-level2";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -886,7 +905,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores identity state fields", async () => {
|
||||
inputFieldData.autoCompleteType = "address-level1";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -902,7 +921,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores identity postal code fields", async () => {
|
||||
inputFieldData.autoCompleteType = "postal-code";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -918,7 +937,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores identity country fields", async () => {
|
||||
inputFieldData.autoCompleteType = "country-name";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -934,7 +953,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores identity company fields", async () => {
|
||||
inputFieldData.autoCompleteType = "organization";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -950,7 +969,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("stores identity phone fields", async () => {
|
||||
inputFieldData.autoCompleteType = "tel";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -969,7 +988,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
.mockReturnValue(false);
|
||||
inputFieldData.autoCompleteType = "email";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -991,7 +1010,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
.mockReturnValue(false);
|
||||
inputFieldData.autoCompleteType = "username";
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
inputFieldElement,
|
||||
inputFieldData,
|
||||
pageDetailsMock,
|
||||
@ -1014,7 +1033,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
jest
|
||||
.spyOn(autofillOverlayContentService as any, "triggerFormFieldFocusedAction")
|
||||
.mockImplementation();
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -1071,7 +1090,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement;
|
||||
autofillOverlayContentService["inlineMenuVisibility"] =
|
||||
AutofillOverlayVisibility.OnFieldFocus;
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -1089,7 +1108,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
) as ElementWithOpId<HTMLSelectElement>;
|
||||
autofillFieldData.type = "select";
|
||||
autofillFieldData.autoCompleteType = "cc-type";
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
selectFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -1104,7 +1123,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
});
|
||||
|
||||
it("updates the most recently focused field", async () => {
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -1122,7 +1141,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("removes the overlay list if the autofill visibility is set to onClick", async () => {
|
||||
autofillOverlayContentService["inlineMenuVisibility"] =
|
||||
AutofillOverlayVisibility.OnButtonClick;
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -1142,7 +1161,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
"input",
|
||||
) as ElementWithOpId<HTMLInputElement>;
|
||||
(autofillFieldElement as HTMLInputElement).value = "test";
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -1161,7 +1180,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
(autofillFieldElement as HTMLInputElement).value = "";
|
||||
autofillOverlayContentService["inlineMenuVisibility"] =
|
||||
AutofillOverlayVisibility.OnFieldFocus;
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -1178,7 +1197,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
autofillOverlayContentService["inlineMenuVisibility"] =
|
||||
AutofillOverlayVisibility.OnFieldFocus;
|
||||
jest.spyOn(autofillOverlayContentService as any, "isUserAuthed").mockReturnValue(true);
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -1197,7 +1216,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
jest
|
||||
.spyOn(autofillOverlayContentService as any, "isInlineMenuCiphersPopulated")
|
||||
.mockReturnValue(true);
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -1215,7 +1234,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
describe("hidden form field focus event", () => {
|
||||
it("sets up the inline menu listeners if the autofill field data is in the cache", async () => {
|
||||
autofillFieldData.viewable = false;
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -1249,7 +1268,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
|
||||
it("skips setting up the inline menu listeners if the autofill field data is not in the cache", async () => {
|
||||
autofillFieldData.viewable = false;
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -1267,13 +1286,45 @@ describe("AutofillOverlayContentService", () => {
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalledWith(
|
||||
EVENTS.CLICK,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(autofillFieldElement.removeEventListener).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hidden form field input event", () => {
|
||||
it("sets up the inline menu listeners if the autofill field data is in the cache", async () => {
|
||||
autofillFieldData.viewable = false;
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
);
|
||||
|
||||
autofillFieldElement.dispatchEvent(new Event("input"));
|
||||
await flushPromises();
|
||||
|
||||
expect(autofillFieldElement.addEventListener).toHaveBeenCalledWith(
|
||||
EVENTS.BLUR,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(autofillFieldElement.addEventListener).toHaveBeenCalledWith(
|
||||
EVENTS.KEYUP,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(autofillFieldElement.addEventListener).toHaveBeenCalledWith(
|
||||
EVENTS.INPUT,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalledWith(
|
||||
expect(autofillFieldElement.addEventListener).toHaveBeenCalledWith(
|
||||
EVENTS.CLICK,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(autofillFieldElement.addEventListener).toHaveBeenCalledWith(
|
||||
EVENTS.FOCUS,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(autofillFieldElement.removeEventListener).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -1299,7 +1350,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
});
|
||||
|
||||
it("sets up the input card field listeners", async () => {
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
inputCardFieldData,
|
||||
pageDetailsMock,
|
||||
@ -1336,7 +1387,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
selectCardFieldElement.opid = "op-2";
|
||||
jest.spyOn(selectCardFieldElement, "addEventListener");
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
selectCardFieldElement,
|
||||
selectCardFieldData,
|
||||
pageDetailsMock,
|
||||
@ -1385,7 +1436,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
});
|
||||
|
||||
it("sets up the field listeners on the field", async () => {
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
inputAccountFieldData,
|
||||
pageDetailsMock,
|
||||
@ -1419,6 +1470,187 @@ describe("AutofillOverlayContentService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sets up form submission event listeners", () => {
|
||||
describe("listeners set up on a fields with a form", () => {
|
||||
let form: HTMLFormElement;
|
||||
|
||||
beforeEach(() => {
|
||||
form = document.getElementById("validFormId") as HTMLFormElement;
|
||||
});
|
||||
|
||||
it("sends a `formFieldSubmitted` message to the background on submission of the form", async () => {
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
);
|
||||
form.dispatchEvent(new Event("submit"));
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("formFieldSubmitted", {
|
||||
uri: globalThis.document.URL,
|
||||
username: "",
|
||||
password: "",
|
||||
newPassword: "",
|
||||
});
|
||||
});
|
||||
|
||||
describe("triggering submission through interaction of a generic input element", () => {
|
||||
let genericSubmitElement: HTMLInputElement;
|
||||
|
||||
beforeEach(() => {
|
||||
genericSubmitElement = document.createElement("input");
|
||||
genericSubmitElement.type = "submit";
|
||||
genericSubmitElement.value = "Login In";
|
||||
form.appendChild(genericSubmitElement);
|
||||
});
|
||||
|
||||
it("ignores keyup events triggered on a generic input element if the key is not `Enter` or `Space`", async () => {
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
);
|
||||
genericSubmitElement.dispatchEvent(new KeyboardEvent("keyup", { code: "Tab" }));
|
||||
|
||||
expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith(
|
||||
"formFieldSubmitted",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a `formFieldSubmitted` message to the background on interaction of a generic input element", async () => {
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
);
|
||||
genericSubmitElement.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
|
||||
"formFieldSubmitted",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("listeners set up on a fields without a form", () => {
|
||||
let autofillFieldElement: ElementWithOpId<FormFieldElement>;
|
||||
let autofillFieldData: AutofillField;
|
||||
let pageDetailsMock: AutofillPageDetails;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="form-div">
|
||||
<div>
|
||||
<input type="password" id="password-field-1" placeholder="new password" />
|
||||
</div>
|
||||
<div>
|
||||
<input type="password" id="password-field-2" placeholder="confirm new password" />
|
||||
</div>
|
||||
<button id="button-el">Change Password</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
autofillFieldElement = document.getElementById(
|
||||
"password-field-1",
|
||||
) as ElementWithOpId<FormFieldElement>;
|
||||
autofillFieldElement.opid = "op-1";
|
||||
jest.spyOn(autofillFieldElement, "addEventListener");
|
||||
jest.spyOn(autofillFieldElement, "removeEventListener");
|
||||
autofillFieldData = createAutofillFieldMock({
|
||||
opid: "new-password-field",
|
||||
placeholder: "new password",
|
||||
autoCompleteType: "new-password",
|
||||
elementNumber: 1,
|
||||
form: "",
|
||||
});
|
||||
const passwordFieldData = createAutofillFieldMock({
|
||||
opid: "confirm-new-password-field",
|
||||
elementNumber: 2,
|
||||
autoCompleteType: "new-password",
|
||||
type: "password",
|
||||
form: "",
|
||||
});
|
||||
pageDetailsMock = mock<AutofillPageDetails>({
|
||||
forms: {},
|
||||
fields: [autofillFieldData, passwordFieldData],
|
||||
});
|
||||
});
|
||||
|
||||
it("skips triggering submission if a button is not found", async () => {
|
||||
const submitButton = document.querySelector("button");
|
||||
submitButton.remove();
|
||||
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
);
|
||||
submitButton.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
|
||||
|
||||
expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith(
|
||||
"formFieldSubmitted",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("triggers submission through interaction of a submit button", async () => {
|
||||
const submitButton = document.querySelector("button");
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
);
|
||||
submitButton.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
|
||||
"formFieldSubmitted",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("captures submit buttons when the field is structured within a shadow DOM", async () => {
|
||||
document.body.innerHTML = `<div id="form-div">
|
||||
<div id="shadow-root"></div>
|
||||
<button id="button-el">Change Password</button>
|
||||
</div>`;
|
||||
const shadowRoot = document.getElementById("shadow-root").attachShadow({ mode: "open" });
|
||||
shadowRoot.innerHTML = `
|
||||
<input type="password" id="password-field-1" placeholder="new password" />
|
||||
`;
|
||||
autofillFieldElement = shadowRoot.getElementById(
|
||||
"password-field-1",
|
||||
) as ElementWithOpId<FormFieldElement>;
|
||||
autofillFieldElement.opid = "op-1";
|
||||
autofillFieldData = createAutofillFieldMock({
|
||||
opid: "new-password-field",
|
||||
placeholder: "new password",
|
||||
autoCompleteType: "new-password",
|
||||
elementNumber: 1,
|
||||
form: "",
|
||||
});
|
||||
pageDetailsMock = mock<AutofillPageDetails>({
|
||||
forms: {},
|
||||
fields: [autofillFieldData],
|
||||
});
|
||||
const buttonElement = document.getElementById("button-el");
|
||||
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
);
|
||||
buttonElement.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
|
||||
"formFieldSubmitted",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("skips triggering the form field focused handler if the document is not focused", async () => {
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
|
||||
const documentRoot = autofillFieldElement.getRootNode() as Document;
|
||||
@ -1427,7 +1659,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
writable: true,
|
||||
});
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -1444,7 +1676,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
writable: true,
|
||||
});
|
||||
|
||||
await autofillOverlayContentService.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -1459,7 +1691,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
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.setupInlineMenu(
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -2345,6 +2577,39 @@ describe("AutofillOverlayContentService", () => {
|
||||
expect(autofillOverlayContentService.destroy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFormFieldDataForNotification message handler", () => {
|
||||
it("returns early if a field is currently focused", async () => {
|
||||
jest
|
||||
.spyOn(autofillOverlayContentService as any, "isFieldCurrentlyFocused")
|
||||
.mockReturnValue(true);
|
||||
|
||||
sendMockExtensionMessage(
|
||||
{ command: "getFormFieldDataForNotification" },
|
||||
mock<chrome.runtime.MessageSender>(),
|
||||
sendResponseSpy,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(sendResponseSpy).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("returns the form field data for a notification", async () => {
|
||||
sendMockExtensionMessage(
|
||||
{ command: "getFormFieldDataForNotification" },
|
||||
mock<chrome.runtime.MessageSender>(),
|
||||
sendResponseSpy,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(sendResponseSpy).toHaveBeenCalledWith({
|
||||
uri: globalThis.document.URL,
|
||||
username: "",
|
||||
password: "",
|
||||
newPassword: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("destroy", () => {
|
||||
@ -2381,7 +2646,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
forms: { validFormId: mock<AutofillForm>() },
|
||||
fields: [autofillFieldData, passwordFieldData],
|
||||
});
|
||||
void autofillOverlayContentService.setupInlineMenu(
|
||||
void autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
@ -2449,5 +2714,15 @@ describe("AutofillOverlayContentService", () => {
|
||||
autofillOverlayContentService["closeInlineMenuOnRedirectTimeout"],
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes all cached user filled field DOM elements", () => {
|
||||
autofillOverlayContentService["userFilledFields"] = {
|
||||
username: autofillFieldElement as FillableFormFieldElement,
|
||||
};
|
||||
|
||||
autofillOverlayContentService.destroy();
|
||||
|
||||
expect(autofillOverlayContentService["userFilledFields"]).toEqual(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -7,6 +7,7 @@ import {
|
||||
EVENTS,
|
||||
AutofillOverlayVisibility,
|
||||
AUTOFILL_OVERLAY_HANDLE_REPOSITION,
|
||||
AUTOFILL_TRIGGER_FORM_FIELD_SUBMIT,
|
||||
} from "@bitwarden/common/autofill/constants";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
|
||||
@ -38,9 +39,11 @@ import {
|
||||
import {
|
||||
AutofillOverlayContentExtensionMessageHandlers,
|
||||
AutofillOverlayContentService as AutofillOverlayContentServiceInterface,
|
||||
NotificationFormFieldData,
|
||||
OpenAutofillInlineMenuOptions,
|
||||
SubFrameDataFromWindowMessage,
|
||||
} from "./abstractions/autofill-overlay-content.service";
|
||||
import { DomQueryService } from "./abstractions/dom-query.service";
|
||||
import { InlineMenuFieldQualificationService } from "./abstractions/inline-menu-field-qualifications.service";
|
||||
import { AutoFillConstants } from "./autofill-constants";
|
||||
|
||||
@ -52,6 +55,9 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
private formFieldElements: Map<ElementWithOpId<FormFieldElement>, AutofillField> = new Map();
|
||||
private hiddenFormFieldElements: WeakMap<ElementWithOpId<FormFieldElement>, AutofillField> =
|
||||
new WeakMap();
|
||||
private formElements: Set<HTMLFormElement> = new Set();
|
||||
private submitElements: Set<HTMLElement> = new Set();
|
||||
private fieldsWithSubmitElements: WeakMap<FillableFormFieldElement, HTMLElement> = new WeakMap();
|
||||
private ignoredFieldTypes: Set<string> = new Set(AutoFillConstants.ExcludedInlineMenuTypes);
|
||||
private userFilledFields: Record<string, FillableFormFieldElement> = {};
|
||||
private authStatus: AuthenticationStatus;
|
||||
@ -79,6 +85,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
checkMostRecentlyFocusedFieldHasValue: () => this.mostRecentlyFocusedFieldHasValue(),
|
||||
setupRebuildSubFrameOffsetsListeners: () => this.setupRebuildSubFrameOffsetsListeners(),
|
||||
destroyAutofillInlineMenuListeners: () => this.destroy(),
|
||||
getFormFieldDataForNotification: () => this.handleGetFormFieldDataForNotificationMessage(),
|
||||
};
|
||||
private readonly cardFieldQualifiers: Record<string, CallableFunction> = {
|
||||
[AutofillFieldQualifier.cardholderName]:
|
||||
@ -126,10 +133,14 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
this.inlineMenuFieldQualificationService.isFieldForIdentityEmail,
|
||||
[AutofillFieldQualifier.identityUsername]:
|
||||
this.inlineMenuFieldQualificationService.isFieldForIdentityUsername,
|
||||
[AutofillFieldQualifier.password]: this.inlineMenuFieldQualificationService.isNewPasswordField,
|
||||
[AutofillFieldQualifier.newPassword]:
|
||||
this.inlineMenuFieldQualificationService.isNewPasswordField,
|
||||
};
|
||||
|
||||
constructor(private inlineMenuFieldQualificationService: InlineMenuFieldQualificationService) {}
|
||||
constructor(
|
||||
private domQueryService: DomQueryService,
|
||||
private inlineMenuFieldQualificationService: InlineMenuFieldQualificationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Initializes the autofill overlay content service by setting up the mutation observers.
|
||||
@ -160,7 +171,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
* @param autofillFieldData - Autofill field data captured from the form field element.
|
||||
* @param pageDetails - The collected page details from the tab.
|
||||
*/
|
||||
async setupInlineMenu(
|
||||
async setupOverlayListeners(
|
||||
formFieldElement: ElementWithOpId<FormFieldElement>,
|
||||
autofillFieldData: AutofillField,
|
||||
pageDetails: AutofillPageDetails,
|
||||
@ -176,7 +187,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
return;
|
||||
}
|
||||
|
||||
await this.setupInlineMenuOnQualifiedField(formFieldElement, autofillFieldData);
|
||||
await this.setupOverlayListenersOnQualifiedField(formFieldElement, autofillFieldData);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -250,11 +261,13 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
*/
|
||||
async addNewVaultItem({ addNewCipherType }: AutofillExtensionMessage) {
|
||||
const command = "autofillOverlayAddNewVaultItem";
|
||||
const password =
|
||||
this.userFilledFields["newPassword"]?.value || this.userFilledFields["password"]?.value;
|
||||
|
||||
if (addNewCipherType === CipherType.Login) {
|
||||
const login: NewLoginCipherData = {
|
||||
username: this.userFilledFields["username"]?.value || "",
|
||||
password: this.userFilledFields["password"]?.value || "",
|
||||
password: password || "",
|
||||
uri: globalThis.document.URL,
|
||||
hostname: globalThis.document.location.hostname,
|
||||
};
|
||||
@ -394,6 +407,214 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up listeners on the submit button that triggers a submission of the field's form.
|
||||
*
|
||||
* @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(
|
||||
formFieldElement: ElementWithOpId<FormFieldElement>,
|
||||
autofillFieldData: AutofillField,
|
||||
) {
|
||||
if (
|
||||
!elementIsFillableFormField(formFieldElement) ||
|
||||
autofillFieldData.filledByCipherType === CipherType.Card
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (autofillFieldData.form) {
|
||||
this.setupSubmitListenerOnFieldWithForms(formFieldElement);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setupSubmitListenerOnFormlessField(formFieldElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the submit listener on the form field element that contains a form element.
|
||||
* Will establish on submit event listeners on the form element and click listeners on
|
||||
* the submit button element that triggers the submission of the form.
|
||||
*
|
||||
* @param formFieldElement - The form field element to set up the submit listener for.
|
||||
*/
|
||||
private 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);
|
||||
this.setupSubmitButtonEventListeners(closesSubmitButton);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the submit listener on the form field element that does not contain a form element.
|
||||
* Will establish a submit button event listener on the closest formless submit button element.
|
||||
*
|
||||
* @param formFieldElement - The form field element to set up the submit listener for.
|
||||
*/
|
||||
private setupSubmitListenerOnFormlessField(formFieldElement: FillableFormFieldElement) {
|
||||
if (formFieldElement && !this.fieldsWithSubmitElements.has(formFieldElement)) {
|
||||
const closesSubmitButton = this.findClosestFormlessSubmitButton(formFieldElement);
|
||||
this.setupSubmitButtonEventListeners(closesSubmitButton);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the closest formless submit button element to the form field element.
|
||||
*
|
||||
* @param formFieldElement - The form field element to find the closest formless submit button for.
|
||||
*/
|
||||
private findClosestFormlessSubmitButton(
|
||||
formFieldElement: FillableFormFieldElement,
|
||||
): HTMLElement | null {
|
||||
let currentElement: HTMLElement = formFieldElement;
|
||||
|
||||
while (currentElement && currentElement.tagName !== "HTML") {
|
||||
const submitButton = this.findSubmitButton(currentElement);
|
||||
if (submitButton) {
|
||||
this.formFieldElements.forEach((_, element) => {
|
||||
if (currentElement.contains(element)) {
|
||||
this.fieldsWithSubmitElements.set(element as FillableFormFieldElement, submitButton);
|
||||
}
|
||||
});
|
||||
|
||||
return submitButton;
|
||||
}
|
||||
|
||||
if (!currentElement.parentElement && currentElement.getRootNode() instanceof ShadowRoot) {
|
||||
currentElement = (currentElement.getRootNode() as ShadowRoot).host as any;
|
||||
continue;
|
||||
}
|
||||
|
||||
currentElement = currentElement.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the submit button element within the provided element. Will attempt to find a generic
|
||||
* submit element before attempting to find a button or button-like element.
|
||||
*
|
||||
* @param element - The element to find the submit button within.
|
||||
*/
|
||||
private findSubmitButton(element: HTMLElement): HTMLElement | null {
|
||||
const genericSubmitElement = this.querySubmitButtonElement(element, "[type='submit']");
|
||||
if (genericSubmitElement) {
|
||||
return genericSubmitElement;
|
||||
}
|
||||
|
||||
const submitButtonElement = this.querySubmitButtonElement(element, "button, [type='button']");
|
||||
if (submitButtonElement) {
|
||||
return submitButtonElement;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the provided element for a submit button element using the provided selector.
|
||||
*
|
||||
* @param element - The element to query for a submit button.
|
||||
* @param selector - The selector to use to query the element for a submit button.
|
||||
*/
|
||||
private querySubmitButtonElement(element: HTMLElement, selector: string) {
|
||||
const submitButtonElements = this.domQueryService.deepQueryElements<HTMLButtonElement>(
|
||||
element,
|
||||
selector,
|
||||
);
|
||||
for (let index = 0; index < submitButtonElements.length; index++) {
|
||||
const submitElement = submitButtonElements[index];
|
||||
if (this.isElementSubmitButton(submitElement)) {
|
||||
return submitElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the provided element is a submit button element.
|
||||
*
|
||||
* @param element - The element to determine if it is a submit button.
|
||||
*/
|
||||
private isElementSubmitButton(element: HTMLElement) {
|
||||
return (
|
||||
this.inlineMenuFieldQualificationService.isElementLoginSubmitButton(element) ||
|
||||
this.inlineMenuFieldQualificationService.isElementChangePasswordSubmitButton(element)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the event listeners that trigger an indication that a form has been submitted.
|
||||
*
|
||||
* @param submitButton - The submit button element to set up the event listeners for.
|
||||
*/
|
||||
private setupSubmitButtonEventListeners = (submitButton: HTMLElement) => {
|
||||
if (!submitButton || this.submitElements.has(submitButton)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitElements.add(submitButton);
|
||||
|
||||
const handler = this.useEventHandlersMemo(
|
||||
throttle(this.handleSubmitButtonInteraction, 150),
|
||||
AUTOFILL_TRIGGER_FORM_FIELD_SUBMIT,
|
||||
);
|
||||
submitButton.addEventListener(EVENTS.KEYUP, handler);
|
||||
globalThis.document.addEventListener(EVENTS.CLICK, handler);
|
||||
globalThis.document.addEventListener(EVENTS.MOUSEUP, handler);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles click and keyup events that trigger behavior for a submit button element.
|
||||
*
|
||||
* @param event - The event that triggered the submit button interaction.
|
||||
*/
|
||||
private handleSubmitButtonInteraction = (event: PointerEvent) => {
|
||||
if (
|
||||
!this.submitElements.has(event.target as HTMLElement) ||
|
||||
(event.type === "keyup" &&
|
||||
!["Enter", "Space"].includes((event as unknown as KeyboardEvent).code))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleFormFieldSubmitEvent();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the repositioning of the autofill overlay when the form is submitted.
|
||||
*/
|
||||
private handleFormFieldSubmitEvent = () => {
|
||||
void this.sendExtensionMessage("formFieldSubmitted", this.getFormFieldDataForNotification());
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private handleGetFormFieldDataForNotificationMessage = async () => {
|
||||
if (await this.isFieldCurrentlyFocused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.getFormFieldDataForNotification();
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the form field data used for add login and change password notifications.
|
||||
*/
|
||||
private getFormFieldDataForNotification = (): NotificationFormFieldData => {
|
||||
return {
|
||||
uri: globalThis.document.URL,
|
||||
username: this.userFilledFields["username"]?.value || "",
|
||||
password: this.userFilledFields["password"]?.value || "",
|
||||
newPassword: this.userFilledFields["newPassword"]?.value || "",
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper method that facilitates registration of an event handler to a form field element.
|
||||
*
|
||||
@ -437,7 +658,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
*
|
||||
* @param event - The keyup event.
|
||||
*/
|
||||
private handleFormFieldKeyupEvent = async (event: KeyboardEvent) => {
|
||||
private handleFormFieldKeyupEvent = async (event: globalThis.KeyboardEvent) => {
|
||||
const eventCode = event.code;
|
||||
if (eventCode === "Escape") {
|
||||
void this.sendExtensionMessage("closeAutofillInlineMenu", {
|
||||
@ -614,15 +835,16 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
return;
|
||||
}
|
||||
|
||||
const clonedNode = formFieldElement.cloneNode() as FillableFormFieldElement;
|
||||
const identityLoginFields: AutofillFieldQualifierType[] = [
|
||||
AutofillFieldQualifier.identityUsername,
|
||||
AutofillFieldQualifier.identityEmail,
|
||||
];
|
||||
if (identityLoginFields.includes(autofillFieldData.fieldQualifier)) {
|
||||
this.userFilledFields[AutofillFieldQualifier.username] = formFieldElement;
|
||||
this.userFilledFields[AutofillFieldQualifier.username] = clonedNode;
|
||||
}
|
||||
|
||||
this.userFilledFields[autofillFieldData.fieldQualifier] = formFieldElement;
|
||||
this.userFilledFields[autofillFieldData.fieldQualifier] = clonedNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -947,6 +1169,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
) {
|
||||
this.hiddenFormFieldElements.set(formFieldElement, autofillFieldData);
|
||||
formFieldElement.addEventListener(EVENTS.FOCUS, this.handleHiddenFieldFocusEvent);
|
||||
formFieldElement.addEventListener(EVENTS.INPUT, this.handleHiddenFieldInputEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -957,6 +1180,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
*/
|
||||
private removeHiddenFieldFallbackListener(formFieldElement: ElementWithOpId<FormFieldElement>) {
|
||||
formFieldElement.removeEventListener(EVENTS.FOCUS, this.handleHiddenFieldFocusEvent);
|
||||
formFieldElement.removeEventListener(EVENTS.INPUT, this.handleHiddenFieldInputEvent);
|
||||
this.hiddenFormFieldElements.delete(formFieldElement);
|
||||
}
|
||||
|
||||
@ -968,12 +1192,36 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
*/
|
||||
private handleHiddenFieldFocusEvent = (event: FocusEvent) => {
|
||||
const formFieldElement = event.target as ElementWithOpId<FormFieldElement>;
|
||||
this.handleHiddenElementFallbackEvent(formFieldElement);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles an input event on a hidden field. When triggered, the inline menu is set up on the
|
||||
* field. We also capture the input value for the field to facilitate presentation of the value
|
||||
* for the field in the notification bar.
|
||||
*
|
||||
* @param event - The input event.
|
||||
*/
|
||||
private handleHiddenFieldInputEvent = async (event: InputEvent) => {
|
||||
const formFieldElement = event.target as ElementWithOpId<FormFieldElement>;
|
||||
this.handleHiddenElementFallbackEvent(formFieldElement);
|
||||
await this.triggerFormFieldInput(formFieldElement);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles updating the hidden element when a fallback event is triggered.
|
||||
*
|
||||
* @param formFieldElement - The form field element that triggered the focus event.
|
||||
*/
|
||||
private handleHiddenElementFallbackEvent = (
|
||||
formFieldElement: ElementWithOpId<FormFieldElement>,
|
||||
) => {
|
||||
const autofillFieldData = this.hiddenFormFieldElements.get(formFieldElement);
|
||||
if (autofillFieldData) {
|
||||
autofillFieldData.readonly = getAttributeBoolean(formFieldElement, "disabled");
|
||||
autofillFieldData.disabled = getAttributeBoolean(formFieldElement, "disabled");
|
||||
autofillFieldData.viewable = true;
|
||||
void this.setupInlineMenuOnQualifiedField(formFieldElement, autofillFieldData);
|
||||
void this.setupOverlayListenersOnQualifiedField(formFieldElement, autofillFieldData);
|
||||
}
|
||||
|
||||
this.removeHiddenFieldFallbackListener(formFieldElement);
|
||||
@ -985,7 +1233,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
* @param formFieldElement - The form field element to set up the inline menu on.
|
||||
* @param autofillFieldData - Autofill field data captured from the form field element.
|
||||
*/
|
||||
private async setupInlineMenuOnQualifiedField(
|
||||
private async setupOverlayListenersOnQualifiedField(
|
||||
formFieldElement: ElementWithOpId<FormFieldElement>,
|
||||
autofillFieldData: AutofillField,
|
||||
) {
|
||||
@ -1000,6 +1248,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
}
|
||||
|
||||
this.setupFormFieldElementEventListeners(formFieldElement);
|
||||
this.setupFormSubmissionEventListeners(formFieldElement, autofillFieldData);
|
||||
|
||||
if (
|
||||
globalThis.document.hasFocus() &&
|
||||
@ -1072,6 +1321,13 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
return (await this.sendExtensionMessage("checkIsAutofillInlineMenuListVisible")) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the field is currently focused within the top frame.
|
||||
*/
|
||||
private async isFieldCurrentlyFocused() {
|
||||
return (await this.sendExtensionMessage("checkIsFieldCurrentlyFocused")) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current tab contains ciphers that can be used to populate the inline menu.
|
||||
*/
|
||||
@ -1465,6 +1721,12 @@ 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.userFilledFields = null;
|
||||
globalThis.removeEventListener(EVENTS.MESSAGE, this.handleWindowMessageEvent);
|
||||
globalThis.document.removeEventListener(
|
||||
EVENTS.VISIBILITYCHANGE,
|
||||
|
@ -10,9 +10,11 @@ import {
|
||||
DefaultDomainSettingsService,
|
||||
DomainSettingsService,
|
||||
} from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service";
|
||||
import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag, FeatureFlagValueType } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@ -27,6 +29,7 @@ import {
|
||||
subscribeTo,
|
||||
} from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FieldType, LinkedIdType, LoginLinkedId, CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
@ -35,7 +38,6 @@ import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
|
||||
import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view";
|
||||
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
|
||||
import { TotpService } from "@bitwarden/common/vault/services/totp.service";
|
||||
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
@ -88,6 +90,9 @@ describe("AutofillService", () => {
|
||||
let activeAccountStatusMock$: BehaviorSubject<AuthenticationStatus>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let enableChangedPasswordPromptMock$: BehaviorSubject<boolean>;
|
||||
let enableAddedLoginPromptMock$: BehaviorSubject<boolean>;
|
||||
let userNotificationsSettings: MockProxy<UserNotificationSettingsServiceAbstraction>;
|
||||
let messageListener: MockProxy<MessageListener>;
|
||||
|
||||
beforeEach(() => {
|
||||
@ -100,6 +105,11 @@ describe("AutofillService", () => {
|
||||
authService.activeAccountStatus$ = activeAccountStatusMock$;
|
||||
configService = mock<ConfigService>();
|
||||
messageListener = mock<MessageListener>();
|
||||
enableChangedPasswordPromptMock$ = new BehaviorSubject(true);
|
||||
enableAddedLoginPromptMock$ = new BehaviorSubject(true);
|
||||
userNotificationsSettings = mock<UserNotificationSettingsServiceAbstraction>();
|
||||
userNotificationsSettings.enableChangedPasswordPrompt$ = enableChangedPasswordPromptMock$;
|
||||
userNotificationsSettings.enableAddedLoginPrompt$ = enableAddedLoginPromptMock$;
|
||||
autofillService = new AutofillService(
|
||||
cipherService,
|
||||
autofillSettingsService,
|
||||
@ -113,6 +123,7 @@ describe("AutofillService", () => {
|
||||
accountService,
|
||||
authService,
|
||||
configService,
|
||||
userNotificationsSettings,
|
||||
messageListener,
|
||||
);
|
||||
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider);
|
||||
@ -349,13 +360,18 @@ describe("AutofillService", () => {
|
||||
describe("injectAutofillScripts", () => {
|
||||
const autofillBootstrapScript = "bootstrap-autofill.js";
|
||||
const autofillOverlayBootstrapScript = "bootstrap-autofill-overlay.js";
|
||||
const autofillOverlayMenuBootstrapScript = "bootstrap-autofill-overlay-menu.js";
|
||||
const autofillOverlayNotificationsBootstrapScript =
|
||||
"bootstrap-autofill-overlay-notifications.js";
|
||||
const defaultAutofillScripts = ["autofiller.js", "notificationBar.js", "contextMenuHandler.js"];
|
||||
const defaultExecuteScriptOptions = { runAt: "document_start" };
|
||||
let tabMock: chrome.tabs.Tab;
|
||||
let sender: chrome.runtime.MessageSender;
|
||||
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
configService.getFeatureFlag.mockImplementation(
|
||||
async (_feature) => true as FeatureFlagValueType<any>,
|
||||
);
|
||||
tabMock = createChromeTabMock();
|
||||
sender = { tab: tabMock, frameId: 1 };
|
||||
jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation();
|
||||
@ -366,9 +382,16 @@ describe("AutofillService", () => {
|
||||
});
|
||||
|
||||
it("accepts an extension message sender and injects the autofill scripts into the tab of the sender", async () => {
|
||||
configService.getFeatureFlag.mockImplementation(async (_feature) => {
|
||||
if (_feature === FeatureFlag.NotificationBarAddLoginImprovements) {
|
||||
return false as FeatureFlagValueType<any>;
|
||||
}
|
||||
|
||||
return true as FeatureFlagValueType<any>;
|
||||
});
|
||||
await autofillService.injectAutofillScripts(sender.tab, sender.frameId, true);
|
||||
|
||||
[autofillOverlayBootstrapScript, ...defaultAutofillScripts].forEach((scriptName) => {
|
||||
[autofillOverlayMenuBootstrapScript, ...defaultAutofillScripts].forEach((scriptName) => {
|
||||
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
|
||||
file: `content/${scriptName}`,
|
||||
frameId: sender.frameId,
|
||||
@ -420,6 +443,13 @@ describe("AutofillService", () => {
|
||||
jest
|
||||
.spyOn(autofillService, "getInlineMenuVisibility")
|
||||
.mockResolvedValue(AutofillOverlayVisibility.Off);
|
||||
configService.getFeatureFlag.mockImplementation(async (_feature) => {
|
||||
if (_feature === FeatureFlag.NotificationBarAddLoginImprovements) {
|
||||
return false as FeatureFlagValueType<any>;
|
||||
}
|
||||
|
||||
return true as FeatureFlagValueType<any>;
|
||||
});
|
||||
|
||||
await autofillService.injectAutofillScripts(sender.tab, sender.frameId);
|
||||
|
||||
@ -435,6 +465,21 @@ describe("AutofillService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("will inject the bootstrap-autofill-overlay-notifications script if the user has the notification bar turned on but does not have the inline menu turned on", async () => {
|
||||
jest
|
||||
.spyOn(autofillService, "getInlineMenuVisibility")
|
||||
.mockResolvedValue(AutofillOverlayVisibility.Off);
|
||||
enableChangedPasswordPromptMock$.next(true);
|
||||
|
||||
await autofillService.injectAutofillScripts(sender.tab, sender.frameId);
|
||||
|
||||
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
|
||||
file: `content/${autofillOverlayNotificationsBootstrapScript}`,
|
||||
frameId: sender.frameId,
|
||||
...defaultExecuteScriptOptions,
|
||||
});
|
||||
});
|
||||
|
||||
it("injects the content-message-handler script if not injecting on page load", async () => {
|
||||
await autofillService.injectAutofillScripts(sender.tab, sender.frameId, false);
|
||||
|
||||
|
@ -2,13 +2,14 @@ import { filter, firstValueFrom, Observable, scan, startWith } from "rxjs";
|
||||
import { pairwise } from "rxjs/operators";
|
||||
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
|
||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service";
|
||||
import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
@ -20,6 +21,7 @@ import {
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessageListener } from "@bitwarden/common/platform/messaging";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||
import { FieldType, CipherType } from "@bitwarden/common/vault/enums";
|
||||
@ -71,6 +73,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
private accountService: AccountService,
|
||||
private authService: AuthService,
|
||||
private configService: ConfigService,
|
||||
private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction,
|
||||
private messageListener: MessageListener,
|
||||
) {}
|
||||
|
||||
@ -164,25 +167,14 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
|
||||
const accountIsUnlocked = authStatus === AuthenticationStatus.Unlocked;
|
||||
let inlineMenuVisibility: InlineMenuVisibilitySetting = AutofillOverlayVisibility.Off;
|
||||
let autoFillOnPageLoadIsEnabled = false;
|
||||
const addLoginImprovementsFlagActive = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.NotificationBarAddLoginImprovements,
|
||||
);
|
||||
|
||||
if (activeAccount) {
|
||||
inlineMenuVisibility = await this.getInlineMenuVisibility();
|
||||
}
|
||||
|
||||
let mainAutofillScript = "bootstrap-autofill.js";
|
||||
|
||||
if (inlineMenuVisibility) {
|
||||
const inlineMenuPositioningImprovements = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.InlineMenuPositioningImprovements,
|
||||
);
|
||||
mainAutofillScript = inlineMenuPositioningImprovements
|
||||
? "bootstrap-autofill-overlay.js"
|
||||
: "bootstrap-legacy-autofill-overlay.js";
|
||||
}
|
||||
|
||||
const injectedScripts = [mainAutofillScript];
|
||||
const injectedScripts = [
|
||||
await this.getBootstrapAutofillContentScript(activeAccount, addLoginImprovementsFlagActive),
|
||||
];
|
||||
|
||||
if (activeAccount && accountIsUnlocked) {
|
||||
autoFillOnPageLoadIsEnabled = await this.getAutofillOnPageLoad();
|
||||
@ -199,7 +191,11 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
});
|
||||
}
|
||||
|
||||
injectedScripts.push("notificationBar.js", "contextMenuHandler.js");
|
||||
if (!addLoginImprovementsFlagActive) {
|
||||
injectedScripts.push("notificationBar.js");
|
||||
}
|
||||
|
||||
injectedScripts.push("contextMenuHandler.js");
|
||||
|
||||
for (const injectedScript of injectedScripts) {
|
||||
await this.scriptInjectorService.inject({
|
||||
@ -213,6 +209,55 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies the correct autofill script to inject based on whether the
|
||||
* inline menu is enabled, and whether the user has the notification bar
|
||||
* enabled.
|
||||
*
|
||||
* @param activeAccount - The active account
|
||||
* @param addLoginImprovementsFlagActive - Whether the add login improvements feature flag is active
|
||||
*/
|
||||
private async getBootstrapAutofillContentScript(
|
||||
activeAccount: { id: UserId | undefined } & AccountInfo,
|
||||
addLoginImprovementsFlagActive = false,
|
||||
): Promise<string> {
|
||||
let inlineMenuVisibility: InlineMenuVisibilitySetting = AutofillOverlayVisibility.Off;
|
||||
|
||||
if (activeAccount) {
|
||||
inlineMenuVisibility = await this.getInlineMenuVisibility();
|
||||
}
|
||||
|
||||
const inlineMenuPositioningImprovements = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.InlineMenuPositioningImprovements,
|
||||
);
|
||||
if (!inlineMenuPositioningImprovements) {
|
||||
return "bootstrap-legacy-autofill-overlay.js";
|
||||
}
|
||||
|
||||
const enableChangedPasswordPrompt = await firstValueFrom(
|
||||
this.userNotificationSettingsService.enableChangedPasswordPrompt$,
|
||||
);
|
||||
const enableAddedLoginPrompt = await firstValueFrom(
|
||||
this.userNotificationSettingsService.enableAddedLoginPrompt$,
|
||||
);
|
||||
const isNotificationBarEnabled =
|
||||
addLoginImprovementsFlagActive && (enableChangedPasswordPrompt || enableAddedLoginPrompt);
|
||||
|
||||
if (!inlineMenuVisibility && !isNotificationBarEnabled) {
|
||||
return "bootstrap-autofill.js";
|
||||
}
|
||||
|
||||
if (!inlineMenuVisibility && isNotificationBarEnabled) {
|
||||
return "bootstrap-autofill-overlay-notifications.js";
|
||||
}
|
||||
|
||||
if (inlineMenuVisibility && !isNotificationBarEnabled) {
|
||||
return "bootstrap-autofill-overlay-menu.js";
|
||||
}
|
||||
|
||||
return "bootstrap-autofill-overlay.js";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all forms with password fields and formats the data
|
||||
* for both forms and password input elements.
|
||||
|
@ -15,6 +15,7 @@ import { InlineMenuFieldQualificationService } from "./abstractions/inline-menu-
|
||||
import { AutofillOverlayContentService } from "./autofill-overlay-content.service";
|
||||
import { CollectAutofillContentService } from "./collect-autofill-content.service";
|
||||
import DomElementVisibilityService from "./dom-element-visibility.service";
|
||||
import { DomQueryService } from "./dom-query.service";
|
||||
|
||||
const mockLoginForm = `
|
||||
<div id="root">
|
||||
@ -30,7 +31,9 @@ const waitForIdleCallback = () => new Promise((resolve) => globalThis.requestIdl
|
||||
describe("CollectAutofillContentService", () => {
|
||||
const domElementVisibilityService = new DomElementVisibilityService();
|
||||
const inlineMenuFieldQualificationService = mock<InlineMenuFieldQualificationService>();
|
||||
const domQueryService = new DomQueryService();
|
||||
const autofillOverlayContentService = new AutofillOverlayContentService(
|
||||
domQueryService,
|
||||
inlineMenuFieldQualificationService,
|
||||
);
|
||||
let collectAutofillContentService: CollectAutofillContentService;
|
||||
@ -43,6 +46,7 @@ describe("CollectAutofillContentService", () => {
|
||||
document.body.innerHTML = mockLoginForm;
|
||||
collectAutofillContentService = new CollectAutofillContentService(
|
||||
domElementVisibilityService,
|
||||
domQueryService,
|
||||
autofillOverlayContentService,
|
||||
);
|
||||
window.IntersectionObserver = jest.fn(() => mockIntersectionObserver);
|
||||
@ -254,7 +258,7 @@ describe("CollectAutofillContentService", () => {
|
||||
.mockResolvedValue(true);
|
||||
const setupAutofillOverlayListenerOnFieldSpy = jest.spyOn(
|
||||
collectAutofillContentService["autofillOverlayContentService"],
|
||||
"setupInlineMenu",
|
||||
"setupOverlayListeners",
|
||||
);
|
||||
|
||||
await collectAutofillContentService.getPageDetails();
|
||||
@ -457,51 +461,6 @@ describe("CollectAutofillContentService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deepQueryElements", () => {
|
||||
beforeEach(() => {
|
||||
collectAutofillContentService["mutationObserver"] = mock<MutationObserver>();
|
||||
});
|
||||
|
||||
it("queries form field elements that are nested within a ShadowDOM", () => {
|
||||
const root = document.createElement("div");
|
||||
const shadowRoot = root.attachShadow({ mode: "open" });
|
||||
const form = document.createElement("form");
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
form.appendChild(input);
|
||||
shadowRoot.appendChild(form);
|
||||
|
||||
const formFieldElements = collectAutofillContentService.deepQueryElements(
|
||||
shadowRoot,
|
||||
"input",
|
||||
true,
|
||||
);
|
||||
|
||||
expect(formFieldElements).toStrictEqual([input]);
|
||||
});
|
||||
|
||||
it("queries form field elements that are nested within multiple ShadowDOM elements", () => {
|
||||
const root = document.createElement("div");
|
||||
const shadowRoot1 = root.attachShadow({ mode: "open" });
|
||||
const root2 = document.createElement("div");
|
||||
const shadowRoot2 = root2.attachShadow({ mode: "open" });
|
||||
const form = document.createElement("form");
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
form.appendChild(input);
|
||||
shadowRoot2.appendChild(form);
|
||||
shadowRoot1.appendChild(root2);
|
||||
|
||||
const formFieldElements = collectAutofillContentService.deepQueryElements(
|
||||
shadowRoot1,
|
||||
"input",
|
||||
true,
|
||||
);
|
||||
|
||||
expect(formFieldElements).toStrictEqual([input]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAutofillFormsData", () => {
|
||||
it("will not attempt to gather data from a cached form element", () => {
|
||||
const documentTitle = "Test Page";
|
||||
@ -2570,7 +2529,7 @@ describe("CollectAutofillContentService", () => {
|
||||
);
|
||||
setupAutofillOverlayListenerOnFieldSpy = jest.spyOn(
|
||||
collectAutofillContentService["autofillOverlayContentService"],
|
||||
"setupInlineMenu",
|
||||
"setupOverlayListeners",
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -30,11 +30,10 @@ import {
|
||||
UpdateAutofillDataAttributeParams,
|
||||
} from "./abstractions/collect-autofill-content.service";
|
||||
import { DomElementVisibilityService } from "./abstractions/dom-element-visibility.service";
|
||||
import { DomQueryService } from "./abstractions/dom-query.service";
|
||||
|
||||
export class CollectAutofillContentService implements CollectAutofillContentServiceInterface {
|
||||
private readonly sendExtensionMessage = sendExtensionMessage;
|
||||
private readonly domElementVisibilityService: DomElementVisibilityService;
|
||||
private readonly autofillOverlayContentService: AutofillOverlayContentService;
|
||||
private readonly getAttributeBoolean = getAttributeBoolean;
|
||||
private readonly getPropertyOrAttribute = getPropertyOrAttribute;
|
||||
private noFieldsFound = false;
|
||||
@ -61,12 +60,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
private useTreeWalkerStrategyFlagSet = true;
|
||||
|
||||
constructor(
|
||||
domElementVisibilityService: DomElementVisibilityService,
|
||||
autofillOverlayContentService?: AutofillOverlayContentService,
|
||||
private domElementVisibilityService: DomElementVisibilityService,
|
||||
private domQueryService: DomQueryService,
|
||||
private autofillOverlayContentService?: AutofillOverlayContentService,
|
||||
) {
|
||||
this.domElementVisibilityService = domElementVisibilityService;
|
||||
this.autofillOverlayContentService = autofillOverlayContentService;
|
||||
|
||||
let inputQuery = "input:not([data-bwignore])";
|
||||
for (const type of this.ignoredInputTypes) {
|
||||
inputQuery += `:not([type="${type}"])`;
|
||||
@ -126,7 +123,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
|
||||
this.domRecentlyMutated = false;
|
||||
const pageDetails = this.getFormattedPageDetails(autofillFormsData, autofillFieldsData);
|
||||
this.setupInlineMenuListeners(pageDetails);
|
||||
this.setupOverlayListeners(pageDetails);
|
||||
|
||||
return pageDetails;
|
||||
}
|
||||
@ -161,89 +158,6 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
return fieldElementsWithOpid[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries all elements in the DOM that match the given query string.
|
||||
* Also, recursively queries all shadow roots for the element.
|
||||
*
|
||||
* @param root - The root element to start the query from
|
||||
* @param queryString - The query string to match elements against
|
||||
* @param isObservingShadowRoot - Determines whether to observe shadow roots
|
||||
*/
|
||||
deepQueryElements<T>(
|
||||
root: Document | ShadowRoot | Element,
|
||||
queryString: string,
|
||||
isObservingShadowRoot = false,
|
||||
): T[] {
|
||||
let elements = this.queryElements<T>(root, queryString);
|
||||
const shadowRoots = this.recursivelyQueryShadowRoots(root, isObservingShadowRoot);
|
||||
for (let index = 0; index < shadowRoots.length; index++) {
|
||||
const shadowRoot = shadowRoots[index];
|
||||
elements = elements.concat(this.queryElements<T>(shadowRoot, queryString));
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the DOM for elements based on the given query string.
|
||||
*
|
||||
* @param root - The root element to start the query from
|
||||
* @param queryString - The query string to match elements against
|
||||
*/
|
||||
private queryElements<T>(root: Document | ShadowRoot | Element, queryString: string): T[] {
|
||||
if (!root.querySelector(queryString)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(root.querySelectorAll(queryString)) as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively queries all shadow roots found within the given root element.
|
||||
* Will also set up a mutation observer on the shadow root if the
|
||||
* `isObservingShadowRoot` parameter is set to true.
|
||||
*
|
||||
* @param root - The root element to start the query from
|
||||
* @param isObservingShadowRoot - Determines whether to observe shadow roots
|
||||
*/
|
||||
private recursivelyQueryShadowRoots(
|
||||
root: Document | ShadowRoot | Element,
|
||||
isObservingShadowRoot = false,
|
||||
): ShadowRoot[] {
|
||||
let shadowRoots = this.queryShadowRoots(root);
|
||||
for (let index = 0; index < shadowRoots.length; index++) {
|
||||
const shadowRoot = shadowRoots[index];
|
||||
shadowRoots = shadowRoots.concat(this.recursivelyQueryShadowRoots(shadowRoot));
|
||||
if (isObservingShadowRoot) {
|
||||
this.mutationObserver.observe(shadowRoot, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return shadowRoots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries any immediate shadow roots found within the given root element.
|
||||
*
|
||||
* @param root - The root element to start the query from
|
||||
*/
|
||||
private queryShadowRoots(root: Document | ShadowRoot | Element): ShadowRoot[] {
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
const potentialShadowRoots = root.querySelectorAll(":defined");
|
||||
for (let index = 0; index < potentialShadowRoots.length; index++) {
|
||||
const shadowRoot = this.getShadowRoot(potentialShadowRoots[index]);
|
||||
if (shadowRoot) {
|
||||
shadowRoots.push(shadowRoot);
|
||||
}
|
||||
}
|
||||
|
||||
return shadowRoots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts the AutofillFieldElements map by the elementNumber property.
|
||||
* @private
|
||||
@ -290,7 +204,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
autofillField.viewable = await this.domElementVisibilityService.isFormFieldViewable(element);
|
||||
|
||||
if (!previouslyViewable && autofillField.viewable) {
|
||||
this.setupInlineMenu(element, autofillField);
|
||||
this.setupOverlayOnField(element, autofillField);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -385,7 +299,11 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
if (!formFieldElements) {
|
||||
formFieldElements = this.useTreeWalkerStrategyFlagSet
|
||||
? this.queryTreeWalkerForAutofillFormFieldElements()
|
||||
: this.deepQueryElements(document, this.formFieldQueryString, true);
|
||||
: this.domQueryService.deepQueryElements(
|
||||
document,
|
||||
this.formFieldQueryString,
|
||||
this.mutationObserver,
|
||||
);
|
||||
}
|
||||
|
||||
if (!fieldsLimit || formFieldElements.length <= fieldsLimit) {
|
||||
@ -918,10 +836,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
return this.queryTreeWalkerForAutofillFormAndFieldElements();
|
||||
}
|
||||
|
||||
const queriedElements = this.deepQueryElements<HTMLElement>(
|
||||
const queriedElements = this.domQueryService.deepQueryElements<HTMLElement>(
|
||||
document,
|
||||
`form, ${this.formFieldQueryString}`,
|
||||
true,
|
||||
this.mutationObserver,
|
||||
);
|
||||
const formElements: HTMLFormElement[] = [];
|
||||
const formFieldElements: FormFieldElement[] = [];
|
||||
@ -1123,7 +1041,11 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
|
||||
const autofillElements = this.useTreeWalkerStrategyFlagSet
|
||||
? this.queryTreeWalkerForMutatedElements(node)
|
||||
: this.deepQueryElements<HTMLElement>(node, `form, ${this.formFieldQueryString}`, true);
|
||||
: this.domQueryService.deepQueryElements<HTMLElement>(
|
||||
node,
|
||||
`form, ${this.formFieldQueryString}`,
|
||||
this.mutationObserver,
|
||||
);
|
||||
if (autofillElements.length) {
|
||||
mutatedElements = mutatedElements.concat(autofillElements);
|
||||
}
|
||||
@ -1394,7 +1316,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
}
|
||||
|
||||
cachedAutofillFieldElement.viewable = true;
|
||||
this.setupInlineMenu(formFieldElement, cachedAutofillFieldElement);
|
||||
this.setupOverlayOnField(formFieldElement, cachedAutofillFieldElement);
|
||||
|
||||
this.intersectionObserver?.unobserve(entry.target);
|
||||
}
|
||||
@ -1405,14 +1327,12 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
*
|
||||
* @param pageDetails - The page details to use for the inline menu listeners
|
||||
*/
|
||||
private setupInlineMenuListeners(pageDetails: AutofillPageDetails) {
|
||||
if (!this.autofillOverlayContentService) {
|
||||
return;
|
||||
private setupOverlayListeners(pageDetails: AutofillPageDetails) {
|
||||
if (this.autofillOverlayContentService) {
|
||||
this.autofillFieldElements.forEach((autofillField, formFieldElement) => {
|
||||
this.setupOverlayOnField(formFieldElement, autofillField, pageDetails);
|
||||
});
|
||||
}
|
||||
|
||||
this.autofillFieldElements.forEach((autofillField, formFieldElement) => {
|
||||
this.setupInlineMenu(formFieldElement, autofillField, pageDetails);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1422,27 +1342,25 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
* @param autofillField - The metadata for the form field
|
||||
* @param pageDetails - The page details to use for the inline menu listeners
|
||||
*/
|
||||
private setupInlineMenu(
|
||||
private setupOverlayOnField(
|
||||
formFieldElement: ElementWithOpId<FormFieldElement>,
|
||||
autofillField: AutofillField,
|
||||
pageDetails?: AutofillPageDetails,
|
||||
) {
|
||||
if (!this.autofillOverlayContentService) {
|
||||
return;
|
||||
}
|
||||
if (this.autofillOverlayContentService) {
|
||||
const autofillPageDetails =
|
||||
pageDetails ||
|
||||
this.getFormattedPageDetails(
|
||||
this.getFormattedAutofillFormsData(),
|
||||
this.getFormattedAutofillFieldsData(),
|
||||
);
|
||||
|
||||
const autofillPageDetails =
|
||||
pageDetails ||
|
||||
this.getFormattedPageDetails(
|
||||
this.getFormattedAutofillFormsData(),
|
||||
this.getFormattedAutofillFieldsData(),
|
||||
void this.autofillOverlayContentService.setupOverlayListeners(
|
||||
formFieldElement,
|
||||
autofillField,
|
||||
autofillPageDetails,
|
||||
);
|
||||
|
||||
void this.autofillOverlayContentService.setupInlineMenu(
|
||||
formFieldElement,
|
||||
autofillField,
|
||||
autofillPageDetails,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1457,78 +1375,6 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
this.intersectionObserver?.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the DOM for all the nodes that match the given filter callback
|
||||
* and returns a collection of nodes.
|
||||
* @param rootNode
|
||||
* @param filterCallback
|
||||
* @param isObservingShadowRoot
|
||||
*
|
||||
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
||||
*/
|
||||
queryAllTreeWalkerNodes(
|
||||
rootNode: Node,
|
||||
filterCallback: CallableFunction,
|
||||
isObservingShadowRoot = true,
|
||||
): Node[] {
|
||||
const treeWalkerQueryResults: Node[] = [];
|
||||
|
||||
this.buildTreeWalkerNodesQueryResults(
|
||||
rootNode,
|
||||
treeWalkerQueryResults,
|
||||
filterCallback,
|
||||
isObservingShadowRoot,
|
||||
);
|
||||
|
||||
return treeWalkerQueryResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively builds a collection of nodes that match the given filter callback.
|
||||
* If a node has a ShadowRoot, it will be observed for mutations.
|
||||
*
|
||||
* @param rootNode
|
||||
* @param treeWalkerQueryResults
|
||||
* @param filterCallback
|
||||
*
|
||||
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
||||
*/
|
||||
private buildTreeWalkerNodesQueryResults(
|
||||
rootNode: Node,
|
||||
treeWalkerQueryResults: Node[],
|
||||
filterCallback: CallableFunction,
|
||||
isObservingShadowRoot: boolean,
|
||||
) {
|
||||
const treeWalker = document?.createTreeWalker(rootNode, NodeFilter.SHOW_ELEMENT);
|
||||
let currentNode = treeWalker?.currentNode;
|
||||
|
||||
while (currentNode) {
|
||||
if (filterCallback(currentNode)) {
|
||||
treeWalkerQueryResults.push(currentNode);
|
||||
}
|
||||
|
||||
const nodeShadowRoot = this.getShadowRoot(currentNode);
|
||||
if (nodeShadowRoot) {
|
||||
if (isObservingShadowRoot) {
|
||||
this.mutationObserver.observe(nodeShadowRoot, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
this.buildTreeWalkerNodesQueryResults(
|
||||
nodeShadowRoot,
|
||||
treeWalkerQueryResults,
|
||||
filterCallback,
|
||||
isObservingShadowRoot,
|
||||
);
|
||||
}
|
||||
|
||||
currentNode = treeWalker?.nextNode();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
||||
*/
|
||||
@ -1538,19 +1384,23 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
} {
|
||||
const formElements: HTMLFormElement[] = [];
|
||||
const formFieldElements: FormFieldElement[] = [];
|
||||
this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) => {
|
||||
if (nodeIsFormElement(node)) {
|
||||
formElements.push(node);
|
||||
return true;
|
||||
}
|
||||
this.domQueryService.queryAllTreeWalkerNodes(
|
||||
document.documentElement,
|
||||
(node: Node) => {
|
||||
if (nodeIsFormElement(node)) {
|
||||
formElements.push(node);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.isNodeFormFieldElement(node)) {
|
||||
formFieldElements.push(node as FormFieldElement);
|
||||
return true;
|
||||
}
|
||||
if (this.isNodeFormFieldElement(node)) {
|
||||
formFieldElements.push(node as FormFieldElement);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
return false;
|
||||
},
|
||||
this.mutationObserver,
|
||||
);
|
||||
|
||||
return { formElements, formFieldElements };
|
||||
}
|
||||
@ -1559,8 +1409,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
||||
*/
|
||||
private queryTreeWalkerForAutofillFormFieldElements(): FormFieldElement[] {
|
||||
return this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) =>
|
||||
this.isNodeFormFieldElement(node),
|
||||
return this.domQueryService.queryAllTreeWalkerNodes(
|
||||
document.documentElement,
|
||||
(node: Node) => this.isNodeFormFieldElement(node),
|
||||
this.mutationObserver,
|
||||
) as FormFieldElement[];
|
||||
}
|
||||
|
||||
@ -1570,10 +1422,11 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
* @param node - The node to query
|
||||
*/
|
||||
private queryTreeWalkerForMutatedElements(node: Node): HTMLElement[] {
|
||||
return this.queryAllTreeWalkerNodes(
|
||||
return this.domQueryService.queryAllTreeWalkerNodes(
|
||||
node,
|
||||
(walkerNode: Node) =>
|
||||
nodeIsFormElement(walkerNode) || this.isNodeFormFieldElement(walkerNode),
|
||||
this.mutationObserver,
|
||||
) as HTMLElement[];
|
||||
}
|
||||
|
||||
@ -1581,10 +1434,9 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
||||
*/
|
||||
private queryTreeWalkerForPasswordElements(): HTMLElement[] {
|
||||
return this.queryAllTreeWalkerNodes(
|
||||
return this.domQueryService.queryAllTreeWalkerNodes(
|
||||
document.documentElement,
|
||||
(node: Node) => nodeIsInputElement(node) && node.type === "password",
|
||||
false,
|
||||
) as HTMLElement[];
|
||||
}
|
||||
|
||||
@ -1598,6 +1450,8 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
return Boolean(this.queryTreeWalkerForPasswordElements()?.length);
|
||||
}
|
||||
|
||||
return Boolean(this.deepQueryElements(document, `input[type="password"]`)?.length);
|
||||
return Boolean(
|
||||
this.domQueryService.deepQueryElements(document, `input[type="password"]`)?.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
82
apps/browser/src/autofill/services/dom-query.service.spec.ts
Normal file
82
apps/browser/src/autofill/services/dom-query.service.spec.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { mockQuerySelectorAllDefinedCall } from "../spec/testing-utils";
|
||||
|
||||
import { DomQueryService } from "./dom-query.service";
|
||||
|
||||
describe("DomQueryService", () => {
|
||||
let domQueryService: DomQueryService;
|
||||
let mutationObserver: MutationObserver;
|
||||
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
|
||||
|
||||
beforeEach(() => {
|
||||
domQueryService = new DomQueryService();
|
||||
mutationObserver = new MutationObserver(() => {});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mockQuerySelectorAll.mockRestore();
|
||||
});
|
||||
|
||||
describe("deepQueryElements", () => {
|
||||
it("queries form field elements that are nested within a ShadowDOM", () => {
|
||||
const root = document.createElement("div");
|
||||
const shadowRoot = root.attachShadow({ mode: "open" });
|
||||
const form = document.createElement("form");
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
form.appendChild(input);
|
||||
shadowRoot.appendChild(form);
|
||||
|
||||
const formFieldElements = domQueryService.deepQueryElements(
|
||||
shadowRoot,
|
||||
"input",
|
||||
mutationObserver,
|
||||
);
|
||||
|
||||
expect(formFieldElements).toStrictEqual([input]);
|
||||
});
|
||||
|
||||
it("queries form field elements that are nested within multiple ShadowDOM elements", () => {
|
||||
const root = document.createElement("div");
|
||||
const shadowRoot1 = root.attachShadow({ mode: "open" });
|
||||
const root2 = document.createElement("div");
|
||||
const shadowRoot2 = root2.attachShadow({ mode: "open" });
|
||||
const form = document.createElement("form");
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
form.appendChild(input);
|
||||
shadowRoot2.appendChild(form);
|
||||
shadowRoot1.appendChild(root2);
|
||||
|
||||
const formFieldElements = domQueryService.deepQueryElements(
|
||||
shadowRoot1,
|
||||
"input",
|
||||
mutationObserver,
|
||||
);
|
||||
|
||||
expect(formFieldElements).toStrictEqual([input]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryAllTreeWalkerNodes", () => {
|
||||
it("queries form field elements that are nested within multiple ShadowDOM elements", () => {
|
||||
const root = document.createElement("div");
|
||||
const shadowRoot1 = root.attachShadow({ mode: "open" });
|
||||
const root2 = document.createElement("div");
|
||||
const shadowRoot2 = root2.attachShadow({ mode: "open" });
|
||||
const form = document.createElement("form");
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
form.appendChild(input);
|
||||
shadowRoot2.appendChild(form);
|
||||
shadowRoot1.appendChild(root2);
|
||||
|
||||
const formFieldElements = domQueryService.queryAllTreeWalkerNodes(
|
||||
shadowRoot1,
|
||||
(element: Element) => element.tagName === "INPUT",
|
||||
mutationObserver,
|
||||
);
|
||||
|
||||
expect(formFieldElements).toStrictEqual([input]);
|
||||
});
|
||||
});
|
||||
});
|
185
apps/browser/src/autofill/services/dom-query.service.ts
Normal file
185
apps/browser/src/autofill/services/dom-query.service.ts
Normal file
@ -0,0 +1,185 @@
|
||||
import { nodeIsElement } from "../utils";
|
||||
|
||||
import { DomQueryService as DomQueryServiceInterface } from "./abstractions/dom-query.service";
|
||||
|
||||
export class DomQueryService implements DomQueryServiceInterface {
|
||||
/**
|
||||
* Queries all elements in the DOM that match the given query string.
|
||||
* Also, recursively queries all shadow roots for the element.
|
||||
*
|
||||
* @param root - The root element to start the query from
|
||||
* @param queryString - The query string to match elements against
|
||||
* @param mutationObserver - The MutationObserver to use for observing shadow roots
|
||||
*/
|
||||
deepQueryElements<T>(
|
||||
root: Document | ShadowRoot | Element,
|
||||
queryString: string,
|
||||
mutationObserver?: MutationObserver,
|
||||
): T[] {
|
||||
let elements = this.queryElements<T>(root, queryString);
|
||||
const shadowRoots = this.recursivelyQueryShadowRoots(root, mutationObserver);
|
||||
for (let index = 0; index < shadowRoots.length; index++) {
|
||||
const shadowRoot = shadowRoots[index];
|
||||
elements = elements.concat(this.queryElements<T>(shadowRoot, queryString));
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the DOM for elements based on the given query string.
|
||||
*
|
||||
* @param root - The root element to start the query from
|
||||
* @param queryString - The query string to match elements against
|
||||
*/
|
||||
private queryElements<T>(root: Document | ShadowRoot | Element, queryString: string): T[] {
|
||||
if (!root.querySelector(queryString)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(root.querySelectorAll(queryString)) as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively queries all shadow roots found within the given root element.
|
||||
* Will also set up a mutation observer on the shadow root if the
|
||||
* `isObservingShadowRoot` parameter is set to true.
|
||||
*
|
||||
* @param root - The root element to start the query from
|
||||
* @param mutationObserver - The MutationObserver to use for observing shadow roots
|
||||
*/
|
||||
private recursivelyQueryShadowRoots(
|
||||
root: Document | ShadowRoot | Element,
|
||||
mutationObserver?: MutationObserver,
|
||||
): ShadowRoot[] {
|
||||
let shadowRoots = this.queryShadowRoots(root);
|
||||
for (let index = 0; index < shadowRoots.length; index++) {
|
||||
const shadowRoot = shadowRoots[index];
|
||||
shadowRoots = shadowRoots.concat(this.recursivelyQueryShadowRoots(shadowRoot));
|
||||
if (mutationObserver) {
|
||||
mutationObserver.observe(shadowRoot, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return shadowRoots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries any immediate shadow roots found within the given root element.
|
||||
*
|
||||
* @param root - The root element to start the query from
|
||||
*/
|
||||
private queryShadowRoots(root: Document | ShadowRoot | Element): ShadowRoot[] {
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
const potentialShadowRoots = root.querySelectorAll(":defined");
|
||||
for (let index = 0; index < potentialShadowRoots.length; index++) {
|
||||
const shadowRoot = this.getShadowRoot(potentialShadowRoots[index]);
|
||||
if (shadowRoot) {
|
||||
shadowRoots.push(shadowRoot);
|
||||
}
|
||||
}
|
||||
|
||||
return shadowRoots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to get the ShadowRoot of the passed node. If support for the
|
||||
* extension based openOrClosedShadowRoot API is available, it will be used.
|
||||
* Will return null if the node is not an HTMLElement or if the node has
|
||||
* child nodes.
|
||||
*
|
||||
* @param {Node} node
|
||||
*/
|
||||
private getShadowRoot(node: Node): ShadowRoot | null {
|
||||
if (!nodeIsElement(node)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.shadowRoot) {
|
||||
return node.shadowRoot;
|
||||
}
|
||||
|
||||
if ((chrome as any).dom?.openOrClosedShadowRoot) {
|
||||
try {
|
||||
return (chrome as any).dom.openOrClosedShadowRoot(node);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (node as any).openOrClosedShadowRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the DOM for all the nodes that match the given filter callback
|
||||
* and returns a collection of nodes.
|
||||
* @param rootNode
|
||||
* @param filterCallback
|
||||
* @param mutationObserver
|
||||
*/
|
||||
queryAllTreeWalkerNodes(
|
||||
rootNode: Node,
|
||||
filterCallback: CallableFunction,
|
||||
mutationObserver?: MutationObserver,
|
||||
): Node[] {
|
||||
const treeWalkerQueryResults: Node[] = [];
|
||||
|
||||
this.buildTreeWalkerNodesQueryResults(
|
||||
rootNode,
|
||||
treeWalkerQueryResults,
|
||||
filterCallback,
|
||||
mutationObserver,
|
||||
);
|
||||
|
||||
return treeWalkerQueryResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively builds a collection of nodes that match the given filter callback.
|
||||
* If a node has a ShadowRoot, it will be observed for mutations.
|
||||
*
|
||||
* @param rootNode
|
||||
* @param treeWalkerQueryResults
|
||||
* @param filterCallback
|
||||
* @param mutationObserver
|
||||
*/
|
||||
private buildTreeWalkerNodesQueryResults(
|
||||
rootNode: Node,
|
||||
treeWalkerQueryResults: Node[],
|
||||
filterCallback: CallableFunction,
|
||||
mutationObserver?: MutationObserver,
|
||||
) {
|
||||
const treeWalker = document?.createTreeWalker(rootNode, NodeFilter.SHOW_ELEMENT);
|
||||
let currentNode = treeWalker?.currentNode;
|
||||
|
||||
while (currentNode) {
|
||||
if (filterCallback(currentNode)) {
|
||||
treeWalkerQueryResults.push(currentNode);
|
||||
}
|
||||
|
||||
const nodeShadowRoot = this.getShadowRoot(currentNode);
|
||||
if (nodeShadowRoot) {
|
||||
if (mutationObserver) {
|
||||
mutationObserver.observe(nodeShadowRoot, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
this.buildTreeWalkerNodesQueryResults(
|
||||
nodeShadowRoot,
|
||||
treeWalkerQueryResults,
|
||||
filterCallback,
|
||||
mutationObserver,
|
||||
);
|
||||
}
|
||||
|
||||
currentNode = treeWalker?.nextNode();
|
||||
}
|
||||
}
|
||||
}
|
@ -502,7 +502,7 @@ describe("InlineMenuFieldQualificationService", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is structured on a page with multiple viewable password field", () => {
|
||||
it("is structured on a page with multiple viewable password fields", () => {
|
||||
const field = mock<AutofillField>({
|
||||
type: "text",
|
||||
autoCompleteType: "",
|
||||
@ -534,7 +534,7 @@ describe("InlineMenuFieldQualificationService", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is structured on a page with a with no visible password fields and but contains a disabled autocomplete type", () => {
|
||||
it("contains a disabled autocomplete type when multiple password fields are on the page", () => {
|
||||
const field = mock<AutofillField>({
|
||||
type: "text",
|
||||
autoCompleteType: "off",
|
||||
@ -552,7 +552,16 @@ describe("InlineMenuFieldQualificationService", () => {
|
||||
form: "validFormId",
|
||||
viewable: false,
|
||||
});
|
||||
pageDetails.fields = [field, passwordField];
|
||||
const secondPasswordField = mock<AutofillField>({
|
||||
type: "password",
|
||||
autoCompleteType: "current-password",
|
||||
htmlID: "second-password",
|
||||
htmlName: "second-password",
|
||||
placeholder: "second-password",
|
||||
form: "validFormId",
|
||||
viewable: false,
|
||||
});
|
||||
pageDetails.fields = [field, passwordField, secondPasswordField];
|
||||
|
||||
expect(
|
||||
inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails),
|
||||
|
@ -5,11 +5,14 @@ import { sendExtensionMessage } from "../utils";
|
||||
import {
|
||||
AutofillKeywordsMap,
|
||||
InlineMenuFieldQualificationService as InlineMenuFieldQualificationServiceInterface,
|
||||
SubmitButtonKeywordsMap,
|
||||
} from "./abstractions/inline-menu-field-qualifications.service";
|
||||
import {
|
||||
AutoFillConstants,
|
||||
CreditCardAutoFillConstants,
|
||||
IdentityAutoFillConstants,
|
||||
SubmitChangePasswordButtonNames,
|
||||
SubmitLoginButtonNames,
|
||||
} from "./autofill-constants";
|
||||
|
||||
export class InlineMenuFieldQualificationService
|
||||
@ -31,6 +34,7 @@ export class InlineMenuFieldQualificationService
|
||||
private currentPasswordAutocompleteValue = "current-password";
|
||||
private newPasswordAutoCompleteValue = "new-password";
|
||||
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 = [
|
||||
@ -378,7 +382,9 @@ export class InlineMenuFieldQualificationService
|
||||
// If the provided field is set with an autocomplete of "username", we should assume that
|
||||
// the page developer intends for this field to be interpreted as a username field.
|
||||
if (this.fieldContainsAutocompleteValues(field, this.loginUsernameAutocompleteValues)) {
|
||||
const newPasswordFieldsInPageDetails = pageDetails.fields.filter(this.isNewPasswordField);
|
||||
const newPasswordFieldsInPageDetails = pageDetails.fields.filter(
|
||||
(field) => field.viewable && this.isNewPasswordField(field),
|
||||
);
|
||||
return newPasswordFieldsInPageDetails.length === 0;
|
||||
}
|
||||
|
||||
@ -435,7 +441,7 @@ export class InlineMenuFieldQualificationService
|
||||
// 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.
|
||||
const fieldsWithinForm = pageDetails.fields.filter(
|
||||
(pageDetailsField) => pageDetailsField.form === field.form && pageDetailsField.viewable,
|
||||
(pageDetailsField) => pageDetailsField.form === field.form,
|
||||
);
|
||||
return fieldsWithinForm.length === 1;
|
||||
}
|
||||
@ -455,6 +461,12 @@ 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 (passwordFieldsInPageDetails.length === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no visible password fields are found, this field might be part of a multipart form.
|
||||
// Check for an invalid autocompleteType to determine if the field is part of a login form.
|
||||
return !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues);
|
||||
@ -1002,6 +1014,67 @@ export class InlineMenuFieldQualificationService
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the provided field to indicate if the field is a submit button for a login form.
|
||||
*
|
||||
* @param element - The element to validate
|
||||
*/
|
||||
isElementLoginSubmitButton = (element: HTMLElement): boolean => {
|
||||
const keywordValues = this.getSubmitButtonKeywords(element);
|
||||
return SubmitLoginButtonNames.some((keyword) => keywordValues.indexOf(keyword) > -1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates the provided field to indicate if the field is a submit button for a change password form.
|
||||
*
|
||||
* @param element - The element to validate
|
||||
*/
|
||||
isElementChangePasswordSubmitButton = (element: HTMLElement): boolean => {
|
||||
const keywordValues = this.getSubmitButtonKeywords(element);
|
||||
return SubmitChangePasswordButtonNames.some((keyword) => keywordValues.indexOf(keyword) > -1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gather the keywords from the provided element to validate the submit button.
|
||||
*
|
||||
* @param element - The element to validate
|
||||
*/
|
||||
private getSubmitButtonKeywords(element: HTMLElement): string {
|
||||
if (!this.submitButtonKeywordsMap.has(element)) {
|
||||
const keywords = [
|
||||
element.textContent,
|
||||
element.getAttribute("value"),
|
||||
element.getAttribute("aria-label"),
|
||||
element.getAttribute("aria-labelledby"),
|
||||
element.getAttribute("aria-describedby"),
|
||||
element.getAttribute("title"),
|
||||
element.getAttribute("id"),
|
||||
element.getAttribute("name"),
|
||||
element.getAttribute("class"),
|
||||
];
|
||||
|
||||
const keywordsSet = new Set<string>();
|
||||
for (let i = 0; i < keywords.length; i++) {
|
||||
if (typeof keywords[i] === "string") {
|
||||
keywords[i]
|
||||
.toLowerCase()
|
||||
.replace(/[-\s]/g, "")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "|")
|
||||
.split("|")
|
||||
.forEach((keyword) => {
|
||||
if (keyword) {
|
||||
keywordsSet.add(keyword);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.submitButtonKeywordsMap.set(element, Array.from(keywordsSet).join(","));
|
||||
}
|
||||
|
||||
return this.submitButtonKeywordsMap.get(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the provided field to indicate if the field has any of the provided keywords.
|
||||
*
|
||||
|
@ -10,6 +10,7 @@ import { InlineMenuFieldQualificationService } from "./abstractions/inline-menu-
|
||||
import { AutofillOverlayContentService } from "./autofill-overlay-content.service";
|
||||
import { CollectAutofillContentService } from "./collect-autofill-content.service";
|
||||
import DomElementVisibilityService from "./dom-element-visibility.service";
|
||||
import { DomQueryService } from "./dom-query.service";
|
||||
import InsertAutofillContentService from "./insert-autofill-content.service";
|
||||
|
||||
const mockLoginForm = `
|
||||
@ -68,12 +69,15 @@ function setMockWindowLocation({
|
||||
|
||||
describe("InsertAutofillContentService", () => {
|
||||
const inlineMenuFieldQualificationService = mock<InlineMenuFieldQualificationService>();
|
||||
const domQueryService = new DomQueryService();
|
||||
const domElementVisibilityService = new DomElementVisibilityService();
|
||||
const autofillOverlayContentService = new AutofillOverlayContentService(
|
||||
domQueryService,
|
||||
inlineMenuFieldQualificationService,
|
||||
);
|
||||
const collectAutofillContentService = new CollectAutofillContentService(
|
||||
domElementVisibilityService,
|
||||
domQueryService,
|
||||
autofillOverlayContentService,
|
||||
);
|
||||
let insertAutofillContentService: InsertAutofillContentService;
|
||||
|
@ -14,8 +14,6 @@ import { CollectAutofillContentService } from "./collect-autofill-content.servic
|
||||
import DomElementVisibilityService from "./dom-element-visibility.service";
|
||||
|
||||
class InsertAutofillContentService implements InsertAutofillContentServiceInterface {
|
||||
private readonly domElementVisibilityService: DomElementVisibilityService;
|
||||
private readonly collectAutofillContentService: CollectAutofillContentService;
|
||||
private readonly autofillInsertActions: AutofillInsertActions = {
|
||||
fill_by_opid: ({ opid, value }) => this.handleFillFieldByOpidAction(opid, value),
|
||||
click_on_opid: ({ opid }) => this.handleClickOnFieldByOpidAction(opid),
|
||||
@ -27,12 +25,9 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
|
||||
* DomElementVisibilityService and CollectAutofillContentService classes.
|
||||
*/
|
||||
constructor(
|
||||
domElementVisibilityService: DomElementVisibilityService,
|
||||
collectAutofillContentService: CollectAutofillContentService,
|
||||
) {
|
||||
this.domElementVisibilityService = domElementVisibilityService;
|
||||
this.collectAutofillContentService = collectAutofillContentService;
|
||||
}
|
||||
private domElementVisibilityService: DomElementVisibilityService,
|
||||
private collectAutofillContentService: CollectAutofillContentService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handles autofill of the forms on the current page based on the
|
||||
|
@ -165,6 +165,15 @@ export function triggerWebRequestOnBeforeRedirectEvent(
|
||||
});
|
||||
}
|
||||
|
||||
export function triggerWebRequestOnCompletedEvent(details: chrome.webRequest.WebRequestDetails) {
|
||||
(chrome.webRequest.onCompleted.addListener as unknown as jest.SpyInstance).mock.calls.forEach(
|
||||
(call) => {
|
||||
const callback = call[0];
|
||||
callback(details);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function mockQuerySelectorAllDefinedCall() {
|
||||
const originalDocumentQuerySelectorAll = document.querySelectorAll;
|
||||
document.querySelectorAll = function (selector: string) {
|
||||
|
@ -346,7 +346,7 @@ export function getPropertyOrAttribute(element: HTMLElement, attributeName: stri
|
||||
* @param callback - The callback function to throttle.
|
||||
* @param limit - The time in milliseconds to throttle the callback.
|
||||
*/
|
||||
export function throttle(callback: () => void, limit: number) {
|
||||
export function throttle(callback: (_args: any) => any, limit: number) {
|
||||
let waitingDelay = false;
|
||||
return function (...args: unknown[]) {
|
||||
if (!waitingDelay) {
|
||||
|
@ -24,3 +24,6 @@ export const viewCipherIcon =
|
||||
|
||||
export const passkeyIcon =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="15" viewBox="0 0 14 15" fill="none"><path fill="#6D757E" d="M2.35 12.112a.713.713 0 1 1 0-1.426.713.713 0 0 1 0 1.426Z"/><path fill="#6D757E" fill-rule="evenodd" d="M4.597 7.695a3.5 3.5 0 1 1 3.741 0A5.33 5.33 0 0 1 10.5 9.186c.154.172.29.328.384.461l1.562-.001L14 11.14l-2.188 1.952-.874-.875-.876.875-.874-.875-.876.84-2.613-.003a3.152 3.152 0 0 1-2.634 1.307c-1.729-.036-3.101-1.436-3.064-3.127C.038 9.543 1.469 8.2 3.199 8.237c.098.002.195.009.291.02a6.76 6.76 0 0 1 .296-.181c.257-.149.528-.276.81-.381Zm1.176 1.957 3.952-.004a4.11 4.11 0 0 0-.498-.462 4.452 4.452 0 0 0-2.76-.95c-.647 0-1.262.137-1.817.384a3.12 3.12 0 0 1 1.123 1.032Zm-1.93-4.916a2.625 2.625 0 1 0 5.25 0 2.625 2.625 0 0 0-5.25 0Zm1.407 7.442-.262.366a2.277 2.277 0 0 1-1.904.942C1.819 13.459.85 12.442.876 11.253c.025-1.19 1.04-2.168 2.304-2.141a2.27 2.27 0 0 1 1.86 1.019l.26.396 6.794-.006.619.595-.866.773-.91-.909-.874.875-.863-.862-1.239 1.19-2.711-.005Z" clip-rule="evenodd"/></svg>';
|
||||
|
||||
export const circleCheckIcon =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><g clip-path="url(#a)"><path fill="#017E45" d="M8 15.5a8.383 8.383 0 0 1-4.445-1.264A7.627 7.627 0 0 1 .61 10.87a7.063 7.063 0 0 1-.455-4.333 7.368 7.368 0 0 1 2.19-3.84A8.181 8.181 0 0 1 6.438.644a8.498 8.498 0 0 1 4.623.427 7.912 7.912 0 0 1 3.59 2.762A7.171 7.171 0 0 1 16 8c-.002 1.988-.846 3.895-2.345 5.3-1.5 1.406-3.534 2.198-5.655 2.2ZM8 1.437a7.337 7.337 0 0 0-3.889 1.106 6.672 6.672 0 0 0-2.578 2.945 6.182 6.182 0 0 0-.399 3.792 6.448 6.448 0 0 0 1.916 3.36 7.156 7.156 0 0 0 3.584 1.796 7.434 7.434 0 0 0 4.044-.374 6.924 6.924 0 0 0 3.142-2.417A6.275 6.275 0 0 0 15 8c-.002-1.74-.74-3.407-2.053-4.638C11.635 2.131 9.856 1.44 8 1.437Zm-1.351 9.905a.361.361 0 0 1-.245-.094l-2.257-2.07a.326.326 0 0 1-.103-.232c0-.043.009-.085.027-.125a.334.334 0 0 1 .076-.107.366.366 0 0 1 .246-.097c.093 0 .182.033.249.093l1.843 1.687a.166.166 0 0 0 .126.044.17.17 0 0 0 .066-.018.157.157 0 0 0 .052-.041l4.623-5.636a.34.34 0 0 1 .102-.088.375.375 0 0 1 .27-.038.34.34 0 0 1 .216.156.311.311 0 0 1-.033.37L6.93 11.21a.344.344 0 0 1-.112.09.376.376 0 0 1-.141.039l-.03.003h.001Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 .5h16v15H0z"/></clipPath></defs></svg>';
|
||||
|
@ -204,10 +204,12 @@ import {
|
||||
VaultExportServiceAbstraction,
|
||||
} from "@bitwarden/vault-export-core";
|
||||
|
||||
import { OverlayNotificationsBackground as OverlayNotificationsBackgroundInterface } from "../autofill/background/abstractions/overlay-notifications.background";
|
||||
import { OverlayBackground as OverlayBackgroundInterface } from "../autofill/background/abstractions/overlay.background";
|
||||
import { AutoSubmitLoginBackground } from "../autofill/background/auto-submit-login.background";
|
||||
import ContextMenusBackground from "../autofill/background/context-menus.background";
|
||||
import NotificationBackground from "../autofill/background/notification.background";
|
||||
import { OverlayNotificationsBackground } from "../autofill/background/overlay-notifications.background";
|
||||
import { OverlayBackground } from "../autofill/background/overlay.background";
|
||||
import TabsBackground from "../autofill/background/tabs.background";
|
||||
import WebRequestBackground from "../autofill/background/web-request.background";
|
||||
@ -368,6 +370,7 @@ export default class MainBackground {
|
||||
private idleBackground: IdleBackground;
|
||||
private notificationBackground: NotificationBackground;
|
||||
private overlayBackground: OverlayBackgroundInterface;
|
||||
private overlayNotificationsBackground: OverlayNotificationsBackgroundInterface;
|
||||
private filelessImporterBackground: FilelessImporterBackground;
|
||||
private runtimeBackground: RuntimeBackground;
|
||||
private tabsBackground: TabsBackground;
|
||||
@ -948,6 +951,7 @@ export default class MainBackground {
|
||||
this.accountService,
|
||||
this.authService,
|
||||
this.configService,
|
||||
this.userNotificationSettingsService,
|
||||
messageListener,
|
||||
);
|
||||
this.auditService = new AuditService(this.cryptoFunctionService, this.apiService);
|
||||
@ -1105,6 +1109,12 @@ export default class MainBackground {
|
||||
this.accountService,
|
||||
);
|
||||
|
||||
this.overlayNotificationsBackground = new OverlayNotificationsBackground(
|
||||
this.logService,
|
||||
this.configService,
|
||||
this.notificationBackground,
|
||||
);
|
||||
|
||||
this.filelessImporterBackground = new FilelessImporterBackground(
|
||||
this.configService,
|
||||
this.authService,
|
||||
@ -1238,7 +1248,8 @@ export default class MainBackground {
|
||||
await this.vaultTimeoutService.init(true);
|
||||
this.fido2Background.init();
|
||||
await this.runtimeBackground.init();
|
||||
this.notificationBackground.init();
|
||||
await this.notificationBackground.init();
|
||||
this.overlayNotificationsBackground.init();
|
||||
this.filelessImporterBackground.init();
|
||||
this.commandsBackground.init();
|
||||
this.contextMenusBackground?.init();
|
||||
|
@ -329,6 +329,7 @@ const safeProviders: SafeProvider[] = [
|
||||
AccountServiceAbstraction,
|
||||
AuthService,
|
||||
ConfigService,
|
||||
UserNotificationSettingsServiceAbstraction,
|
||||
MessageListener,
|
||||
],
|
||||
}),
|
||||
|
@ -163,6 +163,10 @@ const webRequest = {
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
},
|
||||
onCompleted: {
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const alarms = {
|
||||
|
@ -226,6 +226,10 @@ const mainConfig = {
|
||||
"./src/autofill/content/trigger-autofill-script-injection.ts",
|
||||
"content/bootstrap-autofill": "./src/autofill/content/bootstrap-autofill.ts",
|
||||
"content/bootstrap-autofill-overlay": "./src/autofill/content/bootstrap-autofill-overlay.ts",
|
||||
"content/bootstrap-autofill-overlay-menu":
|
||||
"./src/autofill/content/bootstrap-autofill-overlay-menu.ts",
|
||||
"content/bootstrap-autofill-overlay-notifications":
|
||||
"./src/autofill/content/bootstrap-autofill-overlay-notifications.ts",
|
||||
"content/bootstrap-legacy-autofill-overlay":
|
||||
"./src/autofill/deprecated/content/bootstrap-legacy-autofill-overlay.ts",
|
||||
"content/autofiller": "./src/autofill/content/autofiller.ts",
|
||||
|
@ -23,6 +23,8 @@ export const EVENTS = {
|
||||
VISIBILITYCHANGE: "visibilitychange",
|
||||
MOUSEENTER: "mouseenter",
|
||||
MOUSELEAVE: "mouseleave",
|
||||
MOUSEUP: "mouseup",
|
||||
SUBMIT: "submit",
|
||||
} as const;
|
||||
|
||||
export const ClearClipboardDelay = {
|
||||
@ -58,6 +60,8 @@ export const AUTOFILL_OVERLAY_HANDLE_REPOSITION = "autofill-overlay-handle-repos
|
||||
|
||||
export const UPDATE_PASSKEYS_HEADINGS_ON_SCROLL = "update-passkeys-headings-on-scroll";
|
||||
|
||||
export const AUTOFILL_TRIGGER_FORM_FIELD_SUBMIT = "autofill-trigger-form-field-submit";
|
||||
|
||||
export const AutofillOverlayVisibility = {
|
||||
Off: 0,
|
||||
OnButtonClick: 1,
|
||||
@ -101,3 +105,5 @@ export const ExtensionCommand = {
|
||||
} as const;
|
||||
|
||||
export type ExtensionCommandType = (typeof ExtensionCommand)[keyof typeof ExtensionCommand];
|
||||
|
||||
export const CLEAR_NOTIFICATION_LOGIN_DATA_DURATION = 60 * 1000; // 1 minute
|
||||
|
@ -32,6 +32,7 @@ export enum FeatureFlag {
|
||||
GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor",
|
||||
DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2",
|
||||
AccountDeprovisioning = "pm-10308-account-deprovisioning",
|
||||
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
|
||||
}
|
||||
|
||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||
@ -74,6 +75,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
|
||||
[FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE,
|
||||
[FeatureFlag.AccountDeprovisioning]: FALSE,
|
||||
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
|
||||
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||
|
||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||
|
Loading…
Reference in New Issue
Block a user