passbolt-styleguide
Version:
Passbolt styleguide contains common styling assets used by the different sites, plugin, etc.
617 lines (563 loc) • 23 kB
JavaScript
/**
* Passbolt ~ Open source password manager for teams
* Copyright (c) 2021 Passbolt SA (https://www.passbolt.com)
*
* Licensed under GNU Affero General Public License version 3 of the or any later version.
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) 2021 Passbolt SA (https://www.passbolt.com)
* @license https://opensource.org/licenses/AGPL-3.0 AGPL License
* @link https://www.passbolt.com Passbolt(tm)
* @since 3.3.0
*/
import InFormCallToActionField from "./InFormCallToActionField";
import InFormFieldSelector from "./InFormFieldSelector";
import InFormMenuField from "./InformMenuField";
import InFormCredentialsFormField from "./InFormCredentialsFormField";
import DomUtils from "../Dom/DomUtils";
import debounce from "debounce-promise";
import UserEventsService from "../User/UserEventsService";
import ClipboardServiceWorkerService from "../../../shared/services/serviceWorker/clipboard/clipboardServiceWorkerService";
import { TotpCodeGeneratorService } from "../../../shared/services/otp/TotpCodeGeneratorService";
const Z_INDEX_MAX = 2147483647;
/**
* Manages the in-form web integration including call-to-action and menu
*/
class InFormManager {
/**
* Default constructor
*/
constructor() {
/** In-form username and password callToActionFields in the target page*/
this.callToActionFields = [];
/** In-form menu menuField in the target page*/
this.menuField = null;
/** In-form form fields in the target page*/
this.credentialsFormFields = [];
/** Mutation observers to detect any change on the DOM */
this.mutationObserver = null;
/** The shadow root with the host **/
this.host = null;
this.shadowRoot = null;
this.hostMutationObserver = null;
this.htmlMutationObserver = null;
this.bodyMutationObserver = null;
this.bindCallbacks();
}
/**
* Create the shadow host and shadow root and insert in the body
*/
createAndInsertShadowRootWithHost() {
this.host = document.createElement("div");
/*
* Remove all style the component could have inherited from its environment.
* Enforce the following style:
* - position fixed to have the positioning relative to the viewport
* - display block to ensure the component is always displayed
* - z-index fixed to the maximum allowed value to ensure the component is always displayed above all the page's components.
*/
this.host.setAttribute(
"style",
`all: initial; position: fixed !important; display: block !important; z-index: ${Z_INDEX_MAX} !important`,
);
// Block any setter and getter property style, however it can be bypassed with setAttribute.
Object.defineProperty(this.host, "style", {
set: () => {},
get: () => null,
});
// Attach shadow in closed mode to not have access except with the reference
this.shadowRoot = this.host.attachShadow({ mode: "closed" });
/*
* Block any click event that is not ins the shadow root
* This prevents an attacker to add element in the host and try to add event listener
*/
this.host.addEventListener(
"click",
(event) => {
if (!this.shadowRoot.contains(event.target)) {
event.stopImmediatePropagation(); // Block any external event
}
},
true,
); // Capture phase
// Insert the host in the body
document.body.appendChild(this.host);
}
/**
* Initializes the in-form manager
*/
async initialize() {
/**
* Wait for all animations to finish before checking if the page is visible.
* Note: There is a risk that applications with continuous animations may prevent
* the Passbolt in-form application from initializing.
*/
await this.waitingAnimations(document.documentElement);
await this.waitingAnimations(document.body);
// Do not initialize if the page is not visible enough before inserting elements
if (this.isPageNotVisible()) {
console.debug("Cannot insert the in-form menu manager into a page that is not visible.");
return;
}
this.clipboardServiceWorkerService = new ClipboardServiceWorkerService(port);
this.createAndInsertShadowRootWithHost();
this.findAndSetAuthenticationFields();
this.handleDomChange();
this.handleInformCallToActionRepositionEvent();
this.handlePortDestroyEvent();
this.handleInFormMenuInsertionEvent();
this.handleInFormMenuRemoveEvent();
this.handleInformCallToActionClickEvent();
this.handleGetLastCallToActionClickedInput();
this.handleGetCurrentCredentials();
this.handleFillCredentials();
this.handleFillPassword();
this.handleClipboardEvent();
this.handleApplicationOverlaidEvent();
this.handleDomStyleMutation();
}
/**
* Binds the callbacks
*/
bindCallbacks() {
this.findAndSetAuthenticationFields = this.findAndSetAuthenticationFields.bind(this);
this.handleInformCallToActionClickEvent = this.handleInformCallToActionClickEvent.bind(this);
this.clean = this.clean.bind(this);
this.destroy = this.destroy.bind(this);
this.handleClipboardChange = this.handleClipboardChange.bind(this);
}
/**
* Monitor inline `style` attribute mutations on the host, <html>, and <body>.
* If a mutation makes any of these elements non-visible (e.g., display:none, opacity:0,
* visibility:hidden), the component is destroyed as a defensive measure.
*/
handleDomStyleMutation() {
// Check any DOM style changes on the element
this.hostMutationObserver = new MutationObserver(() => this.destroyIfElementNotVisible(this.host));
this.htmlMutationObserver = new MutationObserver(() => this.destroyIfElementNotVisible(document.documentElement));
this.bodyMutationObserver = new MutationObserver(() => this.destroyIfElementNotVisible(document.body));
this.hostMutationObserver.observe(this.host, { attributes: true });
this.htmlMutationObserver.observe(document.documentElement, { attributes: true });
this.bodyMutationObserver.observe(document.body, { attributes: true });
}
/**
* Destroy all if element is not visible enough
* @param element
*/
destroyIfElementNotVisible(element) {
if (this.isElementNotVisible(element)) {
this.destroy();
}
}
/**
* Is element not visible
* @param element
* @return {boolean}
*/
isElementNotVisible(element) {
const visibilityOptions = {
visibilityProperty: true,
};
return getComputedStyle(element).opacity < 0.4 || !element.checkVisibility(visibilityOptions);
}
/**
* Waiting all animations on element
* @param element
* @return {Promise<void>}
*/
async waitingAnimations(element) {
const animations = element.getAnimations();
await Promise.all(
animations.map(
(animation) =>
new Promise((resolve) => {
animation.addEventListener("finish", resolve, { once: true });
}),
),
);
}
/**
* Is page not visible
* @return {boolean}
*/
isPageNotVisible() {
return this.isElementNotVisible(document.documentElement) || this.isElementNotVisible(document.body);
}
/**
* Find authentication fields in the document and set them as object properties
*/
findAndSetAuthenticationFields() {
this.findAndSetInputFields();
this.findAndSetCredentialsFormFields();
}
/**
* Find authentication callToActionFields in the document and set them as object properties
*/
findAndSetInputFields() {
/*
* We find the username / passwords / OTP DOM callToActionFields.
* If it was previously found, we reuse the same InformUsernameField, otherwise we create one
* Else we clean and reset callToActionFields
*/
const newUsernameFields = InFormCallToActionField.findAll(InFormFieldSelector.USERNAME_FIELD_SELECTOR);
const newPasswordFields = InFormCallToActionField.findAll(InFormFieldSelector.PASSWORD_FIELD_SELECTOR);
const newOTPFields = InFormCallToActionField.findAll(InFormFieldSelector.OTP_FIELD_SELECTOR);
/**
* A function factory to map a field to an existing field or create a new one
* @param {"username"|"password"|"otp"} fieldType The type of field to create
* @returns {function(HTMLElement): InFormCallToActionField} The function to map a field to an InFormCallToActionField
*/
const mapField = (fieldType) => (field) => {
const existingField = this.callToActionFields.find(({ field: ctaField }) => ctaField === field);
return existingField ?? new InFormCallToActionField(field, fieldType, this.shadowRoot);
};
let newCTAFields = [
...newUsernameFields.map(mapField("username")),
...newPasswordFields.map(mapField("password")),
...newOTPFields.map(mapField("otp")),
];
if (newCTAFields.length > 0) {
this.removeCallToActionFieldsNotMatching(newCTAFields);
} else {
this.clean();
}
this.callToActionFields = newCTAFields;
}
/**
* Remove call to action fields that does not match new fields
* @param newFields The new fields
*/
removeCallToActionFieldsNotMatching(newFields) {
const newFieldsSet = new Set(newFields.map(({ field }) => field));
this.callToActionFields.forEach((ctaField) => {
// Check if the ctaField is still in the document
if (!newFieldsSet.has(ctaField.field)) {
// If not, we remove its iframe
ctaField.removeIframe();
}
});
}
/**
* Find authentication formFields in the document and set them as object properties
*/
findAndSetCredentialsFormFields() {
/**
* We find the form DOM formFields.
* If it was previously found, we reuse the same InformFormField, otherwise we create one
*/
const newCredentialsFormFields = InFormCredentialsFormField.findAll();
if (newCredentialsFormFields.length > 0) {
// Get all fields filtered by their types
const { usernameCtaFields, passwordCtaFields } = this.callToActionFields.reduce(
(acc, ctaField) => {
if (ctaField.fieldType === "username") {
acc.usernameCtaFields.push(ctaField);
} else if (ctaField.fieldType === "password") {
acc.passwordCtaFields.push(ctaField);
}
return acc;
},
{ usernameCtaFields: [], passwordCtaFields: [] },
);
this.credentialsFormFields = newCredentialsFormFields.map((newField) => {
const existingField = this.credentialsFormFields.find(({ field: formField }) => formField === newField);
if (!existingField) {
// We try to find username and password fields contained in the new form field
const usernameField = usernameCtaFields.find((ctaField) => newField.contains(ctaField.field));
const passwordField = passwordCtaFields.find((ctaField) => newField.contains(ctaField.field));
return new InFormCredentialsFormField(newField, usernameField?.field, passwordField?.field);
}
return existingField;
});
} else {
this.credentialsFormFields = [];
}
}
/**
* Clean the DOM of in-form entities
*/
clean() {
this.callToActionFields.forEach((field) => field.removeIframe());
this.menuField?.removeIframe();
}
/**
* Whenever the DOM changes
*/
handleDomChange() {
const updateAuthenticationFields = () => {
/*
* The only way to prevent an attacker trying to move the host into another parent element and add opacity
* If the host is not in the body anymore destroy
*/
if (this.host.parentNode !== document.body) {
console.debug("Someone has moved the host of the shadow root");
this.destroy();
return;
}
this.findAndSetAuthenticationFields();
this.handleInformCallToActionClickEvent();
};
// Use requestIdleCallback when available to schedule work during browser idle periods,
// This enables us perform background and low priority work on the main thread, without
// impacting latency-critical events such as animation and input response.
// https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback
// If requestIdleCallback is not available as in the case of Safari, fall back to a
// simple debounce to avoid too many requests.
const updateAuthenticationFieldsDebounce = window.requestIdleCallback
? debounce(
() => {
requestIdleCallback(
() => {
updateAuthenticationFields();
},
{ timeout: 1000 },
);
},
300,
{
leading: false,
accumulate: false,
},
)
: debounce(updateAuthenticationFields, 1000, {
leading: true,
accumulate: false,
});
// Search again for authentication callToActionFields to attach when the DOM changes
// The mutation observer does not detect mutation in a closed shadow dom
this.mutationObserver = new MutationObserver(updateAuthenticationFieldsDebounce);
this.mutationObserver.observe(document.body, { subtree: true, childList: true });
}
/**
* Whenever the username / password callToActionFields change its position, reposition the call-to-action
*/
handleInformCallToActionRepositionEvent() {
window.addEventListener("resize", this.clean);
}
/**
* Whenever the user clicks on the in-form call-to-action, it inserts the in-form menu iframe
*/
handleInFormMenuInsertionEvent() {
port.on("passbolt.in-form-menu.open", () => {
this.menuField?.destroy();
this.menuField = new InFormMenuField(this.lastCallToActionFieldClicked.field, this.shadowRoot);
});
}
/**
* Whenever the user clicks on the in-form menu, it removes the in-form menu iframe
*/
handleInFormMenuRemoveEvent() {
port.on("passbolt.in-form-menu.close", () => {
this.menuField.removeIframe();
});
}
/**
* Handle the click on the in-form call-to-action (iframe)
*/
handleInformCallToActionClickEvent() {
const setLastCallToActionFieldClicked = (callToActionField) =>
callToActionField.onClick(() => {
this.lastCallToActionFieldClicked = callToActionField;
});
this.callToActionFields.forEach(setLastCallToActionFieldClicked);
}
/** Whenever one requires to get the type and value of the input attached to the last call-to-action performed */
handleGetLastCallToActionClickedInput() {
port.on("passbolt.web-integration.last-performed-call-to-action-input", (requestId) => {
port.emit(requestId, "SUCCESS", {
type: this.lastCallToActionFieldClicked.fieldType,
value: this.lastCallToActionFieldClicked.field.value,
});
});
}
/** Whenever one requires to get the current credentials */
handleGetCurrentCredentials() {
port.on("passbolt.web-integration.get-credentials", (requestId) => {
const currentFieldType = this.lastCallToActionFieldClicked?.fieldType;
const isUsernameType = currentFieldType === "username";
const isPasswordType = currentFieldType === "password";
let username = null;
let password = null;
if (!isUsernameType) {
username = this.callToActionFields.find((field) => field.fieldType === "username")?.field.value || "";
password = this.lastCallToActionFieldClicked?.field.value;
}
if (!isPasswordType) {
username = this.lastCallToActionFieldClicked?.field.value;
password = this.callToActionFields.find((field) => field.fieldType === "password")?.field.value || "";
}
port.emit(requestId, "SUCCESS", { username, password });
});
}
/**
* Whenever one requests to fill the current page form with given credentials
*/
handleFillCredentials() {
port.on("passbolt.web-integration.fill-credentials", ({ username, password, totp }) => {
const currentFieldType = this.lastCallToActionFieldClicked?.fieldType;
const isUsernameType = currentFieldType === "username";
const isPasswordType = currentFieldType === "password";
const isOTPType = currentFieldType === "otp";
if (!isOTPType) {
if (!isUsernameType) {
// Simulate a user to autofill the password field
UserEventsService.autofill(this.lastCallToActionFieldClicked.field, password);
// Get username fields and find the one with the lowest common ancestor
const usernameFields = this.callToActionFields.filter(
(callToActionField) => callToActionField.fieldType === "username",
);
const usernameField = DomUtils.getFieldWithLowestCommonAncestor(
this.lastCallToActionFieldClicked.field,
usernameFields,
);
if (usernameField) {
// Simulate a user to autofill the username field
UserEventsService.autofill(usernameField.field, username);
}
} else if (!isPasswordType) {
// Simulate a user to autofill the username field
UserEventsService.autofill(this.lastCallToActionFieldClicked.field, username);
// Get password fields and find the one with the lowest common ancestor
const passwordFields = this.callToActionFields.filter(
(callToActionField) => callToActionField.fieldType === "password",
);
const passwordField = DomUtils.getFieldWithLowestCommonAncestor(
this.lastCallToActionFieldClicked.field,
passwordFields,
);
if (passwordField) {
// Simulate a user to autofill the password field
UserEventsService.autofill(passwordField.field, password);
}
}
} else if (totp) {
// If an OTP value is provided, fill the OTP field
const totpValue = TotpCodeGeneratorService.generate(totp);
if (!totpValue) {
throw new TypeError("Error while generating the TOTP.");
}
UserEventsService.autofill(this.lastCallToActionFieldClicked.field, totpValue);
}
});
}
/**
* Whenever one requests to fill the current page form with a password
*/
handleFillPassword() {
port.on("passbolt.web-integration.fill-password", (password) => {
const passwordFields = this.callToActionFields.filter(
(callToActionField) => callToActionField.fieldType === "password",
);
// Autofill only empty passwords field
passwordFields.forEach(
(callToActionField) =>
!callToActionField.field.value && UserEventsService.autofill(callToActionField.field, password),
);
this.menuField.removeIframe();
// Listen the auto-save on the appropriate form field
const formField = this.credentialsFormFields.find((formField) =>
formField.field.contains(this.lastCallToActionFieldClicked.field),
);
formField?.handleAutoSaveEvent();
});
}
/**
* Starts listening to "cut" and "copy" events
*/
handleClipboardEvent() {
document.addEventListener("cut", this.handleClipboardChange);
document.addEventListener("copy", this.handleClipboardChange);
}
/**
* Handler of the "cut" and "copy" event.
*/
handleClipboardChange() {
this.clipboardServiceWorkerService.cancelClipboardFlush();
}
/**
* Whenever one requested to check if the application is overlaid
*/
handleApplicationOverlaidEvent() {
port.on("passbolt.web-integration.is-application-overlaid", async (requestId, applicationId) => {
const application =
this.menuField?.id === applicationId
? this.menuField
: this.callToActionFields.find((field) => field.id === applicationId);
const isOverlay = this.isApplicationOverlaid(application);
await port.emit(requestId, "SUCCESS", isOverlay);
if (isOverlay) {
application.removeIframe();
}
});
}
/**
* Is application overlaid
* @param application
* @return {boolean}
*/
isApplicationOverlaid(application) {
const iframe = this.shadowRoot.getElementById(application.iframeId);
// Get all elements having pointer-event none
const pointerEventNoneElements = this.elementsWithPointerEventNone;
// Set pointer-event to auto to enable the detection of an overlay on application
pointerEventNoneElements.forEach((pointerEl) => pointerEl.style.setProperty("pointer-events", "auto", "important"));
const points = DomUtils.generateUniquePointsInElement(iframe);
// Elements with pointer-events set to none will be ignored, and the element below it will be returned.
const elements = points.map((point) => document.elementFromPoint(point.x, point.y));
// Set back pointer-event to none
pointerEventNoneElements.forEach((pointerEl) => (pointerEl.style.pointerEvents = "none"));
return elements.some((element) => element !== this.host);
}
/**
* Get elements with pointer event none
* @return {[]}
*/
get elementsWithPointerEventNone() {
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, {
acceptNode: function (node) {
const style = window.getComputedStyle(node);
return style.pointerEvents === "none" ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
},
});
const elements = [];
let currentNode;
while ((currentNode = treeWalker.nextNode())) {
elements.push(currentNode);
}
return elements;
}
/**
* Remove all event, observer and iframe
*/
destroy() {
this.mutationObserver.disconnect();
this.hostMutationObserver.disconnect();
this.htmlMutationObserver.disconnect();
this.bodyMutationObserver.disconnect();
this.callToActionFields.forEach((field) => field.destroy());
this.menuField?.destroy();
this.credentialsFormFields.forEach((field) => field.destroy());
window.removeEventListener("resize", this.clean);
document.removeEventListener("cut", this.handleClipboardChange);
document.removeEventListener("copy", this.handleClipboardChange);
this.host.remove();
}
/**
* Whenever the port should be destroyed due to an update of the extension
*/
handlePortDestroyEvent() {
/*
* This is extremely important, when an extension is available
* so the port receive the message 'passbolt.port.destroy' to clean all data and listeners
*/
port.on("passbolt.content-script.destroy", this.destroy);
/*
* If the port has not been destroyed correctly,
* The port cannot reconnect due to an invalid context in case of a manual update of the extension,
* So to prevent error, a callback destroy listeners is assigned
*/
port.onConnectError(this.destroy);
}
}
export default new InFormManager();