1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 13:23:34 +00:00

[PM-16641] Remove "inline-menu-positioning-improvements" feature flag (#14225)

* remove inline-menu-positioning-improvements flag

* remove unused LegacyOverlayBackground

* remove unused deprecated files

* appease ts error TS2564

* remove deleted resources from the manifest files
This commit is contained in:
Jonathan Prusik
2025-04-16 13:15:43 -04:00
committed by GitHub
parent 6bd3fceaa1
commit 1efdcacd16
46 changed files with 29 additions and 10371 deletions

View File

@@ -1,7 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import MainBackground from "../../background/main.background";
import { OverlayBackground } from "./abstractions/overlay.background";
@@ -14,7 +10,7 @@ export default class TabsBackground {
private overlayBackground: OverlayBackground,
) {}
private focusedWindowId: number;
private focusedWindowId: number = -1;
/**
* Initializes the window and tab listeners.
@@ -90,14 +86,6 @@ export default class TabsBackground {
changeInfo: chrome.tabs.TabChangeInfo,
tab: chrome.tabs.Tab,
) => {
const overlayImprovementsFlag = await this.main.configService.getFeatureFlag(
FeatureFlag.InlineMenuPositioningImprovements,
);
const removePageDetailsStatus = new Set(["loading", "unloaded"]);
if (!overlayImprovementsFlag && removePageDetailsStatus.has(changeInfo.status)) {
this.overlayBackground.removePageDetails(tabId);
}
if (this.focusedWindowId > 0 && tab.windowId !== this.focusedWindowId) {
return;
}

View File

@@ -1,124 +0,0 @@
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { LockedVaultPendingNotificationsData } from "../../../background/abstractions/notification.background";
import AutofillPageDetails from "../../../models/autofill-page-details";
type WebsiteIconData = {
imageEnabled: boolean;
image: string;
fallbackImage: string;
icon: string;
};
type OverlayAddNewItemMessage = {
login?: {
uri?: string;
hostname: string;
username: string;
password: string;
};
};
type OverlayBackgroundExtensionMessage = {
[key: string]: any;
command: string;
tab?: chrome.tabs.Tab;
sender?: string;
details?: AutofillPageDetails;
overlayElement?: string;
display?: string;
data?: LockedVaultPendingNotificationsData;
} & OverlayAddNewItemMessage;
type OverlayPortMessage = {
[key: string]: any;
command: string;
direction?: string;
overlayCipherId?: string;
};
type FocusedFieldData = {
focusedFieldStyles: Partial<CSSStyleDeclaration>;
focusedFieldRects: Partial<DOMRect>;
tabId?: number;
};
type OverlayCipherData = {
id: string;
name: string;
type: CipherType;
reprompt: CipherRepromptType;
favorite: boolean;
icon: { imageEnabled: boolean; image: string; fallbackImage: string; icon: string };
login?: { username: string };
card?: string;
};
type BackgroundMessageParam = {
message: OverlayBackgroundExtensionMessage;
};
type BackgroundSenderParam = {
sender: chrome.runtime.MessageSender;
};
type BackgroundOnMessageHandlerParams = BackgroundMessageParam & BackgroundSenderParam;
type OverlayBackgroundExtensionMessageHandlers = {
[key: string]: CallableFunction;
openAutofillOverlay: () => void;
autofillOverlayElementClosed: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
autofillOverlayAddNewVaultItem: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
getAutofillOverlayVisibility: () => void;
checkAutofillOverlayFocused: () => void;
focusAutofillOverlayList: () => void;
updateAutofillOverlayPosition: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
updateAutofillOverlayHidden: ({ message }: BackgroundMessageParam) => void;
updateFocusedFieldData: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
collectPageDetailsResponse: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
unlockCompleted: ({ message }: BackgroundMessageParam) => void;
addedCipher: () => void;
addEditCipherSubmitted: () => void;
editedCipher: () => void;
deletedCipher: () => void;
};
type PortMessageParam = {
message: OverlayPortMessage;
};
type PortConnectionParam = {
port: chrome.runtime.Port;
};
type PortOnMessageHandlerParams = PortMessageParam & PortConnectionParam;
type OverlayButtonPortMessageHandlers = {
[key: string]: CallableFunction;
overlayButtonClicked: ({ port }: PortConnectionParam) => void;
closeAutofillOverlay: ({ port }: PortConnectionParam) => void;
forceCloseAutofillOverlay: ({ port }: PortConnectionParam) => void;
overlayPageBlurred: () => void;
redirectOverlayFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void;
};
type OverlayListPortMessageHandlers = {
[key: string]: CallableFunction;
checkAutofillOverlayButtonFocused: () => void;
forceCloseAutofillOverlay: ({ port }: PortConnectionParam) => void;
overlayPageBlurred: () => void;
unlockVault: ({ port }: PortConnectionParam) => void;
fillSelectedListItem: ({ message, port }: PortOnMessageHandlerParams) => void;
addNewVaultItem: ({ port }: PortConnectionParam) => void;
viewSelectedCipher: ({ message, port }: PortOnMessageHandlerParams) => void;
redirectOverlayFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void;
};
export {
WebsiteIconData,
OverlayBackgroundExtensionMessage,
OverlayPortMessage,
FocusedFieldData,
OverlayCipherData,
OverlayAddNewItemMessage,
OverlayBackgroundExtensionMessageHandlers,
OverlayButtonPortMessageHandlers,
OverlayListPortMessageHandlers,
};

View File

@@ -1,811 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { SHOW_AUTOFILL_BUTTON } 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 { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { openUnlockPopout } from "../../../auth/popup/utils/auth-popout-window";
import { BrowserApi } from "../../../platform/browser/browser-api";
import {
openViewVaultItemPopout,
openAddEditVaultItemPopout,
} from "../../../vault/popup/utils/vault-popout-window";
import { LockedVaultPendingNotificationsData } from "../../background/abstractions/notification.background";
import { OverlayBackground as OverlayBackgroundInterface } from "../../background/abstractions/overlay.background";
import { AutofillOverlayElement, AutofillOverlayPort } from "../../enums/autofill-overlay.enum";
import { AutofillService, PageDetail } from "../../services/abstractions/autofill.service";
import {
FocusedFieldData,
OverlayBackgroundExtensionMessageHandlers,
OverlayButtonPortMessageHandlers,
OverlayCipherData,
OverlayListPortMessageHandlers,
OverlayBackgroundExtensionMessage,
OverlayAddNewItemMessage,
OverlayPortMessage,
WebsiteIconData,
} from "./abstractions/overlay.background.deprecated";
class LegacyOverlayBackground implements OverlayBackgroundInterface {
private readonly openUnlockPopout = openUnlockPopout;
private readonly openViewVaultItemPopout = openViewVaultItemPopout;
private readonly openAddEditVaultItemPopout = openAddEditVaultItemPopout;
private overlayLoginCiphers: Map<string, CipherView> = new Map();
private pageDetailsForTab: Record<
chrome.runtime.MessageSender["tab"]["id"],
Map<chrome.runtime.MessageSender["frameId"], PageDetail>
> = {};
private userAuthStatus: AuthenticationStatus = AuthenticationStatus.LoggedOut;
private overlayButtonPort: chrome.runtime.Port;
private overlayListPort: chrome.runtime.Port;
private expiredPorts: chrome.runtime.Port[] = [];
private focusedFieldData: FocusedFieldData;
private overlayPageTranslations: Record<string, string>;
private iconsServerUrl: string;
private readonly extensionMessageHandlers: OverlayBackgroundExtensionMessageHandlers = {
openAutofillOverlay: () => this.openOverlay(false),
autofillOverlayElementClosed: ({ message, sender }) =>
this.overlayElementClosed(message, sender),
autofillOverlayAddNewVaultItem: ({ message, sender }) => this.addNewVaultItem(message, sender),
getAutofillOverlayVisibility: () => this.getOverlayVisibility(),
checkAutofillOverlayFocused: () => this.checkOverlayFocused(),
focusAutofillOverlayList: () => this.focusOverlayList(),
updateAutofillOverlayPosition: ({ message, sender }) =>
this.updateOverlayPosition(message, sender),
updateAutofillOverlayHidden: ({ message }) => this.updateOverlayHidden(message),
updateFocusedFieldData: ({ message, sender }) => this.setFocusedFieldData(message, sender),
collectPageDetailsResponse: ({ message, sender }) => this.storePageDetails(message, sender),
unlockCompleted: ({ message }) => this.unlockCompleted(message),
addedCipher: () => this.updateOverlayCiphers(),
addEditCipherSubmitted: () => this.updateOverlayCiphers(),
editedCipher: () => this.updateOverlayCiphers(),
deletedCipher: () => this.updateOverlayCiphers(),
};
private readonly overlayButtonPortMessageHandlers: OverlayButtonPortMessageHandlers = {
overlayButtonClicked: ({ port }) => this.handleOverlayButtonClicked(port),
closeAutofillOverlay: ({ port }) => this.closeOverlay(port),
forceCloseAutofillOverlay: ({ port }) => this.closeOverlay(port, true),
overlayPageBlurred: () => this.checkOverlayListFocused(),
redirectOverlayFocusOut: ({ message, port }) => this.redirectOverlayFocusOut(message, port),
};
private readonly overlayListPortMessageHandlers: OverlayListPortMessageHandlers = {
checkAutofillOverlayButtonFocused: () => this.checkOverlayButtonFocused(),
forceCloseAutofillOverlay: ({ port }) => this.closeOverlay(port, true),
overlayPageBlurred: () => this.checkOverlayButtonFocused(),
unlockVault: ({ port }) => this.unlockVault(port),
fillSelectedListItem: ({ message, port }) => this.fillSelectedOverlayListItem(message, port),
addNewVaultItem: ({ port }) => this.getNewVaultItemDetails(port),
viewSelectedCipher: ({ message, port }) => this.viewSelectedCipher(message, port),
redirectOverlayFocusOut: ({ message, port }) => this.redirectOverlayFocusOut(message, port),
};
constructor(
private cipherService: CipherService,
private autofillService: AutofillService,
private authService: AuthService,
private environmentService: EnvironmentService,
private domainSettingsService: DomainSettingsService,
private autofillSettingsService: AutofillSettingsServiceAbstraction,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private themeStateService: ThemeStateService,
private accountService: AccountService,
) {}
/**
* Removes cached page details for a tab
* based on the passed tabId.
*
* @param tabId - Used to reference the page details of a specific tab
*/
removePageDetails(tabId: number) {
if (!this.pageDetailsForTab[tabId]) {
return;
}
this.pageDetailsForTab[tabId].clear();
delete this.pageDetailsForTab[tabId];
}
/**
* Sets up the extension message listeners and gets the settings for the
* overlay's visibility and the user's authentication status.
*/
async init() {
this.setupExtensionMessageListeners();
const env = await firstValueFrom(this.environmentService.environment$);
this.iconsServerUrl = env.getIconsUrl();
await this.getOverlayVisibility();
await this.getAuthStatus();
}
/**
* Updates the overlay list's ciphers and sends the updated list to the overlay list iframe.
* Queries all ciphers for the given url, and sorts them by last used. Will not update the
* list of ciphers if the extension is not unlocked.
*/
async updateOverlayCiphers() {
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
if (authStatus !== AuthenticationStatus.Unlocked) {
return;
}
const currentTab = await BrowserApi.getTabFromCurrentWindowId();
if (!currentTab?.url) {
return;
}
this.overlayLoginCiphers = new Map();
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const ciphersViews = (
await this.cipherService.getAllDecryptedForUrl(currentTab.url, activeUserId)
).sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b));
for (let cipherIndex = 0; cipherIndex < ciphersViews.length; cipherIndex++) {
this.overlayLoginCiphers.set(`overlay-cipher-${cipherIndex}`, ciphersViews[cipherIndex]);
}
const ciphers = await this.getOverlayCipherData();
this.overlayListPort?.postMessage({ command: "updateOverlayListCiphers", ciphers });
await BrowserApi.tabSendMessageData(currentTab, "updateIsOverlayCiphersPopulated", {
isOverlayCiphersPopulated: Boolean(ciphers.length),
});
}
/**
* Strips out unnecessary data from the ciphers and returns an array of
* objects that contain the cipher data needed for the overlay list.
*/
private async getOverlayCipherData(): Promise<OverlayCipherData[]> {
const showFavicons = await firstValueFrom(this.domainSettingsService.showFavicons$);
const overlayCiphersArray = Array.from(this.overlayLoginCiphers);
const overlayCipherData: OverlayCipherData[] = [];
let loginCipherIcon: WebsiteIconData;
for (let cipherIndex = 0; cipherIndex < overlayCiphersArray.length; cipherIndex++) {
const [overlayCipherId, cipher] = overlayCiphersArray[cipherIndex];
if (!loginCipherIcon && cipher.type === CipherType.Login) {
loginCipherIcon = buildCipherIcon(this.iconsServerUrl, cipher, showFavicons);
}
overlayCipherData.push({
id: overlayCipherId,
name: cipher.name,
type: cipher.type,
reprompt: cipher.reprompt,
favorite: cipher.favorite,
icon:
cipher.type === CipherType.Login
? loginCipherIcon
: buildCipherIcon(this.iconsServerUrl, cipher, showFavicons),
login: cipher.type === CipherType.Login ? { username: cipher.login.username } : null,
card: cipher.type === CipherType.Card ? cipher.card.subTitle : null,
});
}
return overlayCipherData;
}
/**
* Handles aggregation of page details for a tab. Stores the page details
* in association with the tabId of the tab that sent the message.
*
* @param message - Message received from the `collectPageDetailsResponse` command
* @param sender - The sender of the message
*/
private storePageDetails(
message: OverlayBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender,
) {
const pageDetails = {
frameId: sender.frameId,
tab: sender.tab,
details: message.details,
};
const pageDetailsMap = this.pageDetailsForTab[sender.tab.id];
if (!pageDetailsMap) {
this.pageDetailsForTab[sender.tab.id] = new Map([[sender.frameId, pageDetails]]);
return;
}
pageDetailsMap.set(sender.frameId, pageDetails);
}
/**
* Triggers autofill for the selected cipher in the overlay list. Also places
* the selected cipher at the top of the list of ciphers.
*
* @param overlayCipherId - Cipher ID corresponding to the overlayLoginCiphers map. Does not correspond to the actual cipher's ID.
* @param sender - The sender of the port message
*/
private async fillSelectedOverlayListItem(
{ overlayCipherId }: OverlayPortMessage,
{ sender }: chrome.runtime.Port,
) {
const pageDetails = this.pageDetailsForTab[sender.tab.id];
if (!overlayCipherId || !pageDetails?.size) {
return;
}
const cipher = this.overlayLoginCiphers.get(overlayCipherId);
if (await this.autofillService.isPasswordRepromptRequired(cipher, sender.tab)) {
return;
}
const totpCode = await this.autofillService.doAutoFill({
tab: sender.tab,
cipher: cipher,
pageDetails: Array.from(pageDetails.values()),
fillNewPassword: true,
allowTotpAutofill: true,
});
if (totpCode) {
this.platformUtilsService.copyToClipboard(totpCode);
}
this.overlayLoginCiphers = new Map([[overlayCipherId, cipher], ...this.overlayLoginCiphers]);
}
/**
* Checks if the overlay is focused. Will check the overlay list
* if it is open, otherwise it will check the overlay button.
*/
private checkOverlayFocused() {
if (this.overlayListPort) {
this.checkOverlayListFocused();
return;
}
this.checkOverlayButtonFocused();
}
/**
* Posts a message to the overlay button iframe to check if it is focused.
*/
private checkOverlayButtonFocused() {
this.overlayButtonPort?.postMessage({ command: "checkAutofillOverlayButtonFocused" });
}
/**
* Posts a message to the overlay list iframe to check if it is focused.
*/
private checkOverlayListFocused() {
this.overlayListPort?.postMessage({ command: "checkAutofillOverlayListFocused" });
}
/**
* Sends a message to the sender tab to close the autofill overlay.
*
* @param sender - The sender of the port message
* @param forceCloseOverlay - Identifies whether the overlay should be force closed
*/
private closeOverlay({ sender }: chrome.runtime.Port, forceCloseOverlay = false) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
BrowserApi.tabSendMessageData(sender.tab, "closeAutofillOverlay", { forceCloseOverlay });
}
/**
* Handles cleanup when an overlay element is closed. Disconnects
* the list and button ports and sets them to null.
*
* @param overlayElement - The overlay element that was closed, either the list or button
* @param sender - The sender of the port message
*/
private overlayElementClosed(
{ overlayElement }: OverlayBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender,
) {
if (sender.tab.id !== this.focusedFieldData?.tabId) {
this.expiredPorts.forEach((port) => port.disconnect());
this.expiredPorts = [];
return;
}
if (overlayElement === AutofillOverlayElement.Button) {
this.overlayButtonPort?.disconnect();
this.overlayButtonPort = null;
return;
}
this.overlayListPort?.disconnect();
this.overlayListPort = null;
}
/**
* Updates the position of either the overlay list or button. The position
* is based on the focused field's position and dimensions.
*
* @param overlayElement - The overlay element to update, either the list or button
* @param sender - The sender of the port message
*/
private updateOverlayPosition(
{ overlayElement }: { overlayElement?: string },
sender: chrome.runtime.MessageSender,
) {
if (!overlayElement || sender.tab.id !== this.focusedFieldData?.tabId) {
return;
}
if (overlayElement === AutofillOverlayElement.Button) {
this.overlayButtonPort?.postMessage({
command: "updateIframePosition",
styles: this.getOverlayButtonPosition(),
});
return;
}
this.overlayListPort?.postMessage({
command: "updateIframePosition",
styles: this.getOverlayListPosition(),
});
}
/**
* Gets the position of the focused field and calculates the position
* of the overlay button based on the focused field's position and dimensions.
*/
private getOverlayButtonPosition() {
if (!this.focusedFieldData) {
return;
}
const { top, left, width, height } = this.focusedFieldData.focusedFieldRects;
const { paddingRight, paddingLeft } = this.focusedFieldData.focusedFieldStyles;
let elementOffset = height * 0.37;
if (height >= 35) {
elementOffset = height >= 50 ? height * 0.47 : height * 0.42;
}
const elementHeight = height - elementOffset;
const elementTopPosition = top + elementOffset / 2;
let elementLeftPosition = left + width - height + elementOffset / 2;
const fieldPaddingRight = parseInt(paddingRight, 10);
const fieldPaddingLeft = parseInt(paddingLeft, 10);
if (fieldPaddingRight > fieldPaddingLeft) {
elementLeftPosition = left + width - height - (fieldPaddingRight - elementOffset + 2);
}
return {
top: `${Math.round(elementTopPosition)}px`,
left: `${Math.round(elementLeftPosition)}px`,
height: `${Math.round(elementHeight)}px`,
width: `${Math.round(elementHeight)}px`,
};
}
/**
* Gets the position of the focused field and calculates the position
* of the overlay list based on the focused field's position and dimensions.
*/
private getOverlayListPosition() {
if (!this.focusedFieldData) {
return;
}
const { top, left, width, height } = this.focusedFieldData.focusedFieldRects;
return {
width: `${Math.round(width)}px`,
top: `${Math.round(top + height)}px`,
left: `${Math.round(left)}px`,
};
}
/**
* Sets the focused field data to the data passed in the extension message.
*
* @param focusedFieldData - Contains the rects and styles of the focused field.
* @param sender - The sender of the extension message
*/
private setFocusedFieldData(
{ focusedFieldData }: OverlayBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender,
) {
this.focusedFieldData = { ...focusedFieldData, tabId: sender.tab.id };
}
/**
* Updates the overlay's visibility based on the display property passed in the extension message.
*
* @param display - The display property of the overlay, either "block" or "none"
*/
private updateOverlayHidden({ display }: OverlayBackgroundExtensionMessage) {
if (!display) {
return;
}
const portMessage = { command: "updateOverlayHidden", styles: { display } };
this.overlayButtonPort?.postMessage(portMessage);
this.overlayListPort?.postMessage(portMessage);
}
/**
* Sends a message to the currently active tab to open the autofill overlay.
*
* @param isFocusingFieldElement - Identifies whether the field element should be focused when the overlay is opened
* @param isOpeningFullOverlay - Identifies whether the full overlay should be forced open regardless of other states
*/
private async openOverlay(isFocusingFieldElement = false, isOpeningFullOverlay = false) {
const currentTab = await BrowserApi.getTabFromCurrentWindowId();
await BrowserApi.tabSendMessageData(currentTab, "openAutofillOverlay", {
isFocusingFieldElement,
isOpeningFullOverlay,
authStatus: await this.getAuthStatus(),
});
}
/**
* Gets the overlay's visibility setting from the settings service.
*/
private async getOverlayVisibility(): Promise<InlineMenuVisibilitySetting> {
return await firstValueFrom(this.autofillSettingsService.inlineMenuVisibility$);
}
/**
* Gets the user's authentication status from the auth service. If the user's
* authentication status has changed, the overlay button's authentication status
* will be updated and the overlay list's ciphers will be updated.
*/
private async getAuthStatus() {
const formerAuthStatus = this.userAuthStatus;
this.userAuthStatus = await this.authService.getAuthStatus();
if (
this.userAuthStatus !== formerAuthStatus &&
this.userAuthStatus === AuthenticationStatus.Unlocked
) {
this.updateOverlayButtonAuthStatus();
await this.updateOverlayCiphers();
}
return this.userAuthStatus;
}
/**
* Sends a message to the overlay button to update its authentication status.
*/
private updateOverlayButtonAuthStatus() {
this.overlayButtonPort?.postMessage({
command: "updateOverlayButtonAuthStatus",
authStatus: this.userAuthStatus,
});
}
/**
* Handles the overlay button being clicked. If the user is not authenticated,
* the vault will be unlocked. If the user is authenticated, the overlay will
* be opened.
*
* @param port - The port of the overlay button
*/
private handleOverlayButtonClicked(port: chrome.runtime.Port) {
if (this.userAuthStatus !== AuthenticationStatus.Unlocked) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.unlockVault(port);
return;
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.openOverlay(false, true);
}
/**
* Facilitates opening the unlock popout window.
*
* @param port - The port of the overlay list
*/
private async unlockVault(port: chrome.runtime.Port) {
const { sender } = port;
this.closeOverlay(port);
const retryMessage: LockedVaultPendingNotificationsData = {
commandToRetry: { message: { command: "openAutofillOverlay" }, sender },
target: "overlay.background",
};
await BrowserApi.tabSendMessageData(
sender.tab,
"addToLockedVaultPendingNotifications",
retryMessage,
);
await this.openUnlockPopout(sender.tab, true);
}
/**
* Triggers the opening of a vault item popout window associated
* with the passed cipher ID.
* @param overlayCipherId - Cipher ID corresponding to the overlayLoginCiphers map. Does not correspond to the actual cipher's ID.
* @param sender - The sender of the port message
*/
private async viewSelectedCipher(
{ overlayCipherId }: OverlayPortMessage,
{ sender }: chrome.runtime.Port,
) {
const cipher = this.overlayLoginCiphers.get(overlayCipherId);
if (!cipher) {
return;
}
await this.openViewVaultItemPopout(sender.tab, {
cipherId: cipher.id,
action: SHOW_AUTOFILL_BUTTON,
});
}
/**
* Facilitates redirecting focus to the overlay list.
*/
private focusOverlayList() {
this.overlayListPort?.postMessage({ command: "focusOverlayList" });
}
/**
* Updates the authentication status for the user and opens the overlay if
* a followup command is present in the message.
*
* @param message - Extension message received from the `unlockCompleted` command
*/
private async unlockCompleted(message: OverlayBackgroundExtensionMessage) {
await this.getAuthStatus();
if (message.data?.commandToRetry?.message?.command === "openAutofillOverlay") {
await this.openOverlay(true);
}
}
/**
* Gets the translations for the overlay page.
*/
private getTranslations() {
if (!this.overlayPageTranslations) {
this.overlayPageTranslations = {
locale: BrowserApi.getUILanguage(),
opensInANewWindow: this.i18nService.translate("opensInANewWindow"),
buttonPageTitle: this.i18nService.translate("bitwardenOverlayButton"),
toggleBitwardenVaultOverlay: this.i18nService.translate("toggleBitwardenVaultOverlay"),
listPageTitle: this.i18nService.translate("bitwardenVault"),
unlockYourAccount: this.i18nService.translate("unlockYourAccountToViewMatchingLogins"),
unlockAccount: this.i18nService.translate("unlockAccount"),
fillCredentialsFor: this.i18nService.translate("fillCredentialsFor"),
partialUsername: this.i18nService.translate("partialUsername"),
view: this.i18nService.translate("view"),
noItemsToShow: this.i18nService.translate("noItemsToShow"),
newItem: this.i18nService.translate("newItem"),
addNewVaultItem: this.i18nService.translate("addNewVaultItem"),
};
}
return this.overlayPageTranslations;
}
/**
* Facilitates redirecting focus out of one of the
* overlay elements to elements on the page.
*
* @param direction - The direction to redirect focus to (either "next", "previous" or "current)
* @param sender - The sender of the port message
*/
private redirectOverlayFocusOut(
{ direction }: OverlayPortMessage,
{ sender }: chrome.runtime.Port,
) {
if (!direction) {
return;
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
BrowserApi.tabSendMessageData(sender.tab, "redirectOverlayFocusOut", { direction });
}
/**
* Triggers adding a new vault item from the overlay. Gathers data
* input by the user before calling to open the add/edit window.
*
* @param sender - The sender of the port message
*/
private getNewVaultItemDetails({ sender }: chrome.runtime.Port) {
void BrowserApi.tabSendMessage(sender.tab, { command: "addNewVaultItemFromOverlay" });
}
/**
* Handles adding a new vault item from the overlay. Gathers data login
* data captured in the extension message.
*
* @param login - The login data captured from the extension message
* @param sender - The sender of the extension message
*/
private async addNewVaultItem(
{ login }: OverlayAddNewItemMessage,
sender: chrome.runtime.MessageSender,
) {
if (!login) {
return;
}
const uriView = new LoginUriView();
uriView.uri = login.uri;
const loginView = new LoginView();
loginView.uris = [uriView];
loginView.username = login.username || "";
loginView.password = login.password || "";
const cipherView = new CipherView();
cipherView.name = (Utils.getHostname(login.uri) || login.hostname).replace(/^www\./, "");
cipherView.folderId = null;
cipherView.type = CipherType.Login;
cipherView.login = loginView;
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
await this.cipherService.setAddEditCipherInfo(
{
cipher: cipherView,
collectionIds: cipherView.collectionIds,
},
activeUserId,
);
await this.openAddEditVaultItemPopout(sender.tab, { cipherId: cipherView.id });
}
/**
* Sets up the extension message listeners for the overlay.
*/
private setupExtensionMessageListeners() {
BrowserApi.messageListener("overlay.background", this.handleExtensionMessage);
BrowserApi.addListener(chrome.runtime.onConnect, this.handlePortOnConnect);
}
/**
* Handles extension messages sent to the extension background.
*
* @param message - The message received from the extension
* @param sender - The sender of the message
* @param sendResponse - The response to send back to the sender
*/
private handleExtensionMessage = (
message: OverlayBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender,
sendResponse: (response?: any) => void,
) => {
const handler: CallableFunction | undefined = this.extensionMessageHandlers[message?.command];
if (!handler) {
return null;
}
const messageResponse = handler({ message, sender });
if (typeof messageResponse === "undefined") {
return null;
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Promise.resolve(messageResponse).then((response) => sendResponse(response));
return true;
};
/**
* Handles the connection of a port to the extension background.
*
* @param port - The port that connected to the extension background
*/
private handlePortOnConnect = async (port: chrome.runtime.Port) => {
const isOverlayListPort = port.name === AutofillOverlayPort.List;
const isOverlayButtonPort = port.name === AutofillOverlayPort.Button;
if (!isOverlayListPort && !isOverlayButtonPort) {
return;
}
this.storeOverlayPort(port);
port.onMessage.addListener(this.handleOverlayElementPortMessage);
port.postMessage({
command: `initAutofillOverlay${isOverlayListPort ? "List" : "Button"}`,
authStatus: await this.getAuthStatus(),
styleSheetUrl: chrome.runtime.getURL(`overlay/${isOverlayListPort ? "list" : "button"}.css`),
theme: await firstValueFrom(this.themeStateService.selectedTheme$),
translations: this.getTranslations(),
ciphers: isOverlayListPort ? await this.getOverlayCipherData() : null,
});
this.updateOverlayPosition(
{
overlayElement: isOverlayListPort
? AutofillOverlayElement.List
: AutofillOverlayElement.Button,
},
port.sender,
);
};
/**
* Stores the connected overlay port and sets up any existing ports to be disconnected.
*
* @param port - The port to store
| */
private storeOverlayPort(port: chrome.runtime.Port) {
if (port.name === AutofillOverlayPort.List) {
this.storeExpiredOverlayPort(this.overlayListPort);
this.overlayListPort = port;
return;
}
if (port.name === AutofillOverlayPort.Button) {
this.storeExpiredOverlayPort(this.overlayButtonPort);
this.overlayButtonPort = port;
}
}
/**
* When registering a new connection, we want to ensure that the port is disconnected.
* This method places an existing port in the expiredPorts array to be disconnected
* at a later time.
*
* @param port - The port to store in the expiredPorts array
*/
private storeExpiredOverlayPort(port: chrome.runtime.Port | null) {
if (port) {
this.expiredPorts.push(port);
}
}
/**
* Handles messages sent to the overlay list or button ports.
*
* @param message - The message received from the port
* @param port - The port that sent the message
*/
private handleOverlayElementPortMessage = (
message: OverlayBackgroundExtensionMessage,
port: chrome.runtime.Port,
) => {
const command = message?.command;
let handler: CallableFunction | undefined;
if (port.name === AutofillOverlayPort.Button) {
handler = this.overlayButtonPortMessageHandlers[command];
}
if (port.name === AutofillOverlayPort.List) {
handler = this.overlayListPortMessageHandlers[command];
}
if (!handler) {
return;
}
handler({ message, port });
};
}
export default LegacyOverlayBackground;

View File

@@ -1,41 +0,0 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import AutofillScript from "../../../models/autofill-script";
type AutofillExtensionMessage = {
command: string;
tab?: chrome.tabs.Tab;
sender?: string;
fillScript?: AutofillScript;
url?: string;
pageDetailsUrl?: string;
ciphers?: any;
data?: {
authStatus?: AuthenticationStatus;
isFocusingFieldElement?: boolean;
isOverlayCiphersPopulated?: boolean;
direction?: "previous" | "next";
isOpeningFullOverlay?: boolean;
forceCloseOverlay?: boolean;
autofillOverlayVisibility?: number;
};
};
type AutofillExtensionMessageParam = { message: AutofillExtensionMessage };
type AutofillExtensionMessageHandlers = {
[key: string]: CallableFunction;
collectPageDetails: ({ message }: AutofillExtensionMessageParam) => void;
collectPageDetailsImmediately: ({ message }: AutofillExtensionMessageParam) => void;
fillForm: ({ message }: AutofillExtensionMessageParam) => void;
openAutofillOverlay: ({ message }: AutofillExtensionMessageParam) => void;
closeAutofillOverlay: ({ message }: AutofillExtensionMessageParam) => void;
addNewVaultItemFromOverlay: () => void;
redirectOverlayFocusOut: ({ message }: AutofillExtensionMessageParam) => void;
updateIsOverlayCiphersPopulated: ({ message }: AutofillExtensionMessageParam) => void;
bgUnlockPopoutOpened: () => void;
bgVaultItemRepromptPopoutOpened: () => void;
updateAutofillOverlayVisibility: ({ message }: AutofillExtensionMessageParam) => void;
};
export { AutofillExtensionMessage, AutofillExtensionMessageHandlers };

View File

@@ -1,604 +0,0 @@
import { mock } from "jest-mock-extended";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
import { RedirectFocusDirection } from "../../enums/autofill-overlay.enum";
import AutofillPageDetails from "../../models/autofill-page-details";
import AutofillScript from "../../models/autofill-script";
import {
flushPromises,
mockQuerySelectorAllDefinedCall,
sendMockExtensionMessage,
} from "../../spec/testing-utils";
import AutofillOverlayContentServiceDeprecated from "../services/autofill-overlay-content.service.deprecated";
import { AutofillExtensionMessage } from "./abstractions/autofill-init.deprecated";
import AutofillInitDeprecated from "./autofill-init.deprecated";
describe("AutofillInit", () => {
let autofillInit: AutofillInitDeprecated;
const autofillOverlayContentService = mock<AutofillOverlayContentServiceDeprecated>();
const originalDocumentReadyState = document.readyState;
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
beforeEach(() => {
chrome.runtime.connect = jest.fn().mockReturnValue({
onDisconnect: {
addListener: jest.fn(),
},
});
autofillInit = new AutofillInitDeprecated(autofillOverlayContentService);
window.IntersectionObserver = jest.fn(() => mock<IntersectionObserver>());
});
afterEach(() => {
jest.resetModules();
jest.clearAllMocks();
Object.defineProperty(document, "readyState", {
value: originalDocumentReadyState,
writable: true,
});
});
afterAll(() => {
mockQuerySelectorAll.mockRestore();
});
describe("init", () => {
it("sets up the extension message listeners", () => {
jest.spyOn(autofillInit as any, "setupExtensionMessageListeners");
autofillInit.init();
expect(autofillInit["setupExtensionMessageListeners"]).toHaveBeenCalled();
});
it("triggers a collection of page details if the document is in a `complete` ready state", () => {
jest.useFakeTimers();
Object.defineProperty(document, "readyState", { value: "complete", writable: true });
autofillInit.init();
jest.advanceTimersByTime(250);
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith(
{
command: "bgCollectPageDetails",
sender: "autofillInit",
},
expect.any(Function),
);
});
it("registers a window load listener to collect the page details if the document is not in a `complete` ready state", () => {
jest.spyOn(window, "addEventListener");
Object.defineProperty(document, "readyState", { value: "loading", writable: true });
autofillInit.init();
expect(window.addEventListener).toHaveBeenCalledWith("load", expect.any(Function));
});
});
describe("setupExtensionMessageListeners", () => {
it("sets up a chrome runtime on message listener", () => {
jest.spyOn(chrome.runtime.onMessage, "addListener");
autofillInit["setupExtensionMessageListeners"]();
expect(chrome.runtime.onMessage.addListener).toHaveBeenCalledWith(
autofillInit["handleExtensionMessage"],
);
});
});
describe("handleExtensionMessage", () => {
let message: AutofillExtensionMessage;
let sender: chrome.runtime.MessageSender;
const sendResponse = jest.fn();
beforeEach(() => {
message = {
command: "collectPageDetails",
tab: mock<chrome.tabs.Tab>(),
sender: "sender",
};
sender = mock<chrome.runtime.MessageSender>();
});
it("returns a undefined value if a extension message handler is not found with the given message command", () => {
message.command = "unknownCommand";
const response = autofillInit["handleExtensionMessage"](message, sender, sendResponse);
expect(response).toBe(null);
});
it("returns a undefined value if the message handler does not return a response", async () => {
const response1 = await autofillInit["handleExtensionMessage"](message, sender, sendResponse);
await flushPromises();
expect(response1).not.toBe(false);
message.command = "removeAutofillOverlay";
message.fillScript = mock<AutofillScript>();
const response2 = autofillInit["handleExtensionMessage"](message, sender, sendResponse);
await flushPromises();
expect(response2).toBe(null);
});
it("returns a true value and calls sendResponse if the message handler returns a response", async () => {
message.command = "collectPageDetailsImmediately";
const pageDetails: AutofillPageDetails = {
title: "title",
url: "http://example.com",
documentUrl: "documentUrl",
forms: {},
fields: [],
collectedTimestamp: 0,
};
jest
.spyOn(autofillInit["collectAutofillContentService"], "getPageDetails")
.mockResolvedValue(pageDetails);
const response = await autofillInit["handleExtensionMessage"](message, sender, sendResponse);
await flushPromises();
expect(response).toBe(true);
expect(sendResponse).toHaveBeenCalledWith(pageDetails);
});
describe("extension message handlers", () => {
beforeEach(() => {
autofillInit.init();
});
describe("collectPageDetails", () => {
it("sends the collected page details for autofill using a background script message", async () => {
const pageDetails: AutofillPageDetails = {
title: "title",
url: "http://example.com",
documentUrl: "documentUrl",
forms: {},
fields: [],
collectedTimestamp: 0,
};
const message = {
command: "collectPageDetails",
sender: "sender",
tab: mock<chrome.tabs.Tab>(),
};
jest
.spyOn(autofillInit["collectAutofillContentService"], "getPageDetails")
.mockResolvedValue(pageDetails);
sendMockExtensionMessage(message, sender, sendResponse);
await flushPromises();
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
command: "collectPageDetailsResponse",
tab: message.tab,
details: pageDetails,
sender: message.sender,
});
});
});
describe("collectPageDetailsImmediately", () => {
it("returns collected page details for autofill if set to send the details in the response", async () => {
const pageDetails: AutofillPageDetails = {
title: "title",
url: "http://example.com",
documentUrl: "documentUrl",
forms: {},
fields: [],
collectedTimestamp: 0,
};
jest
.spyOn(autofillInit["collectAutofillContentService"], "getPageDetails")
.mockResolvedValue(pageDetails);
sendMockExtensionMessage(
{ command: "collectPageDetailsImmediately" },
sender,
sendResponse,
);
await flushPromises();
expect(autofillInit["collectAutofillContentService"].getPageDetails).toHaveBeenCalled();
expect(sendResponse).toBeCalledWith(pageDetails);
expect(chrome.runtime.sendMessage).not.toHaveBeenCalledWith({
command: "collectPageDetailsResponse",
tab: message.tab,
details: pageDetails,
sender: message.sender,
});
});
});
describe("fillForm", () => {
let fillScript: AutofillScript;
beforeEach(() => {
fillScript = mock<AutofillScript>();
jest.spyOn(autofillInit["insertAutofillContentService"], "fillForm").mockImplementation();
});
it("skips calling the InsertAutofillContentService and does not fill the form if the url to fill is not equal to the current tab url", async () => {
const fillScript = mock<AutofillScript>();
const message = {
command: "fillForm",
fillScript,
pageDetailsUrl: "https://a-different-url.com",
};
sendMockExtensionMessage(message);
await flushPromises();
expect(autofillInit["insertAutofillContentService"].fillForm).not.toHaveBeenCalledWith(
fillScript,
);
});
it("calls the InsertAutofillContentService to fill the form", async () => {
sendMockExtensionMessage({
command: "fillForm",
fillScript,
pageDetailsUrl: window.location.href,
});
await flushPromises();
expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith(
fillScript,
);
});
it("removes the overlay when filling the form", async () => {
const blurAndRemoveOverlaySpy = jest.spyOn(autofillInit as any, "blurAndRemoveOverlay");
sendMockExtensionMessage({
command: "fillForm",
fillScript,
pageDetailsUrl: window.location.href,
});
await flushPromises();
expect(blurAndRemoveOverlaySpy).toHaveBeenCalled();
});
it("updates the isCurrentlyFilling property of the overlay to true after filling", async () => {
jest.useFakeTimers();
jest.spyOn(autofillInit as any, "updateOverlayIsCurrentlyFilling");
jest
.spyOn(autofillInit["autofillOverlayContentService"], "focusMostRecentOverlayField")
.mockImplementation();
sendMockExtensionMessage({
command: "fillForm",
fillScript,
pageDetailsUrl: window.location.href,
});
await flushPromises();
jest.advanceTimersByTime(300);
expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(1, true);
expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith(
fillScript,
);
expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(2, false);
});
it("skips attempting to focus the most recent field if the autofillOverlayContentService is not present", async () => {
jest.useFakeTimers();
const newAutofillInit = new AutofillInitDeprecated(undefined);
newAutofillInit.init();
jest.spyOn(newAutofillInit as any, "updateOverlayIsCurrentlyFilling");
jest
.spyOn(newAutofillInit["insertAutofillContentService"], "fillForm")
.mockImplementation();
sendMockExtensionMessage({
command: "fillForm",
fillScript,
pageDetailsUrl: window.location.href,
});
await flushPromises();
jest.advanceTimersByTime(300);
expect(newAutofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(
1,
true,
);
expect(newAutofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith(
fillScript,
);
expect(newAutofillInit["updateOverlayIsCurrentlyFilling"]).not.toHaveBeenNthCalledWith(
2,
false,
);
});
});
describe("openAutofillOverlay", () => {
const message = {
command: "openAutofillOverlay",
data: {
isFocusingFieldElement: true,
isOpeningFullOverlay: true,
authStatus: AuthenticationStatus.Unlocked,
},
};
it("skips attempting to open the autofill overlay if the autofillOverlayContentService is not present", () => {
const newAutofillInit = new AutofillInitDeprecated(undefined);
newAutofillInit.init();
sendMockExtensionMessage(message);
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
});
it("opens the autofill overlay", () => {
sendMockExtensionMessage(message);
expect(
autofillInit["autofillOverlayContentService"].openAutofillOverlay,
).toHaveBeenCalledWith({
isFocusingFieldElement: message.data.isFocusingFieldElement,
isOpeningFullOverlay: message.data.isOpeningFullOverlay,
authStatus: message.data.authStatus,
});
});
});
describe("closeAutofillOverlay", () => {
beforeEach(() => {
autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = false;
autofillInit["autofillOverlayContentService"].isCurrentlyFilling = false;
});
it("skips attempting to remove the overlay if the autofillOverlayContentService is not present", () => {
const newAutofillInit = new AutofillInitDeprecated(undefined);
newAutofillInit.init();
jest.spyOn(newAutofillInit as any, "removeAutofillOverlay");
sendMockExtensionMessage({
command: "closeAutofillOverlay",
data: { forceCloseOverlay: false },
});
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
});
it("removes the autofill overlay if the message flags a forced closure", () => {
sendMockExtensionMessage({
command: "closeAutofillOverlay",
data: { forceCloseOverlay: true },
});
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlay,
).toHaveBeenCalled();
});
it("ignores the message if a field is currently focused", () => {
autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = true;
sendMockExtensionMessage({ command: "closeAutofillOverlay" });
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlayList,
).not.toHaveBeenCalled();
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlay,
).not.toHaveBeenCalled();
});
it("removes the autofill overlay list if the overlay is currently filling", () => {
autofillInit["autofillOverlayContentService"].isCurrentlyFilling = true;
sendMockExtensionMessage({ command: "closeAutofillOverlay" });
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlayList,
).toHaveBeenCalled();
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlay,
).not.toHaveBeenCalled();
});
it("removes the entire overlay if the overlay is not currently filling", () => {
sendMockExtensionMessage({ command: "closeAutofillOverlay" });
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlayList,
).not.toHaveBeenCalled();
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlay,
).toHaveBeenCalled();
});
});
describe("addNewVaultItemFromOverlay", () => {
it("will not add a new vault item if the autofillOverlayContentService is not present", () => {
const newAutofillInit = new AutofillInitDeprecated(undefined);
newAutofillInit.init();
sendMockExtensionMessage({ command: "addNewVaultItemFromOverlay" });
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
});
it("will add a new vault item", () => {
sendMockExtensionMessage({ command: "addNewVaultItemFromOverlay" });
expect(autofillInit["autofillOverlayContentService"].addNewVaultItem).toHaveBeenCalled();
});
});
describe("redirectOverlayFocusOut", () => {
const message = {
command: "redirectOverlayFocusOut",
data: {
direction: RedirectFocusDirection.Next,
},
};
it("ignores the message to redirect focus if the autofillOverlayContentService does not exist", () => {
const newAutofillInit = new AutofillInitDeprecated(undefined);
newAutofillInit.init();
sendMockExtensionMessage(message);
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
});
it("redirects the overlay focus", () => {
sendMockExtensionMessage(message);
expect(
autofillInit["autofillOverlayContentService"].redirectOverlayFocusOut,
).toHaveBeenCalledWith(message.data.direction);
});
});
describe("updateIsOverlayCiphersPopulated", () => {
const message = {
command: "updateIsOverlayCiphersPopulated",
data: {
isOverlayCiphersPopulated: true,
},
};
it("skips updating whether the ciphers are populated if the autofillOverlayContentService does note exist", () => {
const newAutofillInit = new AutofillInitDeprecated(undefined);
newAutofillInit.init();
sendMockExtensionMessage(message);
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
});
it("updates whether the overlay ciphers are populated", () => {
sendMockExtensionMessage(message);
expect(autofillInit["autofillOverlayContentService"].isOverlayCiphersPopulated).toEqual(
message.data.isOverlayCiphersPopulated,
);
});
});
describe("bgUnlockPopoutOpened", () => {
it("skips attempting to blur and remove the overlay if the autofillOverlayContentService is not present", () => {
const newAutofillInit = new AutofillInitDeprecated(undefined);
newAutofillInit.init();
jest.spyOn(newAutofillInit as any, "removeAutofillOverlay");
sendMockExtensionMessage({ command: "bgUnlockPopoutOpened" });
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
expect(newAutofillInit["removeAutofillOverlay"]).not.toHaveBeenCalled();
});
it("blurs the most recently focused feel and remove the autofill overlay", () => {
jest.spyOn(autofillInit["autofillOverlayContentService"], "blurMostRecentOverlayField");
jest.spyOn(autofillInit as any, "removeAutofillOverlay");
sendMockExtensionMessage({ command: "bgUnlockPopoutOpened" });
expect(
autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField,
).toHaveBeenCalled();
expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled();
});
});
describe("bgVaultItemRepromptPopoutOpened", () => {
it("skips attempting to blur and remove the overlay if the autofillOverlayContentService is not present", () => {
const newAutofillInit = new AutofillInitDeprecated(undefined);
newAutofillInit.init();
jest.spyOn(newAutofillInit as any, "removeAutofillOverlay");
sendMockExtensionMessage({ command: "bgVaultItemRepromptPopoutOpened" });
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
expect(newAutofillInit["removeAutofillOverlay"]).not.toHaveBeenCalled();
});
it("blurs the most recently focused feel and remove the autofill overlay", () => {
jest.spyOn(autofillInit["autofillOverlayContentService"], "blurMostRecentOverlayField");
jest.spyOn(autofillInit as any, "removeAutofillOverlay");
sendMockExtensionMessage({ command: "bgVaultItemRepromptPopoutOpened" });
expect(
autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField,
).toHaveBeenCalled();
expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled();
});
});
describe("updateAutofillOverlayVisibility", () => {
beforeEach(() => {
autofillInit["autofillOverlayContentService"].autofillOverlayVisibility =
AutofillOverlayVisibility.OnButtonClick;
});
it("skips attempting to update the overlay visibility if the autofillOverlayVisibility data value is not present", () => {
sendMockExtensionMessage({
command: "updateAutofillOverlayVisibility",
data: {},
});
expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual(
AutofillOverlayVisibility.OnButtonClick,
);
});
it("updates the overlay visibility value", () => {
const message = {
command: "updateAutofillOverlayVisibility",
data: {
autofillOverlayVisibility: AutofillOverlayVisibility.Off,
},
};
sendMockExtensionMessage(message);
expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual(
message.data.autofillOverlayVisibility,
);
});
});
});
});
describe("destroy", () => {
it("clears the timeout used to collect page details on load", () => {
jest.spyOn(window, "clearTimeout");
autofillInit.init();
autofillInit.destroy();
expect(window.clearTimeout).toHaveBeenCalledWith(
autofillInit["collectPageDetailsOnLoadTimeout"],
);
});
it("removes the extension message listeners", () => {
autofillInit.destroy();
expect(chrome.runtime.onMessage.removeListener).toHaveBeenCalledWith(
autofillInit["handleExtensionMessage"],
);
});
it("destroys the collectAutofillContentService", () => {
jest.spyOn(autofillInit["collectAutofillContentService"], "destroy");
autofillInit.destroy();
expect(autofillInit["collectAutofillContentService"].destroy).toHaveBeenCalled();
});
});
});

