[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:
Cesar Gonzalez 2024-08-27 13:31:44 -05:00 committed by GitHub
parent 9041a4cd4c
commit 5b4e4d8f1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 3412 additions and 484 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
export const AutofillFieldQualifier = {
password: "password",
newPassword: "newPassword",
username: "username",
cardholderName: "cardholderName",
cardNumber: "cardNumber",

View File

@ -4,6 +4,8 @@ type NotificationBarIframeInitData = {
theme?: string;
removeIndividualVault?: boolean;
importType?: string;
applyRedesign?: boolean;
launchTimestamp?: number;
};
type NotificationBarWindowMessage = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.

View File

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

View File

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

View 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]);
});
});
});

View 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();
}
}
}

View File

@ -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),

View File

@ -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.
*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -329,6 +329,7 @@ const safeProviders: SafeProvider[] = [
AccountServiceAbstraction,
AuthService,
ConfigService,
UserNotificationSettingsServiceAbstraction,
MessageListener,
],
}),

View File

@ -163,6 +163,10 @@ const webRequest = {
addListener: jest.fn(),
removeListener: jest.fn(),
},
onCompleted: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
};
const alarms = {

View File

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

View File

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

View File

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