bitwarden-browser/apps/browser/src/autofill/overlay/pages/list/autofill-overlay-list.ts

604 lines
21 KiB
TypeScript

import "@webcomponents/custom-elements";
import "lit/polyfill-support.js";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { OverlayCipherData } from "../../../background/abstractions/overlay.background";
import { EVENTS } from "../../../constants";
import { globeIcon, lockIcon, plusIcon, viewCipherIcon } from "../../../utils/svg-icons";
import { buildSvgDomElement } from "../../../utils/utils";
import {
InitAutofillOverlayListMessage,
OverlayListWindowMessageHandlers,
} from "../../abstractions/autofill-overlay-list";
import AutofillOverlayPageElement from "../shared/autofill-overlay-page-element";
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: 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("button", styleSheetUrl, translations);
globalThis.document.documentElement.classList.add(theme);
this.overlayListContainer = globalThis.document.createElement("div");
this.overlayListContainer.classList.add("overlay-list-container", theme);
this.overlayListContainer.setAttribute("role", "dialog");
this.overlayListContainer.setAttribute("aria-modal", "true");
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;
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})`;
return cipherIcon;
} catch {
// Silently default to the globe icon element if the image URL is invalid
}
}
if (cipher.icon?.icon) {
cipherIcon.classList.add("cipher-icon", "bwi", cipher.icon.icon);
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() {
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;