View File

@@ -1,315 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
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";
import {
AutofillExtensionMessage,
AutofillExtensionMessageHandlers,
} from "./abstractions/autofill-init.deprecated";
class LegacyAutofillInit implements AutofillInit {
private readonly autofillOverlayContentService: LegacyAutofillOverlayContentService | undefined;
private readonly domElementVisibilityService: DomElementVisibilityService;
private readonly collectAutofillContentService: CollectAutofillContentService;
private readonly insertAutofillContentService: InsertAutofillContentService;
private collectPageDetailsOnLoadTimeout: number | NodeJS.Timeout | undefined;
private readonly extensionMessageHandlers: AutofillExtensionMessageHandlers = {
collectPageDetails: ({ message }) => this.collectPageDetails(message),
collectPageDetailsImmediately: ({ message }) => this.collectPageDetails(message, true),
fillForm: ({ message }) => this.fillForm(message),
openAutofillOverlay: ({ message }) => this.openAutofillOverlay(message),
closeAutofillOverlay: ({ message }) => this.removeAutofillOverlay(message),
addNewVaultItemFromOverlay: () => this.addNewVaultItemFromOverlay(),
redirectOverlayFocusOut: ({ message }) => this.redirectOverlayFocusOut(message),
updateIsOverlayCiphersPopulated: ({ message }) => this.updateIsOverlayCiphersPopulated(message),
bgUnlockPopoutOpened: () => this.blurAndRemoveOverlay(),
bgVaultItemRepromptPopoutOpened: () => this.blurAndRemoveOverlay(),
updateAutofillOverlayVisibility: ({ message }) => this.updateAutofillOverlayVisibility(message),
};
/**
* AutofillInit constructor. Initializes the DomElementVisibilityService,
* CollectAutofillContentService and InsertAutofillContentService classes.
*
* @param autofillOverlayContentService - The autofill overlay content service, potentially undefined.
*/
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(
this.domElementVisibilityService,
this.collectAutofillContentService,
);
}
/**
* Initializes the autofill content script, setting up
* the extension message listeners. This method should
* be called once when the content script is loaded.
*/
init() {
this.setupExtensionMessageListeners();
this.autofillOverlayContentService?.init();
this.collectPageDetailsOnLoad();
}
/**
* Triggers a collection of the page details from the
* background script, ensuring that autofill is ready
* to act on the page.
*/
private collectPageDetailsOnLoad() {
const sendCollectDetailsMessage = () => {
this.clearCollectPageDetailsOnLoadTimeout();
this.collectPageDetailsOnLoadTimeout = setTimeout(
() => sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }),
250,
);
};
if (globalThis.document.readyState === "complete") {
sendCollectDetailsMessage();
}
globalThis.addEventListener("load", sendCollectDetailsMessage);
}
/**
* Collects the page details and sends them to the
* extension background script. If the `sendDetailsInResponse`
* parameter is set to true, the page details will be
* returned to facilitate sending the details in the
* response to the extension message.
*
* @param message - The extension message.
* @param sendDetailsInResponse - Determines whether to send the details in the response.
*/
private async collectPageDetails(
message: AutofillExtensionMessage,
sendDetailsInResponse = false,
): Promise<AutofillPageDetails | void> {
const pageDetails: AutofillPageDetails =
await this.collectAutofillContentService.getPageDetails();
if (sendDetailsInResponse) {
return pageDetails;
}
void chrome.runtime.sendMessage({
command: "collectPageDetailsResponse",
tab: message.tab,
details: pageDetails,
sender: message.sender,
});
}
/**
* Fills the form with the given fill script.
*
* @param {AutofillExtensionMessage} message
*/
private async fillForm({ fillScript, pageDetailsUrl }: AutofillExtensionMessage) {
if ((document.defaultView || window).location.href !== pageDetailsUrl) {
return;
}
this.blurAndRemoveOverlay();
this.updateOverlayIsCurrentlyFilling(true);
await this.insertAutofillContentService.fillForm(fillScript);
if (!this.autofillOverlayContentService) {
return;
}
setTimeout(() => this.updateOverlayIsCurrentlyFilling(false), 250);
}
/**
* Handles updating the overlay is currently filling value.
*
* @param isCurrentlyFilling - Indicates if the overlay is currently filling
*/
private updateOverlayIsCurrentlyFilling(isCurrentlyFilling: boolean) {
if (!this.autofillOverlayContentService) {
return;
}
this.autofillOverlayContentService.isCurrentlyFilling = isCurrentlyFilling;
}
/**
* Opens the autofill overlay.
*
* @param data - The extension message data.
*/
private openAutofillOverlay({ data }: AutofillExtensionMessage) {
if (!this.autofillOverlayContentService) {
return;
}
this.autofillOverlayContentService.openAutofillOverlay(data);
}
/**
* Blurs the most recent overlay field and removes the overlay. Used
* in cases where the background unlock or vault item reprompt popout
* is opened.
*/
private blurAndRemoveOverlay() {
if (!this.autofillOverlayContentService) {
return;
}
this.autofillOverlayContentService.blurMostRecentOverlayField();
this.removeAutofillOverlay();
}
/**
* Removes the autofill overlay if the field is not currently focused.
* If the autofill is currently filling, only the overlay list will be
* removed.
*/
private removeAutofillOverlay(message?: AutofillExtensionMessage) {
if (message?.data?.forceCloseOverlay) {
this.autofillOverlayContentService?.removeAutofillOverlay();
return;
}
if (
!this.autofillOverlayContentService ||
this.autofillOverlayContentService.isFieldCurrentlyFocused
) {
return;
}
if (this.autofillOverlayContentService.isCurrentlyFilling) {
this.autofillOverlayContentService.removeAutofillOverlayList();
return;
}
this.autofillOverlayContentService.removeAutofillOverlay();
}
/**
* Adds a new vault item from the overlay.
*/
private addNewVaultItemFromOverlay() {
if (!this.autofillOverlayContentService) {
return;
}
this.autofillOverlayContentService.addNewVaultItem();
}
/**
* Redirects the overlay focus out of an overlay iframe.
*
* @param data - Contains the direction to redirect the focus.
*/
private redirectOverlayFocusOut({ data }: AutofillExtensionMessage) {
if (!this.autofillOverlayContentService) {
return;
}
this.autofillOverlayContentService.redirectOverlayFocusOut(data?.direction);
}
/**
* Updates whether the current tab has ciphers that can populate the overlay list
*
* @param data - Contains the isOverlayCiphersPopulated value
*
*/
private updateIsOverlayCiphersPopulated({ data }: AutofillExtensionMessage) {
if (!this.autofillOverlayContentService) {
return;
}
this.autofillOverlayContentService.isOverlayCiphersPopulated = Boolean(
data?.isOverlayCiphersPopulated,
);
}
/**
* Updates the autofill overlay visibility.
*
* @param data - Contains the autoFillOverlayVisibility value
*/
private updateAutofillOverlayVisibility({ data }: AutofillExtensionMessage) {
if (!this.autofillOverlayContentService || isNaN(data?.autofillOverlayVisibility)) {
return;
}
this.autofillOverlayContentService.autofillOverlayVisibility = data?.autofillOverlayVisibility;
}
/**
* Clears the send collect details message timeout.
*/
private clearCollectPageDetailsOnLoadTimeout() {
if (this.collectPageDetailsOnLoadTimeout) {
clearTimeout(this.collectPageDetailsOnLoadTimeout);
}
}
/**
* Sets up the extension message listeners for the content script.
*/
private setupExtensionMessageListeners() {
chrome.runtime.onMessage.addListener(this.handleExtensionMessage);
}
/**
* Handles the extension messages sent to the content script.
*
* @param message - The extension message.
* @param sender - The message sender.
* @param sendResponse - The send response callback.
*/
private handleExtensionMessage = (
message: AutofillExtensionMessage,
sender: chrome.runtime.MessageSender,
sendResponse: (response?: any) => void,
): boolean => {
const command: string = message.command;
const handler: CallableFunction | undefined = this.extensionMessageHandlers[command];
if (!handler) {
return null;
}
const messageResponse = handler({ message, sender });
if (typeof messageResponse === "undefined") {
return null;
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Promise.resolve(messageResponse).then((response) => sendResponse(response));
return true;
};
/**
* Handles destroying the autofill init content script. Removes all
* listeners, timeouts, and object instances to prevent memory leaks.
*/
destroy() {
this.clearCollectPageDetailsOnLoadTimeout();
chrome.runtime.onMessage.removeListener(this.handleExtensionMessage);
this.collectAutofillContentService.destroy();
this.autofillOverlayContentService?.destroy();
}
}
export default LegacyAutofillInit;

View File

@@ -1,14 +0,0 @@
import { setupAutofillInitDisconnectAction } from "../../utils";
import LegacyAutofillOverlayContentService from "../services/autofill-overlay-content.service.deprecated";
import LegacyAutofillInit from "./autofill-init.deprecated";
(function (windowContext) {
if (!windowContext.bitwardenAutofillInit) {
const autofillOverlayContentService = new LegacyAutofillOverlayContentService();
windowContext.bitwardenAutofillInit = new LegacyAutofillInit(autofillOverlayContentService);
setupAutofillInitDisconnectAction(windowContext);
windowContext.bitwardenAutofillInit.init();
}
})(window);

View File

@@ -1,29 +0,0 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
type OverlayButtonMessage = { command: string; colorScheme?: string };
type UpdateAuthStatusMessage = OverlayButtonMessage & { authStatus: AuthenticationStatus };
type InitAutofillOverlayButtonMessage = UpdateAuthStatusMessage & {
styleSheetUrl: string;
translations: Record<string, string>;
};
type OverlayButtonWindowMessageHandlers = {
[key: string]: CallableFunction;
initAutofillOverlayButton: ({ message }: { message: InitAutofillOverlayButtonMessage }) => void;
checkAutofillOverlayButtonFocused: () => void;
updateAutofillOverlayButtonAuthStatus: ({
message,
}: {
message: UpdateAuthStatusMessage;
}) => void;
updateOverlayPageColorScheme: ({ message }: { message: OverlayButtonMessage }) => void;
};
export {
UpdateAuthStatusMessage,
OverlayButtonMessage,
InitAutofillOverlayButtonMessage,
OverlayButtonWindowMessageHandlers,
};

View File

@@ -1,33 +0,0 @@
type AutofillOverlayIframeExtensionMessage = {
command: string;
styles?: Partial<CSSStyleDeclaration>;
theme?: string;
};
type AutofillOverlayIframeWindowMessageHandlers = {
[key: string]: CallableFunction;
updateAutofillOverlayListHeight: (message: AutofillOverlayIframeExtensionMessage) => void;
getPageColorScheme: () => void;
};
type AutofillOverlayIframeExtensionMessageParam = {
message: AutofillOverlayIframeExtensionMessage;
};
type BackgroundPortMessageHandlers = {
[key: string]: CallableFunction;
initAutofillOverlayList: ({ message }: AutofillOverlayIframeExtensionMessageParam) => void;
updateIframePosition: ({ message }: AutofillOverlayIframeExtensionMessageParam) => void;
updateOverlayHidden: ({ message }: AutofillOverlayIframeExtensionMessageParam) => void;
};
interface AutofillOverlayIframeService {
initOverlayIframe(initStyles: Partial<CSSStyleDeclaration>, ariaAlert?: string): void;
}
export {
AutofillOverlayIframeExtensionMessage,
AutofillOverlayIframeWindowMessageHandlers,
BackgroundPortMessageHandlers,
AutofillOverlayIframeService,
};

View File

@@ -1,31 +0,0 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { OverlayCipherData } from "../../background/abstractions/overlay.background.deprecated";
type OverlayListMessage = { command: string };
type UpdateOverlayListCiphersMessage = OverlayListMessage & {
ciphers: OverlayCipherData[];
};
type InitAutofillOverlayListMessage = OverlayListMessage & {
authStatus: AuthenticationStatus;
styleSheetUrl: string;
theme: string;
translations: Record<string, string>;
ciphers?: OverlayCipherData[];
};
type OverlayListWindowMessageHandlers = {
[key: string]: CallableFunction;
initAutofillOverlayList: ({ message }: { message: InitAutofillOverlayListMessage }) => void;
checkAutofillOverlayListFocused: () => void;
updateOverlayListCiphers: ({ message }: { message: UpdateOverlayListCiphersMessage }) => void;
focusOverlayList: () => void;
};
export {
UpdateOverlayListCiphersMessage,
InitAutofillOverlayListMessage,
OverlayListWindowMessageHandlers,
};

View File

@@ -1,13 +0,0 @@
import { OverlayButtonWindowMessageHandlers } from "./autofill-overlay-button.deprecated";
import { OverlayListWindowMessageHandlers } from "./autofill-overlay-list.deprecated";
type WindowMessageHandlers = OverlayButtonWindowMessageHandlers | OverlayListWindowMessageHandlers;
type AutofillOverlayPageElementWindowMessage = {
[key: string]: any;
command: string;
overlayCipherId?: string;
height?: number;
};
export { WindowMessageHandlers, AutofillOverlayPageElementWindowMessage };

View File

@@ -1,23 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AutofillOverlayIframeService initOverlayIframe creates an aria alert element if the ariaAlert param is passed 1`] = `
<div
aria-atomic="true"
aria-live="polite"
role="status"
style="position: absolute !important; top: -9999px !important; left: -9999px !important; width: 1px !important; height: 1px !important; overflow: hidden !important; opacity: 0 !important; pointer-events: none;"
>
aria alert
</div>
`;
exports[`AutofillOverlayIframeService initOverlayIframe sets up the iframe's attributes 1`] = `
<iframe
allowtransparency="true"
sandbox="allow-scripts"
src="chrome-extension://id/overlay/legacy-list.html"
style="all: initial !important; position: fixed !important; display: block !important; z-index: 2147483647 !important; line-height: 0 !important; overflow: hidden !important; transition: opacity 125ms ease-out 0s !important; visibility: visible !important; clip-path: none !important; pointer-events: auto !important; margin: 0px !important; padding: 0px !important; color-scheme: normal !important; opacity: 0 !important; height: 0px;"
tabindex="-1"
title="title"
/>
`;

View File

@@ -1,26 +0,0 @@
import AutofillOverlayButtonIframe from "./autofill-overlay-button-iframe.deprecated";
describe("AutofillOverlayButtonIframe", () => {
window.customElements.define(
"autofill-overlay-button-iframe",
class extends HTMLElement {
constructor() {
super();
new AutofillOverlayButtonIframe(this);
}
},
);
afterAll(() => {
jest.clearAllMocks();
});
it("creates a custom element that is an instance of the AutofillIframeElement parent class", () => {
document.body.innerHTML = "<autofill-overlay-button-iframe></autofill-overlay-button-iframe>";
const iframe = document.querySelector("autofill-overlay-button-iframe");
expect(iframe).toBeInstanceOf(HTMLElement);
expect(iframe.shadowRoot).toBeDefined();
});
});

View File

@@ -1,21 +0,0 @@
import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum";
import AutofillOverlayIframeElement from "./autofill-overlay-iframe-element.deprecated";
class AutofillOverlayButtonIframe extends AutofillOverlayIframeElement {
constructor(element: HTMLElement) {
super(
element,
"overlay/button.html",
AutofillOverlayPort.Button,
{
background: "transparent",
border: "none",
},
chrome.i18n.getMessage("bitwardenOverlayButton"),
chrome.i18n.getMessage("bitwardenOverlayMenuAvailable"),
);
}
}
export default AutofillOverlayButtonIframe;

View File

@@ -1,46 +0,0 @@
import AutofillOverlayIframeElement from "./autofill-overlay-iframe-element.deprecated";
import AutofillOverlayIframeService from "./autofill-overlay-iframe.service.deprecated";
jest.mock("./autofill-overlay-iframe.service.deprecated");
describe("AutofillOverlayIframeElement", () => {
window.customElements.define(
"autofill-overlay-iframe",
class extends HTMLElement {
constructor() {
super();
new AutofillOverlayIframeElement(
this,
"overlay/button.html",
"overlay/button",
{ background: "transparent", border: "none" },
"bitwardenOverlayButton",
);
}
},
);
afterAll(() => {
jest.clearAllMocks();
});
it("creates a custom element that is an instance of the HTMLElement parent class", () => {
document.body.innerHTML = "<autofill-overlay-iframe></autofill-overlay-iframe>";
const iframe = document.querySelector("autofill-overlay-iframe");
expect(iframe).toBeInstanceOf(HTMLElement);
});
it("attaches a closed shadow DOM", () => {
document.body.innerHTML = "<autofill-overlay-iframe></autofill-overlay-iframe>";
const iframe = document.querySelector("autofill-overlay-iframe");
expect(iframe.shadowRoot).toBeNull();
});
it("instantiates the autofill overlay iframe service for each attached custom element", () => {
expect(AutofillOverlayIframeService).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,22 +0,0 @@
import AutofillOverlayIframeService from "./autofill-overlay-iframe.service.deprecated";
class AutofillOverlayIframeElement {
constructor(
element: HTMLElement,
iframePath: string,
portName: string,
initStyles: Partial<CSSStyleDeclaration>,
iframeTitle: string,
ariaAlert?: string,
) {
const shadow: ShadowRoot = element.attachShadow({ mode: "closed" });
const autofillOverlayIframeService = new AutofillOverlayIframeService(
iframePath,
portName,
shadow,
);
autofillOverlayIframeService.initOverlayIframe(initStyles, iframeTitle, ariaAlert);
}
}
export default AutofillOverlayIframeElement;

View File

@@ -1,521 +0,0 @@
import { mock } from "jest-mock-extended";
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum";
import { createPortSpyMock } from "../../../spec/autofill-mocks";
import {
flushPromises,
sendPortMessage,
triggerPortOnDisconnectEvent,
} from "../../../spec/testing-utils";
import AutofillOverlayIframeService from "./autofill-overlay-iframe.service.deprecated";
describe("AutofillOverlayIframeService", () => {
const iframePath = "overlay/legacy-list.html";
let autofillOverlayIframeService: AutofillOverlayIframeService;
let portSpy: chrome.runtime.Port;
let shadowAppendSpy: jest.SpyInstance;
let handlePortDisconnectSpy: jest.SpyInstance;
let handlePortMessageSpy: jest.SpyInstance;
let handleWindowMessageSpy: jest.SpyInstance;
beforeEach(() => {
const shadow = document.createElement("div").attachShadow({ mode: "open" });
autofillOverlayIframeService = new AutofillOverlayIframeService(
iframePath,
AutofillOverlayPort.Button,
shadow,
);
shadowAppendSpy = jest.spyOn(shadow, "appendChild");
handlePortDisconnectSpy = jest.spyOn(
autofillOverlayIframeService as any,
"handlePortDisconnect",
);
handlePortMessageSpy = jest.spyOn(autofillOverlayIframeService as any, "handlePortMessage");
handleWindowMessageSpy = jest.spyOn(autofillOverlayIframeService as any, "handleWindowMessage");
chrome.runtime.connect = jest.fn((connectInfo: chrome.runtime.ConnectInfo) =>
createPortSpyMock(connectInfo.name),
) as unknown as typeof chrome.runtime.connect;
});
afterEach(() => {
jest.clearAllMocks();
});
describe("initOverlayIframe", () => {
it("sets up the iframe's attributes", () => {
autofillOverlayIframeService.initOverlayIframe({ height: "0px" }, "title");
expect(autofillOverlayIframeService["iframe"]).toMatchSnapshot();
});
it("appends the iframe to the shadowDom", () => {
jest.spyOn(autofillOverlayIframeService["shadow"], "appendChild");
autofillOverlayIframeService.initOverlayIframe({}, "title");
expect(autofillOverlayIframeService["shadow"].appendChild).toBeCalledWith(
autofillOverlayIframeService["iframe"],
);
});
it("creates an aria alert element if the ariaAlert param is passed", () => {
const ariaAlert = "aria alert";
jest.spyOn(autofillOverlayIframeService as any, "createAriaAlertElement");
autofillOverlayIframeService.initOverlayIframe({}, "title", ariaAlert);
expect(autofillOverlayIframeService["createAriaAlertElement"]).toBeCalledWith(ariaAlert);
expect(autofillOverlayIframeService["ariaAlertElement"]).toMatchSnapshot();
});
describe("on load of the iframe source", () => {
beforeEach(() => {
autofillOverlayIframeService.initOverlayIframe({ height: "0px" }, "title", "ariaAlert");
});
it("sets up and connects the port message listener to the extension background", () => {
jest.spyOn(globalThis, "addEventListener");
autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
portSpy = autofillOverlayIframeService["port"];
expect(chrome.runtime.connect).toBeCalledWith({ name: AutofillOverlayPort.Button });
expect(portSpy.onDisconnect.addListener).toBeCalledWith(handlePortDisconnectSpy);
expect(portSpy.onMessage.addListener).toBeCalledWith(handlePortMessageSpy);
expect(globalThis.addEventListener).toBeCalledWith(EVENTS.MESSAGE, handleWindowMessageSpy);
});
it("skips announcing the aria alert if the aria alert element is not populated", () => {
jest.spyOn(globalThis, "setTimeout");
autofillOverlayIframeService["ariaAlertElement"] = undefined;
autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
expect(globalThis.setTimeout).not.toBeCalled();
});
it("announces the aria alert if the aria alert element is populated", () => {
jest.useFakeTimers();
jest.spyOn(globalThis, "setTimeout");
autofillOverlayIframeService["ariaAlertElement"] = document.createElement("div");
autofillOverlayIframeService["ariaAlertTimeout"] = setTimeout(jest.fn(), 2000);
autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
expect(globalThis.setTimeout).toBeCalled();
jest.advanceTimersByTime(2000);
expect(shadowAppendSpy).toBeCalledWith(autofillOverlayIframeService["ariaAlertElement"]);
});
});
});
describe("event listeners", () => {
beforeEach(() => {
autofillOverlayIframeService.initOverlayIframe({ height: "0px" }, "title", "ariaAlert");
autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
Object.defineProperty(autofillOverlayIframeService["iframe"], "contentWindow", {
value: {
postMessage: jest.fn(),
},
writable: true,
});
jest.spyOn(autofillOverlayIframeService["iframe"].contentWindow, "postMessage");
portSpy = autofillOverlayIframeService["port"];
});
describe("handlePortDisconnect", () => {
it("ignores ports that do not have the correct port name", () => {
portSpy.name = "wrong-port-name";
triggerPortOnDisconnectEvent(portSpy);
expect(autofillOverlayIframeService["port"]).not.toBeNull();
});
it("resets the iframe element's opacity, height, and display styles", () => {
triggerPortOnDisconnectEvent(portSpy);
expect(autofillOverlayIframeService["iframe"].style.opacity).toBe("0");
expect(autofillOverlayIframeService["iframe"].style.height).toBe("0px");
expect(autofillOverlayIframeService["iframe"].style.display).toBe("block");
});
it("removes the global message listener", () => {
jest.spyOn(globalThis, "removeEventListener");
triggerPortOnDisconnectEvent(portSpy);
expect(globalThis.removeEventListener).toBeCalledWith(
EVENTS.MESSAGE,
handleWindowMessageSpy,
);
});
it("removes the port's onMessage listener", () => {
triggerPortOnDisconnectEvent(portSpy);
expect(portSpy.onMessage.removeListener).toBeCalledWith(handlePortMessageSpy);
});
it("removes the port's onDisconnect listener", () => {
triggerPortOnDisconnectEvent(portSpy);
expect(portSpy.onDisconnect.removeListener).toBeCalledWith(handlePortDisconnectSpy);
});
it("disconnects the port", () => {
triggerPortOnDisconnectEvent(portSpy);
expect(portSpy.disconnect).toBeCalled();
expect(autofillOverlayIframeService["port"]).toBeNull();
});
});
describe("handlePortMessage", () => {
it("ignores port messages that do not correlate to the correct port name", () => {
portSpy.name = "wrong-port-name";
sendPortMessage(portSpy, {});
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).not.toBeCalled();
});
it("passes on the message to the iframe if the message is not registered with the message handlers", () => {
const message = { command: "unregisteredMessage" };
sendPortMessage(portSpy, message);
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).toBeCalledWith(
message,
"*",
);
});
it("handles port messages that are registered with the message handlers and does not pass the message on to the iframe", () => {
jest.spyOn(autofillOverlayIframeService as any, "updateIframePosition");
sendPortMessage(portSpy, { command: "updateIframePosition" });
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).not.toBeCalled();
});
describe("initializing the overlay list", () => {
let updateElementStylesSpy: jest.SpyInstance;
beforeEach(() => {
updateElementStylesSpy = jest.spyOn(
autofillOverlayIframeService as any,
"updateElementStyles",
);
});
it("passes the message on to the iframe element", () => {
const message = {
command: "initAutofillOverlayList",
theme: ThemeType.Light,
};
sendPortMessage(portSpy, message);
expect(updateElementStylesSpy).not.toBeCalled();
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).toBeCalledWith(
message,
"*",
);
});
it("sets a light theme based on the user's system preferences", () => {
window.matchMedia = jest.fn(() => mock<MediaQueryList>({ matches: false }));
const message = {
command: "initAutofillOverlayList",
theme: ThemeType.System,
};
sendPortMessage(portSpy, message);
expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)");
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).toBeCalledWith(
{
command: "initAutofillOverlayList",
theme: ThemeType.Light,
},
"*",
);
});
it("sets a dark theme based on the user's system preferences", () => {
window.matchMedia = jest.fn(() => mock<MediaQueryList>({ matches: true }));
const message = {
command: "initAutofillOverlayList",
theme: ThemeType.System,
};
sendPortMessage(portSpy, message);
expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)");
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).toBeCalledWith(
{
command: "initAutofillOverlayList",
theme: ThemeType.Dark,
},
"*",
);
});
it("updates the border to match the `dark` theme", () => {
const message = {
command: "initAutofillOverlayList",
theme: ThemeType.Dark,
};
sendPortMessage(portSpy, message);
expect(updateElementStylesSpy).toBeCalledWith(autofillOverlayIframeService["iframe"], {
borderColor: "#4c525f",
});
});
});
describe("updating the iframe's position", () => {
beforeEach(() => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
});
it("ignores updating the iframe position if the document does not have focus", () => {
jest.spyOn(autofillOverlayIframeService as any, "updateElementStyles");
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
sendPortMessage(portSpy, {
command: "updateIframePosition",
styles: { top: 100, left: 100 },
});
expect(autofillOverlayIframeService["updateElementStyles"]).not.toBeCalled();
});
it("updates the iframe position if the document has focus", () => {
const styles = { top: "100px", left: "100px" };
sendPortMessage(portSpy, {
command: "updateIframePosition",
styles,
});
expect(autofillOverlayIframeService["iframe"].style.top).toBe(styles.top);
expect(autofillOverlayIframeService["iframe"].style.left).toBe(styles.left);
});
it("fades the iframe element in after positioning the element", () => {
jest.useFakeTimers();
const styles = { top: "100px", left: "100px" };
sendPortMessage(portSpy, {
command: "updateIframePosition",
styles,
});
expect(autofillOverlayIframeService["iframe"].style.opacity).toBe("0");
jest.advanceTimersByTime(10);
expect(autofillOverlayIframeService["iframe"].style.opacity).toBe("1");
});
it("announces the opening of the iframe using an aria alert", () => {
jest.useFakeTimers();
const styles = { top: "100px", left: "100px" };
sendPortMessage(portSpy, {
command: "updateIframePosition",
styles,
});
jest.advanceTimersByTime(2000);
expect(shadowAppendSpy).toBeCalledWith(autofillOverlayIframeService["ariaAlertElement"]);
});
});
it("updates the visibility of the iframe", () => {
sendPortMessage(portSpy, {
command: "updateOverlayHidden",
styles: { display: "none" },
});
expect(autofillOverlayIframeService["iframe"].style.display).toBe("none");
});
});
describe("handleWindowMessage", () => {
it("ignores window messages when the port is not set", () => {
autofillOverlayIframeService["port"] = null;
globalThis.dispatchEvent(new MessageEvent("message", { data: {} }));
expect(autofillOverlayIframeService["port"]).toBeNull();
});
it("ignores window messages whose source is not the iframe's content window", () => {
globalThis.dispatchEvent(
new MessageEvent("message", {
data: {},
source: window,
}),
);
expect(portSpy.postMessage).not.toBeCalled();
});
it("ignores window messages whose origin is not from the extension origin", () => {
globalThis.dispatchEvent(
new MessageEvent("message", {
data: {},
source: autofillOverlayIframeService["iframe"].contentWindow,
origin: "https://www.google.com",
}),
);
expect(portSpy.postMessage).not.toBeCalled();
});
it("passes the window message from an iframe element to the background port", () => {
globalThis.dispatchEvent(
new MessageEvent("message", {
data: { command: "not-a-handled-command" },
source: autofillOverlayIframeService["iframe"].contentWindow,
origin: "chrome-extension://id",
}),
);
expect(portSpy.postMessage).toBeCalledWith({ command: "not-a-handled-command" });
});
it("updates the overlay list height", () => {
globalThis.dispatchEvent(
new MessageEvent("message", {
data: { command: "updateAutofillOverlayListHeight", styles: { height: "300px" } },
source: autofillOverlayIframeService["iframe"].contentWindow,
origin: "chrome-extension://id",
}),
);
expect(autofillOverlayIframeService["iframe"].style.height).toBe("300px");
});
describe("getPageColorScheme window message", () => {
afterEach(() => {
globalThis.document.head.innerHTML = "";
});
it("gets and updates the overlay page color scheme", () => {
const colorSchemeMetaTag = globalThis.document.createElement("meta");
colorSchemeMetaTag.setAttribute("name", "color-scheme");
colorSchemeMetaTag.setAttribute("content", "dark");
globalThis.document.head.append(colorSchemeMetaTag);
globalThis.dispatchEvent(
new MessageEvent("message", {
data: { command: "getPageColorScheme" },
source: autofillOverlayIframeService["iframe"].contentWindow,
origin: "chrome-extension://id",
}),
);
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).toBeCalledWith(
{ command: "updateOverlayPageColorScheme", colorScheme: "dark" },
"*",
);
});
it("sends a normal color scheme if the color scheme meta tag is not present", () => {
globalThis.dispatchEvent(
new MessageEvent("message", {
data: { command: "getPageColorScheme" },
source: autofillOverlayIframeService["iframe"].contentWindow,
origin: "chrome-extension://id",
}),
);
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).toBeCalledWith(
{ command: "updateOverlayPageColorScheme", colorScheme: "normal" },
"*",
);
});
});
});
});
describe("mutation observer", () => {
beforeEach(() => {
autofillOverlayIframeService.initOverlayIframe({ height: "0px" }, "title", "ariaAlert");
autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
portSpy = autofillOverlayIframeService["port"];
});
it("skips handling found mutations if excessive mutations are triggering", async () => {
jest.useFakeTimers();
jest
.spyOn(
autofillOverlayIframeService as any,
"isTriggeringExcessiveMutationObserverIterations",
)
.mockReturnValue(true);
jest.spyOn(autofillOverlayIframeService as any, "updateElementStyles");
autofillOverlayIframeService["iframe"].style.visibility = "hidden";
await flushPromises();
expect(autofillOverlayIframeService["updateElementStyles"]).not.toBeCalled();
});
it("reverts any styles changes made directly to the iframe", async () => {
jest.useFakeTimers();
autofillOverlayIframeService["iframe"].style.visibility = "hidden";
await flushPromises();
expect(autofillOverlayIframeService["iframe"].style.visibility).toBe("visible");
});
it("force closes the autofill overlay if more than 9 foreign mutations are triggered", async () => {
jest.useFakeTimers();
autofillOverlayIframeService["foreignMutationsCount"] = 10;
autofillOverlayIframeService["iframe"].src = "http://malicious-site.com";
await flushPromises();
expect(portSpy.postMessage).toBeCalledWith({ command: "forceCloseAutofillOverlay" });
});
it("force closes the autofill overlay if excessive mutations are being triggered", async () => {
jest.useFakeTimers();
autofillOverlayIframeService["mutationObserverIterations"] = 20;
autofillOverlayIframeService["iframe"].src = "http://malicious-site.com";
await flushPromises();
expect(portSpy.postMessage).toBeCalledWith({ command: "forceCloseAutofillOverlay" });
});
it("resets the excessive mutations and foreign mutation counters", async () => {
jest.useFakeTimers();
autofillOverlayIframeService["foreignMutationsCount"] = 9;
autofillOverlayIframeService["mutationObserverIterations"] = 19;
autofillOverlayIframeService["iframe"].src = "http://malicious-site.com";
jest.advanceTimersByTime(2001);
await flushPromises();
expect(autofillOverlayIframeService["foreignMutationsCount"]).toBe(0);
expect(autofillOverlayIframeService["mutationObserverIterations"]).toBe(0);
});
it("resets any mutated default attributes for the iframe", async () => {
jest.useFakeTimers();
autofillOverlayIframeService["iframe"].title = "some-other-title";
await flushPromises();
expect(autofillOverlayIframeService["iframe"].title).toBe("title");
});
});
});

View File

@@ -1,429 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
import { setElementStyles } from "../../../utils";
import {
BackgroundPortMessageHandlers,
AutofillOverlayIframeService as AutofillOverlayIframeServiceInterface,
AutofillOverlayIframeExtensionMessage,
AutofillOverlayIframeWindowMessageHandlers,
} from "../abstractions/autofill-overlay-iframe.service.deprecated";
class AutofillOverlayIframeService implements AutofillOverlayIframeServiceInterface {
private port: chrome.runtime.Port | null = null;
private extensionOriginsSet: Set<string>;
private iframeMutationObserver: MutationObserver;
private iframe: HTMLIFrameElement;
private ariaAlertElement: HTMLDivElement;
private ariaAlertTimeout: number | NodeJS.Timeout;
private iframeStyles: Partial<CSSStyleDeclaration> = {
all: "initial",
position: "fixed",
display: "block",
zIndex: "2147483647",
lineHeight: "0",
overflow: "hidden",
transition: "opacity 125ms ease-out 0s",
visibility: "visible",
clipPath: "none",
pointerEvents: "auto",
margin: "0",
padding: "0",
colorScheme: "normal",
opacity: "0",
};
private defaultIframeAttributes: Record<string, string> = {
src: "",
title: "",
sandbox: "allow-scripts",
allowtransparency: "true",
tabIndex: "-1",
};
private foreignMutationsCount = 0;
private mutationObserverIterations = 0;
private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout;
private readonly windowMessageHandlers: AutofillOverlayIframeWindowMessageHandlers = {
updateAutofillOverlayListHeight: (message) =>
this.updateElementStyles(this.iframe, message.styles),
getPageColorScheme: () => this.updateOverlayPageColorScheme(),
};
private readonly backgroundPortMessageHandlers: BackgroundPortMessageHandlers = {
initAutofillOverlayList: ({ message }) => this.initAutofillOverlayList(message),
updateIframePosition: ({ message }) => this.updateIframePosition(message.styles),
updateOverlayHidden: ({ message }) => this.updateElementStyles(this.iframe, message.styles),
};
constructor(
private iframePath: string,
private portName: string,
private shadow: ShadowRoot,
) {
this.extensionOriginsSet = new Set([
chrome.runtime.getURL("").slice(0, -1).toLowerCase(), // Remove the trailing slash and normalize the extension url to lowercase
"null",
]);
this.iframeMutationObserver = new MutationObserver(this.handleMutations);
}
/**
* Handles initialization of the iframe which includes applying initial styles
* to the iframe, setting the source, and adding listener that connects the
* iframe to the background script each time it loads. Can conditionally
* create an aria alert element to announce to screen readers when the iframe
* is loaded. The end result is append to the shadowDOM of the custom element
* that is declared.
*
*
* @param initStyles - Initial styles to apply to the iframe
* @param iframeTitle - Title to apply to the iframe
* @param ariaAlert - Text to announce to screen readers when the iframe is loaded
*/
initOverlayIframe(
initStyles: Partial<CSSStyleDeclaration>,
iframeTitle: string,
ariaAlert?: string,
) {
this.defaultIframeAttributes.src = chrome.runtime.getURL(this.iframePath);
this.defaultIframeAttributes.title = iframeTitle;
this.iframe = globalThis.document.createElement("iframe");
this.updateElementStyles(this.iframe, { ...this.iframeStyles, ...initStyles });
for (const [attribute, value] of Object.entries(this.defaultIframeAttributes)) {
this.iframe.setAttribute(attribute, value);
}
this.iframe.addEventListener(EVENTS.LOAD, this.setupPortMessageListener);
if (ariaAlert) {
this.createAriaAlertElement(ariaAlert);
}
this.shadow.appendChild(this.iframe);
}
/**
* Creates an aria alert element that is used to announce to screen readers
* when the iframe is loaded.
*
* @param ariaAlertText - Text to announce to screen readers when the iframe is loaded
*/
private createAriaAlertElement(ariaAlertText: string) {
this.ariaAlertElement = globalThis.document.createElement("div");
this.ariaAlertElement.setAttribute("role", "status");
this.ariaAlertElement.setAttribute("aria-live", "polite");
this.ariaAlertElement.setAttribute("aria-atomic", "true");
this.updateElementStyles(this.ariaAlertElement, {
position: "absolute",
top: "-9999px",
left: "-9999px",
width: "1px",
height: "1px",
overflow: "hidden",
opacity: "0",
pointerEvents: "none",
});
this.ariaAlertElement.textContent = ariaAlertText;
}
/**
* Sets up the port message listener to the extension background script. This
* listener is used to communicate between the iframe and the background script.
* This also facilitates announcing to screen readers when the iframe is loaded.
*/
private setupPortMessageListener = () => {
this.port = chrome.runtime.connect({ name: this.portName });
this.port.onDisconnect.addListener(this.handlePortDisconnect);
this.port.onMessage.addListener(this.handlePortMessage);
globalThis.addEventListener(EVENTS.MESSAGE, this.handleWindowMessage);
this.announceAriaAlert();
};
/**
* Announces the aria alert element to screen readers when the iframe is loaded.
*/
private announceAriaAlert() {
if (!this.ariaAlertElement) {
return;
}
this.ariaAlertElement.remove();
if (this.ariaAlertTimeout) {
clearTimeout(this.ariaAlertTimeout);
}
this.ariaAlertTimeout = setTimeout(() => this.shadow.appendChild(this.ariaAlertElement), 2000);
}
/**
* Handles disconnecting the port message listener from the extension background
* script. This also removes the listener that facilitates announcing to screen
* readers when the iframe is loaded.
*
* @param port - The port that is disconnected
*/
private handlePortDisconnect = (port: chrome.runtime.Port) => {
if (port.name !== this.portName) {
return;
}
this.updateElementStyles(this.iframe, { opacity: "0", height: "0px", display: "block" });
globalThis.removeEventListener("message", this.handleWindowMessage);
this.unobserveIframe();
this.port?.onMessage.removeListener(this.handlePortMessage);
this.port?.onDisconnect.removeListener(this.handlePortDisconnect);
this.port?.disconnect();
this.port = null;
};
/**
* Handles messages sent from the extension background script to the iframe.
* Triggers behavior within the iframe as well as on the custom element that
* contains the iframe element.
*
* @param message
* @param port
*/
private handlePortMessage = (
message: AutofillOverlayIframeExtensionMessage,
port: chrome.runtime.Port,
) => {
if (port.name !== this.portName) {
return;
}
if (this.backgroundPortMessageHandlers[message.command]) {
this.backgroundPortMessageHandlers[message.command]({ message, port });
return;
}
this.iframe.contentWindow?.postMessage(message, "*");
};
/**
* Handles messages sent from the iframe to the extension background script.
* Will adjust the border element to fit the user's set theme.
*
* @param message - The message sent from the iframe
*/
private initAutofillOverlayList(message: AutofillOverlayIframeExtensionMessage) {
const { theme } = message;
let borderColor: string;
let verifiedTheme = theme;
if (verifiedTheme === ThemeTypes.System) {
verifiedTheme = globalThis.matchMedia("(prefers-color-scheme: dark)").matches
? ThemeTypes.Dark
: ThemeTypes.Light;
}
if (verifiedTheme === ThemeTypes.Dark) {
borderColor = "#4c525f";
}
if (borderColor) {
this.updateElementStyles(this.iframe, { borderColor });
}
message.theme = verifiedTheme;
this.iframe.contentWindow?.postMessage(message, "*");
}
/**
* Updates the position of the iframe element. Will also announce
* to screen readers that the iframe is open.
*
* @param position - The position styles to apply to the iframe
*/
private updateIframePosition(position: Partial<CSSStyleDeclaration>) {
if (!globalThis.document.hasFocus()) {
return;
}
this.updateElementStyles(this.iframe, position);
setTimeout(() => this.updateElementStyles(this.iframe, { opacity: "1" }), 0);
this.announceAriaAlert();
}
/**
* Gets the page color scheme meta tag and sends a message to the iframe
* to update its color scheme. Will default to "normal" if the meta tag
* does not exist.
*/
private updateOverlayPageColorScheme() {
const colorSchemeValue = globalThis.document
.querySelector("meta[name='color-scheme']")
?.getAttribute("content");
this.iframe.contentWindow?.postMessage(
{ command: "updateOverlayPageColorScheme", colorScheme: colorSchemeValue || "normal" },
"*",
);
}
/**
* Handles messages sent from the iframe. If the message does not have a
* specified handler set, it passes the message to the background script.
*
* @param event - The message event
*/
private handleWindowMessage = (event: MessageEvent) => {
if (
!this.port ||
event.source !== this.iframe.contentWindow ||
!this.isFromExtensionOrigin(event.origin.toLowerCase())
) {
return;
}
const message = event.data;
if (this.windowMessageHandlers[message.command]) {
this.windowMessageHandlers[message.command](message);
return;
}
this.port.postMessage(event.data);
};
/**
* Accepts an element and updates the styles for that element. This method
* will also unobserve the element if it is the iframe element. This is
* done to ensure that we do not trigger the mutation observer when we
* update the styles for the iframe.
*
* @param customElement - The element to update the styles for
* @param styles - The styles to apply to the element
*/
private updateElementStyles(customElement: HTMLElement, styles: Partial<CSSStyleDeclaration>) {
if (!customElement) {
return;
}
this.unobserveIframe();
setElementStyles(customElement, styles, true);
this.iframeStyles = { ...this.iframeStyles, ...styles };
this.observeIframe();
}
/**
* Chrome returns null for any sandboxed iframe sources.
* Firefox references the extension URI as its origin.
* Any other origin value is a security risk.
*
* @param messageOrigin - The origin of the window message
*/
private isFromExtensionOrigin(messageOrigin: string): boolean {
return this.extensionOriginsSet.has(messageOrigin);
}
/**
* Handles mutations to the iframe element. The ensures that the iframe
* element's styles are not modified by a third party source.
*
* @param mutations - The mutations to the iframe element
*/
private handleMutations = (mutations: MutationRecord[]) => {
if (this.isTriggeringExcessiveMutationObserverIterations()) {
return;
}
for (let index = 0; index < mutations.length; index++) {
const mutation = mutations[index];
if (mutation.type !== "attributes") {
continue;
}
const element = mutation.target as HTMLElement;
if (mutation.attributeName !== "style") {
this.handleElementAttributeMutation(element);
continue;
}
this.iframe.removeAttribute("style");
this.updateElementStyles(this.iframe, this.iframeStyles);
}
};
/**
* Handles mutations to the iframe element's attributes. This ensures that
* the iframe element's attributes are not modified by a third party source.
*
* @param element - The element to handle attribute mutations for
*/
private handleElementAttributeMutation(element: HTMLElement) {
const attributes = Array.from(element.attributes);
for (let attributeIndex = 0; attributeIndex < attributes.length; attributeIndex++) {
const attribute = attributes[attributeIndex];
if (attribute.name === "style") {
continue;
}
if (this.foreignMutationsCount >= 10) {
this.port?.postMessage({ command: "forceCloseAutofillOverlay" });
break;
}
const defaultIframeAttribute = this.defaultIframeAttributes[attribute.name];
if (!defaultIframeAttribute) {
this.iframe.removeAttribute(attribute.name);
this.foreignMutationsCount++;
continue;
}
if (attribute.value === defaultIframeAttribute) {
continue;
}
this.iframe.setAttribute(attribute.name, defaultIframeAttribute);
this.foreignMutationsCount++;
}
}
/**
* Observes the iframe element for mutations to its style attribute.
*/
private observeIframe() {
this.iframeMutationObserver.observe(this.iframe, { attributes: true });
}
/**
* Unobserves the iframe element for mutations to its style attribute.
*/
private unobserveIframe() {
this.iframeMutationObserver?.disconnect();
}
/**
* Identifies if the mutation observer is triggering excessive iterations.
* Will remove the autofill overlay if any set mutation observer is
* triggering excessive iterations.
*/
private isTriggeringExcessiveMutationObserverIterations() {
const resetCounters = () => {
this.mutationObserverIterations = 0;
this.foreignMutationsCount = 0;
};
if (this.mutationObserverIterationsResetTimeout) {
clearTimeout(this.mutationObserverIterationsResetTimeout);
}
this.mutationObserverIterations++;
this.mutationObserverIterationsResetTimeout = setTimeout(() => resetCounters(), 2000);
if (this.mutationObserverIterations > 20) {
clearTimeout(this.mutationObserverIterationsResetTimeout);
resetCounters();
this.port?.postMessage({ command: "forceCloseAutofillOverlay" });
return true;
}
return false;
}
}
export default AutofillOverlayIframeService;

View File

@@ -1,26 +0,0 @@
import AutofillOverlayListIframe from "./autofill-overlay-list-iframe.deprecated";
describe("AutofillOverlayListIframe", () => {
window.customElements.define(
"autofill-overlay-list-iframe",
class extends HTMLElement {
constructor() {
super();
new AutofillOverlayListIframe(this);
}
},
);
afterAll(() => {
jest.clearAllMocks();
});
it("creates a custom element that is an instance of the AutofillIframeElement parent class", () => {
document.body.innerHTML = "<autofill-overlay-list-iframe></autofill-overlay-list-iframe>";
const iframe = document.querySelector("autofill-overlay-list-iframe");
expect(iframe).toBeInstanceOf(HTMLElement);
expect(iframe.shadowRoot).toBeDefined();
});
});

View File

@@ -1,26 +0,0 @@
import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum";
import AutofillOverlayIframeElement from "./autofill-overlay-iframe-element.deprecated";
class AutofillOverlayListIframe extends AutofillOverlayIframeElement {
constructor(element: HTMLElement) {
super(
element,
"overlay/list.html",
AutofillOverlayPort.List,
{
height: "0px",
minWidth: "250px",
maxHeight: "180px",
boxShadow: "rgba(0, 0, 0, 0.1) 2px 4px 6px 0px",
borderRadius: "4px",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "rgb(206, 212, 220)",
},
chrome.i18n.getMessage("bitwardenVault"),
);
}
}
export default AutofillOverlayListIframe;

View File

@@ -1,83 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AutofillOverlayButton initAutofillOverlayButton creates the button element with the locked icon when the user's auth status is not Unlocked 1`] = `
<button
aria-label="toggleBitwardenVaultOverlay"
class="overlay-button"
tabindex="-1"
type="button"
>
<svg
aria-hidden="true"
class="overlay-button-svg-icon logo-locked-icon"
fill="none"
height="16"
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M12.66.175A.566.566 0 0 0 12.25 0H1.75a.559.559 0 0 0-.409.175.561.561 0 0 0-.175.41v7c.002.532.105 1.06.305 1.554.189.488.444.948.756 1.368.322.42.682.81 1.076 1.163.365.335.75.649 1.152.939.35.248.718.483 1.103.706.385.222.656.372.815.45.16.08.29.141.386.182A.53.53 0 0 0 7 14a.509.509 0 0 0 .238-.055c.098-.043.225-.104.387-.182.162-.079.438-.23.816-.45.378-.222.75-.459 1.102-.707.403-.29.788-.604 1.154-.939a8.435 8.435 0 0 0 1.076-1.163c.312-.42.567-.88.757-1.367a4.19 4.19 0 0 0 .304-1.555v-7a.55.55 0 0 0-.174-.407Z"
fill="#175DDC"
/>
<path
d="M7 12.365s4.306-2.18 4.306-4.717V1.5H7v10.865Z"
fill="#fff"
/>
<circle
cx="12.889"
cy="12.889"
fill="#F8F9FA"
r="4.889"
/>
<path
d="M11.26 11.717h2.37v-.848c0-.313-.116-.58-.348-.8a1.17 1.17 0 0 0-.838-.332c-.327 0-.606.11-.838.332a1.066 1.066 0 0 0-.347.8v.848Zm3.851.424v2.546a.4.4 0 0 1-.13.3.44.44 0 0 1-.314.124h-4.445a.44.44 0 0 1-.315-.124.4.4 0 0 1-.13-.3V12.14a.4.4 0 0 1 .13-.3.44.44 0 0 1 .315-.124h.148v-.848c0-.542.204-1.008.612-1.397a2.044 2.044 0 0 1 1.462-.583c.568 0 1.056.194 1.463.583.408.39.611.855.611 1.397v.848h.149a.44.44 0 0 1 .315.124.4.4 0 0 1 .13.3Z"
fill="#555"
/>
</g>
<defs>
<clippath
id="a"
>
<rect
fill="#fff"
height="16"
rx="2"
width="16"
/>
</clippath>
</defs>
</svg>
</button>
`;
exports[`AutofillOverlayButton initAutofillOverlayButton creates the button element with the normal icon when the user's auth status is Unlocked 1`] = `
<button
aria-label="toggleBitwardenVaultOverlay"
class="overlay-button"
tabindex="-1"
type="button"
>
<svg
aria-hidden="true"
class="overlay-button-svg-icon logo-icon"
fill="none"
height="14"
viewBox="0 0 14 14"
width="14"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.66.175A.566.566 0 0 0 12.25 0H1.75a.559.559 0 0 0-.409.175.561.561 0 0 0-.175.41v7c.002.532.105 1.06.305 1.554.189.488.444.948.756 1.368.322.42.682.81 1.076 1.163.365.335.75.649 1.152.939.35.248.718.483 1.103.706.385.222.656.372.815.45.16.08.29.141.386.182A.53.53 0 0 0 7 14a.509.509 0 0 0 .238-.055c.098-.043.225-.104.387-.182.162-.079.438-.23.816-.45.378-.222.75-.459 1.102-.707.403-.29.788-.604 1.154-.939a8.435 8.435 0 0 0 1.076-1.163c.312-.42.567-.88.757-1.367a4.19 4.19 0 0 0 .304-1.555v-7a.55.55 0 0 0-.174-.407Z"
fill="#175DDC"
/>
<path
d="M7 12.365s4.306-2.18 4.306-4.717V1.5H7v10.865Z"
fill="#fff"
/>
</svg>
</button>
`;

View File

@@ -1,135 +0,0 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { postWindowMessage } from "../../../../spec/testing-utils";
import { InitAutofillOverlayButtonMessage } from "../../abstractions/autofill-overlay-button.deprecated";
import AutofillOverlayButton from "./autofill-overlay-button.deprecated";
const overlayPagesTranslations = {
locale: "en",
buttonPageTitle: "buttonPageTitle",
listPageTitle: "listPageTitle",
opensInANewWindow: "opensInANewWindow",
toggleBitwardenVaultOverlay: "toggleBitwardenVaultOverlay",
unlockYourAccount: "unlockYourAccount",
unlockAccount: "unlockAccount",
fillCredentialsFor: "fillCredentialsFor",
partialUsername: "partialUsername",
view: "view",
noItemsToShow: "noItemsToShow",
newItem: "newItem",
addNewVaultItem: "addNewVaultItem",
};
function createInitAutofillOverlayButtonMessageMock(
customFields = {},
): InitAutofillOverlayButtonMessage {
return {
command: "initAutofillOverlayButton",
translations: overlayPagesTranslations,
styleSheetUrl: "https://jest-testing-website.com",
authStatus: AuthenticationStatus.Unlocked,
...customFields,
};
}
describe("AutofillOverlayButton", () => {
globalThis.customElements.define("autofill-overlay-button", AutofillOverlayButton);
let autofillOverlayButton: AutofillOverlayButton;
beforeEach(() => {
document.body.innerHTML = `<autofill-overlay-button></autofill-overlay-button>`;
autofillOverlayButton = document.querySelector("autofill-overlay-button");
autofillOverlayButton["messageOrigin"] = "https://localhost/";
jest.spyOn(globalThis.document, "createElement");
jest.spyOn(globalThis.parent, "postMessage");
});
afterEach(() => {
jest.clearAllMocks();
});
describe("initAutofillOverlayButton", () => {
it("creates the button element with the locked icon when the user's auth status is not Unlocked", () => {
postWindowMessage(
createInitAutofillOverlayButtonMessageMock({ authStatus: AuthenticationStatus.Locked }),
);
expect(autofillOverlayButton["buttonElement"]).toMatchSnapshot();
expect(autofillOverlayButton["buttonElement"].querySelector("svg")).toBe(
autofillOverlayButton["logoLockedIconElement"],
);
});
it("creates the button element with the normal icon when the user's auth status is Unlocked ", () => {
postWindowMessage(createInitAutofillOverlayButtonMessageMock());
expect(autofillOverlayButton["buttonElement"]).toMatchSnapshot();
expect(autofillOverlayButton["buttonElement"].querySelector("svg")).toBe(
autofillOverlayButton["logoIconElement"],
);
});
it("posts a message to the background indicating that the icon was clicked", () => {
postWindowMessage(createInitAutofillOverlayButtonMessageMock());
autofillOverlayButton["buttonElement"].click();
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "overlayButtonClicked" },
"https://localhost/",
);
});
});
describe("global event listeners", () => {
beforeEach(() => {
postWindowMessage(createInitAutofillOverlayButtonMessageMock());
});
it("does not post a message to close the autofill overlay if the element is focused during the focus check", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
postWindowMessage({ command: "checkAutofillOverlayButtonFocused" });
expect(globalThis.parent.postMessage).not.toHaveBeenCalledWith({
command: "closeAutofillOverlay",
});
});
it("posts a message to close the autofill overlay if the element is not focused during the focus check", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
postWindowMessage({ command: "checkAutofillOverlayButtonFocused" });
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "closeAutofillOverlay" },
"https://localhost/",
);
});
it("updates the user's auth status", () => {
autofillOverlayButton["authStatus"] = AuthenticationStatus.Locked;
postWindowMessage({
command: "updateAutofillOverlayButtonAuthStatus",
authStatus: AuthenticationStatus.Unlocked,
});
expect(autofillOverlayButton["authStatus"]).toBe(AuthenticationStatus.Unlocked);
});
it("updates the page color scheme meta tag", () => {
const colorSchemeMetaTag = globalThis.document.createElement("meta");
colorSchemeMetaTag.setAttribute("name", "color-scheme");
colorSchemeMetaTag.setAttribute("content", "light");
globalThis.document.head.append(colorSchemeMetaTag);
postWindowMessage({
command: "updateOverlayPageColorScheme",
colorScheme: "dark",
});
expect(colorSchemeMetaTag.getAttribute("content")).toBe("dark");
});
});
});

View File

@@ -1,124 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import "@webcomponents/custom-elements";
import "lit/polyfill-support.js";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { buildSvgDomElement } from "../../../../utils";
import { logoIcon, logoLockedIcon } from "../../../../utils/svg-icons";
import {
InitAutofillOverlayButtonMessage,
OverlayButtonMessage,
OverlayButtonWindowMessageHandlers,
} from "../../abstractions/autofill-overlay-button.deprecated";
import AutofillOverlayPageElement from "../shared/autofill-overlay-page-element.deprecated";
class AutofillOverlayButton extends AutofillOverlayPageElement {
private authStatus: AuthenticationStatus = AuthenticationStatus.LoggedOut;
private readonly buttonElement: HTMLButtonElement;
private readonly logoIconElement: HTMLElement;
private readonly logoLockedIconElement: HTMLElement;
private readonly overlayButtonWindowMessageHandlers: OverlayButtonWindowMessageHandlers = {
initAutofillOverlayButton: ({ message }) => this.initAutofillOverlayButton(message),
checkAutofillOverlayButtonFocused: () => this.checkButtonFocused(),
updateAutofillOverlayButtonAuthStatus: ({ message }) =>
this.updateAuthStatus(message.authStatus),
updateOverlayPageColorScheme: ({ message }) => this.updatePageColorScheme(message),
};
constructor() {
super();
this.buttonElement = globalThis.document.createElement("button");
this.setupGlobalListeners(this.overlayButtonWindowMessageHandlers);
this.logoIconElement = buildSvgDomElement(logoIcon);
this.logoIconElement.classList.add("overlay-button-svg-icon", "logo-icon");
this.logoLockedIconElement = buildSvgDomElement(logoLockedIcon);
this.logoLockedIconElement.classList.add("overlay-button-svg-icon", "logo-locked-icon");
}
/**
* Initializes the overlay button. Facilitates ensuring that the page
* is set up with the expected styles and translations.
*
* @param authStatus - The authentication status of the user
* @param styleSheetUrl - The URL of the stylesheet to apply to the page
* @param translations - The translations to apply to the page
* @private
*/
private async initAutofillOverlayButton({
authStatus,
styleSheetUrl,
translations,
}: InitAutofillOverlayButtonMessage) {
const linkElement = this.initOverlayPage("button", styleSheetUrl, translations);
this.buttonElement.tabIndex = -1;
this.buttonElement.type = "button";
this.buttonElement.classList.add("overlay-button");
this.buttonElement.setAttribute(
"aria-label",
this.getTranslation("toggleBitwardenVaultOverlay"),
);
this.buttonElement.addEventListener(EVENTS.CLICK, this.handleButtonElementClick);
this.postMessageToParent({ command: "getPageColorScheme" });
this.updateAuthStatus(authStatus);
this.shadowDom.append(linkElement, this.buttonElement);
}
/**
* Updates the authentication status of the user. This will update the icon
* displayed on the button.
*
* @param authStatus - The authentication status of the user
*/
private updateAuthStatus(authStatus: AuthenticationStatus) {
this.authStatus = authStatus;
this.buttonElement.innerHTML = "";
const iconElement =
this.authStatus === AuthenticationStatus.Unlocked
? this.logoIconElement
: this.logoLockedIconElement;
this.buttonElement.append(iconElement);
}
/**
* Handles updating the page color scheme meta tag. Ensures that the button
* does not present with a non-transparent background on dark mode pages.
*
* @param colorScheme - The color scheme of the iframe's parent page
*/
private updatePageColorScheme({ colorScheme }: OverlayButtonMessage) {
const colorSchemeMetaTag = globalThis.document.querySelector("meta[name='color-scheme']");
colorSchemeMetaTag?.setAttribute("content", colorScheme);
}
/**
* Handles a click event on the button element. Posts a message to the
* parent window indicating that the button was clicked.
*/
private handleButtonElementClick = () => {
this.postMessageToParent({ command: "overlayButtonClicked" });
};
/**
* Checks if the button is focused. If it is not, then it posts a message
* to the parent window indicating that the overlay should be closed.
*/
private checkButtonFocused() {
if (globalThis.document.hasFocus()) {
return;
}
this.postMessageToParent({ command: "closeAutofillOverlay" });
}
}
export default AutofillOverlayButton;

View File

@@ -1,11 +0,0 @@
import { AutofillOverlayElement } from "../../../../enums/autofill-overlay.enum";
import AutofillOverlayButton from "./autofill-overlay-button.deprecated";
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./legacy-button.scss");
(function () {
globalThis.customElements.define(AutofillOverlayElement.Button, AutofillOverlayButton);
})();

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>Bitwarden overlay button</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="normal" />
</head>
<body>
<autofill-inline-menu-button></autofill-inline-menu-button>
</body>
</html>

View File

@@ -1,36 +0,0 @@
@import "../../../../shared/styles/variables";
* {
box-sizing: border-box;
}
body {
width: 100%;
min-width: 100vw;
height: 100%;
min-height: 100vh;
padding: 0;
margin: 0;
background: transparent;
overflow: hidden;
}
autofill-overlay-button {
width: 100%;
height: auto;
}
.overlay-button {
display: block;
width: 100%;
padding: 0;
margin: auto;
border: none;
background: transparent;
cursor: pointer;
.overlay-button-svg-icon {
display: block;
width: 100%;
height: auto;
}
}

View File

@@ -1,537 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AutofillOverlayList initAutofillOverlayList the list of ciphers for an authenticated user creates the view for a list of ciphers 1`] = `
<div
class="overlay-list-container theme_light"
>
<ul
class="overlay-actions-list"
role="list"
>
<li
class="overlay-actions-list-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="partialUsername, username1"
aria-label="fillCredentialsFor website login 1"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon"
style="background-image: url(https://jest-testing-website.com/image.png);"
/>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 1"
>
website login 1
</span>
<span
class="cipher-user-login"
title="username1"
>
username1
</span>
</span>
</button>
<button
aria-label="view website login 1, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="overlay-actions-list-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="partialUsername, username2"
aria-label="fillCredentialsFor website login 2"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon bwi bw-icon"
/>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 2"
>
website login 2
</span>
<span
class="cipher-user-login"
title="username2"
>
username2
</span>
</span>
</button>
<button
aria-label="view website login 2, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="overlay-actions-list-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="partialUsername, "
aria-label="fillCredentialsFor "
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon bwi bw-icon"
/>
<span
class="cipher-details"
/>
</button>
<button
aria-label="view , opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="overlay-actions-list-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="partialUsername, username4"
aria-label="fillCredentialsFor website login 4"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon"
>
<svg
aria-hidden="true"
fill="none"
height="25"
viewBox="0 0 24 25"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M18.026 17.842c-1.418 1.739-3.517 2.84-5.86 2.84a7.364 7.364 0 0 1-3.431-.848l.062-.15.062-.151.063-.157c.081-.203.17-.426.275-.646.133-.28.275-.522.426-.68.026-.028.101-.075.275-.115.165-.037.376-.059.629-.073.138-.008.288-.014.447-.02.399-.016.847-.034 1.266-.092.314-.044.566-.131.755-.271a.884.884 0 0 0 .352-.555c.037-.2.008-.392-.03-.543-.035-.135-.084-.264-.12-.355l-.01-.03a4.26 4.26 0 0 0-.145-.33c-.126-.264-.237-.497-.288-1.085-.03-.344.09-.73.251-1.138l.089-.22c.05-.123.1-.247.14-.355.064-.171.129-.375.129-.566a1.51 1.51 0 0 0-.134-.569 2.573 2.573 0 0 0-.319-.547c-.246-.323-.635-.669-1.093-.669-.44 0-1.006.169-1.487.368-.246.102-.48.216-.68.33-.192.111-.372.235-.492.359-.93.96-1.48 1.239-1.81 1.258-.277.017-.478-.15-.736-.525a9.738 9.738 0 0 1-.19-.29l-.006-.01a11.568 11.568 0 0 0-.198-.305 2.76 2.76 0 0 0-.521-.6 1.39 1.39 0 0 0-1.088-.314 8.302 8.302 0 0 1 1.987-3.936c.055.342.146.626.272.856.23.42.561.64.926.716.406.086.857-.061 1.26-.216.125-.047.248-.097.372-.147.309-.125.618-.25.947-.341.26-.072.581-.057.959.012.264.049.529.118.8.19l.36.091c.379.094.782.178 1.135.148.374-.032.733-.197.934-.623a.874.874 0 0 0 .024-.752c-.087-.197-.24-.355-.35-.47-.26-.267-.412-.427-.412-.685 0-.125.037-.2.09-.263a.982.982 0 0 1 .303-.211c.059-.03.119-.058.183-.089l.036-.016a3.79 3.79 0 0 0 .236-.118c.047-.026.098-.056.148-.093 1.936.747 3.51 2.287 4.368 4.249a7.739 7.739 0 0 0-.031-.004c-.38-.047-.738-.056-1.063.061-.34.123-.603.368-.817.74-.122.211-.284.43-.463.67l-.095.129c-.207.281-.431.595-.58.92-.15.326-.245.705-.142 1.103.104.397.387.738.837 1.036.099.065.225.112.314.145l.02.008c.108.04.195.074.268.117.07.042.106.08.124.114.017.03.037.087.022.206-.047.376-.069.73-.052 1.034.017.292.071.59.218.809.118.174.12.421.108.786v.01a2.46 2.46 0 0 0 .021.518.809.809 0 0 0 .15.35Zm1.357.059a9.654 9.654 0 0 0 1.62-5.386c0-5.155-3.957-9.334-8.836-9.334-4.88 0-8.836 4.179-8.836 9.334 0 3.495 1.82 6.543 4.513 8.142v.093h.161a8.426 8.426 0 0 0 4.162 1.098c2.953 0 5.568-1.53 7.172-3.882a.569.569 0 0 0 .048-.062l-.004-.003ZM8.152 19.495a43.345 43.345 0 0 1 .098-.238l.057-.142c.082-.205.182-.455.297-.698.143-.301.323-.624.552-.864.163-.172.392-.254.602-.302.219-.05.473-.073.732-.088.162-.01.328-.016.495-.023.386-.015.782-.03 1.168-.084.255-.036.392-.099.461-.15.06-.045.076-.084.083-.12a.534.534 0 0 0-.02-.223 2.552 2.552 0 0 0-.095-.278l-.01-.027a3.128 3.128 0 0 0-.104-.232c-.134-.282-.31-.65-.374-1.381-.046-.533.138-1.063.3-1.472.035-.09.069-.172.1-.249.046-.11.086-.21.123-.31.062-.169.083-.264.083-.312a.812.812 0 0 0-.076-.283 1.867 1.867 0 0 0-.23-.394c-.21-.274-.428-.408-.577-.408-.315 0-.788.13-1.246.32a5.292 5.292 0 0 0-.603.293 1.727 1.727 0 0 0-.347.244c-.936.968-1.641 1.421-2.235 1.457-.646.04-1.036-.413-1.31-.813-.07-.103-.139-.21-.203-.311l-.005-.007c-.064-.101-.125-.197-.188-.29a2.098 2.098 0 0 0-.387-.453.748.748 0 0 0-.436-.18c-.1-.006-.22.005-.365.046a8.707 8.707 0 0 0-.056.992c0 2.957 1.488 5.547 3.716 6.98Zm10.362-2.316.003-.192.002-.046c.01-.305.026-.786-.232-1.169-.036-.054-.082-.189-.096-.444-.014-.243.003-.55.047-.9a1.051 1.051 0 0 0-.105-.649.987.987 0 0 0-.374-.374 2.285 2.285 0 0 0-.367-.166h-.003a1.243 1.243 0 0 1-.205-.088c-.369-.244-.505-.46-.549-.629-.044-.168-.015-.364.099-.61.115-.25.297-.511.507-.796l.089-.12c.178-.239.368-.495.512-.745.152-.263.302-.382.466-.441.18-.065.416-.073.77-.03.142.018.275.04.397.063.274.837.423 1.736.423 2.671a8.45 8.45 0 0 1-1.384 4.665Zm-4.632-12.63a7.362 7.362 0 0 0-1.715-.201c-1.89 0-3.621.716-4.965 1.905.025.54.12.887.24 1.105.13.238.295.34.482.38.2.042.484-.027.905-.188l.328-.13c.32-.13.681-.275 1.048-.377.398-.111.833-.075 1.24 0 .289.053.59.132.871.205l.326.084c.383.094.694.151.932.13.216-.017.326-.092.395-.237.039-.083.027-.114.014-.144-.027-.062-.088-.136-.212-.264l-.043-.044c-.218-.222-.567-.578-.567-1.142 0-.305.101-.547.262-.734.137-.159.308-.267.46-.347Z"
fill="#777"
fill-rule="evenodd"
/>
</svg>
</span>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 4"
>
website login 4
</span>
<span
class="cipher-user-login"
title="username4"
>
username4
</span>
</span>
</button>
<button
aria-label="view website login 4, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="overlay-actions-list-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="partialUsername, username5"
aria-label="fillCredentialsFor website login 5"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon"
style="background-image: url(https://jest-testing-website.com/image.png);"
/>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 5"
>
website login 5
</span>
<span
class="cipher-user-login"
title="username5"
>
username5
</span>
</span>
</button>
<button
aria-label="view website login 5, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="overlay-actions-list-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="partialUsername, username6"
aria-label="fillCredentialsFor website login 6"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon"
style="background-image: url(https://jest-testing-website.com/image.png);"
/>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 6"
>
website login 6
</span>
<span
class="cipher-user-login"
title="username6"
>
username6
</span>
</span>
</button>
<button
aria-label="view website login 6, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
</ul>
</div>
`;
exports[`AutofillOverlayList initAutofillOverlayList the locked overlay for an unauthenticated user creates the views for the locked overlay 1`] = `
<div
class="overlay-list-container theme_light"
>
<div
class="locked-overlay overlay-list-message"
id="locked-overlay-description"
>
unlockYourAccount
</div>
<div
class="overlay-list-button-container"
>
<button
aria-label="unlockAccount, opensInANewWindow"
class="unlock-button overlay-list-button"
id="unlock-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="17"
viewBox="0 0 17 17"
width="17"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M8.799 11.633a.68.68 0 0 0-.639.422.695.695 0 0 0-.054.264.682.682 0 0 0 .374.6v1.13a.345.345 0 1 0 .693 0v-1.17a.68.68 0 0 0 .315-.56.695.695 0 0 0-.204-.486.682.682 0 0 0-.485-.2Zm4.554-4.657h-7.11a.438.438 0 0 1-.406-.26A3.81 3.81 0 0 1 5.584 4.3c.112-.435.312-.842.588-1.195A3.196 3.196 0 0 1 7.19 2.25a3.468 3.468 0 0 1 3.225-.059A3.62 3.62 0 0 1 11.94 3.71l.327.59a.502.502 0 1 0 .885-.483l-.307-.552a4.689 4.689 0 0 0-2.209-2.078 4.466 4.466 0 0 0-3.936.185A4.197 4.197 0 0 0 5.37 2.49a4.234 4.234 0 0 0-.768 1.565 4.714 4.714 0 0 0 .162 2.682.182.182 0 0 1-.085.22.173.173 0 0 1-.082.02h-.353a1.368 1.368 0 0 0-1.277.842c-.07.168-.107.348-.109.53v7.1a1.392 1.392 0 0 0 .412.974 1.352 1.352 0 0 0 .974.394h9.117c.363.001.711-.142.97-.4a1.39 1.39 0 0 0 .407-.972v-7.1a1.397 1.397 0 0 0-.414-.973 1.368 1.368 0 0 0-.972-.396Zm.37 8.469a.373.373 0 0 1-.11.26.364.364 0 0 1-.26.107H4.246a.366.366 0 0 1-.26-.107.374.374 0 0 1-.11-.261V8.349a.374.374 0 0 1 .11-.26.366.366 0 0 1 .26-.108h9.116a.366.366 0 0 1 .37.367l-.008 7.097Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M.798.817h16v16h-16z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
unlockAccount
</button>
</div>
</div>
`;
exports[`AutofillOverlayList initAutofillOverlayList the overlay with an empty list of ciphers creates the views for the no results overlay 1`] = `
<div
class="overlay-list-container theme_light"
>
<div
class="no-items overlay-list-message"
>
noItemsToShow
</div>
<div
class="overlay-list-button-container"
>
<button
aria-label="addNewVaultItem, opensInANewWindow"
class="add-new-item-button overlay-list-button"
id="new-item-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="17"
width="17"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
clip-rule="evenodd"
d="M9.607 7.15h5.35c.322 0 .627.133.847.362a1.213 1.213 0 0 1 .002 1.68c-.221.23-.527.363-.85.363H9.607v5.652c0 .312-.12.613-.336.839a1.176 1.176 0 0 1-1.696.003 1.21 1.21 0 0 1-.34-.842V9.555H1.888a1.173 1.173 0 0 1-.847-.361A1.193 1.193 0 0 1 .7 8.352a1.219 1.219 0 0 1 .336-.838 1.175 1.175 0 0 1 .85-.364h5.349V1.635c0-.31.118-.611.336-.84A1.176 1.176 0 0 1 9.268.795c.222.228.34.533.34.841V7.15Z"
fill="#175DDC"
fill-rule="evenodd"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M.421.421h16v16h-16z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
newItem
</button>
</div>
</div>
`;

View File

@@ -1,467 +0,0 @@
import { mock } from "jest-mock-extended";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { createAutofillOverlayCipherDataMock } from "../../../../spec/autofill-mocks";
import { postWindowMessage } from "../../../../spec/testing-utils";
import { InitAutofillOverlayListMessage } from "../../abstractions/autofill-overlay-list.deprecated";
import AutofillOverlayList from "./autofill-overlay-list.deprecated";
const overlayPagesTranslations = {
locale: "en",
buttonPageTitle: "buttonPageTitle",
listPageTitle: "listPageTitle",
opensInANewWindow: "opensInANewWindow",
toggleBitwardenVaultOverlay: "toggleBitwardenVaultOverlay",
unlockYourAccount: "unlockYourAccount",
unlockAccount: "unlockAccount",
fillCredentialsFor: "fillCredentialsFor",
partialUsername: "partialUsername",
view: "view",
noItemsToShow: "noItemsToShow",
newItem: "newItem",
addNewVaultItem: "addNewVaultItem",
};
function createInitAutofillOverlayListMessageMock(
customFields = {},
): InitAutofillOverlayListMessage {
return {
command: "initAutofillOverlayList",
translations: overlayPagesTranslations,
styleSheetUrl: "https://jest-testing-website.com",
theme: ThemeType.Light,
authStatus: AuthenticationStatus.Unlocked,
ciphers: [
createAutofillOverlayCipherDataMock(1, {
icon: {
imageEnabled: true,
image: "https://jest-testing-website.com/image.png",
fallbackImage: "",
icon: "bw-icon",
},
}),
createAutofillOverlayCipherDataMock(2, {
icon: {
imageEnabled: true,
image: "",
fallbackImage: "https://jest-testing-website.com/fallback.png",
icon: "bw-icon",
},
}),
createAutofillOverlayCipherDataMock(3, {
name: "",
login: { username: "" },
icon: { imageEnabled: true, image: "", fallbackImage: "", icon: "bw-icon" },
}),
createAutofillOverlayCipherDataMock(4, {
icon: { imageEnabled: false, image: "", fallbackImage: "", icon: "" },
}),
createAutofillOverlayCipherDataMock(5),
createAutofillOverlayCipherDataMock(6),
createAutofillOverlayCipherDataMock(7),
createAutofillOverlayCipherDataMock(8),
],
...customFields,
};
}
describe("AutofillOverlayList", () => {
globalThis.customElements.define("autofill-overlay-list", AutofillOverlayList);
global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));
let autofillOverlayList: AutofillOverlayList;
beforeEach(() => {
document.body.innerHTML = `<autofill-overlay-list></autofill-overlay-list>`;
autofillOverlayList = document.querySelector("autofill-overlay-list");
autofillOverlayList["messageOrigin"] = "https://localhost/";
jest.spyOn(globalThis.document, "createElement");
jest.spyOn(globalThis.parent, "postMessage");
});
afterEach(() => {
jest.clearAllMocks();
});
describe("initAutofillOverlayList", () => {
describe("the locked overlay for an unauthenticated user", () => {
beforeEach(() => {
postWindowMessage(
createInitAutofillOverlayListMessageMock({
authStatus: AuthenticationStatus.Locked,
cipherList: [],
}),
);
});
it("creates the views for the locked overlay", () => {
expect(autofillOverlayList["overlayListContainer"]).toMatchSnapshot();
});
it("allows the user to unlock the vault", () => {
const unlockButton =
autofillOverlayList["overlayListContainer"].querySelector("#unlock-button");
unlockButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "unlockVault" },
"https://localhost/",
);
});
});
describe("the overlay with an empty list of ciphers", () => {
beforeEach(() => {
postWindowMessage(
createInitAutofillOverlayListMessageMock({
authStatus: AuthenticationStatus.Unlocked,
ciphers: [],
}),
);
});
it("creates the views for the no results overlay", () => {
expect(autofillOverlayList["overlayListContainer"]).toMatchSnapshot();
});
it("allows the user to add a vault item", () => {
const addVaultItemButton =
autofillOverlayList["overlayListContainer"].querySelector("#new-item-button");
addVaultItemButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "addNewVaultItem" },
"https://localhost/",
);
});
});
describe("the list of ciphers for an authenticated user", () => {
beforeEach(() => {
postWindowMessage(createInitAutofillOverlayListMessageMock());
});
it("creates the view for a list of ciphers", () => {
expect(autofillOverlayList["overlayListContainer"]).toMatchSnapshot();
});
it("loads ciphers on scroll one page at a time", () => {
jest.useFakeTimers();
const originalListOfElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".cipher-container");
autofillOverlayList["handleCiphersListScrollEvent"]();
jest.runAllTimers();
const updatedListOfElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".cipher-container");
expect(originalListOfElements.length).toBe(6);
expect(updatedListOfElements.length).toBe(8);
});
it("debounces the ciphers scroll handler", () => {
jest.useFakeTimers();
autofillOverlayList["cipherListScrollDebounceTimeout"] = setTimeout(jest.fn, 0);
const handleDebouncedScrollEventSpy = jest.spyOn(
autofillOverlayList as any,
"handleDebouncedScrollEvent",
);
autofillOverlayList["handleCiphersListScrollEvent"]();
jest.advanceTimersByTime(100);
autofillOverlayList["handleCiphersListScrollEvent"]();
jest.advanceTimersByTime(100);
autofillOverlayList["handleCiphersListScrollEvent"]();
jest.advanceTimersByTime(400);
expect(handleDebouncedScrollEventSpy).toHaveBeenCalledTimes(1);
});
describe("fill cipher button event listeners", () => {
beforeEach(() => {
postWindowMessage(createInitAutofillOverlayListMessageMock());
});
it("allows the user to fill a cipher on click", () => {
const fillCipherButton =
autofillOverlayList["overlayListContainer"].querySelector(".fill-cipher-button");
fillCipherButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "fillSelectedListItem", overlayCipherId: "1" },
"https://localhost/",
);
});
it("allows the user to move keyboard focus to the next cipher element on ArrowDown", () => {
const fillCipherElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".fill-cipher-button");
const firstFillCipherElement = fillCipherElements[0];
const secondFillCipherElement = fillCipherElements[1];
jest.spyOn(secondFillCipherElement as HTMLElement, "focus");
firstFillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowDown" }));
expect((secondFillCipherElement as HTMLElement).focus).toBeCalled();
});
it("directs focus to the first item in the cipher list if no cipher is present after the current one when pressing ArrowDown", () => {
const fillCipherElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".fill-cipher-button");
const lastFillCipherElement = fillCipherElements[fillCipherElements.length - 1];
const firstFillCipherElement = fillCipherElements[0];
jest.spyOn(firstFillCipherElement as HTMLElement, "focus");
lastFillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowDown" }));
expect((firstFillCipherElement as HTMLElement).focus).toBeCalled();
});
it("allows the user to move keyboard focus to the previous cipher element on ArrowUp", () => {
const fillCipherElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".fill-cipher-button");
const firstFillCipherElement = fillCipherElements[0];
const secondFillCipherElement = fillCipherElements[1];
jest.spyOn(firstFillCipherElement as HTMLElement, "focus");
secondFillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowUp" }));
expect((firstFillCipherElement as HTMLElement).focus).toBeCalled();
});
it("directs focus to the last item in the cipher list if no cipher is present before the current one when pressing ArrowUp", () => {
const fillCipherElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".fill-cipher-button");
const firstFillCipherElement = fillCipherElements[0];
const lastFillCipherElement = fillCipherElements[fillCipherElements.length - 1];
jest.spyOn(lastFillCipherElement as HTMLElement, "focus");
firstFillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowUp" }));
expect((lastFillCipherElement as HTMLElement).focus).toBeCalled();
});
it("allows the user to move keyboard focus to the view cipher button on ArrowRight", () => {
const cipherContainerElement =
autofillOverlayList["overlayListContainer"].querySelector(".cipher-container");
const fillCipherElement = cipherContainerElement.querySelector(".fill-cipher-button");
const viewCipherButton = cipherContainerElement.querySelector(".view-cipher-button");
jest.spyOn(viewCipherButton as HTMLElement, "focus");
fillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowRight" }));
expect((viewCipherButton as HTMLElement).focus).toBeCalled();
});
it("ignores keyup events that do not include ArrowUp, ArrowDown, or ArrowRight", () => {
const fillCipherElement =
autofillOverlayList["overlayListContainer"].querySelector(".fill-cipher-button");
jest.spyOn(fillCipherElement as HTMLElement, "focus");
fillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowLeft" }));
expect((fillCipherElement as HTMLElement).focus).not.toBeCalled();
});
});
describe("view cipher button event listeners", () => {
beforeEach(() => {
postWindowMessage(createInitAutofillOverlayListMessageMock());
});
it("allows the user to view a cipher on click", () => {
const viewCipherButton =
autofillOverlayList["overlayListContainer"].querySelector(".view-cipher-button");
viewCipherButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "viewSelectedCipher", overlayCipherId: "1" },
"https://localhost/",
);
});
it("allows the user to move keyboard focus to the current cipher element on ArrowLeft", () => {
const cipherContainerElement =
autofillOverlayList["overlayListContainer"].querySelector(".cipher-container");
const fillCipherButton = cipherContainerElement.querySelector(".fill-cipher-button");
const viewCipherButton = cipherContainerElement.querySelector(".view-cipher-button");
jest.spyOn(fillCipherButton as HTMLElement, "focus");
viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowLeft" }));
expect((fillCipherButton as HTMLElement).focus).toBeCalled();
});
it("allows the user to move keyboard to the next cipher element on ArrowDown", () => {
const cipherContainerElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".cipher-container");
const viewCipherButton = cipherContainerElements[0].querySelector(".view-cipher-button");
const secondFillCipherButton =
cipherContainerElements[1].querySelector(".fill-cipher-button");
jest.spyOn(secondFillCipherButton as HTMLElement, "focus");
viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowDown" }));
expect((secondFillCipherButton as HTMLElement).focus).toBeCalled();
});
it("allows the user to move keyboard focus to the previous cipher element on ArrowUp", () => {
const cipherContainerElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".cipher-container");
const viewCipherButton = cipherContainerElements[1].querySelector(".view-cipher-button");
const firstFillCipherButton =
cipherContainerElements[0].querySelector(".fill-cipher-button");
jest.spyOn(firstFillCipherButton as HTMLElement, "focus");
viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowUp" }));
expect((firstFillCipherButton as HTMLElement).focus).toBeCalled();
});
it("ignores keyup events that do not include ArrowUp, ArrowDown, or ArrowRight", () => {
const viewCipherButton =
autofillOverlayList["overlayListContainer"].querySelector(".view-cipher-button");
jest.spyOn(viewCipherButton as HTMLElement, "focus");
viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowRight" }));
expect((viewCipherButton as HTMLElement).focus).not.toBeCalled();
});
});
});
});
describe("global event listener handlers", () => {
it("does not post a `checkAutofillOverlayButtonFocused` message to the parent if the overlay is currently focused", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
postWindowMessage({ command: "checkAutofillOverlayListFocused" });
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
});
it("posts a `checkAutofillOverlayButtonFocused` message to the parent if the overlay is not currently focused", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
postWindowMessage({ command: "checkAutofillOverlayListFocused" });
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "checkAutofillOverlayButtonFocused" },
"https://localhost/",
);
});
it("updates the list of ciphers", () => {
postWindowMessage(createInitAutofillOverlayListMessageMock());
const updateCiphersSpy = jest.spyOn(autofillOverlayList as any, "updateListItems");
postWindowMessage({ command: "updateOverlayListCiphers" });
expect(updateCiphersSpy).toHaveBeenCalled();
});
describe("directing user focus into the overlay list", () => {
it("sets ARIA attributes that define the list as a `dialog` to screen reader users", () => {
postWindowMessage(
createInitAutofillOverlayListMessageMock({
authStatus: AuthenticationStatus.Locked,
cipherList: [],
}),
);
const overlayContainerSetAttributeSpy = jest.spyOn(
autofillOverlayList["overlayListContainer"],
"setAttribute",
);
postWindowMessage({ command: "focusOverlayList" });
expect(overlayContainerSetAttributeSpy).toHaveBeenCalledWith("role", "dialog");
expect(overlayContainerSetAttributeSpy).toHaveBeenCalledWith("aria-modal", "true");
});
it("focuses the unlock button element if the user is not authenticated", () => {
postWindowMessage(
createInitAutofillOverlayListMessageMock({
authStatus: AuthenticationStatus.Locked,
cipherList: [],
}),
);
const unlockButton =
autofillOverlayList["overlayListContainer"].querySelector("#unlock-button");
jest.spyOn(unlockButton as HTMLElement, "focus");
postWindowMessage({ command: "focusOverlayList" });
expect((unlockButton as HTMLElement).focus).toBeCalled();
});
it("focuses the new item button element if the cipher list is empty", () => {
postWindowMessage(createInitAutofillOverlayListMessageMock({ ciphers: [] }));
const newItemButton =
autofillOverlayList["overlayListContainer"].querySelector("#new-item-button");
jest.spyOn(newItemButton as HTMLElement, "focus");
postWindowMessage({ command: "focusOverlayList" });
expect((newItemButton as HTMLElement).focus).toBeCalled();
});
it("focuses the first cipher button element if the cipher list is populated", () => {
postWindowMessage(createInitAutofillOverlayListMessageMock());
const firstCipherItem =
autofillOverlayList["overlayListContainer"].querySelector(".fill-cipher-button");
jest.spyOn(firstCipherItem as HTMLElement, "focus");
postWindowMessage({ command: "focusOverlayList" });
expect((firstCipherItem as HTMLElement).focus).toBeCalled();
});
});
});
describe("handleResizeObserver", () => {
beforeEach(() => {
postWindowMessage(createInitAutofillOverlayListMessageMock());
});
it("ignores resize entries whose target is not the overlay list", () => {
const entries = [
{
target: mock<HTMLElement>(),
contentRect: { height: 300 },
},
];
autofillOverlayList["handleResizeObserver"](entries as unknown as ResizeObserverEntry[]);
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
});
it("posts a message to update the overlay list height if the list container is resized", () => {
const entries = [
{
target: autofillOverlayList["overlayListContainer"],
contentRect: { height: 300 },
},
];
autofillOverlayList["handleResizeObserver"](entries as unknown as ResizeObserverEntry[]);
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "updateAutofillOverlayListHeight", styles: { height: "300px" } },
"https://localhost/",
);
});
});
});

View File

@@ -1,621 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import "@webcomponents/custom-elements";
import "lit/polyfill-support.js";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { buildSvgDomElement } from "../../../../utils";
import { globeIcon, lockIcon, plusIcon, viewCipherIcon } from "../../../../utils/svg-icons";
import { OverlayCipherData } from "../../../background/abstractions/overlay.background.deprecated";
import {
InitAutofillOverlayListMessage,
OverlayListWindowMessageHandlers,
} from "../../abstractions/autofill-overlay-list.deprecated";
import AutofillOverlayPageElement from "../shared/autofill-overlay-page-element.deprecated";
class AutofillOverlayList extends AutofillOverlayPageElement {
private overlayListContainer: HTMLDivElement;
private resizeObserver: ResizeObserver;
private eventHandlersMemo: { [key: string]: EventListener } = {};
private ciphers: OverlayCipherData[] = [];
private ciphersList: HTMLUListElement;
private cipherListScrollIsDebounced = false;
private cipherListScrollDebounceTimeout: number | NodeJS.Timeout;
private currentCipherIndex = 0;
private readonly showCiphersPerPage = 6;
private readonly overlayListWindowMessageHandlers: OverlayListWindowMessageHandlers = {
initAutofillOverlayList: ({ message }) => this.initAutofillOverlayList(message),
checkAutofillOverlayListFocused: () => this.checkOverlayListFocused(),
updateOverlayListCiphers: ({ message }) => this.updateListItems(message.ciphers),
focusOverlayList: () => this.focusOverlayList(),
};
constructor() {
super();
this.setupOverlayListGlobalListeners();
}
/**
* Initializes the overlay list and updates the list items with the passed ciphers.
* If the auth status is not `Unlocked`, the locked overlay is built.
*
* @param translations - The translations to use for the overlay list.
* @param styleSheetUrl - The URL of the stylesheet to use for the overlay list.
* @param theme - The theme to use for the overlay list.
* @param authStatus - The current authentication status.
* @param ciphers - The ciphers to display in the overlay list.
*/
private async initAutofillOverlayList({
translations,
styleSheetUrl,
theme,
authStatus,
ciphers,
}: InitAutofillOverlayListMessage) {
const linkElement = this.initOverlayPage("list", styleSheetUrl, translations);
const themeClass = `theme_${theme}`;
globalThis.document.documentElement.classList.add(themeClass);
this.overlayListContainer = globalThis.document.createElement("div");
this.overlayListContainer.classList.add("overlay-list-container", themeClass);
this.resizeObserver.observe(this.overlayListContainer);
this.shadowDom.append(linkElement, this.overlayListContainer);
if (authStatus === AuthenticationStatus.Unlocked) {
this.updateListItems(ciphers);
return;
}
this.buildLockedOverlay();
}
/**
* Builds the locked overlay, which is displayed when the user is not authenticated.
* Facilitates the ability to unlock the extension from the overlay.
*/
private buildLockedOverlay() {
const lockedOverlay = globalThis.document.createElement("div");
lockedOverlay.id = "locked-overlay-description";
lockedOverlay.classList.add("locked-overlay", "overlay-list-message");
lockedOverlay.textContent = this.getTranslation("unlockYourAccount");
const unlockButtonElement = globalThis.document.createElement("button");
unlockButtonElement.id = "unlock-button";
unlockButtonElement.tabIndex = -1;
unlockButtonElement.classList.add("unlock-button", "overlay-list-button");
unlockButtonElement.textContent = this.getTranslation("unlockAccount");
unlockButtonElement.setAttribute(
"aria-label",
`${this.getTranslation("unlockAccount")}, ${this.getTranslation("opensInANewWindow")}`,
);
unlockButtonElement.prepend(buildSvgDomElement(lockIcon));
unlockButtonElement.addEventListener(EVENTS.CLICK, this.handleUnlockButtonClick);
const overlayListButtonContainer = globalThis.document.createElement("div");
overlayListButtonContainer.classList.add("overlay-list-button-container");
overlayListButtonContainer.appendChild(unlockButtonElement);
this.overlayListContainer.append(lockedOverlay, overlayListButtonContainer);
}
/**
* Handles the click event for the unlock button.
* Sends a message to the parent window to unlock the vault.
*/
private handleUnlockButtonClick = () => {
this.postMessageToParent({ command: "unlockVault" });
};
/**
* Updates the list items with the passed ciphers.
* If no ciphers are passed, the no results overlay is built.
*
* @param ciphers - The ciphers to display in the overlay list.
*/
private updateListItems(ciphers: OverlayCipherData[]) {
this.ciphers = ciphers;
this.currentCipherIndex = 0;
if (this.overlayListContainer) {
this.overlayListContainer.innerHTML = "";
}
if (!ciphers?.length) {
this.buildNoResultsOverlayList();
return;
}
this.ciphersList = globalThis.document.createElement("ul");
this.ciphersList.classList.add("overlay-actions-list");
this.ciphersList.setAttribute("role", "list");
globalThis.addEventListener(EVENTS.SCROLL, this.handleCiphersListScrollEvent);
this.loadPageOfCiphers();
this.overlayListContainer.appendChild(this.ciphersList);
}
/**
* Overlay view that is presented when no ciphers are found for a given page.
* Facilitates the ability to add a new vault item from the overlay.
*/
private buildNoResultsOverlayList() {
const noItemsMessage = globalThis.document.createElement("div");
noItemsMessage.classList.add("no-items", "overlay-list-message");
noItemsMessage.textContent = this.getTranslation("noItemsToShow");
const newItemButton = globalThis.document.createElement("button");
newItemButton.tabIndex = -1;
newItemButton.id = "new-item-button";
newItemButton.classList.add("add-new-item-button", "overlay-list-button");
newItemButton.textContent = this.getTranslation("newItem");
newItemButton.setAttribute(
"aria-label",
`${this.getTranslation("addNewVaultItem")}, ${this.getTranslation("opensInANewWindow")}`,
);
newItemButton.prepend(buildSvgDomElement(plusIcon));
newItemButton.addEventListener(EVENTS.CLICK, this.handeNewItemButtonClick);
const overlayListButtonContainer = globalThis.document.createElement("div");
overlayListButtonContainer.classList.add("overlay-list-button-container");
overlayListButtonContainer.appendChild(newItemButton);
this.overlayListContainer.append(noItemsMessage, overlayListButtonContainer);
}
/**
* Handles the click event for the new item button.
* Sends a message to the parent window to add a new vault item.
*/
private handeNewItemButtonClick = () => {
this.postMessageToParent({ command: "addNewVaultItem" });
};
/**
* Loads a page of ciphers into the overlay list container.
*/
private loadPageOfCiphers() {
const lastIndex = Math.min(
this.currentCipherIndex + this.showCiphersPerPage,
this.ciphers.length,
);
for (let cipherIndex = this.currentCipherIndex; cipherIndex < lastIndex; cipherIndex++) {
this.ciphersList.appendChild(this.buildOverlayActionsListItem(this.ciphers[cipherIndex]));
this.currentCipherIndex++;
}
if (this.currentCipherIndex >= this.ciphers.length) {
globalThis.removeEventListener(EVENTS.SCROLL, this.handleCiphersListScrollEvent);
}
}
/**
* Handles updating the list of ciphers when the
* user scrolls to the bottom of the list.
*/
private handleCiphersListScrollEvent = () => {
if (this.cipherListScrollIsDebounced) {
return;
}
this.cipherListScrollIsDebounced = true;
if (this.cipherListScrollDebounceTimeout) {
clearTimeout(this.cipherListScrollDebounceTimeout);
}
this.cipherListScrollDebounceTimeout = setTimeout(this.handleDebouncedScrollEvent, 300);
};
/**
* Debounced handler for updating the list of ciphers when the user scrolls to
* the bottom of the list. Triggers at most once every 300ms.
*/
private handleDebouncedScrollEvent = () => {
this.cipherListScrollIsDebounced = false;
if (globalThis.scrollY + globalThis.innerHeight >= this.ciphersList.clientHeight - 300) {
this.loadPageOfCiphers();
}
};
/**
* Builds the list item for a given cipher.
*
* @param cipher - The cipher to build the list item for.
*/
private buildOverlayActionsListItem(cipher: OverlayCipherData) {
const fillCipherElement = this.buildFillCipherElement(cipher);
const viewCipherElement = this.buildViewCipherElement(cipher);
const cipherContainerElement = globalThis.document.createElement("div");
cipherContainerElement.classList.add("cipher-container");
cipherContainerElement.append(fillCipherElement, viewCipherElement);
const overlayActionsListItem = globalThis.document.createElement("li");
overlayActionsListItem.setAttribute("role", "listitem");
overlayActionsListItem.classList.add("overlay-actions-list-item");
overlayActionsListItem.appendChild(cipherContainerElement);
return overlayActionsListItem;
}
/**
* Builds the fill cipher button for a given cipher.
* Wraps the cipher icon and details.
*
* @param cipher - The cipher to build the fill cipher button for.
*/
private buildFillCipherElement(cipher: OverlayCipherData) {
const cipherIcon = this.buildCipherIconElement(cipher);
const cipherDetailsElement = this.buildCipherDetailsElement(cipher);
const fillCipherElement = globalThis.document.createElement("button");
fillCipherElement.tabIndex = -1;
fillCipherElement.classList.add("fill-cipher-button");
fillCipherElement.setAttribute(
"aria-label",
`${this.getTranslation("fillCredentialsFor")} ${cipher.name}`,
);
fillCipherElement.setAttribute(
"aria-description",
`${this.getTranslation("partialUsername")}, ${cipher.login.username}`,
);
fillCipherElement.append(cipherIcon, cipherDetailsElement);
fillCipherElement.addEventListener(EVENTS.CLICK, this.handleFillCipherClickEvent(cipher));
fillCipherElement.addEventListener(EVENTS.KEYUP, this.handleFillCipherKeyUpEvent);
return fillCipherElement;
}
/**
* Handles the click event for the fill cipher button.
* Sends a message to the parent window to fill the selected cipher.
*
* @param cipher - The cipher to fill.
*/
private handleFillCipherClickEvent = (cipher: OverlayCipherData) => {
return this.useEventHandlersMemo(
() =>
this.postMessageToParent({
command: "fillSelectedListItem",
overlayCipherId: cipher.id,
}),
`${cipher.id}-fill-cipher-button-click-handler`,
);
};
/**
* Handles the keyup event for the fill cipher button. Facilitates
* selecting the next/previous cipher item on ArrowDown/ArrowUp. Also
* facilitates moving keyboard focus to the view cipher button on ArrowRight.
*
* @param event - The keyup event.
*/
private handleFillCipherKeyUpEvent = (event: KeyboardEvent) => {
const listenedForKeys = new Set(["ArrowDown", "ArrowUp", "ArrowRight"]);
if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) {
return;
}
event.preventDefault();
const currentListItem = event.target.closest(".overlay-actions-list-item") as HTMLElement;
if (event.code === "ArrowDown") {
this.focusNextListItem(currentListItem);
return;
}
if (event.code === "ArrowUp") {
this.focusPreviousListItem(currentListItem);
return;
}
this.focusViewCipherButton(currentListItem, event.target as HTMLElement);
};
/**
* Builds the button that facilitates viewing a cipher in the vault.
*
* @param cipher - The cipher to view.
*/
private buildViewCipherElement(cipher: OverlayCipherData) {
const viewCipherElement = globalThis.document.createElement("button");
viewCipherElement.tabIndex = -1;
viewCipherElement.classList.add("view-cipher-button");
viewCipherElement.setAttribute(
"aria-label",
`${this.getTranslation("view")} ${cipher.name}, ${this.getTranslation("opensInANewWindow")}`,
);
viewCipherElement.append(buildSvgDomElement(viewCipherIcon));
viewCipherElement.addEventListener(EVENTS.CLICK, this.handleViewCipherClickEvent(cipher));
viewCipherElement.addEventListener(EVENTS.KEYUP, this.handleViewCipherKeyUpEvent);
return viewCipherElement;
}
/**
* Handles the click event for the view cipher button. Sends a
* message to the parent window to view the selected cipher.
*
* @param cipher - The cipher to view.
*/
private handleViewCipherClickEvent = (cipher: OverlayCipherData) => {
return this.useEventHandlersMemo(
() => this.postMessageToParent({ command: "viewSelectedCipher", overlayCipherId: cipher.id }),
`${cipher.id}-view-cipher-button-click-handler`,
);
};
/**
* Handles the keyup event for the view cipher button. Facilitates
* selecting the next/previous cipher item on ArrowDown/ArrowUp.
* Also facilitates moving keyboard focus to the current fill
* cipher button on ArrowLeft.
*
* @param event - The keyup event.
*/
private handleViewCipherKeyUpEvent = (event: KeyboardEvent) => {
const listenedForKeys = new Set(["ArrowDown", "ArrowUp", "ArrowLeft"]);
if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) {
return;
}
event.preventDefault();
const currentListItem = event.target.closest(".overlay-actions-list-item") as HTMLElement;
const cipherContainer = currentListItem.querySelector(".cipher-container") as HTMLElement;
cipherContainer?.classList.remove("remove-outline");
if (event.code === "ArrowDown") {
this.focusNextListItem(currentListItem);
return;
}
if (event.code === "ArrowUp") {
this.focusPreviousListItem(currentListItem);
return;
}
const previousSibling = event.target.previousElementSibling as HTMLElement;
previousSibling?.focus();
};
/**
* Builds the icon for a given cipher. Prioritizes the favicon from a given cipher url
* and the default icon element within the extension. If neither are available, the
* globe icon is used.
*
* @param cipher - The cipher to build the icon for.
*/
private buildCipherIconElement(cipher: OverlayCipherData) {
const cipherIcon = globalThis.document.createElement("span");
cipherIcon.classList.add("cipher-icon");
cipherIcon.setAttribute("aria-hidden", "true");
if (cipher.icon?.image) {
try {
const url = new URL(cipher.icon.image);
cipherIcon.style.backgroundImage = `url(${url.href})`;
const dummyImageElement = globalThis.document.createElement("img");
dummyImageElement.src = url.href;
dummyImageElement.addEventListener("error", () => {
cipherIcon.style.backgroundImage = "";
cipherIcon.classList.add("cipher-icon");
cipherIcon.append(buildSvgDomElement(globeIcon));
});
dummyImageElement.remove();
return cipherIcon;
} catch {
// Silently default to the globe icon element if the image URL is invalid
}
}
if (cipher.icon?.icon) {
const iconClasses = cipher.icon.icon.split(" ");
cipherIcon.classList.add("cipher-icon", "bwi", ...iconClasses);
return cipherIcon;
}
cipherIcon.append(buildSvgDomElement(globeIcon));
return cipherIcon;
}
/**
* Builds the details for a given cipher. Includes the cipher name and username login.
*
* @param cipher - The cipher to build the details for.
*/
private buildCipherDetailsElement(cipher: OverlayCipherData) {
const cipherNameElement = this.buildCipherNameElement(cipher);
const cipherUserLoginElement = this.buildCipherUserLoginElement(cipher);
const cipherDetailsElement = globalThis.document.createElement("span");
cipherDetailsElement.classList.add("cipher-details");
if (cipherNameElement) {
cipherDetailsElement.appendChild(cipherNameElement);
}
if (cipherUserLoginElement) {
cipherDetailsElement.appendChild(cipherUserLoginElement);
}
return cipherDetailsElement;
}
/**
* Builds the name element for a given cipher.
*
* @param cipher - The cipher to build the name element for.
*/
private buildCipherNameElement(cipher: OverlayCipherData): HTMLSpanElement | null {
if (!cipher.name) {
return null;
}
const cipherNameElement = globalThis.document.createElement("span");
cipherNameElement.classList.add("cipher-name");
cipherNameElement.textContent = cipher.name;
cipherNameElement.setAttribute("title", cipher.name);
return cipherNameElement;
}
/**
* Builds the username login element for a given cipher.
*
* @param cipher - The cipher to build the username login element for.
*/
private buildCipherUserLoginElement(cipher: OverlayCipherData): HTMLSpanElement | null {
if (!cipher.login?.username) {
return null;
}
const cipherUserLoginElement = globalThis.document.createElement("span");
cipherUserLoginElement.classList.add("cipher-user-login");
cipherUserLoginElement.textContent = cipher.login.username;
cipherUserLoginElement.setAttribute("title", cipher.login.username);
return cipherUserLoginElement;
}
/**
* Validates whether the overlay list iframe is currently focused.
* If not focused, will check if the button element is focused.
*/
private checkOverlayListFocused() {
if (globalThis.document.hasFocus()) {
return;
}
this.postMessageToParent({ command: "checkAutofillOverlayButtonFocused" });
}
/**
* Focuses the overlay list iframe. The element that receives focus is
* determined by the presence of the unlock button, new item button, or
* the first cipher button.
*/
private focusOverlayList() {
this.overlayListContainer.setAttribute("role", "dialog");
this.overlayListContainer.setAttribute("aria-modal", "true");
const unlockButtonElement = this.overlayListContainer.querySelector(
"#unlock-button",
) as HTMLElement;
if (unlockButtonElement) {
unlockButtonElement.focus();
return;
}
const newItemButtonElement = this.overlayListContainer.querySelector(
"#new-item-button",
) as HTMLElement;
if (newItemButtonElement) {
newItemButtonElement.focus();
return;
}
const firstCipherElement = this.overlayListContainer.querySelector(
".fill-cipher-button",
) as HTMLElement;
firstCipherElement?.focus();
}
/**
* Sets up the global listeners for the overlay list iframe.
*/
private setupOverlayListGlobalListeners() {
this.setupGlobalListeners(this.overlayListWindowMessageHandlers);
this.resizeObserver = new ResizeObserver(this.handleResizeObserver);
}
/**
* Handles the resize observer event. Facilitates updating the height of the
* overlay list iframe when the height of the list changes.
*
* @param entries - The resize observer entries.
*/
private handleResizeObserver = (entries: ResizeObserverEntry[]) => {
for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) {
const entry = entries[entryIndex];
if (entry.target !== this.overlayListContainer) {
continue;
}
const { height } = entry.contentRect;
this.postMessageToParent({
command: "updateAutofillOverlayListHeight",
styles: { height: `${height}px` },
});
break;
}
};
/**
* Establishes a memoized event handler for a given event.
*
* @param eventHandler - The event handler to memoize.
* @param memoIndex - The memo index to use for the event handler.
*/
private useEventHandlersMemo = (eventHandler: EventListener, memoIndex: string) => {
return this.eventHandlersMemo[memoIndex] || (this.eventHandlersMemo[memoIndex] = eventHandler);
};
/**
* Focuses the next list item in the overlay list. If the current list item is the last
* item in the list, the first item is focused.
*
* @param currentListItem - The current list item.
*/
private focusNextListItem(currentListItem: HTMLElement) {
const nextListItem = currentListItem.nextSibling as HTMLElement;
const nextSibling = nextListItem?.querySelector(".fill-cipher-button") as HTMLElement;
if (nextSibling) {
nextSibling.focus();
return;
}
const firstListItem = currentListItem.parentElement?.firstChild as HTMLElement;
const firstSibling = firstListItem?.querySelector(".fill-cipher-button") as HTMLElement;
firstSibling?.focus();
}
/**
* Focuses the previous list item in the overlay list. If the current list item is the first
* item in the list, the last item is focused.
*
* @param currentListItem - The current list item.
*/
private focusPreviousListItem(currentListItem: HTMLElement) {
const previousListItem = currentListItem.previousSibling as HTMLElement;
const previousSibling = previousListItem?.querySelector(".fill-cipher-button") as HTMLElement;
if (previousSibling) {
previousSibling.focus();
return;
}
const lastListItem = currentListItem.parentElement?.lastChild as HTMLElement;
const lastSibling = lastListItem?.querySelector(".fill-cipher-button") as HTMLElement;
lastSibling?.focus();
}
/**
* Focuses the view cipher button relative to the current fill cipher button.
*
* @param currentListItem - The current list item.
* @param currentButtonElement - The current button element.
*/
private focusViewCipherButton(currentListItem: HTMLElement, currentButtonElement: HTMLElement) {
const cipherContainer = currentListItem.querySelector(".cipher-container") as HTMLElement;
cipherContainer.classList.add("remove-outline");
const nextSibling = currentButtonElement.nextElementSibling as HTMLElement;
nextSibling?.focus();
}
}
export default AutofillOverlayList;

View File

@@ -1,11 +0,0 @@
import { AutofillOverlayElement } from "../../../../enums/autofill-overlay.enum";
import AutofillOverlayList from "./autofill-overlay-list.deprecated";
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./legacy-list.scss");
(function () {
globalThis.customElements.define(AutofillOverlayElement.List, AutofillOverlayList);
})();

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>Bitwarden vault</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="normal" />
</head>
<body>
<autofill-inline-menu-list></autofill-inline-menu-list>
</body>
</html>

View File

@@ -1,292 +0,0 @@
@import "../../../../shared/styles/webfonts";
@import "../../../../shared/styles/variables";
@import "../../../../../../../../libs/angular/src/scss/icons";
* {
box-sizing: border-box;
}
html {
font-size: 10px;
}
body {
width: 100%;
padding: 0;
margin: 0;
@include themify($themes) {
color: themed("textColor");
background-color: themed("backgroundColor");
}
}
.overlay-list-message {
font-family: $font-family-sans-serif;
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
width: 100%;
padding: 0.8rem;
@include themify($themes) {
color: themed("textColor");
}
&.no-items {
font-size: 1.6rem;
}
}
.overlay-list-button-container {
width: 100%;
padding: 0.2rem;
background: transparent;
transition: background-color 0.2s ease-in-out;
border-top-width: 0.1rem;
border-top-style: solid;
@include themify($themes) {
border-top-color: themed("borderColor");
}
&:hover {
@include themify($themes) {
background: themed("backgroundOffsetColor");
}
}
}
.overlay-list-button {
display: flex;
align-content: center;
justify-content: flex-start;
width: 100%;
font-family: $font-family-sans-serif;
font-size: 1.6rem;
font-weight: 700;
text-align: left;
background: transparent;
border: none;
padding: 0.7rem;
margin: 0;
cursor: pointer;
border-radius: 0.4rem;
@include themify($themes) {
color: themed("primaryColor");
}
&:focus:focus-visible {
outline-width: 0.2rem;
outline-style: solid;
@include themify($themes) {
outline-color: themed("focusOutlineColor");
}
}
svg {
position: relative;
margin-left: 0.4rem;
margin-right: 0.8rem;
path {
@include themify($themes) {
fill: themed("primaryColor") !important;
}
}
}
}
.unlock-button {
svg {
top: 0.2rem;
width: 1.6rem;
height: 1.7rem;
}
}
.add-new-item-button {
svg {
top: 0.2rem;
width: 1.7rem;
height: 1.7rem;
}
}
.overlay-actions-list {
padding: 0;
margin: 0;
}
.overlay-actions-list-item {
transition: background-color 0.2s ease-in-out;
list-style: none;
padding: 0.2rem;
&:not(:last-child) {
border-bottom-width: 0.1rem;
border-bottom-style: solid;
@include themify($themes) {
border-bottom-color: themed("borderColor");
}
}
&:hover {
@include themify($themes) {
background: themed("backgroundOffsetColor");
}
}
.cipher-container {
display: flex;
align-content: flex-start;
align-items: center;
justify-content: flex-start;
padding: 0.7rem 0.3rem 0.7rem 0.7rem;
border-radius: 0.4rem;
&:focus-within:not(.remove-outline) {
outline-width: 0.2rem;
outline-style: solid;
@include themify($themes) {
outline-color: themed("focusOutlineColor");
}
}
}
.fill-cipher-button,
.view-cipher-button {
padding: 0;
margin: 0;
line-height: 0;
background-color: transparent;
border: none;
cursor: pointer;
}
.fill-cipher-button {
display: flex;
align-items: center;
align-content: center;
justify-content: flex-start;
width: calc(100% - 4rem);
outline: none;
}
.view-cipher-button {
flex-shrink: 0;
width: 4rem;
height: 4rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.4rem;
&:focus:focus-visible {
outline-width: 0.2rem;
outline-style: solid;
@include themify($themes) {
outline-color: themed("focusOutlineColor");
}
}
svg {
path {
@include themify($themes) {
fill: themed("primaryColor") !important;
}
}
}
}
.cipher-icon {
display: flex;
align-content: center;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 3.2rem;
height: 3.2rem;
margin: 0 1rem 0 0;
line-height: 0;
background-size: 2.6rem;
background-position: center;
background-repeat: no-repeat;
@include themify($themes) {
color: themed("mutedTextColor");
}
svg {
width: 100%;
height: auto;
flex-shrink: 0;
path {
@include themify($themes) {
fill: themed("primaryColor") !important;
}
}
}
&.bwi {
font-size: 2.6rem;
&:not(.cipher-icon) {
@include themify($themes) {
color: themed("primaryColor");
}
svg {
path {
@include themify($themes) {
fill: themed("primaryColor") !important;
}
}
}
}
}
}
.cipher-details {
display: block;
width: 100%;
text-overflow: ellipsis;
overflow: hidden;
text-align: left;
}
.cipher-name,
.cipher-user-login {
display: block;
width: 100%;
line-height: 1.5;
font-family: $font-family-sans-serif;
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
cursor: pointer;
}
.cipher-name {
font-size: 1.6rem;
@include themify($themes) {
color: themed("textColor");
}
}
.cipher-user-login {
font-size: 1.4rem;
@include themify($themes) {
color: themed("mutedTextColor");
}
}
}

View File

@@ -1,222 +0,0 @@
import { mock } from "jest-mock-extended";
import { OverlayButtonWindowMessageHandlers } from "../../abstractions/autofill-overlay-button.deprecated";
import AutofillOverlayPageElementDeprecated from "./autofill-overlay-page-element.deprecated";
describe("AutofillOverlayPageElement", () => {
globalThis.customElements.define(
"autofill-overlay-page-element",
AutofillOverlayPageElementDeprecated,
);
let autofillOverlayPageElement: AutofillOverlayPageElementDeprecated;
const translations = {
locale: "en",
buttonPageTitle: "buttonPageTitle",
listPageTitle: "listPageTitle",
};
beforeEach(() => {
jest.spyOn(globalThis.parent, "postMessage");
jest.spyOn(globalThis, "addEventListener");
jest.spyOn(globalThis.document, "addEventListener");
document.body.innerHTML = "<autofill-overlay-page-element></autofill-overlay-page-element>";
autofillOverlayPageElement = document.querySelector("autofill-overlay-page-element");
});
afterEach(() => {
jest.clearAllMocks();
});
describe("initOverlayPage", () => {
beforeEach(() => {
jest.spyOn(globalThis.document.documentElement, "setAttribute");
jest.spyOn(globalThis.document, "createElement");
});
it("initializes the button overlay page", () => {
const linkElement = autofillOverlayPageElement["initOverlayPage"](
"button",
"https://jest-testing-website.com",
translations,
);
expect(globalThis.document.documentElement.setAttribute).toHaveBeenCalledWith(
"lang",
translations.locale,
);
expect(globalThis.document.head.title).toEqual(translations.buttonPageTitle);
expect(globalThis.document.createElement).toHaveBeenCalledWith("link");
expect(linkElement.getAttribute("rel")).toEqual("stylesheet");
expect(linkElement.getAttribute("href")).toEqual("https://jest-testing-website.com");
});
});
describe("postMessageToParent", () => {
it("skips posting a message to the parent if the message origin in not set", () => {
autofillOverlayPageElement["postMessageToParent"]({ command: "test" });
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
});
it("posts a message to the parent", () => {
autofillOverlayPageElement["messageOrigin"] = "https://jest-testing-website.com";
autofillOverlayPageElement["postMessageToParent"]({ command: "test" });
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "test" },
"https://jest-testing-website.com",
);
});
});
describe("getTranslation", () => {
it("returns an empty value if the translation doesn't exist in the translations object", () => {
autofillOverlayPageElement["translations"] = translations;
expect(autofillOverlayPageElement["getTranslation"]("test")).toEqual("");
});
});
describe("global event listeners", () => {
it("sets up global event listeners", () => {
const handleWindowMessageSpy = jest.spyOn(
autofillOverlayPageElement as any,
"handleWindowMessage",
);
const handleWindowBlurEventSpy = jest.spyOn(
autofillOverlayPageElement as any,
"handleWindowBlurEvent",
);
const handleDocumentKeyDownEventSpy = jest.spyOn(
autofillOverlayPageElement as any,
"handleDocumentKeyDownEvent",
);
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>(),
);
expect(globalThis.addEventListener).toHaveBeenCalledWith("message", handleWindowMessageSpy);
expect(globalThis.addEventListener).toHaveBeenCalledWith("blur", handleWindowBlurEventSpy);
expect(globalThis.document.addEventListener).toHaveBeenCalledWith(
"keydown",
handleDocumentKeyDownEventSpy,
);
});
it("sets the message origin when handling the first passed window message", () => {
const initAutofillOverlayButtonSpy = jest.fn();
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>({
initAutofillOverlayButton: initAutofillOverlayButtonSpy,
}),
);
globalThis.dispatchEvent(
new MessageEvent("message", {
data: { command: "initAutofillOverlayButton" },
origin: "https://jest-testing-website.com",
}),
);
expect(autofillOverlayPageElement["messageOrigin"]).toEqual(
"https://jest-testing-website.com",
);
});
it("handles window messages that are part of the passed windowMessageHandlers object", () => {
const initAutofillOverlayButtonSpy = jest.fn();
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>({
initAutofillOverlayButton: initAutofillOverlayButtonSpy,
}),
);
const data = { command: "initAutofillOverlayButton" };
globalThis.dispatchEvent(new MessageEvent("message", { data }));
expect(initAutofillOverlayButtonSpy).toHaveBeenCalledWith({ message: data });
});
it("skips attempting to handle window messages that are not part of the passed windowMessageHandlers object", () => {
const initAutofillOverlayButtonSpy = jest.fn();
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>({
initAutofillOverlayButton: initAutofillOverlayButtonSpy,
}),
);
globalThis.dispatchEvent(new MessageEvent("message", { data: { command: "test" } }));
expect(initAutofillOverlayButtonSpy).not.toHaveBeenCalled();
});
it("posts a message to the parent when the window is blurred", () => {
autofillOverlayPageElement["messageOrigin"] = "https://jest-testing-website.com";
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>(),
);
globalThis.dispatchEvent(new Event("blur"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "overlayPageBlurred" },
"https://jest-testing-website.com",
);
});
it("skips redirecting keyboard focus when a KeyDown event triggers and the key is not a `Tab` or `Escape` key", () => {
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>(),
);
globalThis.document.dispatchEvent(new KeyboardEvent("keydown", { code: "test" }));
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
});
it("redirects the overlay focus out to the previous element on KeyDown of the `Tab+Shift` keys", () => {
autofillOverlayPageElement["messageOrigin"] = "https://jest-testing-website.com";
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>(),
);
globalThis.document.dispatchEvent(
new KeyboardEvent("keydown", { code: "Tab", shiftKey: true }),
);
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "redirectOverlayFocusOut", direction: "previous" },
"https://jest-testing-website.com",
);
});
it("redirects the overlay focus out to the next element on KeyDown of the `Tab` key", () => {
autofillOverlayPageElement["messageOrigin"] = "https://jest-testing-website.com";
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>(),
);
globalThis.document.dispatchEvent(new KeyboardEvent("keydown", { code: "Tab" }));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "redirectOverlayFocusOut", direction: "next" },
"https://jest-testing-website.com",
);
});
it("redirects the overlay focus out to the current element on KeyDown of the `Escape` key", () => {
autofillOverlayPageElement["messageOrigin"] = "https://jest-testing-website.com";
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>(),
);
globalThis.document.dispatchEvent(new KeyboardEvent("keydown", { code: "Escape" }));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "redirectOverlayFocusOut", direction: "current" },
"https://jest-testing-website.com",
);
});
});
});

View File

@@ -1,157 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { RedirectFocusDirection } from "../../../../enums/autofill-overlay.enum";
import {
AutofillOverlayPageElementWindowMessage,
WindowMessageHandlers,
} from "../../abstractions/autofill-overlay-page-element.deprecated";
class AutofillOverlayPageElement extends HTMLElement {
protected shadowDom: ShadowRoot;
protected messageOrigin: string;
protected translations: Record<string, string>;
protected windowMessageHandlers: WindowMessageHandlers;
constructor() {
super();
this.shadowDom = this.attachShadow({ mode: "closed" });
}
/**
* Initializes the overlay page element. Facilitates ensuring that the page
* is set up with the expected styles and translations.
*
* @param elementName - The name of the element, e.g. "button" or "list"
* @param styleSheetUrl - The URL of the stylesheet to apply to the page
* @param translations - The translations to apply to the page
*/
protected initOverlayPage(
elementName: "button" | "list",
styleSheetUrl: string,
translations: Record<string, string>,
): HTMLLinkElement {
this.translations = translations;
globalThis.document.documentElement.setAttribute("lang", this.getTranslation("locale"));
globalThis.document.head.title = this.getTranslation(`${elementName}PageTitle`);
this.shadowDom.innerHTML = "";
const linkElement = globalThis.document.createElement("link");
linkElement.setAttribute("rel", "stylesheet");
linkElement.setAttribute("href", styleSheetUrl);
return linkElement;
}
/**
* Posts a window message to the parent window.
*
* @param message - The message to post
*/
protected postMessageToParent(message: AutofillOverlayPageElementWindowMessage) {
if (!this.messageOrigin) {
return;
}
globalThis.parent.postMessage(message, this.messageOrigin);
}
/**
* Gets a translation from the translations object.
*
* @param key
* @protected
*/
protected getTranslation(key: string): string {
return this.translations[key] || "";
}
/**
* Sets up global listeners for the window message, window blur, and
* document keydown events.
*
* @param windowMessageHandlers - The window message handlers to use
*/
protected setupGlobalListeners(windowMessageHandlers: WindowMessageHandlers) {
this.windowMessageHandlers = windowMessageHandlers;
globalThis.addEventListener(EVENTS.MESSAGE, this.handleWindowMessage);
globalThis.addEventListener(EVENTS.BLUR, this.handleWindowBlurEvent);
globalThis.document.addEventListener(EVENTS.KEYDOWN, this.handleDocumentKeyDownEvent);
}
/**
* Handles window messages from the parent window.
*
* @param event - The window message event
*/
private handleWindowMessage = (event: MessageEvent) => {
if (!this.windowMessageHandlers) {
return;
}
if (!this.messageOrigin) {
this.messageOrigin = event.origin;
}
if (event.origin !== this.messageOrigin) {
return;
}
const message = event?.data;
const handler = this.windowMessageHandlers[message?.command];
if (!handler) {
return;
}
handler({ message });
};
/**
* Handles the window blur event.
*/
private handleWindowBlurEvent = () => {
this.postMessageToParent({ command: "overlayPageBlurred" });
};
/**
* Handles the document keydown event. Facilitates redirecting the
* user focus in the right direction out of the overlay. Also facilitates
* closing the overlay when the user presses the Escape key.
*
* @param event - The document keydown event
*/
private handleDocumentKeyDownEvent = (event: KeyboardEvent) => {
const listenedForKeys = new Set(["Tab", "Escape"]);
if (!listenedForKeys.has(event.code)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (event.code === "Tab") {
this.redirectOverlayFocusOutMessage(
event.shiftKey ? RedirectFocusDirection.Previous : RedirectFocusDirection.Next,
);
return;
}
this.redirectOverlayFocusOutMessage(RedirectFocusDirection.Current);
};
/**
* Redirects the overlay focus out to the previous element on KeyDown of the `Tab+Shift` keys.
* Redirects the overlay focus out to the next element on KeyDown of the `Tab` key.
* Redirects the overlay focus out to the current element on KeyDown of the `Escape` key.
*
* @param direction - The direction to redirect the focus out
*/
private redirectOverlayFocusOutMessage(direction: string) {
this.postMessageToParent({ command: "redirectOverlayFocusOut", direction });
}
}
export default AutofillOverlayPageElement;

View File

@@ -1,37 +0,0 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import AutofillField from "../../../models/autofill-field";
import AutofillPageDetails from "../../../models/autofill-page-details";
import { AutofillOverlayContentService } from "../../../services/abstractions/autofill-overlay-content.service";
import { ElementWithOpId, FormFieldElement } from "../../../types";
type OpenAutofillOverlayOptions = {
isFocusingFieldElement?: boolean;
isOpeningFullOverlay?: boolean;
authStatus?: AuthenticationStatus;
};
interface LegacyAutofillOverlayContentService extends AutofillOverlayContentService {
isFieldCurrentlyFocused: boolean;
isCurrentlyFilling: boolean;
isOverlayCiphersPopulated: boolean;
pageDetailsUpdateRequired: boolean;
autofillOverlayVisibility: number;
init(): void;
setupAutofillOverlayListenerOnField(
autofillFieldElement: ElementWithOpId<FormFieldElement>,
autofillFieldData: AutofillField,
pageDetails: AutofillPageDetails,
): Promise<void>;
openAutofillOverlay(options: OpenAutofillOverlayOptions): void;
removeAutofillOverlay(): void;
removeAutofillOverlayButton(): void;
removeAutofillOverlayList(): void;
addNewVaultItem(): void;
redirectOverlayFocusOut(direction: "previous" | "next"): void;
focusMostRecentOverlayField(): void;
blurMostRecentOverlayField(): void;
destroy(): void;
}
export { OpenAutofillOverlayOptions, LegacyAutofillOverlayContentService };

View File

@@ -43,10 +43,7 @@
</a>
</bit-hint>
</bit-form-control>
<bit-form-control
*ngIf="inlineMenuPositioningImprovementsEnabled && enableInlineMenu"
class="tw-ml-5"
>
<bit-form-control *ngIf="enableInlineMenu" class="tw-ml-5">
<input
bitCheckbox
id="show-inline-menu-identities"
@@ -58,10 +55,7 @@
{{ "showInlineMenuIdentitiesLabel" | i18n }}
</bit-label>
</bit-form-control>
<bit-form-control
*ngIf="inlineMenuPositioningImprovementsEnabled && enableInlineMenu"
class="tw-ml-5"
>
<bit-form-control *ngIf="enableInlineMenu" class="tw-ml-5">
<input
bitCheckbox
id="show-inline-menu-cards"

View File

@@ -92,7 +92,6 @@ export class AutofillComponent implements OnInit {
protected defaultBrowserAutofillDisabled: boolean = false;
protected inlineMenuVisibility: InlineMenuVisibilitySetting =
AutofillOverlayVisibility.OnFieldFocus;
protected inlineMenuPositioningImprovementsEnabled: boolean = false;
protected blockBrowserInjectionsByDomainEnabled: boolean = false;
protected browserClientVendor: BrowserClientVendor = BrowserClientVendors.Unknown;
protected disablePasswordManagerURI: DisablePasswordManagerUri =
@@ -180,21 +179,17 @@ export class AutofillComponent implements OnInit {
this.autofillSettingsService.inlineMenuVisibility$,
);
this.inlineMenuPositioningImprovementsEnabled = await this.configService.getFeatureFlag(
FeatureFlag.InlineMenuPositioningImprovements,
);
this.blockBrowserInjectionsByDomainEnabled = await this.configService.getFeatureFlag(
FeatureFlag.BlockBrowserInjectionsByDomain,
);
this.showInlineMenuIdentities =
this.inlineMenuPositioningImprovementsEnabled &&
(await firstValueFrom(this.autofillSettingsService.showInlineMenuIdentities$));
this.showInlineMenuIdentities = await firstValueFrom(
this.autofillSettingsService.showInlineMenuIdentities$,
);
this.showInlineMenuCards =
this.inlineMenuPositioningImprovementsEnabled &&
(await firstValueFrom(this.autofillSettingsService.showInlineMenuCards$));
this.showInlineMenuCards = await firstValueFrom(
this.autofillSettingsService.showInlineMenuCards$,
);
this.enableInlineMenuOnIconSelect =
this.inlineMenuVisibility === AutofillOverlayVisibility.OnButtonClick;

View File

@@ -284,15 +284,6 @@ export default class AutofillService implements AutofillServiceInterface {
inlineMenuVisibility = await this.getInlineMenuVisibility();
}
const inlineMenuPositioningImprovements = await this.configService.getFeatureFlag(
FeatureFlag.InlineMenuPositioningImprovements,
);
if (!inlineMenuPositioningImprovements) {
return !inlineMenuVisibility
? "bootstrap-autofill.js"
: "bootstrap-legacy-autofill-overlay.js";
}
const enableChangedPasswordPrompt = await firstValueFrom(
this.userNotificationSettingsService.enableChangedPasswordPrompt$,
);

View File

@@ -245,7 +245,6 @@ import WebRequestBackground from "../autofill/background/web-request.background"
import { CipherContextMenuHandler } from "../autofill/browser/cipher-context-menu-handler";
import { ContextMenuClickedHandler } from "../autofill/browser/context-menu-clicked-handler";
import { MainContextMenuHandler } from "../autofill/browser/main-context-menu-handler";
import LegacyOverlayBackground from "../autofill/deprecated/background/overlay.background.deprecated";
import { Fido2Background as Fido2BackgroundAbstraction } from "../autofill/fido2/background/abstractions/fido2.background";
import { Fido2Background } from "../autofill/fido2/background/fido2.background";
import {
@@ -1716,45 +1715,26 @@ export default class MainBackground {
return;
}
const inlineMenuPositioningImprovementsEnabled = await this.configService.getFeatureFlag(
FeatureFlag.InlineMenuPositioningImprovements,
this.overlayBackground = new OverlayBackground(
this.logService,
this.cipherService,
this.autofillService,
this.authService,
this.environmentService,
this.domainSettingsService,
this.autofillSettingsService,
this.i18nService,
this.platformUtilsService,
this.vaultSettingsService,
this.fido2ActiveRequestManager,
this.inlineMenuFieldQualificationService,
this.themeStateService,
this.totpService,
this.accountService,
() => this.generatePassword(),
(password) => this.addPasswordToHistory(password),
);
if (!inlineMenuPositioningImprovementsEnabled) {
this.overlayBackground = new LegacyOverlayBackground(
this.cipherService,
this.autofillService,
this.authService,
this.environmentService,
this.domainSettingsService,
this.autofillSettingsService,
this.i18nService,
this.platformUtilsService,
this.themeStateService,
this.accountService,
);
} else {
this.overlayBackground = new OverlayBackground(
this.logService,
this.cipherService,
this.autofillService,
this.authService,
this.environmentService,
this.domainSettingsService,
this.autofillSettingsService,
this.i18nService,
this.platformUtilsService,
this.vaultSettingsService,
this.fido2ActiveRequestManager,
this.inlineMenuFieldQualificationService,
this.themeStateService,
this.totpService,
this.accountService,
() => this.generatePassword(),
(password) => this.addPasswordToHistory(password),
);
}
this.tabsBackground = new TabsBackground(
this,
this.notificationBackground,

View File

@@ -79,12 +79,7 @@
"__safari__optional_permissions": null,
"content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'",
"sandbox": {
"pages": [
"overlay/menu-button.html",
"overlay/menu-list.html",
"overlay/button.html",
"overlay/list.html"
],
"pages": ["overlay/menu-button.html", "overlay/menu-list.html"],
"content_security_policy": "sandbox allow-scripts; script-src 'self'"
},
"__firefox__sandbox": null,
@@ -140,8 +135,6 @@
"overlay/menu-button.html",
"overlay/menu-list.html",
"overlay/menu.html",
"overlay/button.html",
"overlay/list.html",
"popup/fonts/*"
],
"__firefox__browser_specific_settings": {

View File

@@ -105,12 +105,7 @@
"__chrome__sandbox": "sandbox allow-scripts; script-src 'self'"
},
"sandbox": {
"pages": [
"overlay/menu-button.html",
"overlay/menu-list.html",
"overlay/button.html",
"overlay/list.html"
]
"pages": ["overlay/menu-button.html", "overlay/menu-list.html"]
},
"__firefox__sandbox": null,
"commands": {
@@ -167,8 +162,6 @@
"overlay/menu-button.html",
"overlay/menu-list.html",
"overlay/menu.html",
"overlay/button.html",
"overlay/list.html",
"popup/fonts/*"
],
"matches": ["<all_urls>"]

View File

@@ -135,16 +135,6 @@ const plugins = [
filename: "overlay/menu.html",
chunks: ["overlay/menu"],
}),
new HtmlWebpackPlugin({
template: "./src/autofill/deprecated/overlay/pages/button/legacy-button.html",
filename: "overlay/button.html",
chunks: ["overlay/button"],
}),
new HtmlWebpackPlugin({
template: "./src/autofill/deprecated/overlay/pages/list/legacy-list.html",
filename: "overlay/list.html",
chunks: ["overlay/list"],
}),
new CopyWebpackPlugin({
patterns: [
{
@@ -197,8 +187,6 @@ const mainConfig = {
"./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",
"content/auto-submit-login": "./src/autofill/content/auto-submit-login.ts",
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
@@ -213,10 +201,6 @@ const mainConfig = {
"./src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts",
"overlay/menu":
"./src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts",
"overlay/button":
"./src/autofill/deprecated/overlay/pages/button/bootstrap-autofill-overlay-button.deprecated.ts",
"overlay/list":
"./src/autofill/deprecated/overlay/pages/list/bootstrap-autofill-overlay-list.deprecated.ts",
"encrypt-worker": "../../libs/common/src/key-management/crypto/services/encrypt.worker.ts",
"content/send-on-installed-message": "./src/vault/content/send-on-installed-message.ts",
"content/send-popup-open-message": "./src/vault/content/send-popup-open-message.ts",

View File

@@ -24,7 +24,6 @@ export enum FeatureFlag {
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor",
IdpAutoSubmitLogin = "idp-auto-submit-login",
InlineMenuPositioningImprovements = "inline-menu-positioning-improvements",
NotificationRefresh = "notification-refresh",
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
MacOsNativeCredentialSync = "macos-native-credential-sync",
@@ -89,7 +88,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
[FeatureFlag.InlineMenuPositioningImprovements]: FALSE,
[FeatureFlag.NotificationRefresh]: FALSE,
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,