diff options
Diffstat (limited to '')
36 files changed, 1977 insertions, 1652 deletions
diff --git a/browser/extensions/formautofill/content/addressFormLayout.mjs b/browser/extensions/formautofill/content/addressFormLayout.mjs new file mode 100644 index 0000000000..5e48e6afaa --- /dev/null +++ b/browser/extensions/formautofill/content/addressFormLayout.mjs @@ -0,0 +1,187 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + FormAutofill: "resource://autofill/FormAutofill.sys.mjs", + FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", +}); + +// Defines template descriptors for generating elements in convertLayoutToUI. +const fieldTemplates = { + commonAttributes(item) { + return { + id: item.fieldId, + name: item.fieldId, + required: item.required, + value: item.value ?? "", + }; + }, + input(item) { + return { + tag: "input", + type: item.type ?? "text", + ...this.commonAttributes(item), + }; + }, + textarea(item) { + return { + tag: "textarea", + ...this.commonAttributes(item), + }; + }, + select(item) { + return { + tag: "select", + children: item.options.map(({ value, text }) => ({ + tag: "option", + selected: value === item.value, + value, + text, + })), + ...this.commonAttributes(item), + }; + }, +}; + +/** + * Creates an HTML element with specified attributes and children. + * + * @param {string} tag - Tag name for the element to create. + * @param {object} options - Options object containing attributes and children. + * @param {object} options.attributes - Element's Attributes/Props (id, class, etc.) + * @param {Array} options.children - Element's children (array of objects with tag and options). + * @returns {HTMLElement} The newly created element. + */ +const createElement = (tag, { children = [], ...attributes }) => { + const element = document.createElement(tag); + + for (let [attributeName, attributeValue] of Object.entries(attributes)) { + if (attributeName in element) { + element[attributeName] = attributeValue; + } else { + element.setAttribute(attributeName, attributeValue); + } + } + + for (let { tag: childTag, ...childRest } of children) { + element.appendChild(createElement(childTag, childRest)); + } + + return element; +}; + +/** + * Generator that creates UI elements from `fields` object, using localization from `l10nStrings`. + * + * @param {Array} fields - Array of objects as returned from `FormAutofillUtils.getFormLayout`. + * @param {object} l10nStrings - Key-value pairs for field label localization. + * @yields {HTMLElement} - A localized label element with constructed from a field. + */ +function* convertLayoutToUI(fields, l10nStrings) { + for (const item of fields) { + // eslint-disable-next-line no-nested-ternary + const fieldTag = item.options + ? "select" + : item.multiline + ? "textarea" + : "input"; + + const fieldUI = { + label: { + id: `${item.fieldId}-container`, + class: `container ${item.newLine ? "new-line" : ""}`, + }, + field: fieldTemplates[fieldTag](item), + span: { + class: "label-text", + textContent: l10nStrings[item.l10nId] ?? "", + }, + }; + + const label = createElement("label", fieldUI.label); + const { tag, ...rest } = fieldUI.field; + const field = createElement(tag, rest); + label.appendChild(field); + const span = createElement("span", fieldUI.span); + label.appendChild(span); + + yield label; + } +} + +/** + * Retrieves the current form data from the current form element on the page. + * + * @returns {object} An object containing key-value pairs of form data. + */ +export const getCurrentFormData = () => { + const formElement = document.querySelector("form"); + const formData = new FormData(formElement); + return Object.fromEntries(formData.entries()); +}; + +/** + * Checks if the form can be submitted based on the number of non-empty values. + * TODO(Bug 1891734): Add address validation. Right now we don't do any validation. (2 fields mimics the old behaviour ). + * + * @returns {boolean} True if the form can be submitted + */ +export const canSubmitForm = () => { + const formData = getCurrentFormData(); + const validValues = Object.values(formData).filter(Boolean); + return validValues.length >= 2; +}; + +/** + * Generates a form layout based on record data and localization strings. + * + * @param {HTMLFormElement} formElement - Target form element. + * @param {object} record - Address record, includes at least country code defaulted to FormAutofill.DEFAULT_REGION. + * @param {object} l10nStrings - Localization strings map. + */ +export const createFormLayoutFromRecord = ( + formElement, + record = { country: lazy.FormAutofill.DEFAULT_REGION }, + l10nStrings = {} +) => { + // Always clear select values because they are not persisted between countries. + // For example from US with state NY, we don't want the address-level1 to be NY + // when changing to another country that doesn't have state options + const selects = formElement.querySelectorAll("select:not(#country)"); + for (const select of selects) { + select.value = ""; + } + + // Get old data to persist before clearing form + const formData = getCurrentFormData(); + record = { + ...record, + ...formData, + }; + + formElement.innerHTML = ""; + const fields = lazy.FormAutofillUtils.getFormLayout(record); + + const layoutGenerator = convertLayoutToUI(fields, l10nStrings); + + for (const fieldElement of layoutGenerator) { + formElement.appendChild(fieldElement); + } + + document.querySelector("#country").addEventListener( + "change", + ev => + // Allow some time for the user to type + // before we set the new country and re-render + setTimeout(() => { + record.country = ev.target.value; + createFormLayoutFromRecord(formElement, record, l10nStrings); + }, 300), + { once: true } + ); + + // Used to notify tests that the form has been updated and is ready + window.dispatchEvent(new CustomEvent("FormReadyForTests")); +}; diff --git a/browser/extensions/formautofill/content/autofillEditForms.js b/browser/extensions/formautofill/content/autofillEditForms.js deleted file mode 100644 index 290b436a64..0000000000 --- a/browser/extensions/formautofill/content/autofillEditForms.js +++ /dev/null @@ -1,640 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -/* exported EditAddress, EditCreditCard */ -/* eslint-disable mozilla/balanced-listeners */ // Not relevant since the document gets unloaded. - -"use strict"; - -const { FormAutofill } = ChromeUtils.importESModule( - "resource://autofill/FormAutofill.sys.mjs" -); -const { FormAutofillUtils } = ChromeUtils.importESModule( - "resource://gre/modules/shared/FormAutofillUtils.sys.mjs" -); - -class EditAutofillForm { - constructor(elements) { - this._elements = elements; - } - - /** - * Fill the form with a record object. - * - * @param {object} [record = {}] - */ - loadRecord(record = {}) { - for (let field of this._elements.form.elements) { - let value = record[field.id]; - value = typeof value == "undefined" ? "" : value; - - if (record.guid) { - field.value = value; - } else if (field.localName == "select") { - this.setDefaultSelectedOptionByValue(field, value); - } else { - // Use .defaultValue instead of .value to avoid setting the `dirty` flag - // which triggers form validation UI. - field.defaultValue = value; - } - } - if (!record.guid) { - // Reset the dirty value flag and validity state. - this._elements.form.reset(); - } else { - for (let field of this._elements.form.elements) { - this.updatePopulatedState(field); - this.updateCustomValidity(field); - } - } - } - - setDefaultSelectedOptionByValue(select, value) { - for (let option of select.options) { - option.defaultSelected = option.value == value; - } - } - - /** - * Get a record from the form suitable for a save/update in storage. - * - * @returns {object} - */ - buildFormObject() { - let initialObject = {}; - if (this.hasMailingAddressFields) { - // Start with an empty string for each mailing-address field so that any - // fields hidden for the current country are blanked in the return value. - initialObject = { - "street-address": "", - "address-level3": "", - "address-level2": "", - "address-level1": "", - "postal-code": "", - }; - } - - return Array.from(this._elements.form.elements).reduce((obj, input) => { - if (!input.disabled) { - obj[input.id] = input.value; - } - return obj; - }, initialObject); - } - - /** - * Handle events - * - * @param {DOMEvent} event - */ - handleEvent(event) { - switch (event.type) { - case "change": { - this.handleChange(event); - break; - } - case "input": { - this.handleInput(event); - break; - } - } - } - - /** - * Handle change events - * - * @param {DOMEvent} event - */ - handleChange(event) { - this.updatePopulatedState(event.target); - } - - /** - * Handle input events - */ - handleInput(_e) {} - - /** - * Attach event listener - */ - attachEventListeners() { - this._elements.form.addEventListener("input", this); - } - - /** - * Set the field-populated attribute if the field has a value. - * - * @param {DOMElement} field The field that will be checked for a value. - */ - updatePopulatedState(field) { - let span = field.parentNode.querySelector(".label-text"); - if (!span) { - return; - } - span.toggleAttribute("field-populated", !!field.value.trim()); - } - - /** - * Run custom validity routines specific to the field and type of form. - * - * @param {DOMElement} _field The field that will be validated. - */ - updateCustomValidity(_field) {} -} - -class EditAddress extends EditAutofillForm { - /** - * @param {HTMLElement[]} elements - * @param {object} record - * @param {object} config - * @param {boolean} [config.noValidate=undefined] Whether to validate the form - */ - constructor(elements, record, config) { - super(elements); - - Object.assign(this, config); - let { form } = this._elements; - Object.assign(this._elements, { - addressLevel3Label: form.querySelector( - "#address-level3-container > .label-text" - ), - addressLevel2Label: form.querySelector( - "#address-level2-container > .label-text" - ), - addressLevel1Label: form.querySelector( - "#address-level1-container > .label-text" - ), - postalCodeLabel: form.querySelector( - "#postal-code-container > .label-text" - ), - country: form.querySelector("#country"), - }); - - this.populateCountries(); - // Need to populate the countries before trying to set the initial country. - // Also need to use this._record so it has the default country selected. - this.loadRecord(record); - this.attachEventListeners(); - - form.noValidate = !!config.noValidate; - } - - loadRecord(record) { - this._record = record; - if (!record) { - record = { - country: FormAutofill.DEFAULT_REGION, - }; - } - - let { addressLevel1Options } = FormAutofillUtils.getFormFormat( - record.country - ); - this.populateAddressLevel1(addressLevel1Options, record.country); - - super.loadRecord(record); - this.loadAddressLevel1(record["address-level1"], record.country); - this.formatForm(record.country); - } - - get hasMailingAddressFields() { - let { addressFields } = this._elements.form.dataset; - return ( - !addressFields || - addressFields.trim().split(/\s+/).includes("mailing-address") - ); - } - - /** - * `mailing-address` is a special attribute token to indicate mailing fields + country. - * - * @param {object[]} mailingFieldsOrder - `fieldsOrder` from `getFormFormat` - * @param {string} addressFields - white-space-separated string of requested address fields to show - * @returns {object[]} in the same structure as `mailingFieldsOrder` but including non-mail fields - */ - static computeVisibleFields(mailingFieldsOrder, addressFields) { - if (addressFields) { - let requestedFieldClasses = addressFields.trim().split(/\s+/); - let fieldClasses = []; - if (requestedFieldClasses.includes("mailing-address")) { - fieldClasses = fieldClasses.concat(mailingFieldsOrder); - // `country` isn't part of the `mailingFieldsOrder` so add it when filling a mailing-address - requestedFieldClasses.splice( - requestedFieldClasses.indexOf("mailing-address"), - 1, - "country" - ); - } - - for (let fieldClassName of requestedFieldClasses) { - fieldClasses.push({ - fieldId: fieldClassName, - newLine: fieldClassName == "name", - }); - } - return fieldClasses; - } - - // This is the default which is shown in the management interface and includes all fields. - return mailingFieldsOrder.concat([ - { - fieldId: "country", - }, - { - fieldId: "tel", - }, - { - fieldId: "email", - newLine: true, - }, - ]); - } - - /** - * Format the form based on country. The address-level1 and postal-code labels - * should be specific to the given country. - * - * @param {string} country - */ - formatForm(country) { - const { - addressLevel3L10nId, - addressLevel2L10nId, - addressLevel1L10nId, - addressLevel1Options, - postalCodeL10nId, - fieldsOrder: mailingFieldsOrder, - postalCodePattern, - countryRequiredFields, - } = FormAutofillUtils.getFormFormat(country); - - document.l10n.setAttributes( - this._elements.addressLevel3Label, - addressLevel3L10nId - ); - document.l10n.setAttributes( - this._elements.addressLevel2Label, - addressLevel2L10nId - ); - document.l10n.setAttributes( - this._elements.addressLevel1Label, - addressLevel1L10nId - ); - document.l10n.setAttributes( - this._elements.postalCodeLabel, - postalCodeL10nId - ); - let addressFields = this._elements.form.dataset.addressFields; - let extraRequiredFields = this._elements.form.dataset.extraRequiredFields; - let fieldClasses = EditAddress.computeVisibleFields( - mailingFieldsOrder, - addressFields - ); - let requiredFields = new Set(countryRequiredFields); - if (extraRequiredFields) { - for (let extraRequiredField of extraRequiredFields.trim().split(/\s+/)) { - requiredFields.add(extraRequiredField); - } - } - this.arrangeFields(fieldClasses, requiredFields); - this.updatePostalCodeValidation(postalCodePattern); - this.populateAddressLevel1(addressLevel1Options, country); - } - - /** - * Update address field visibility and order based on libaddressinput data. - * - * @param {object[]} fieldsOrder array of objects with `fieldId` and optional `newLine` properties - * @param {Set} requiredFields Set of `fieldId` strings that mark which fields are required - */ - arrangeFields(fieldsOrder, requiredFields) { - /** - * @see FormAutofillStorage.VALID_ADDRESS_FIELDS - */ - let fields = [ - // `name` is a wrapper for the 3 name fields. - "name", - "organization", - "street-address", - "address-level3", - "address-level2", - "address-level1", - "postal-code", - "country", - "tel", - "email", - ]; - let inputs = []; - for (let i = 0; i < fieldsOrder.length; i++) { - let { fieldId, newLine } = fieldsOrder[i]; - - let container = this._elements.form.querySelector( - `#${fieldId}-container` - ); - let containerInputs = [ - ...container.querySelectorAll("input, textarea, select"), - ]; - containerInputs.forEach(function (input) { - input.disabled = false; - // libaddressinput doesn't list 'country' or 'name' as required. - input.required = - fieldId == "country" || - fieldId == "name" || - requiredFields.has(fieldId); - }); - inputs.push(...containerInputs); - container.style.display = "flex"; - container.style.order = i; - container.style.pageBreakAfter = newLine ? "always" : "auto"; - // Remove the field from the list of fields - fields.splice(fields.indexOf(fieldId), 1); - } - for (let i = 0; i < inputs.length; i++) { - // Assign tabIndex starting from 1 - inputs[i].tabIndex = i + 1; - } - // Hide the remaining fields - for (let field of fields) { - let container = this._elements.form.querySelector(`#${field}-container`); - container.style.display = "none"; - for (let input of [ - ...container.querySelectorAll("input, textarea, select"), - ]) { - input.disabled = true; - } - } - } - - updatePostalCodeValidation(postalCodePattern) { - let postalCodeInput = this._elements.form.querySelector("#postal-code"); - if (postalCodePattern && postalCodeInput.style.display != "none") { - postalCodeInput.setAttribute("pattern", postalCodePattern); - } else { - postalCodeInput.removeAttribute("pattern"); - } - } - - /** - * Set the address-level1 value on the form field (input or select, whichever is present). - * - * @param {string} addressLevel1Value Value of the address-level1 from the autofill record - * @param {string} country The corresponding country - */ - loadAddressLevel1(addressLevel1Value, country) { - let field = this._elements.form.querySelector("#address-level1"); - - if (field.localName == "input") { - field.value = addressLevel1Value || ""; - return; - } - - let matchedSelectOption = FormAutofillUtils.findAddressSelectOption( - field, - { - country, - "address-level1": addressLevel1Value, - }, - "address-level1" - ); - if (matchedSelectOption && !matchedSelectOption.selected) { - field.value = matchedSelectOption.value; - field.dispatchEvent(new Event("input", { bubbles: true })); - field.dispatchEvent(new Event("change", { bubbles: true })); - } else if (addressLevel1Value) { - // If the option wasn't found, insert an option at the beginning of - // the select that matches the stored value. - field.insertBefore( - new Option(addressLevel1Value, addressLevel1Value, true, true), - field.firstChild - ); - } - } - - /** - * Replace the text input for address-level1 with a select dropdown if - * a fixed set of names exists. Otherwise show a text input. - * - * @param {Map?} options Map of options with regionCode -> name mappings - * @param {string} country The corresponding country - */ - populateAddressLevel1(options, country) { - let field = this._elements.form.querySelector("#address-level1"); - - if (field.dataset.country == country) { - return; - } - - if (!options) { - if (field.localName == "input") { - return; - } - - let input = document.createElement("input"); - input.setAttribute("type", "text"); - input.id = "address-level1"; - input.required = field.required; - input.disabled = field.disabled; - input.tabIndex = field.tabIndex; - field.replaceWith(input); - return; - } - - if (field.localName == "input") { - let select = document.createElement("select"); - select.id = "address-level1"; - select.required = field.required; - select.disabled = field.disabled; - select.tabIndex = field.tabIndex; - field.replaceWith(select); - field = select; - } - - field.textContent = ""; - field.dataset.country = country; - let fragment = document.createDocumentFragment(); - fragment.appendChild(new Option(undefined, undefined, true, true)); - for (let [regionCode, regionName] of options) { - let option = new Option(regionName, regionCode); - fragment.appendChild(option); - } - field.appendChild(fragment); - } - - populateCountries() { - let fragment = document.createDocumentFragment(); - // Sort countries by their visible names. - let countries = [...FormAutofill.countries.entries()].sort((e1, e2) => - e1[1].localeCompare(e2[1]) - ); - for (let [country] of countries) { - const countryName = Services.intl.getRegionDisplayNames(undefined, [ - country.toLowerCase(), - ]); - const option = new Option(countryName, country); - fragment.appendChild(option); - } - this._elements.country.appendChild(fragment); - } - - handleChange(event) { - if (event.target == this._elements.country) { - this.formatForm(event.target.value); - } - super.handleChange(event); - } - - attachEventListeners() { - this._elements.form.addEventListener("change", this); - super.attachEventListeners(); - } -} - -class EditCreditCard extends EditAutofillForm { - /** - * @param {HTMLElement[]} elements - * @param {object} record with a decrypted cc-number - * @param {object} addresses in an object with guid keys for the billing address picker. - */ - constructor(elements, record, addresses) { - super(elements); - - this._addresses = addresses; - Object.assign(this._elements, { - ccNumber: this._elements.form.querySelector("#cc-number"), - invalidCardNumberStringElement: this._elements.form.querySelector( - "#invalidCardNumberString" - ), - month: this._elements.form.querySelector("#cc-exp-month"), - year: this._elements.form.querySelector("#cc-exp-year"), - billingAddress: this._elements.form.querySelector("#billingAddressGUID"), - billingAddressRow: - this._elements.form.querySelector(".billingAddressRow"), - }); - - this.attachEventListeners(); - this.loadRecord(record, addresses); - } - - loadRecord(record, addresses, preserveFieldValues) { - // _record must be updated before generateYears and generateBillingAddressOptions are called. - this._record = record; - this._addresses = addresses; - this.generateBillingAddressOptions(preserveFieldValues); - if (!preserveFieldValues) { - // Re-generating the months will reset the selected option. - this.generateMonths(); - // Re-generating the years will reset the selected option. - this.generateYears(); - super.loadRecord(record); - } - } - - generateMonths() { - const count = 12; - - // Clear the list - this._elements.month.textContent = ""; - - // Empty month option - this._elements.month.appendChild(new Option()); - - // Populate month list. Format: "month number - month name" - let dateFormat = new Intl.DateTimeFormat(navigator.language, { - month: "long", - }).format; - for (let i = 0; i < count; i++) { - let monthNumber = (i + 1).toString(); - let monthName = dateFormat(new Date(1970, i)); - let option = new Option(); - option.value = monthNumber; - // XXX: Bug 1446164 - Localize this string. - option.textContent = `${monthNumber.padStart(2, "0")} - ${monthName}`; - this._elements.month.appendChild(option); - } - } - - generateYears() { - const count = 11; - const currentYear = new Date().getFullYear(); - const ccExpYear = this._record && this._record["cc-exp-year"]; - - // Clear the list - this._elements.year.textContent = ""; - - // Provide an empty year option - this._elements.year.appendChild(new Option()); - - if (ccExpYear && ccExpYear < currentYear) { - this._elements.year.appendChild(new Option(ccExpYear)); - } - - for (let i = 0; i < count; i++) { - let year = currentYear + i; - let option = new Option(year); - this._elements.year.appendChild(option); - } - - if (ccExpYear && ccExpYear > currentYear + count) { - this._elements.year.appendChild(new Option(ccExpYear)); - } - } - - generateBillingAddressOptions(preserveFieldValues) { - let billingAddressGUID; - if (preserveFieldValues && this._elements.billingAddress.value) { - billingAddressGUID = this._elements.billingAddress.value; - } else if (this._record) { - billingAddressGUID = this._record.billingAddressGUID; - } - - this._elements.billingAddress.textContent = ""; - - this._elements.billingAddress.appendChild(new Option("", "")); - - let hasAddresses = false; - for (let [guid, address] of Object.entries(this._addresses)) { - hasAddresses = true; - let selected = guid == billingAddressGUID; - let option = new Option( - FormAutofillUtils.getAddressLabel(address), - guid, - selected, - selected - ); - this._elements.billingAddress.appendChild(option); - } - - this._elements.billingAddressRow.hidden = !hasAddresses; - } - - attachEventListeners() { - this._elements.form.addEventListener("change", this); - super.attachEventListeners(); - } - - handleInput(event) { - // Clear the error message if cc-number is valid - if ( - event.target == this._elements.ccNumber && - FormAutofillUtils.isCCNumber(this._elements.ccNumber.value) - ) { - this._elements.ccNumber.setCustomValidity(""); - } - super.handleInput(event); - } - - updateCustomValidity(field) { - super.updateCustomValidity(field); - - // Mark the cc-number field as invalid if the number is empty or invalid. - if ( - field == this._elements.ccNumber && - !FormAutofillUtils.isCCNumber(field.value) - ) { - let invalidCardNumberString = - this._elements.invalidCardNumberStringElement.textContent; - field.setCustomValidity(invalidCardNumberString || " "); - } - } -} diff --git a/browser/extensions/formautofill/content/autofillEditForms.mjs b/browser/extensions/formautofill/content/autofillEditForms.mjs new file mode 100644 index 0000000000..ca74850acd --- /dev/null +++ b/browser/extensions/formautofill/content/autofillEditForms.mjs @@ -0,0 +1,288 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-disable mozilla/balanced-listeners */ // Not relevant since the document gets unloaded. + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", +}); + +class EditAutofillForm { + constructor(elements) { + this._elements = elements; + } + + /** + * Fill the form with a record object. + * + * @param {object} [record = {}] + */ + loadRecord(record = {}) { + for (let field of this._elements.form.elements) { + let value = record[field.id]; + value = typeof value == "undefined" ? "" : value; + + if (record.guid) { + field.value = value; + } else if (field.localName == "select") { + this.setDefaultSelectedOptionByValue(field, value); + } else { + // Use .defaultValue instead of .value to avoid setting the `dirty` flag + // which triggers form validation UI. + field.defaultValue = value; + } + } + if (!record.guid) { + // Reset the dirty value flag and validity state. + this._elements.form.reset(); + } else { + for (let field of this._elements.form.elements) { + this.updatePopulatedState(field); + this.updateCustomValidity(field); + } + } + } + + setDefaultSelectedOptionByValue(select, value) { + for (let option of select.options) { + option.defaultSelected = option.value == value; + } + } + + /** + * Get a record from the form suitable for a save/update in storage. + * + * @returns {object} + */ + buildFormObject() { + let initialObject = {}; + if (this.hasMailingAddressFields) { + // Start with an empty string for each mailing-address field so that any + // fields hidden for the current country are blanked in the return value. + initialObject = { + "street-address": "", + "address-level3": "", + "address-level2": "", + "address-level1": "", + "postal-code": "", + }; + } + + return Array.from(this._elements.form.elements).reduce((obj, input) => { + if (!input.disabled) { + obj[input.id] = input.value; + } + return obj; + }, initialObject); + } + + /** + * Handle events + * + * @param {DOMEvent} event + */ + handleEvent(event) { + switch (event.type) { + case "change": { + this.handleChange(event); + break; + } + case "input": { + this.handleInput(event); + break; + } + } + } + + /** + * Handle change events + * + * @param {DOMEvent} event + */ + handleChange(event) { + this.updatePopulatedState(event.target); + } + + /** + * Handle input events + */ + handleInput(_e) {} + + /** + * Attach event listener + */ + attachEventListeners() { + this._elements.form.addEventListener("input", this); + } + + /** + * Set the field-populated attribute if the field has a value. + * + * @param {DOMElement} field The field that will be checked for a value. + */ + updatePopulatedState(field) { + let span = field.parentNode.querySelector(".label-text"); + if (!span) { + return; + } + span.toggleAttribute("field-populated", !!field.value.trim()); + } + + /** + * Run custom validity routines specific to the field and type of form. + * + * @param {DOMElement} _field The field that will be validated. + */ + updateCustomValidity(_field) {} +} + +export class EditCreditCard extends EditAutofillForm { + /** + * @param {HTMLElement[]} elements + * @param {object} record with a decrypted cc-number + * @param {object} addresses in an object with guid keys for the billing address picker. + */ + constructor(elements, record, addresses) { + super(elements); + + this._addresses = addresses; + Object.assign(this._elements, { + ccNumber: this._elements.form.querySelector("#cc-number"), + invalidCardNumberStringElement: this._elements.form.querySelector( + "#invalidCardNumberString" + ), + month: this._elements.form.querySelector("#cc-exp-month"), + year: this._elements.form.querySelector("#cc-exp-year"), + billingAddress: this._elements.form.querySelector("#billingAddressGUID"), + billingAddressRow: + this._elements.form.querySelector(".billingAddressRow"), + }); + + this.attachEventListeners(); + this.loadRecord(record, addresses); + } + + loadRecord(record, addresses, preserveFieldValues) { + // _record must be updated before generateYears and generateBillingAddressOptions are called. + this._record = record; + this._addresses = addresses; + this.generateBillingAddressOptions(preserveFieldValues); + if (!preserveFieldValues) { + // Re-generating the months will reset the selected option. + this.generateMonths(); + // Re-generating the years will reset the selected option. + this.generateYears(); + super.loadRecord(record); + } + } + + generateMonths() { + const count = 12; + + // Clear the list + this._elements.month.textContent = ""; + + // Empty month option + this._elements.month.appendChild(new Option()); + + // Populate month list. Format: "month number - month name" + let dateFormat = new Intl.DateTimeFormat(navigator.language, { + month: "long", + }).format; + for (let i = 0; i < count; i++) { + let monthNumber = (i + 1).toString(); + let monthName = dateFormat(new Date(1970, i)); + let option = new Option(); + option.value = monthNumber; + // XXX: Bug 1446164 - Localize this string. + option.textContent = `${monthNumber.padStart(2, "0")} - ${monthName}`; + this._elements.month.appendChild(option); + } + } + + generateYears() { + const count = 11; + const currentYear = new Date().getFullYear(); + const ccExpYear = this._record && this._record["cc-exp-year"]; + + // Clear the list + this._elements.year.textContent = ""; + + // Provide an empty year option + this._elements.year.appendChild(new Option()); + + if (ccExpYear && ccExpYear < currentYear) { + this._elements.year.appendChild(new Option(ccExpYear)); + } + + for (let i = 0; i < count; i++) { + let year = currentYear + i; + let option = new Option(year); + this._elements.year.appendChild(option); + } + + if (ccExpYear && ccExpYear > currentYear + count) { + this._elements.year.appendChild(new Option(ccExpYear)); + } + } + + generateBillingAddressOptions(preserveFieldValues) { + let billingAddressGUID; + if (preserveFieldValues && this._elements.billingAddress.value) { + billingAddressGUID = this._elements.billingAddress.value; + } else if (this._record) { + billingAddressGUID = this._record.billingAddressGUID; + } + + this._elements.billingAddress.textContent = ""; + + this._elements.billingAddress.appendChild(new Option("", "")); + + let hasAddresses = false; + for (let [guid, address] of Object.entries(this._addresses)) { + hasAddresses = true; + let selected = guid == billingAddressGUID; + let option = new Option( + lazy.FormAutofillUtils.getAddressLabel(address), + guid, + selected, + selected + ); + this._elements.billingAddress.appendChild(option); + } + + this._elements.billingAddressRow.hidden = !hasAddresses; + } + + attachEventListeners() { + this._elements.form.addEventListener("change", this); + super.attachEventListeners(); + } + + handleInput(event) { + // Clear the error message if cc-number is valid + if ( + event.target == this._elements.ccNumber && + lazy.FormAutofillUtils.isCCNumber(this._elements.ccNumber.value) + ) { + this._elements.ccNumber.setCustomValidity(""); + } + super.handleInput(event); + } + + updateCustomValidity(field) { + super.updateCustomValidity(field); + + // Mark the cc-number field as invalid if the number is empty or invalid. + if ( + field == this._elements.ccNumber && + !lazy.FormAutofillUtils.isCCNumber(field.value) + ) { + let invalidCardNumberString = + this._elements.invalidCardNumberStringElement.textContent; + field.setCustomValidity(invalidCardNumberString || " "); + } + } +} diff --git a/browser/extensions/formautofill/content/editAddress.xhtml b/browser/extensions/formautofill/content/editAddress.xhtml index 47ae4a2a3b..a23fa5ab8c 100644 --- a/browser/extensions/formautofill/content/editAddress.xhtml +++ b/browser/extensions/formautofill/content/editAddress.xhtml @@ -19,65 +19,13 @@ rel="stylesheet" href="chrome://formautofill/content/skin/editDialog.css" /> - <script src="chrome://formautofill/content/editDialog.js"></script> - <script src="chrome://formautofill/content/autofillEditForms.js"></script> <script type="module" src="chrome://global/content/elements/moz-button-group.mjs" ></script> </head> <body> - <form id="form" class="editAddressForm" autocomplete="off"> - <!-- - The <span class="label-text" …/> needs to be after the form field in the same element in - order to get proper label styling with :focus and :user-invalid - --> - <label id="name-container" class="container"> - <input id="name" type="text" required="required" /> - <span data-l10n-id="autofill-address-name" class="label-text" /> - </label> - <label id="organization-container" class="container"> - <input id="organization" type="text" /> - <span data-l10n-id="autofill-address-organization" class="label-text" /> - </label> - <label id="street-address-container" class="container"> - <textarea id="street-address" rows="3" /> - <span data-l10n-id="autofill-address-street" class="label-text" /> - </label> - <label id="address-level3-container" class="container"> - <input id="address-level3" type="text" /> - <span class="label-text" /> - </label> - <label id="address-level2-container" class="container"> - <input id="address-level2" type="text" /> - <span class="label-text" /> - </label> - <label id="address-level1-container" class="container"> - <!-- The address-level1 input will get replaced by a select dropdown - by autofillEditForms.js when the selected country has provided - specific options. --> - <input id="address-level1" type="text" /> - <span class="label-text" /> - </label> - <label id="postal-code-container" class="container"> - <input id="postal-code" type="text" /> - <span class="label-text" /> - </label> - <label id="country-container" class="container"> - <select id="country" required="required"> - <option /> - </select> - <span data-l10n-id="autofill-address-country" class="label-text" /> - </label> - <label id="tel-container" class="container"> - <input id="tel" type="tel" dir="auto" /> - <span data-l10n-id="autofill-address-tel" class="label-text" /> - </label> - <label id="email-container" class="container"> - <input id="email" type="email" required="required" /> - <span data-l10n-id="autofill-address-email" class="label-text" /> - </label> - </form> + <form id="form" class="editAddressForm" autocomplete="off"></form> <div id="controls-container"> <span id="country-warning-message" @@ -88,31 +36,25 @@ <button id="save" class="primary" data-l10n-id="autofill-save-button" /> </moz-button-group> </div> - <script> - <![CDATA[ - "use strict"; + <!-- eslint-disable --> + <script type="module"> + import { createFormLayoutFromRecord } from "chrome://formautofill/content/addressFormLayout.mjs"; + import { EditAddressDialog } from "chrome://formautofill/content/editDialog.mjs"; - const { - record, - noValidate, - } = window.arguments?.[0] ?? {}; + const { record, noValidate, l10nStrings } = window.arguments?.[0] ?? {}; + const formElement = document.querySelector("form"); + formElement.noValidate = !!noValidate; + createFormLayoutFromRecord(formElement, record, l10nStrings); - /* import-globals-from autofillEditForms.js */ - const fieldContainer = new EditAddress({ - form: document.getElementById("form"), - }, record, { - noValidate, - }); - - /* import-globals-from editDialog.js */ - new EditAddressDialog({ - title: document.querySelector("title"), - fieldContainer, - controlsContainer: document.getElementById("controls-container"), - cancel: document.getElementById("cancel"), - save: document.getElementById("save"), - }, record); - ]]> + new EditAddressDialog( + { + title: document.querySelector("title"), + cancel: document.getElementById("cancel"), + save: document.getElementById("save"), + }, + record + ); </script> + <!-- eslint-enable --> </body> </html> diff --git a/browser/extensions/formautofill/content/editCreditCard.xhtml b/browser/extensions/formautofill/content/editCreditCard.xhtml index 920be841c5..8fceb5709b 100644 --- a/browser/extensions/formautofill/content/editCreditCard.xhtml +++ b/browser/extensions/formautofill/content/editCreditCard.xhtml @@ -19,8 +19,6 @@ rel="stylesheet" href="chrome://formautofill/content/skin/editDialog.css" /> - <script src="chrome://formautofill/content/editDialog.js"></script> - <script src="chrome://formautofill/content/autofillEditForms.js"></script> </head> <body> <form id="form" class="editCreditCardForm contentPane" autocomplete="off"> @@ -87,36 +85,32 @@ <button id="cancel" data-l10n-id="autofill-cancel-button" /> <button id="save" class="primary" data-l10n-id="autofill-save-button" /> </div> - <script> - <![CDATA[ - "use strict"; - /* import-globals-from editDialog.js */ + <!-- eslint-disable --> + <script type="module"> + import { EditCreditCardDialog } from "chrome://formautofill/content/editDialog.mjs"; + import { EditCreditCard } from "chrome://formautofill/content/autofillEditForms.mjs"; + const { record } = window.arguments?.[0] ?? {}; - (async () => { - const { - record, - } = window.arguments?.[0] ?? {}; + const fieldContainer = new EditCreditCard( + { + form: document.getElementById("form"), + }, + record, + [] + ); - const addresses = {}; - for (let address of await formAutofillStorage.addresses.getAll()) { - addresses[address.guid] = address; - } - - /* import-globals-from autofillEditForms.js */ - const fieldContainer = new EditCreditCard({ - form: document.getElementById("form"), - }, record, addresses); - - new EditCreditCardDialog({ - title: document.querySelector("title"), - fieldContainer, - controlsContainer: document.getElementById("controls-container"), - cancel: document.getElementById("cancel"), - save: document.getElementById("save"), - }, record); - })(); - ]]> + new EditCreditCardDialog( + { + title: document.querySelector("title"), + fieldContainer, + controlsContainer: document.getElementById("controls-container"), + cancel: document.getElementById("cancel"), + save: document.getElementById("save"), + }, + record + ); </script> + <!-- eslint-enable --> </body> </html> diff --git a/browser/extensions/formautofill/content/editDialog.js b/browser/extensions/formautofill/content/editDialog.js deleted file mode 100644 index 467acbdd07..0000000000 --- a/browser/extensions/formautofill/content/editDialog.js +++ /dev/null @@ -1,233 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -/* exported EditAddressDialog, EditCreditCardDialog */ -/* eslint-disable mozilla/balanced-listeners */ // Not relevant since the document gets unloaded. - -"use strict"; - -ChromeUtils.defineESModuleGetters(this, { - AutofillTelemetry: "resource://gre/modules/shared/AutofillTelemetry.sys.mjs", - formAutofillStorage: "resource://autofill/FormAutofillStorage.sys.mjs", -}); - -class AutofillEditDialog { - constructor(subStorageName, elements, record) { - this._storageInitPromise = formAutofillStorage.initialize(); - this._subStorageName = subStorageName; - this._elements = elements; - this._record = record; - this.localizeDocument(); - window.addEventListener("DOMContentLoaded", this, { once: true }); - } - - async init() { - this.updateSaveButtonState(); - this.attachEventListeners(); - // For testing only: signal to tests that the dialog is ready for testing. - // This is likely no longer needed since retrieving from storage is fully - // handled in manageDialog.js now. - window.dispatchEvent(new CustomEvent("FormReady")); - } - - /** - * Get storage and ensure it has been initialized. - * - * @returns {object} - */ - async getStorage() { - await this._storageInitPromise; - return formAutofillStorage[this._subStorageName]; - } - - /** - * Asks FormAutofillParent to save or update an record. - * - * @param {object} record - * @param {string} guid [optional] - */ - async saveRecord(record, guid) { - let storage = await this.getStorage(); - if (guid) { - await storage.update(guid, record); - } else { - await storage.add(record); - } - } - - /** - * Handle events - * - * @param {DOMEvent} event - */ - handleEvent(event) { - switch (event.type) { - case "DOMContentLoaded": { - this.init(); - break; - } - case "click": { - this.handleClick(event); - break; - } - case "input": { - this.handleInput(event); - break; - } - case "keypress": { - this.handleKeyPress(event); - break; - } - case "contextmenu": { - if ( - !HTMLInputElement.isInstance(event.target) && - !HTMLTextAreaElement.isInstance(event.target) - ) { - event.preventDefault(); - } - break; - } - } - } - - /** - * Handle click events - * - * @param {DOMEvent} event - */ - handleClick(event) { - if (event.target == this._elements.cancel) { - window.close(); - } - if (event.target == this._elements.save) { - this.handleSubmit(); - } - } - - /** - * Handle input events - */ - handleInput(_e) { - this.updateSaveButtonState(); - } - - /** - * Handle key press events - * - * @param {DOMEvent} event - */ - handleKeyPress(event) { - if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) { - window.close(); - } - } - - updateSaveButtonState() { - // Toggle disabled attribute on the save button based on - // whether the form is filled or empty. - if (!Object.keys(this._elements.fieldContainer.buildFormObject()).length) { - this._elements.save.setAttribute("disabled", true); - } else { - this._elements.save.removeAttribute("disabled"); - } - } - - /** - * Attach event listener - */ - attachEventListeners() { - window.addEventListener("keypress", this); - window.addEventListener("contextmenu", this); - this._elements.controlsContainer.addEventListener("click", this); - document.addEventListener("input", this); - } - - // An interface to be inherited. - localizeDocument() {} - - recordFormSubmit() { - let method = this._record?.guid ? "edit" : "add"; - AutofillTelemetry.recordManageEvent(this.telemetryType, method); - } -} - -class EditAddressDialog extends AutofillEditDialog { - telemetryType = AutofillTelemetry.ADDRESS; - - constructor(elements, record) { - super("addresses", elements, record); - if (record) { - AutofillTelemetry.recordManageEvent(this.telemetryType, "show_entry"); - } - } - - localizeDocument() { - if (this._record?.guid) { - document.l10n.setAttributes( - this._elements.title, - "autofill-edit-address-title" - ); - } - } - - async handleSubmit() { - await this.saveRecord( - this._elements.fieldContainer.buildFormObject(), - this._record ? this._record.guid : null - ); - this.recordFormSubmit(); - - window.close(); - } -} - -class EditCreditCardDialog extends AutofillEditDialog { - telemetryType = AutofillTelemetry.CREDIT_CARD; - - constructor(elements, record) { - elements.fieldContainer._elements.billingAddress.disabled = true; - super("creditCards", elements, record); - elements.fieldContainer._elements.ccNumber.addEventListener( - "blur", - this._onCCNumberFieldBlur.bind(this) - ); - if (record) { - AutofillTelemetry.recordManageEvent(this.telemetryType, "show_entry"); - } - } - - _onCCNumberFieldBlur() { - let elem = this._elements.fieldContainer._elements.ccNumber; - this._elements.fieldContainer.updateCustomValidity(elem); - } - - localizeDocument() { - if (this._record?.guid) { - document.l10n.setAttributes( - this._elements.title, - "autofill-edit-card-title2" - ); - } - } - - async handleSubmit() { - let creditCard = this._elements.fieldContainer.buildFormObject(); - if (!this._elements.fieldContainer._elements.form.reportValidity()) { - return; - } - - try { - await this.saveRecord( - creditCard, - this._record ? this._record.guid : null - ); - - this.recordFormSubmit(); - - window.close(); - } catch (ex) { - console.error(ex); - } - } -} diff --git a/browser/extensions/formautofill/content/editDialog.mjs b/browser/extensions/formautofill/content/editDialog.mjs new file mode 100644 index 0000000000..5371051e12 --- /dev/null +++ b/browser/extensions/formautofill/content/editDialog.mjs @@ -0,0 +1,253 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-disable mozilla/balanced-listeners */ // Not relevant since the document gets unloaded. + +import { + getCurrentFormData, + canSubmitForm, +} from "chrome://formautofill/content/addressFormLayout.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + AutofillTelemetry: "resource://gre/modules/shared/AutofillTelemetry.sys.mjs", + formAutofillStorage: "resource://autofill/FormAutofillStorage.sys.mjs", +}); + +class AutofillEditDialog { + constructor(subStorageName, elements, record) { + this._storageInitPromise = lazy.formAutofillStorage.initialize(); + this._subStorageName = subStorageName; + this._elements = elements; + this._record = record; + this.localizeDocument(); + window.addEventListener("load", this, { once: true }); + } + + async init() { + this.updateSaveButtonState(); + this.attachEventListeners(); + // For testing only: signal to tests that the dialog is ready for testing. + // This is likely no longer needed since retrieving from storage is fully + // handled in manageDialog.js now. + window.dispatchEvent(new CustomEvent("FormReadyForTests")); + } + + /** + * Get storage and ensure it has been initialized. + * + * @returns {object} + */ + async getStorage() { + await this._storageInitPromise; + return lazy.formAutofillStorage[this._subStorageName]; + } + + /** + * Asks FormAutofillParent to save or update an record. + * + * @param {object} record + * @param {string} guid [optional] + */ + async saveRecord(record, guid) { + let storage = await this.getStorage(); + if (guid) { + await storage.update(guid, record); + } else { + await storage.add(record); + } + } + + /** + * Handle events + * + * @param {DOMEvent} event + */ + handleEvent(event) { + switch (event.type) { + case "load": { + this.init(); + break; + } + case "click": { + this.handleClick(event); + break; + } + case "input": { + this.handleInput(event); + break; + } + case "keypress": { + this.handleKeyPress(event); + break; + } + case "contextmenu": { + if ( + !HTMLInputElement.isInstance(event.target) && + !HTMLTextAreaElement.isInstance(event.target) + ) { + event.preventDefault(); + } + break; + } + } + } + + /** + * Handle click events + * + * @param {DOMEvent} event + */ + handleClick(event) { + if (event.target == this._elements.cancel) { + window.close(); + } + if (event.target == this._elements.save) { + this.handleSubmit(); + } + } + + /** + * Handle input events + */ + handleInput(_e) { + this.updateSaveButtonState(); + } + + /** + * Handle key press events + * + * @param {DOMEvent} event + */ + handleKeyPress(event) { + if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) { + window.close(); + } + } + + updateSaveButtonState() { + // Toggle disabled attribute on the save button based on + // whether the form is filled or empty. + if (!Object.keys(this._elements.fieldContainer.buildFormObject()).length) { + this._elements.save.setAttribute("disabled", true); + } else { + this._elements.save.removeAttribute("disabled"); + } + } + + /** + * Attach event listener + */ + attachEventListeners() { + window.addEventListener("keypress", this); + window.addEventListener("contextmenu", this); + this._elements.save.addEventListener("click", this); + this._elements.cancel.addEventListener("click", this); + document.addEventListener("input", this); + } + + // An interface to be inherited. + localizeDocument() {} + + recordFormSubmit() { + let method = this._record?.guid ? "edit" : "add"; + lazy.AutofillTelemetry.recordManageEvent(this.telemetryType, method); + } +} + +export class EditAddressDialog extends AutofillEditDialog { + telemetryType = lazy.AutofillTelemetry.ADDRESS; + + constructor(elements, record) { + super("addresses", elements, record); + if (record) { + lazy.AutofillTelemetry.recordManageEvent( + this.telemetryType, + "show_entry" + ); + } + } + + localizeDocument() { + if (this._record?.guid) { + document.l10n.setAttributes( + this._elements.title, + "autofill-edit-address-title" + ); + } + } + + updateSaveButtonState() { + // Toggle disabled attribute on the save button based on + // whether the form is filled or empty. + if (!canSubmitForm()) { + this._elements.save.setAttribute("disabled", true); + } else { + this._elements.save.removeAttribute("disabled"); + } + } + + async handleSubmit() { + await this.saveRecord( + getCurrentFormData(), + this._record ? this._record.guid : null + ); + this.recordFormSubmit(); + + window.close(); + } +} + +export class EditCreditCardDialog extends AutofillEditDialog { + telemetryType = lazy.AutofillTelemetry.CREDIT_CARD; + + constructor(elements, record) { + elements.fieldContainer._elements.billingAddress.disabled = true; + super("creditCards", elements, record); + elements.fieldContainer._elements.ccNumber.addEventListener( + "blur", + this._onCCNumberFieldBlur.bind(this) + ); + if (record) { + lazy.AutofillTelemetry.recordManageEvent( + this.telemetryType, + "show_entry" + ); + } + } + + _onCCNumberFieldBlur() { + let elem = this._elements.fieldContainer._elements.ccNumber; + this._elements.fieldContainer.updateCustomValidity(elem); + } + + localizeDocument() { + if (this._record?.guid) { + document.l10n.setAttributes( + this._elements.title, + "autofill-edit-card-title2" + ); + } + } + + async handleSubmit() { + let creditCard = this._elements.fieldContainer.buildFormObject(); + if (!this._elements.fieldContainer._elements.form.reportValidity()) { + return; + } + + try { + await this.saveRecord( + creditCard, + this._record ? this._record.guid : null + ); + + this.recordFormSubmit(); + + window.close(); + } catch (ex) { + console.error(ex); + } + } +} diff --git a/browser/extensions/formautofill/content/manageAddresses.xhtml b/browser/extensions/formautofill/content/manageAddresses.xhtml index 68e810179e..2c8f0608f7 100644 --- a/browser/extensions/formautofill/content/manageAddresses.xhtml +++ b/browser/extensions/formautofill/content/manageAddresses.xhtml @@ -16,7 +16,6 @@ rel="stylesheet" href="chrome://formautofill/content/manageDialog.css" /> - <script src="chrome://formautofill/content/manageDialog.js"></script> </head> <body> <fieldset> @@ -39,9 +38,12 @@ data-l10n-id="autofill-manage-edit-button" /> </div> - <script> - "use strict"; - /* global ManageAddresses */ + <!-- eslint-disable --> + <!-- For some reason eslint complains here about import only available for sourceType: "module" --> + <!-- even though type is set to module.--> + <script type="module"> + import { ManageAddresses } from "chrome://formautofill/content/manageDialog.mjs"; + new ManageAddresses({ records: document.getElementById("addresses"), controlsContainer: document.getElementById("controls-container"), @@ -50,5 +52,6 @@ edit: document.getElementById("edit"), }); </script> + <!-- eslint-enable --> </body> </html> diff --git a/browser/extensions/formautofill/content/manageCreditCards.xhtml b/browser/extensions/formautofill/content/manageCreditCards.xhtml index e7baf9d364..69aae82df9 100644 --- a/browser/extensions/formautofill/content/manageCreditCards.xhtml +++ b/browser/extensions/formautofill/content/manageCreditCards.xhtml @@ -18,7 +18,6 @@ rel="stylesheet" href="chrome://formautofill/content/manageDialog.css" /> - <script src="chrome://formautofill/content/manageDialog.js"></script> </head> <body> <fieldset> @@ -41,9 +40,12 @@ data-l10n-id="autofill-manage-edit-button" /> </div> - <script> - "use strict"; - /* global ManageCreditCards */ + <!-- eslint-disable --> + <!-- For some reason eslint complains here about import only available for sourceType: "module" --> + <!-- eventhough type is set to module --> + <script type="module"> + import { ManageCreditCards } from "chrome://formautofill/content/manageDialog.mjs"; + new ManageCreditCards({ records: document.getElementById("credit-cards"), controlsContainer: document.getElementById("controls-container"), @@ -52,5 +54,6 @@ edit: document.getElementById("edit"), }); </script> + <!-- eslint-enable --> </body> </html> diff --git a/browser/extensions/formautofill/content/manageDialog.js b/browser/extensions/formautofill/content/manageDialog.js deleted file mode 100644 index ad5cefbb15..0000000000 --- a/browser/extensions/formautofill/content/manageDialog.js +++ /dev/null @@ -1,454 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -/* exported ManageAddresses, ManageCreditCards */ - -"use strict"; - -const EDIT_ADDRESS_URL = "chrome://formautofill/content/editAddress.xhtml"; -const EDIT_CREDIT_CARD_URL = - "chrome://formautofill/content/editCreditCard.xhtml"; - -const { AppConstants } = ChromeUtils.importESModule( - "resource://gre/modules/AppConstants.sys.mjs" -); -const { FormAutofill } = ChromeUtils.importESModule( - "resource://autofill/FormAutofill.sys.mjs" -); -const { AutofillTelemetry } = ChromeUtils.importESModule( - "resource://gre/modules/shared/AutofillTelemetry.sys.mjs" -); - -ChromeUtils.defineESModuleGetters(this, { - CreditCard: "resource://gre/modules/CreditCard.sys.mjs", - FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", - OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", - formAutofillStorage: "resource://autofill/FormAutofillStorage.sys.mjs", -}); - -this.log = null; -ChromeUtils.defineLazyGetter(this, "log", () => - FormAutofill.defineLogGetter(this, "manageAddresses") -); - -class ManageRecords { - constructor(subStorageName, elements) { - this._storageInitPromise = formAutofillStorage.initialize(); - this._subStorageName = subStorageName; - this._elements = elements; - this._newRequest = false; - this._isLoadingRecords = false; - this.prefWin = window.opener; - window.addEventListener("DOMContentLoaded", this, { once: true }); - } - - async init() { - await this.loadRecords(); - this.attachEventListeners(); - // For testing only: Notify when the dialog is ready for interaction - window.dispatchEvent(new CustomEvent("FormReady")); - } - - uninit() { - log.debug("uninit"); - this.detachEventListeners(); - this._elements = null; - } - - /** - * Get the selected options on the addresses element. - * - * @returns {Array<DOMElement>} - */ - get _selectedOptions() { - return Array.from(this._elements.records.selectedOptions); - } - - /** - * Get storage and ensure it has been initialized. - * - * @returns {object} - */ - async getStorage() { - await this._storageInitPromise; - return formAutofillStorage[this._subStorageName]; - } - - /** - * Load records and render them. This function is a wrapper for _loadRecords - * to ensure any reentrant will be handled well. - */ - async loadRecords() { - // This function can be early returned when there is any reentrant happends. - // "_newRequest" needs to be set to ensure all changes will be applied. - if (this._isLoadingRecords) { - this._newRequest = true; - return; - } - this._isLoadingRecords = true; - - await this._loadRecords(); - - // _loadRecords should be invoked again if there is any multiple entrant - // during running _loadRecords(). This step ensures that the latest request - // still is applied. - while (this._newRequest) { - this._newRequest = false; - await this._loadRecords(); - } - this._isLoadingRecords = false; - - // For testing only: Notify when records are loaded - this._elements.records.dispatchEvent(new CustomEvent("RecordsLoaded")); - } - - async _loadRecords() { - let storage = await this.getStorage(); - let records = await storage.getAll(); - // Sort by last used time starting with most recent - records.sort((a, b) => { - let aLastUsed = a.timeLastUsed || a.timeLastModified; - let bLastUsed = b.timeLastUsed || b.timeLastModified; - return bLastUsed - aLastUsed; - }); - await this.renderRecordElements(records); - this.updateButtonsStates(this._selectedOptions.length); - } - - /** - * Render the records onto the page while maintaining selected options if - * they still exist. - * - * @param {Array<object>} records - */ - async renderRecordElements(records) { - let selectedGuids = this._selectedOptions.map(option => option.value); - this.clearRecordElements(); - for (let record of records) { - let { id, args, raw } = await this.getLabelInfo(record); - let option = new Option( - raw ?? "", - record.guid, - false, - selectedGuids.includes(record.guid) - ); - if (id) { - document.l10n.setAttributes(option, id, args); - } - - option.record = record; - this._elements.records.appendChild(option); - } - } - - /** - * Remove all existing record elements. - */ - clearRecordElements() { - let parent = this._elements.records; - while (parent.lastChild) { - parent.removeChild(parent.lastChild); - } - } - - /** - * Remove records by selected options. - * - * @param {Array<DOMElement>} options - */ - async removeRecords(options) { - let storage = await this.getStorage(); - // Pause listening to storage change event to avoid triggering `loadRecords` - // when removing records - Services.obs.removeObserver(this, "formautofill-storage-changed"); - - for (let option of options) { - storage.remove(option.value); - option.remove(); - } - this.updateButtonsStates(this._selectedOptions); - - // Resume listening to storage change event - Services.obs.addObserver(this, "formautofill-storage-changed"); - // For testing only: notify record(s) has been removed - this._elements.records.dispatchEvent(new CustomEvent("RecordsRemoved")); - - for (let i = 0; i < options.length; i++) { - AutofillTelemetry.recordManageEvent(this.telemetryType, "delete"); - } - } - - /** - * Enable/disable the Edit and Remove buttons based on number of selected - * options. - * - * @param {number} selectedCount - */ - updateButtonsStates(selectedCount) { - log.debug("updateButtonsStates:", selectedCount); - if (selectedCount == 0) { - this._elements.edit.setAttribute("disabled", "disabled"); - this._elements.remove.setAttribute("disabled", "disabled"); - } else if (selectedCount == 1) { - this._elements.edit.removeAttribute("disabled"); - this._elements.remove.removeAttribute("disabled"); - } else if (selectedCount > 1) { - this._elements.edit.setAttribute("disabled", "disabled"); - this._elements.remove.removeAttribute("disabled"); - } - this._elements.add.disabled = !Services.prefs.getBoolPref( - `extensions.formautofill.${this._subStorageName}.enabled` - ); - } - - /** - * Handle events - * - * @param {DOMEvent} event - */ - handleEvent(event) { - switch (event.type) { - case "DOMContentLoaded": { - this.init(); - break; - } - case "click": { - this.handleClick(event); - break; - } - case "change": { - this.updateButtonsStates(this._selectedOptions.length); - break; - } - case "unload": { - this.uninit(); - break; - } - case "keypress": { - this.handleKeyPress(event); - break; - } - case "contextmenu": { - event.preventDefault(); - break; - } - } - } - - /** - * Handle click events - * - * @param {DOMEvent} event - */ - handleClick(event) { - if (event.target == this._elements.remove) { - this.removeRecords(this._selectedOptions); - } else if (event.target == this._elements.add) { - this.openEditDialog(); - } else if ( - event.target == this._elements.edit || - (event.target.parentNode == this._elements.records && event.detail > 1) - ) { - this.openEditDialog(this._selectedOptions[0].record); - } - } - - /** - * Handle key press events - * - * @param {DOMEvent} event - */ - handleKeyPress(event) { - if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) { - window.close(); - } - if (event.keyCode == KeyEvent.DOM_VK_DELETE) { - this.removeRecords(this._selectedOptions); - } - } - - observe(_subject, topic, _data) { - switch (topic) { - case "formautofill-storage-changed": { - this.loadRecords(); - } - } - } - - /** - * Attach event listener - */ - attachEventListeners() { - window.addEventListener("unload", this, { once: true }); - window.addEventListener("keypress", this); - window.addEventListener("contextmenu", this); - this._elements.records.addEventListener("change", this); - this._elements.records.addEventListener("click", this); - this._elements.controlsContainer.addEventListener("click", this); - Services.obs.addObserver(this, "formautofill-storage-changed"); - } - - /** - * Remove event listener - */ - detachEventListeners() { - window.removeEventListener("keypress", this); - window.removeEventListener("contextmenu", this); - this._elements.records.removeEventListener("change", this); - this._elements.records.removeEventListener("click", this); - this._elements.controlsContainer.removeEventListener("click", this); - Services.obs.removeObserver(this, "formautofill-storage-changed"); - } -} - -class ManageAddresses extends ManageRecords { - telemetryType = AutofillTelemetry.ADDRESS; - - constructor(elements) { - super("addresses", elements); - elements.add.setAttribute( - "search-l10n-ids", - FormAutofillUtils.EDIT_ADDRESS_L10N_IDS.join(",") - ); - AutofillTelemetry.recordManageEvent(this.telemetryType, "show"); - } - - /** - * Open the edit address dialog to create/edit an address. - * - * @param {object} address [optional] - */ - openEditDialog(address) { - this.prefWin.gSubDialog.open(EDIT_ADDRESS_URL, undefined, { - record: address, - // Don't validate in preferences since it's fine for fields to be missing - // for autofill purposes. For PaymentRequest addresses get more validation. - noValidate: true, - }); - } - - getLabelInfo(address) { - return { raw: FormAutofillUtils.getAddressLabel(address) }; - } -} - -class ManageCreditCards extends ManageRecords { - telemetryType = AutofillTelemetry.CREDIT_CARD; - - constructor(elements) { - super("creditCards", elements); - elements.add.setAttribute( - "search-l10n-ids", - FormAutofillUtils.EDIT_CREDITCARD_L10N_IDS.join(",") - ); - - this._isDecrypted = false; - AutofillTelemetry.recordManageEvent(this.telemetryType, "show"); - } - - /** - * Open the edit address dialog to create/edit a credit card. - * - * @param {object} creditCard [optional] - */ - async openEditDialog(creditCard) { - // Ask for reauth if user is trying to edit an existing credit card. - if (creditCard) { - const promptMessage = FormAutofillUtils.reauthOSPromptMessage( - "autofill-edit-payment-method-os-prompt-macos", - "autofill-edit-payment-method-os-prompt-windows", - "autofill-edit-payment-method-os-prompt-other" - ); - - const loggedIn = await FormAutofillUtils.ensureLoggedIn(promptMessage); - if (!loggedIn.authenticated) { - return; - } - } - - let decryptedCCNumObj = {}; - if (creditCard && creditCard["cc-number-encrypted"]) { - try { - decryptedCCNumObj["cc-number"] = await OSKeyStore.decrypt( - creditCard["cc-number-encrypted"] - ); - } catch (ex) { - if (ex.result == Cr.NS_ERROR_ABORT) { - // User shouldn't be ask to reauth here, but it could happen. - // Return here and skip opening the dialog. - return; - } - // We've got ourselves a real error. - // Recover from encryption error so the user gets a chance to re-enter - // unencrypted credit card number. - decryptedCCNumObj["cc-number"] = ""; - console.error(ex); - } - } - let decryptedCreditCard = Object.assign({}, creditCard, decryptedCCNumObj); - this.prefWin.gSubDialog.open( - EDIT_CREDIT_CARD_URL, - { features: "resizable=no" }, - { - record: decryptedCreditCard, - } - ); - } - - /** - * Get credit card display label. It should display masked numbers and the - * cardholder's name, separated by a comma. - * - * @param {object} creditCard - * @returns {Promise<string>} - */ - async getLabelInfo(creditCard) { - // The card type is displayed visually using an image. For a11y, we need - // to expose it as text. We do this using aria-label. However, - // aria-label overrides the text content, so we must include that also. - // Since the text content is generated by Fluent, aria-label must be - // generated by Fluent also. - const type = creditCard["cc-type"]; - const typeL10nId = CreditCard.getNetworkL10nId(type); - const typeName = typeL10nId - ? await document.l10n.formatValue(typeL10nId) - : type ?? ""; // Unknown card type - return CreditCard.getLabelInfo({ - name: creditCard["cc-name"], - number: creditCard["cc-number"], - month: creditCard["cc-exp-month"], - year: creditCard["cc-exp-year"], - type: typeName, - }); - } - - async renderRecordElements(records) { - // Revert back to encrypted form when re-rendering happens - this._isDecrypted = false; - // Display third-party card icons when possible - this._elements.records.classList.toggle( - "branded", - AppConstants.MOZILLA_OFFICIAL - ); - await super.renderRecordElements(records); - - let options = this._elements.records.options; - for (let option of options) { - let record = option.record; - if (record && record["cc-type"]) { - option.setAttribute("cc-type", record["cc-type"]); - } else { - option.removeAttribute("cc-type"); - } - } - } - - updateButtonsStates(selectedCount) { - super.updateButtonsStates(selectedCount); - } - - handleClick(event) { - super.handleClick(event); - } -} diff --git a/browser/extensions/formautofill/content/manageDialog.mjs b/browser/extensions/formautofill/content/manageDialog.mjs new file mode 100644 index 0000000000..bca0f48f40 --- /dev/null +++ b/browser/extensions/formautofill/content/manageDialog.mjs @@ -0,0 +1,474 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const EDIT_ADDRESS_URL = "chrome://formautofill/content/editAddress.xhtml"; +const EDIT_CREDIT_CARD_URL = + "chrome://formautofill/content/editCreditCard.xhtml"; + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +const { FormAutofill } = ChromeUtils.importESModule( + "resource://autofill/FormAutofill.sys.mjs" +); +const { AutofillTelemetry } = ChromeUtils.importESModule( + "resource://gre/modules/shared/AutofillTelemetry.sys.mjs" +); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + CreditCard: "resource://gre/modules/CreditCard.sys.mjs", + FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", + OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", + formAutofillStorage: "resource://autofill/FormAutofillStorage.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "log", () => + FormAutofill.defineLogGetter(lazy, "manageAddresses") +); + +ChromeUtils.defineLazyGetter( + lazy, + "l10n", + () => new Localization([" browser/preferences/formAutofill.ftl"], true) +); + +class ManageRecords { + constructor(subStorageName, elements) { + this._storageInitPromise = lazy.formAutofillStorage.initialize(); + this._subStorageName = subStorageName; + this._elements = elements; + this._newRequest = false; + this._isLoadingRecords = false; + this.prefWin = window.opener; + window.addEventListener("load", this, { once: true }); + } + + async init() { + await this.loadRecords(); + this.attachEventListeners(); + // For testing only: Notify when the dialog is ready for interaction + window.dispatchEvent(new CustomEvent("FormReadyForTests")); + } + + uninit() { + lazy.log.debug("uninit"); + this.detachEventListeners(); + this._elements = null; + } + + /** + * Get the selected options on the addresses element. + * + * @returns {Array<DOMElement>} + */ + get _selectedOptions() { + return Array.from(this._elements.records.selectedOptions); + } + + /** + * Get storage and ensure it has been initialized. + * + * @returns {object} + */ + async getStorage() { + await this._storageInitPromise; + return lazy.formAutofillStorage[this._subStorageName]; + } + + /** + * Load records and render them. This function is a wrapper for _loadRecords + * to ensure any reentrant will be handled well. + */ + async loadRecords() { + // This function can be early returned when there is any reentrant happends. + // "_newRequest" needs to be set to ensure all changes will be applied. + if (this._isLoadingRecords) { + this._newRequest = true; + return; + } + this._isLoadingRecords = true; + + await this._loadRecords(); + + // _loadRecords should be invoked again if there is any multiple entrant + // during running _loadRecords(). This step ensures that the latest request + // still is applied. + while (this._newRequest) { + this._newRequest = false; + await this._loadRecords(); + } + this._isLoadingRecords = false; + + // For testing only: Notify when records are loaded + this._elements.records.dispatchEvent(new CustomEvent("RecordsLoaded")); + } + + async _loadRecords() { + let storage = await this.getStorage(); + let records = await storage.getAll(); + // Sort by last used time starting with most recent + records.sort((a, b) => { + let aLastUsed = a.timeLastUsed || a.timeLastModified; + let bLastUsed = b.timeLastUsed || b.timeLastModified; + return bLastUsed - aLastUsed; + }); + await this.renderRecordElements(records); + this.updateButtonsStates(this._selectedOptions.length); + } + + /** + * Render the records onto the page while maintaining selected options if + * they still exist. + * + * @param {Array<object>} records + */ + async renderRecordElements(records) { + let selectedGuids = this._selectedOptions.map(option => option.value); + this.clearRecordElements(); + for (let record of records) { + let { id, args, raw } = await this.getLabelInfo(record); + let option = new Option( + raw ?? "", + record.guid, + false, + selectedGuids.includes(record.guid) + ); + if (id) { + document.l10n.setAttributes(option, id, args); + } + + option.record = record; + this._elements.records.appendChild(option); + } + } + + /** + * Remove all existing record elements. + */ + clearRecordElements() { + const parentElement = this._elements.records; + while (parentElement.lastChild) { + parentElement.removeChild(parentElement.lastChild); + } + } + + /** + * Remove records by selected options. + * + * @param {Array<DOMElement>} options + */ + async removeRecords(options) { + let storage = await this.getStorage(); + // Pause listening to storage change event to avoid triggering `loadRecords` + // when removing records + Services.obs.removeObserver(this, "formautofill-storage-changed"); + + for (let option of options) { + storage.remove(option.value); + option.remove(); + } + this.updateButtonsStates(this._selectedOptions); + + // Resume listening to storage change event + Services.obs.addObserver(this, "formautofill-storage-changed"); + // For testing only: notify record(s) has been removed + this._elements.records.dispatchEvent(new CustomEvent("RecordsRemoved")); + + for (let i = 0; i < options.length; i++) { + AutofillTelemetry.recordManageEvent(this.telemetryType, "delete"); + } + } + + /** + * Enable/disable the Edit and Remove buttons based on number of selected + * options. + * + * @param {number} selectedCount + */ + updateButtonsStates(selectedCount) { + lazy.log.debug("updateButtonsStates:", selectedCount); + if (selectedCount == 0) { + this._elements.edit.setAttribute("disabled", "disabled"); + this._elements.remove.setAttribute("disabled", "disabled"); + } else if (selectedCount == 1) { + this._elements.edit.removeAttribute("disabled"); + this._elements.remove.removeAttribute("disabled"); + } else if (selectedCount > 1) { + this._elements.edit.setAttribute("disabled", "disabled"); + this._elements.remove.removeAttribute("disabled"); + } + this._elements.add.disabled = !Services.prefs.getBoolPref( + `extensions.formautofill.${this._subStorageName}.enabled` + ); + } + + /** + * Handle events + * + * @param {DOMEvent} event + */ + handleEvent(event) { + switch (event.type) { + case "load": { + this.init(); + break; + } + case "click": { + this.handleClick(event); + break; + } + case "change": { + this.updateButtonsStates(this._selectedOptions.length); + break; + } + case "unload": { + this.uninit(); + break; + } + case "keypress": { + this.handleKeyPress(event); + break; + } + case "contextmenu": { + event.preventDefault(); + break; + } + } + } + + /** + * Handle click events + * + * @param {DOMEvent} event + */ + handleClick(event) { + if (event.target == this._elements.remove) { + this.removeRecords(this._selectedOptions); + } else if (event.target == this._elements.add) { + this.openEditDialog(); + } else if ( + event.target == this._elements.edit || + (event.target.parentNode == this._elements.records && event.detail > 1) + ) { + this.openEditDialog(this._selectedOptions[0].record); + } + } + + /** + * Handle key press events + * + * @param {DOMEvent} event + */ + handleKeyPress(event) { + if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) { + window.close(); + } + if (event.keyCode == KeyEvent.DOM_VK_DELETE) { + this.removeRecords(this._selectedOptions); + } + } + + observe(_subject, topic, _data) { + switch (topic) { + case "formautofill-storage-changed": { + this.loadRecords(); + } + } + } + + /** + * Attach event listener + */ + attachEventListeners() { + window.addEventListener("unload", this, { once: true }); + window.addEventListener("keypress", this); + window.addEventListener("contextmenu", this); + this._elements.records.addEventListener("change", this); + this._elements.records.addEventListener("click", this); + this._elements.controlsContainer.addEventListener("click", this); + Services.obs.addObserver(this, "formautofill-storage-changed"); + } + + /** + * Remove event listener + */ + detachEventListeners() { + window.removeEventListener("keypress", this); + window.removeEventListener("contextmenu", this); + this._elements.records.removeEventListener("change", this); + this._elements.records.removeEventListener("click", this); + this._elements.controlsContainer.removeEventListener("click", this); + Services.obs.removeObserver(this, "formautofill-storage-changed"); + } +} + +export class ManageAddresses extends ManageRecords { + telemetryType = AutofillTelemetry.ADDRESS; + + constructor(elements) { + super("addresses", elements); + elements.add.setAttribute( + "search-l10n-ids", + lazy.FormAutofillUtils.EDIT_ADDRESS_L10N_IDS.join(",") + ); + AutofillTelemetry.recordManageEvent(this.telemetryType, "show"); + } + + static getAddressL10nStrings() { + const l10nIds = [ + ...lazy.FormAutofillUtils.MANAGE_ADDRESSES_L10N_IDS, + ...lazy.FormAutofillUtils.EDIT_ADDRESS_L10N_IDS, + ]; + + return l10nIds.reduce( + (acc, id) => ({ + ...acc, + [id]: lazy.l10n.formatValueSync(id), + }), + {} + ); + } + + /** + * Open the edit address dialog to create/edit an address. + * + * @param {object} address [optional] + */ + openEditDialog(address) { + this.prefWin.gSubDialog.open(EDIT_ADDRESS_URL, undefined, { + record: address, + // Don't validate in preferences since it's fine for fields to be missing + // for autofill purposes. For PaymentRequest addresses get more validation. + noValidate: true, + l10nStrings: ManageAddresses.getAddressL10nStrings(), + }); + } + + getLabelInfo(address) { + return { raw: lazy.FormAutofillUtils.getAddressLabel(address) }; + } +} + +export class ManageCreditCards extends ManageRecords { + telemetryType = AutofillTelemetry.CREDIT_CARD; + + constructor(elements) { + super("creditCards", elements); + elements.add.setAttribute( + "search-l10n-ids", + lazy.FormAutofillUtils.EDIT_CREDITCARD_L10N_IDS.join(",") + ); + + this._isDecrypted = false; + AutofillTelemetry.recordManageEvent(this.telemetryType, "show"); + } + + /** + * Open the edit address dialog to create/edit a credit card. + * + * @param {object} creditCard [optional] + */ + async openEditDialog(creditCard) { + // Ask for reauth if user is trying to edit an existing credit card. + if (creditCard) { + const promptMessage = lazy.FormAutofillUtils.reauthOSPromptMessage( + "autofill-edit-payment-method-os-prompt-macos", + "autofill-edit-payment-method-os-prompt-windows", + "autofill-edit-payment-method-os-prompt-other" + ); + + const verified = await lazy.FormAutofillUtils.verifyUserOSAuth( + FormAutofill.AUTOFILL_CREDITCARDS_REAUTH_PREF, + promptMessage + ); + if (!verified) { + return; + } + } + let decryptedCCNumObj = {}; + if (creditCard && creditCard["cc-number-encrypted"]) { + try { + decryptedCCNumObj["cc-number"] = await lazy.OSKeyStore.decrypt( + creditCard["cc-number-encrypted"] + ); + } catch (ex) { + if (ex.result == Cr.NS_ERROR_ABORT) { + // User shouldn't be ask to reauth here, but it could happen. + // Return here and skip opening the dialog. + return; + } + // We've got ourselves a real error. + // Recover from encryption error so the user gets a chance to re-enter + // unencrypted credit card number. + decryptedCCNumObj["cc-number"] = ""; + console.error(ex); + } + } + let decryptedCreditCard = Object.assign({}, creditCard, decryptedCCNumObj); + this.prefWin.gSubDialog.open( + EDIT_CREDIT_CARD_URL, + { features: "resizable=no" }, + { + record: decryptedCreditCard, + } + ); + } + + /** + * Get credit card display label. It should display masked numbers and the + * cardholder's name, separated by a comma. + * + * @param {object} creditCard + * @returns {Promise<string>} + */ + async getLabelInfo(creditCard) { + // The card type is displayed visually using an image. For a11y, we need + // to expose it as text. We do this using aria-label. However, + // aria-label overrides the text content, so we must include that also. + // Since the text content is generated by Fluent, aria-label must be + // generated by Fluent also. + const type = creditCard["cc-type"]; + const typeL10nId = lazy.CreditCard.getNetworkL10nId(type); + const typeName = typeL10nId + ? await document.l10n.formatValue(typeL10nId) + : type ?? ""; // Unknown card type + return lazy.CreditCard.getLabelInfo({ + name: creditCard["cc-name"], + number: creditCard["cc-number"], + month: creditCard["cc-exp-month"], + year: creditCard["cc-exp-year"], + type: typeName, + }); + } + + async renderRecordElements(records) { + // Revert back to encrypted form when re-rendering happens + this._isDecrypted = false; + // Display third-party card icons when possible + this._elements.records.classList.toggle( + "branded", + AppConstants.MOZILLA_OFFICIAL + ); + await super.renderRecordElements(records); + + let options = this._elements.records.options; + for (let option of options) { + let record = option.record; + if (record && record["cc-type"]) { + option.setAttribute("cc-type", record["cc-type"]); + } else { + option.removeAttribute("cc-type"); + } + } + } + + updateButtonsStates(selectedCount) { + super.updateButtonsStates(selectedCount); + } + + handleClick(event) { + super.handleClick(event); + } +} diff --git a/browser/extensions/formautofill/skin/shared/editAddress.css b/browser/extensions/formautofill/skin/shared/editAddress.css index c50024e542..7660fd8e55 100644 --- a/browser/extensions/formautofill/skin/shared/editAddress.css +++ b/browser/extensions/formautofill/skin/shared/editAddress.css @@ -18,6 +18,15 @@ dialog:not([subdialog]) .editAddressForm { margin-top: var(--grid-column-row-gap) !important; margin-inline: calc(var(--grid-column-row-gap) / 2); flex-grow: 1; + + &.new-line { + flex: 0 1 100%; + } + + input, textarea, select { + width: 100%; + margin: 0; + } } #country-container { @@ -29,12 +38,6 @@ dialog:not([subdialog]) .editAddressForm { max-width: calc(50% - var(--grid-column-row-gap)); } -#name-container, -#street-address-container { - /* Name and street address are always full-width */ - flex: 0 1 100%; -} - #street-address { resize: vertical; } diff --git a/browser/extensions/formautofill/test/browser/address/browser.toml b/browser/extensions/formautofill/test/browser/address/browser.toml index 8b7f1ec760..e8c72ae1b1 100644 --- a/browser/extensions/formautofill/test/browser/address/browser.toml +++ b/browser/extensions/formautofill/test/browser/address/browser.toml @@ -38,6 +38,8 @@ support-files = [ ["browser_address_doorhanger_ui.js"] +["browser_address_doorhanger_ui_lines.js"] + ["browser_address_doorhanger_unsupported_region.js"] ["browser_address_telemetry.js"] diff --git a/browser/extensions/formautofill/test/browser/address/browser_address_doorhanger_ui_lines.js b/browser/extensions/formautofill/test/browser/address/browser_address_doorhanger_ui_lines.js new file mode 100644 index 0000000000..01e888a5f8 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/address/browser_address_doorhanger_ui_lines.js @@ -0,0 +1,32 @@ +"use strict"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.formautofill.addresses.capture.enabled", true], + ["extensions.formautofill.addresses.supported", "on"], + ], + }); +}); + +add_task( + async function test_address_line_displays_normalized_state_in_save_doorhanger() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: ADDRESS_FORM_URL }, + async function (browser) { + await showAddressDoorhanger(browser, { + "#address-level1": "Nova Scotia", + "#address-level2": "Somerset", + "#country": "CA", + }); + + const p = getNotification().querySelector( + `.address-save-update-row-container p:first-child` + ); + is(p.textContent, "Somerset, NS"); + + await clickAddressDoorhangerButton(SECONDARY_BUTTON); + } + ); + } +); diff --git a/browser/extensions/formautofill/test/browser/browser_editAddressDialog.js b/browser/extensions/formautofill/test/browser/browser_editAddressDialog.js index 62797739fc..dec367d8e9 100644 --- a/browser/extensions/formautofill/test/browser/browser_editAddressDialog.js +++ b/browser/extensions/formautofill/test/browser/browser_editAddressDialog.js @@ -1,7 +1,7 @@ "use strict"; -const { FormAutofillUtils } = ChromeUtils.importESModule( - "resource://gre/modules/shared/FormAutofillUtils.sys.mjs" +const { FormAutofill } = ChromeUtils.importESModule( + "resource://autofill/FormAutofill.sys.mjs" ); ChromeUtils.defineESModuleGetters(this, { @@ -53,7 +53,12 @@ add_task(async function test_defaultCountry() { Region._setHomeRegion("XX", false); await testDialog(EDIT_ADDRESS_DIALOG_URL, win => { let doc = win.document; - is(doc.querySelector("#country").value, "", "Default country set to empty"); + const countries = [...FormAutofill.countries.keys()]; + is( + countries[0], + doc.querySelector("#country").value, + "Default country set to first option in the list" + ); doc.querySelector("#cancel").click(); }); Region._setHomeRegion("US", false); @@ -250,10 +255,9 @@ add_task(async function test_saveAddressCA() { "Postal Code", "CA postal-code label should be 'Postal Code'" ); - is( - doc.querySelector("#address-level3-container").style.display, - "none", - "CA address-level3 should be hidden" + ok( + !doc.querySelector("#address-level3-container"), + "CA address-level3 should not be rendered" ); // Input address info and verify move through form with tab keys @@ -313,15 +317,13 @@ add_task(async function test_saveAddressDE() { "Postal Code", "DE postal-code label should be 'Postal Code'" ); - is( - doc.querySelector("#address-level1-container").style.display, - "none", - "DE address-level1 should be hidden" + ok( + !doc.querySelector("#address-level1-container"), + "DE address-level1 should not be rendered" ); - is( - doc.querySelector("#address-level3-container").style.display, - "none", - "DE address-level3 should be hidden" + ok( + !doc.querySelector("#address-level3-container"), + "DE address-level3 should not be rendered" ); // Input address info and verify move through form with tab keys doc.querySelector("#name").focus(); @@ -434,57 +436,28 @@ add_task(async function test_saveAddressIE() { add_task(async function test_countryAndStateFieldLabels() { await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => { - let doc = win.document; - // Change country to verify labels - doc.querySelector("#country").focus(); - - let mutableLabels = [ - "postal-code-container", - "address-level1-container", - "address-level2-container", - "address-level3-container", - ].map(containerID => - doc.getElementById(containerID).querySelector(":scope > .label-text") - ); - + const doc = win.document; for (let countryOption of doc.querySelector("#country").options) { - if (countryOption.value == "") { - info("Skipping the empty country option"); - continue; - } - // Clear L10N textContent to not leave leftovers between country tests - for (let labelEl of mutableLabels) { + for (const labelEl of doc.querySelectorAll(".label-text")) { doc.l10n.setAttributes(labelEl, ""); labelEl.textContent = ""; } + // Change country to verify labels + doc.querySelector("#country").focus(); + info(`Selecting '${countryOption.label}' (${countryOption.value})`); EventUtils.synthesizeKey(countryOption.label, {}, win); - let l10nResolve; - let l10nReady = new Promise(resolve => { - l10nResolve = resolve; - }); - let verifyL10n = () => { - if (mutableLabels.every(labelEl => labelEl.textContent)) { - win.removeEventListener("MozAfterPaint", verifyL10n); - l10nResolve(); - } - }; - win.addEventListener("MozAfterPaint", verifyL10n); - await l10nReady; - - // Check that the labels were filled - for (let labelEl of mutableLabels) { - isnot( - labelEl.textContent, - "", - "Ensure textContent is non-empty for: " + countryOption.value - ); - } + await waitForFocusAndFormReady(win); + + const allLabelsHaveText = [...doc.querySelectorAll(".label-text")].every( + labelEl => labelEl.textContent + ); + + ok(allLabelsHaveText, "All labels are rendered and have text content"); - let stateOptions = doc.querySelector("#address-level1").options; /* eslint-disable max-len */ let expectedStateOptions = { BS: { @@ -510,22 +483,22 @@ add_task(async function test_countryAndStateFieldLabels() { /* eslint-enable max-len */ if (expectedStateOptions[countryOption.value]) { + const stateOptions = doc.querySelector("#address-level1").options; let { keys, names } = expectedStateOptions[countryOption.value]; is( stateOptions.length, - keys.length + 1, - "stateOptions should list all options plus a blank entry" + keys.length, + "stateOptions should have the same length as the expected options" ); - is(stateOptions[0].value, "", "First State option should be blank"); for (let i = 1; i < stateOptions.length; i++) { is( stateOptions[i].value, - keys[i - 1], + keys[i], "Each State should be listed in alphabetical name order (key)" ); is( stateOptions[i].text, - names[i - 1], + names[i], "Each State should be listed in alphabetical name order (name)" ); } @@ -539,14 +512,15 @@ add_task(async function test_countryAndStateFieldLabels() { }); add_task(async function test_hiddenFieldNotSaved() { - await testDialog(EDIT_ADDRESS_DIALOG_URL, win => { - let doc = win.document; + await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => { + const doc = win.document; doc.querySelector("#address-level2").focus(); EventUtils.synthesizeKey(TEST_ADDRESS_1["address-level2"], {}, win); doc.querySelector("#address-level1").focus(); EventUtils.synthesizeKey(TEST_ADDRESS_1["address-level1"], {}, win); doc.querySelector("#country").focus(); EventUtils.synthesizeKey("Germany", {}, win); + await waitForFocusAndFormReady(win); doc.querySelector("#save").focus(); EventUtils.synthesizeKey("VK_RETURN", {}, win); }); @@ -598,10 +572,11 @@ add_task(async function test_hiddenFieldRemovedWhenCountryChanged() { await testDialog( EDIT_ADDRESS_DIALOG_URL, - win => { - let doc = win.document; + async win => { + const doc = win.document; doc.querySelector("#country").focus(); EventUtils.synthesizeKey("Germany", {}, win); + await waitForFocusAndFormReady(win); win.document.querySelector("#save").click(); }, { @@ -628,63 +603,33 @@ add_task(async function test_hiddenFieldRemovedWhenCountryChanged() { add_task(async function test_countrySpecificFieldsGetRequiredness() { Region._setHomeRegion("RO", false); await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => { - let doc = win.document; + const doc = win.document; is( doc.querySelector("#country").value, "RO", "Default country set to Romania" ); let provinceField = doc.getElementById("address-level1"); - ok( - !provinceField.required, - "address-level1 should not be marked as required" - ); - ok(provinceField.disabled, "address-level1 should be marked as disabled"); - is( - provinceField.parentNode.style.display, - "none", - "address-level1 is hidden for Romania" - ); + ok(!provinceField, "address-level1 should not be rendered"); doc.querySelector("#country").focus(); EventUtils.synthesizeKey("United States", {}, win); + await waitForFocusAndFormReady(win); + const stateField = doc.getElementById("address-level1"); - await TestUtils.waitForCondition( - () => { - provinceField = doc.getElementById("address-level1"); - return provinceField.parentNode.style.display != "none"; - }, - "Wait for address-level1 to become visible", - 10 - ); - - ok(provinceField.required, "address-level1 should be marked as required"); - ok( - !provinceField.disabled, - "address-level1 should not be marked as disabled" - ); + ok(stateField.required, "address-level1 should be marked as required"); + ok(!stateField.disabled, "address-level1 should not be marked as disabled"); // Dispatch a dummy key event so that <select>'s incremental search is cleared. EventUtils.synthesizeKey("VK_ACCEPT", {}, win); - doc.querySelector("#country").focus(); EventUtils.synthesizeKey("Romania", {}, win); - await TestUtils.waitForCondition( - () => { - provinceField = doc.getElementById("address-level1"); - return provinceField.parentNode.style.display == "none"; - }, - "Wait for address-level1 to become hidden", - 10 - ); - + await waitForFocusAndFormReady(win); ok( - provinceField.required, - "address-level1 will still be marked as required" + !doc.getElementById("address-level1"), + "address-level1 is not rendered " ); - ok(provinceField.disabled, "address-level1 should be marked as disabled"); - doc.querySelector("#cancel").click(); }); }); diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser.toml b/browser/extensions/formautofill/test/browser/creditCard/browser.toml index 580ce936d4..ead527488e 100644 --- a/browser/extensions/formautofill/test/browser/creditCard/browser.toml +++ b/browser/extensions/formautofill/test/browser/creditCard/browser.toml @@ -1,7 +1,6 @@ [DEFAULT] prefs = [ "extensions.formautofill.creditCards.enabled=true", - "extensions.formautofill.reauth.enabled=true", "toolkit.telemetry.ipcBatchTimeout=0", # lower the interval for event telemetry in the content process to update the parent process ] support-files = [ @@ -41,11 +40,13 @@ skip-if = [ ] ["browser_creditCard_doorhanger_display.js"] -skip-if = [ - "apple_catalina && !debug", # perma-fail see Bug 1655601 - "apple_silicon && !debug", # perma-fail see Bug 1655601 - "win11_2009 && ccov", # Bug 1655600 -] +skip-if = ["true"] # Bug 1895422 +# Bug 1895422 - Fix this test for linux then uncomment. +# skip-if = [ +# "apple_catalina && !debug", # perma-fail see Bug 1655601 +# "apple_silicon && !debug", # perma-fail see Bug 1655601 +# "win11_2009 && ccov", # Bug 1655600 +# ] ["browser_creditCard_doorhanger_fields.js"] skip-if = [ @@ -102,6 +103,9 @@ skip-if = ["apple_silicon && !debug"] # Bug 1714221 ["browser_creditCard_heuristics_cc_type.js"] skip-if = ["apple_silicon && !debug"] # Bug 1714221 +["browser_creditCard_osAuth.js"] +skip-if = ["os == 'linux'"] + ["browser_creditCard_submission_autodetect_type.js"] skip-if = ["apple_silicon && !debug"] diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_anti_clickjacking.js b/browser/extensions/formautofill/test/browser/creditCard/browser_anti_clickjacking.js index f7fc731e54..0398c6242d 100644 --- a/browser/extensions/formautofill/test/browser/creditCard/browser_anti_clickjacking.js +++ b/browser/extensions/formautofill/test/browser/creditCard/browser_anti_clickjacking.js @@ -16,6 +16,26 @@ add_task(async function setup_storage() { ); }); +async function disableOSAuthForThisTest() { + // Revert head.js change that mocks os auth + sinon.restore(); + + let oldValue = FormAutofillUtils.getOSAuthEnabled( + FormAutofillUtils.AUTOFILL_CREDITCARDS_REAUTH_PREF + ); + FormAutofillUtils.setOSAuthEnabled( + FormAutofillUtils.AUTOFILL_CREDITCARDS_REAUTH_PREF, + false + ); + + registerCleanupFunction(() => { + FormAutofillUtils.setOSAuthEnabled( + FormAutofillUtils.AUTOFILL_CREDITCARDS_REAUTH_PREF, + oldValue + ); + }); +} + add_task(async function test_active_delay() { // This is a workaround for the fact that we don't have a way // to know when the popup was opened exactly and this makes our test @@ -26,11 +46,11 @@ add_task(async function test_active_delay() { // gets opened and listen for it in this test before we check if the item // is disabled. await SpecialPowers.pushPrefEnv({ - set: [ - ["security.notification_enable_delay", 1000], - ["extensions.formautofill.reauth.enabled", false], - ], + set: [["security.notification_enable_delay", 1000]], }); + + await disableOSAuthForThisTest(); + await BrowserTestUtils.withNewTab( { gBrowser, url: CC_URL }, async function (browser) { @@ -86,10 +106,7 @@ add_task(async function test_active_delay() { add_task(async function test_no_delay() { await SpecialPowers.pushPrefEnv({ - set: [ - ["security.notification_enable_delay", 1000], - ["extensions.formautofill.reauth.enabled", false], - ], + set: [["security.notification_enable_delay", 1000]], }); await BrowserTestUtils.withNewTab( { gBrowser, url: ADDRESS_URL }, diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_action.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_action.js index 82122925d7..b5a8019f0d 100644 --- a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_action.js +++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_action.js @@ -132,12 +132,14 @@ add_task(async function test_update_doorhanger_click_save() { await setStorage(TEST_CREDIT_CARD_1); let creditCards = await getCreditCards(); is(creditCards.length, 1, "1 credit card in storage"); + let osKeyStoreLoginShown = null; let onChanged = waitForStorageChangedEvents("add"); await BrowserTestUtils.withNewTab( { gBrowser, url: CREDITCARD_FORM_URL }, async function (browser) { - let osKeyStoreLoginShown = - OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + if (OSKeyStore.canReauth()) { + osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } let onPopupShown = waitForPopupShown(); await openPopupOn(browser, "form #cc-name"); await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); @@ -153,7 +155,10 @@ add_task(async function test_update_doorhanger_click_save() { await onPopupShown; await clickDoorhangerButton(SECONDARY_BUTTON); - await osKeyStoreLoginShown; + if (osKeyStoreLoginShown) { + await osKeyStoreLoginShown; + ok(osKeyStoreLoginShown, "OS re-auth promise Complete"); + } } ); await onChanged; diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_display.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_display.js index 715eceb3eb..8db32d9462 100644 --- a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_display.js +++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_display.js @@ -107,16 +107,20 @@ add_task(async function test_doorhanger_not_shown_when_autofill_untouched() { let creditCards = await getCreditCards(); is(creditCards.length, 1, "1 credit card in storage"); + let osKeyStoreLoginShown = null; let onUsed = waitForStorageChangedEvents("notifyUsed"); await BrowserTestUtils.withNewTab( { gBrowser, url: CREDITCARD_FORM_URL }, async function (browser) { - let osKeyStoreLoginShown = - OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + if (OSKeyStore.canReauth()) { + osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } await openPopupOn(browser, "form #cc-name"); await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); - await osKeyStoreLoginShown; + if (osKeyStoreLoginShown) { + await osKeyStoreLoginShown; + } await waitForAutofill(browser, "#cc-name", "John Doe"); await SpecialPowers.spawn(browser, [], async function () { @@ -186,12 +190,15 @@ add_task( await setStorage(TEST_CREDIT_CARD_1, TEST_CREDIT_CARD_2); let creditCards = await getCreditCards(); is(creditCards.length, 2, "2 credit card in storage"); + let osKeyStoreLoginShown = null; let onUsed = waitForStorageChangedEvents("notifyUsed"); await BrowserTestUtils.withNewTab( { gBrowser, url: CREDITCARD_FORM_URL }, async function (browser) { - let osKeyStoreLoginShown = - OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + if (OSKeyStore.canReauth()) { + osKeyStoreLoginShown = + OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } await openPopupOn(browser, "form #cc-number"); await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); @@ -214,7 +221,9 @@ add_task( await sleep(1000); is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden"); - await osKeyStoreLoginShown; + if (osKeyStoreLoginShown) { + await osKeyStoreLoginShown; + } } ); await onUsed; @@ -242,12 +251,15 @@ add_task( let creditCards = await getCreditCards(); is(creditCards.length, 2, "2 credit card in storage"); + let osKeyStoreLoginShown = null; let onUsed = waitForStorageChangedEvents("notifyUsed"); await BrowserTestUtils.withNewTab( { gBrowser, url: CREDITCARD_FORM_URL }, async function (browser) { - let osKeyStoreLoginShown = - OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + if (OSKeyStore.canReauth()) { + osKeyStoreLoginShown = + OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } await openPopupOn(browser, "form #cc-number"); await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); @@ -267,7 +279,9 @@ add_task( await sleep(1000); is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden"); - await osKeyStoreLoginShown; + if (osKeyStoreLoginShown) { + await osKeyStoreLoginShown; + } } ); await onUsed; diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_fields.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_fields.js index c1ebef737e..7ba8bfab91 100644 --- a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_fields.js +++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_fields.js @@ -13,18 +13,23 @@ add_task(async function test_update_autofill_name_field() { let creditCards = await getCreditCards(); is(creditCards.length, 1, "1 credit card in storage"); + let osKeyStoreLoginShown = null; let onChanged = waitForStorageChangedEvents("update", "notifyUsed"); await BrowserTestUtils.withNewTab( { gBrowser, url: CREDITCARD_FORM_URL }, async function (browser) { - let osKeyStoreLoginShown = - OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + if (OSKeyStore.canReauth()) { + osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } let onPopupShown = waitForPopupShown(); await openPopupOn(browser, "form #cc-name"); await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); - await osKeyStoreLoginShown; + if (osKeyStoreLoginShown) { + await osKeyStoreLoginShown; + ok(osKeyStoreLoginShown, "OS Auth Dialog shown and authenticated"); + } await waitForAutofill(browser, "#cc-name", "John Doe"); await focusUpdateSubmitForm(browser, { @@ -63,17 +68,22 @@ add_task(async function test_update_autofill_exp_date_field() { await setStorage(TEST_CREDIT_CARD_1); let creditCards = await getCreditCards(); is(creditCards.length, 1, "1 credit card in storage"); + let osKeyStoreLoginShown = null; let onChanged = waitForStorageChangedEvents("update", "notifyUsed"); await BrowserTestUtils.withNewTab( { gBrowser, url: CREDITCARD_FORM_URL }, async function (browser) { - let osKeyStoreLoginShown = - OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + if (OSKeyStore.canReauth()) { + osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } let onPopupShown = waitForPopupShown(); await openPopupOn(browser, "form #cc-name"); await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); - await osKeyStoreLoginShown; + if (osKeyStoreLoginShown) { + await osKeyStoreLoginShown; + ok(osKeyStoreLoginShown, "OS Auth Dialog shown and authenticated"); + } await waitForAutofill(browser, "#cc-name", "John Doe"); await focusUpdateSubmitForm(browser, { diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_iframe.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_iframe.js index 2781e5acf6..774c3d7b25 100644 --- a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_iframe.js +++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_iframe.js @@ -30,8 +30,10 @@ add_task(async function test_iframe_submit_untouched_creditCard_form() { await BrowserTestUtils.withNewTab( { gBrowser, url: CREDITCARD_FORM_IFRAME_URL }, async function (browser) { - let osKeyStoreLoginShown = - OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + let osKeyStoreLoginShown = Promise.resolve(); + if (OSKeyStore.canReauth()) { + osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } let iframeBC = browser.browsingContext.children[0]; await openPopupOnSubframe(browser, iframeBC, "form #cc-name"); diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_osAuth.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_osAuth.js new file mode 100644 index 0000000000..0fe6e1e07c --- /dev/null +++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_osAuth.js @@ -0,0 +1,200 @@ +"use strict"; + +const PAGE_PREFS = "about:preferences"; +const PAGE_PRIVACY = PAGE_PREFS + "#privacy"; +const SELECTORS = { + savedCreditCardsBtn: "#creditCardAutofill button", + reauthCheckbox: "#creditCardReauthenticate checkbox", +}; + +// On mac, this test times out in chaos mode +requestLongerTimeout(2); + +add_setup(async function () { + // Revert head.js change that mocks os auth + sinon.restore(); + + // Load in a few credit cards + await SpecialPowers.pushPrefEnv({ + set: [["privacy.reduceTimerPrecision", false]], + }); + await setStorage(TEST_CREDIT_CARD_1, TEST_CREDIT_CARD_2); +}); + +add_task(async function test_os_auth_enabled_with_checkbox() { + let finalPrefPaneLoaded = TestUtils.topicObserved("sync-pane-loaded"); + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_PRIVACY }, + async function (browser) { + await finalPrefPaneLoaded; + + await SpecialPowers.spawn( + browser, + [SELECTORS, AppConstants.NIGHTLY_BUILD], + async (selectors, isNightly) => { + is( + content.document.querySelector(selectors.reauthCheckbox).checked, + isNightly, + "OSReauth for credit cards should be checked" + ); + } + ); + is( + FormAutofillUtils.getOSAuthEnabled( + FormAutofillUtils.AUTOFILL_CREDITCARDS_REAUTH_PREF + ), + AppConstants.NIGHTLY_BUILD, + "OSAuth should be enabled." + ); + } + ); +}); + +add_task(async function test_os_auth_disabled_with_checkbox() { + let finalPrefPaneLoaded = TestUtils.topicObserved("sync-pane-loaded"); + FormAutofillUtils.setOSAuthEnabled( + FormAutofillUtils.AUTOFILL_CREDITCARDS_REAUTH_PREF, + false + ); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_PRIVACY }, + async function (browser) { + await finalPrefPaneLoaded; + + await SpecialPowers.spawn(browser, [SELECTORS], async selectors => { + is( + content.document.querySelector(selectors.reauthCheckbox).checked, + false, + "OSReauth for credit cards should be unchecked" + ); + }); + is( + FormAutofillUtils.getOSAuthEnabled( + FormAutofillUtils.AUTOFILL_CREDITCARDS_REAUTH_PREF + ), + false, + "OSAuth should be disabled" + ); + } + ); + FormAutofillUtils.setOSAuthEnabled( + FormAutofillUtils.AUTOFILL_CREDITCARDS_REAUTH_PREF, + true + ); +}); + +add_task(async function test_OSAuth_enabled_with_random_value_in_pref() { + let finalPrefPaneLoaded = TestUtils.topicObserved("sync-pane-loaded"); + await SpecialPowers.pushPrefEnv({ + set: [ + [FormAutofillUtils.AUTOFILL_CREDITCARDS_REAUTH_PREF, "poutine-gravy"], + ], + }); + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_PRIVACY }, + async function (browser) { + await finalPrefPaneLoaded; + await SpecialPowers.spawn(browser, [SELECTORS], async selectors => { + let reauthCheckbox = content.document.querySelector( + selectors.reauthCheckbox + ); + is( + reauthCheckbox.checked, + true, + "OSReauth for credit cards should be checked" + ); + }); + is( + FormAutofillUtils.getOSAuthEnabled( + FormAutofillUtils.AUTOFILL_CREDITCARDS_REAUTH_PREF + ), + true, + "OSAuth should be enabled since the pref does not decrypt to 'opt out'." + ); + } + ); +}); + +add_task(async function test_osAuth_enabled_behaviour() { + let finalPrefPaneLoaded = TestUtils.topicObserved("sync-pane-loaded"); + await SpecialPowers.pushPrefEnv({ + set: [[FormAutofillUtils.AUTOFILL_CREDITCARDS_REAUTH_PREF, ""]], + }); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_PRIVACY }, + async function (browser) { + await finalPrefPaneLoaded; + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + // The rest of the test uses Edit mode which causes an OS prompt in official builds. + return; + } + let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn(browser, [SELECTORS], async selectors => { + content.document.querySelector(selectors.savedCreditCardsBtn).click(); + }); + let ccManageDialog = await waitForSubDialogLoad( + content, + MANAGE_CREDIT_CARDS_DIALOG_URL + ); + await SpecialPowers.spawn(ccManageDialog, [], async () => { + let selRecords = content.document.getElementById("credit-cards"); + await EventUtils.synthesizeMouseAtCenter( + selRecords.children[0], + [], + content + ); + content.document.querySelector("#edit").click(); + }); + await reauthObserved; // If the OS does not popup, this will cause a timeout in the test. + await waitForSubDialogLoad(content, EDIT_CREDIT_CARD_DIALOG_URL); + } + ); +}); + +add_task(async function test_osAuth_disabled_behavior() { + let finalPrefPaneLoaded = TestUtils.topicObserved("sync-pane-loaded"); + FormAutofillUtils.setOSAuthEnabled( + FormAutofillUtils.AUTOFILL_CREDITCARDS_REAUTH_PREF, + false + ); + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_PRIVACY }, + async function (browser) { + await finalPrefPaneLoaded; + await SpecialPowers.spawn( + browser, + [SELECTORS.savedCreditCardsBtn, SELECTORS.reauthCheckbox], + async (saveButton, reauthCheckbox) => { + is( + content.document.querySelector(reauthCheckbox).checked, + false, + "OSReauth for credit cards should NOT be checked" + ); + content.document.querySelector(saveButton).click(); + } + ); + let ccManageDialog = await waitForSubDialogLoad( + content, + MANAGE_CREDIT_CARDS_DIALOG_URL + ); + await SpecialPowers.spawn(ccManageDialog, [], async () => { + let selRecords = content.document.getElementById("credit-cards"); + await EventUtils.synthesizeMouseAtCenter( + selRecords.children[0], + [], + content + ); + content.document.getElementById("edit").click(); + }); + info("The OS Auth dialog should NOT show up"); + // If OSAuth prompt shows up, the next line would cause a timeout since the edit dialog would not show up. + await waitForSubDialogLoad(content, EDIT_CREDIT_CARD_DIALOG_URL); + } + ); + FormAutofillUtils.setOSAuthEnabled( + FormAutofillUtils.AUTOFILL_CREDITCARDS_REAUTH_PREF, + true + ); +}); diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_telemetry.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_telemetry.js index 7a4bff1e45..ea455df12a 100644 --- a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_telemetry.js +++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_telemetry.js @@ -154,12 +154,15 @@ async function openTabAndUseCreditCard( creditCard, { closeTab = true, submitForm = true } = {} ) { - let osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + let osKeyStoreLoginShown = null; let tab = await BrowserTestUtils.openNewForegroundTab( gBrowser, CREDITCARD_FORM_URL ); + if (OSKeyStore.canReauth()) { + osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } let browser = tab.linkedBrowser; await openPopupOn(browser, "form #cc-name"); @@ -167,7 +170,9 @@ async function openTabAndUseCreditCard( await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); } await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); - await osKeyStoreLoginShown; + if (osKeyStoreLoginShown) { + await osKeyStoreLoginShown; + } await waitForAutofill(browser, "#cc-number", creditCard["cc-number"]); await focusUpdateSubmitForm( browser, @@ -692,10 +697,14 @@ add_task(async function test_submit_creditCard_update() { let creditCards = await getCreditCards(); Assert.equal(creditCards.length, 1, "1 credit card in storage"); - let osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + let osKeyStoreLoginShown = null; await BrowserTestUtils.withNewTab( { gBrowser, url: CREDITCARD_FORM_URL }, async function (browser) { + if (OSKeyStore.canReauth()) { + osKeyStoreLoginShown = + OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } let onPopupShown = waitForPopupShown(); let onChanged; if (expectChanged !== undefined) { @@ -705,7 +714,9 @@ add_task(async function test_submit_creditCard_update() { await openPopupOn(browser, "form #cc-name"); await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); - await osKeyStoreLoginShown; + if (osKeyStoreLoginShown) { + await osKeyStoreLoginShown; + } await waitForAutofill(browser, "#cc-name", "John Doe"); await focusUpdateSubmitForm(browser, { diff --git a/browser/extensions/formautofill/test/browser/head.js b/browser/extensions/formautofill/test/browser/head.js index 3f87f7b5ef..d82ed5076e 100644 --- a/browser/extensions/formautofill/test/browser/head.js +++ b/browser/extensions/formautofill/test/browser/head.js @@ -1,5 +1,9 @@ "use strict"; +const { ManageAddresses } = ChromeUtils.importESModule( + "chrome://formautofill/content/manageDialog.mjs" +); + const { OSKeyStore } = ChromeUtils.importESModule( "resource://gre/modules/OSKeyStore.sys.mjs" ); @@ -20,6 +24,27 @@ const { FormAutofillNameUtils } = ChromeUtils.importESModule( "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs" ); +const { FormAutofillUtils } = ChromeUtils.importESModule( + "resource://gre/modules/shared/FormAutofillUtils.sys.mjs" +); + +let { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +// Always pretend OS Auth is enabled in this dir. +if ( + gTestPath.includes("browser/creditCard") && + OSKeyStoreTestUtils.canTestOSKeyStoreLogin() && + OSKeyStore.canReauth() +) { + info("Stubbing out getOSAuthEnabled so it always returns true"); + sinon.stub(FormAutofillUtils, "getOSAuthEnabled").returns(true); + registerCleanupFunction(() => { + sinon.restore(); + }); +} + const MANAGE_ADDRESSES_DIALOG_URL = "chrome://formautofill/content/manageAddresses.xhtml"; const MANAGE_CREDIT_CARDS_DIALOG_URL = @@ -822,7 +847,7 @@ async function removeAllRecords() { async function waitForFocusAndFormReady(win) { return Promise.all([ new Promise(resolve => waitForFocus(resolve, win)), - BrowserTestUtils.waitForEvent(win, "FormReady"), + BrowserTestUtils.waitForEvent(win, "FormReadyForTests"), ]); } @@ -855,9 +880,12 @@ async function testDialog(url, testFn, arg = undefined) { "cc-number": await OSKeyStore.decrypt(arg.record["cc-number-encrypted"]), }); } - let win = window.openDialog(url, null, "width=600,height=600", arg); + const win = window.openDialog(url, null, "width=600,height=600", { + ...arg, + l10nStrings: ManageAddresses.getAddressL10nStrings(), + }); await waitForFocusAndFormReady(win); - let unloadPromise = BrowserTestUtils.waitForEvent(win, "unload"); + const unloadPromise = BrowserTestUtils.waitForEvent(win, "unload"); await testFn(win); return unloadPromise; } diff --git a/browser/extensions/formautofill/test/mochitest/creditCard/mochitest.toml b/browser/extensions/formautofill/test/mochitest/creditCard/mochitest.toml index ffd504bb45..0d6ea02569 100644 --- a/browser/extensions/formautofill/test/mochitest/creditCard/mochitest.toml +++ b/browser/extensions/formautofill/test/mochitest/creditCard/mochitest.toml @@ -2,7 +2,6 @@ prefs = [ "extensions.formautofill.creditCards.supported=on", "extensions.formautofill.creditCards.enabled=true", - "extensions.formautofill.reauth.enabled=true", ] support-files = [ "!/toolkit/components/satchel/test/satchel_common.js", diff --git a/browser/extensions/formautofill/test/mochitest/creditCard/test_basic_creditcard_autocomplete_form.html b/browser/extensions/formautofill/test/mochitest/creditCard/test_basic_creditcard_autocomplete_form.html index 717d40946f..9fad869629 100644 --- a/browser/extensions/formautofill/test/mochitest/creditCard/test_basic_creditcard_autocomplete_form.html +++ b/browser/extensions/formautofill/test/mochitest/creditCard/test_basic_creditcard_autocomplete_form.html @@ -197,10 +197,15 @@ add_task(async function check_fields_after_form_autofill() { }))); synthesizeKey("KEY_ArrowDown"); - let osKeyStoreLoginShown = waitForOSKeyStoreLogin(true); + let osKeyStoreLoginShown = Promise.resolve(); + if(OSKeyStore.canReauth()) { + osKeyStoreLoginShown = waitForOSKeyStoreLogin(true); + } await new Promise(resolve => SimpleTest.executeSoon(resolve)); await triggerAutofillAndCheckProfile(MOCK_STORAGE[1].cc); await osKeyStoreLoginShown; + // Enforcing this since it is unable to change back in chaos mode. + SpecialPowers.clearUserPref("toolkit.osKeyStore.unofficialBuildOnlyLogin"); }); // Fallback to history search after autofill values (for non-empty fields). diff --git a/browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form.html b/browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form.html index a1a3322c4e..4803151aae 100644 --- a/browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form.html +++ b/browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form.html @@ -124,6 +124,11 @@ add_task(async function simple_clear() { await triggerPopupAndHoverItem("#tel", 0); await confirmClear("#tel"); await checkIsFormCleared(); + + // Ensure the correctness of the autocomplete popup after the form is cleared + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + is(4, getMenuEntries().length, `Checking length of expected menu`); }); add_task(async function clear_adapted_record() { @@ -154,7 +159,10 @@ add_task(async function clear_distinct_section() { document.getElementById("form1").reset(); await triggerPopupAndHoverItem("#cc-name", 0); - let osKeyStoreLoginShown = waitForOSKeyStoreLogin(true); + let osKeyStoreLoginShown = Promise.resolve(); + if(OSKeyStore.canReauth()) { + osKeyStoreLoginShown = waitForOSKeyStoreLogin(true); + } await triggerAutofillAndCheckProfile(MOCK_CC_STORAGE_EXPECTED_FILL[0]); await osKeyStoreLoginShown; @@ -175,6 +183,8 @@ add_task(async function clear_distinct_section() { await triggerPopupAndHoverItem("#cc-name", 0); await confirmClear("#cc-name"); await checkIsFormCleared(); + // Enforcing this since it is unable to change back in chaos mode. + SpecialPowers.clearUserPref("toolkit.osKeyStore.unofficialBuildOnlyLogin"); }); </script> diff --git a/browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form_expiry_select_elements.html b/browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form_expiry_select_elements.html index 6ebef3bba1..f054bc5871 100644 --- a/browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form_expiry_select_elements.html +++ b/browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form_expiry_select_elements.html @@ -127,7 +127,10 @@ add_task(async function clear_distinct_section() { todo(false, "Cannot test OS key store login on official builds."); return; } - let osKeyStoreLoginShown = waitForOSKeyStoreLogin(true); + let osKeyStoreLoginShown = Promise.resolve(); + if(OSKeyStore.canReauth()) { + osKeyStoreLoginShown = waitForOSKeyStoreLogin(true); + } await triggerPopupAndHoverItem("#cc-name", 0); await triggerAutofillAndCheckProfile(MOCK_CC_STORAGE[0]); await osKeyStoreLoginShown; @@ -147,6 +150,8 @@ add_task(async function clear_distinct_section() { "cc-exp-month": "MM", "cc-exp-year": "YY" }); + // Enforcing this since it is unable to change back in chaos mode. + SpecialPowers.clearUserPref("toolkit.osKeyStore.unofficialBuildOnlyLogin"); }); </script> diff --git a/browser/extensions/formautofill/test/mochitest/creditCard/test_preview_highlight_with_multiple_cc_number_fields.html b/browser/extensions/formautofill/test/mochitest/creditCard/test_preview_highlight_with_multiple_cc_number_fields.html index a6d0572ac6..6b317f2392 100644 --- a/browser/extensions/formautofill/test/mochitest/creditCard/test_preview_highlight_with_multiple_cc_number_fields.html +++ b/browser/extensions/formautofill/test/mochitest/creditCard/test_preview_highlight_with_multiple_cc_number_fields.html @@ -143,7 +143,11 @@ add_task(async function check_filled_highlight() { return; } await triggerPopupAndHoverItem("#cc-name", 0); - let osKeyStoreLoginShown = waitForOSKeyStoreLogin(true); + let osKeyStoreLoginShown = Promise.resolve(); + +if (OSKeyStore.canReauth()) { + osKeyStoreLoginShown = waitForOSKeyStoreLogin(true); +} // filled 1st credit card option synthesizeKey("KEY_Enter"); await osKeyStoreLoginShown; @@ -151,6 +155,8 @@ add_task(async function check_filled_highlight() { let profile = MOCK_STORAGE_EXPECTED_FILL[0]; await setupListeners(elements, profile); await checkMultipleCCNumberFormStyle(profile, false); + // Enforcing this since it is unable to change back in chaos mode. + SpecialPowers.clearUserPref("toolkit.osKeyStore.unofficialBuildOnlyLogin"); }); </script> <p id="display"></p> diff --git a/browser/extensions/formautofill/test/mochitest/creditCard/test_preview_highlight_with_site_prefill.html b/browser/extensions/formautofill/test/mochitest/creditCard/test_preview_highlight_with_site_prefill.html index 090eb9290e..5517153f1a 100644 --- a/browser/extensions/formautofill/test/mochitest/creditCard/test_preview_highlight_with_site_prefill.html +++ b/browser/extensions/formautofill/test/mochitest/creditCard/test_preview_highlight_with_site_prefill.html @@ -87,11 +87,17 @@ add_task(async function check_filled_highlight() { return; } await triggerPopupAndHoverItem("#cc-number", 0); - let osKeyStoreLoginShown = waitForOSKeyStoreLogin(true); + + let osKeyStoreLoginShown = Promise.resolve(); + if(OSKeyStore.canReauth()) { + osKeyStoreLoginShown = waitForOSKeyStoreLogin(true); + } // filled 1st credit card option await triggerAutofillAndCheckProfile(MOCK_STORAGE_EXPECTED_FILL[0]); await osKeyStoreLoginShown; await checkFormFieldsStyle(MOCK_STORAGE_EXPECTED_FILL[0], false); + // Enforcing this since it is unable to change back in chaos mode. + SpecialPowers.clearUserPref("toolkit.osKeyStore.unofficialBuildOnlyLogin"); }); </script> <p id="display"></p> diff --git a/browser/extensions/formautofill/test/mochitest/formautofill_common.js b/browser/extensions/formautofill/test/mochitest/formautofill_common.js index 0e371ba3af..dab2d58b4a 100644 --- a/browser/extensions/formautofill/test/mochitest/formautofill_common.js +++ b/browser/extensions/formautofill/test/mochitest/formautofill_common.js @@ -2,6 +2,10 @@ /* import-globals-from ../../../../../testing/mochitest/tests/SimpleTest/EventUtils.js */ /* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */ /* eslint-disable no-unused-vars */ +// Despite a use of `spawnChrome` and thus ChromeUtils, we can't use isInstance +// here as it gets used in plain mochitests which don't have the ChromeOnly +// APIs for it. +/* eslint-disable mozilla/use-isInstance */ "use strict"; @@ -14,6 +18,10 @@ const { FormAutofillUtils } = SpecialPowers.ChromeUtils.importESModule( "resource://gre/modules/shared/FormAutofillUtils.sys.mjs" ); +const { OSKeyStore } = SpecialPowers.ChromeUtils.importESModule( + "resource://gre/modules/OSKeyStore.sys.mjs" +); + async function sleep(ms = 500, reason = "Intentionally wait for UI ready") { SimpleTest.requestFlakyTimeout(reason); await new Promise(resolve => setTimeout(resolve, ms)); @@ -353,7 +361,21 @@ async function canTestOSKeyStoreLogin() { } async function waitForOSKeyStoreLogin(login = false) { - await invokeAsyncChromeTask("FormAutofillTest:OSKeyStoreLogin", { login }); + // Need to fetch this from the parent in order for it to be correct. + let isOSAuthEnabled = await SpecialPowers.spawnChrome([], () => { + // Need to re-import this because we're running in the parent. + // eslint-disable-next-line no-shadow + const { FormAutofillUtils } = ChromeUtils.importESModule( + "resource://gre/modules/shared/FormAutofillUtils.sys.mjs" + ); + + return FormAutofillUtils.getOSAuthEnabled( + FormAutofillUtils.AUTOFILL_CREDITCARDS_REAUTH_PREF + ); + }); + if (isOSAuthEnabled) { + await invokeAsyncChromeTask("FormAutofillTest:OSKeyStoreLogin", { login }); + } } function patchRecordCCNumber(record) { diff --git a/browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js b/browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js index ecc4945135..e8b690a386 100644 --- a/browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js +++ b/browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js @@ -57,7 +57,7 @@ let AVAILABLE_PIP_OVERRIDES; aol: { "https://*.aol.com/*": { - videoWrapperScriptPath: "video-wrappers/yahoo.js", + videoWrapperScriptPath: "video-wrappers/videojsWrapper.js", }, }, @@ -81,12 +81,41 @@ let AVAILABLE_PIP_OVERRIDES; videoWrapperScriptPath: "video-wrappers/videojsWrapper.js", }, }, + + canalplus: { + "https://*.canalplus.com/live/*": { + videoWrapperScriptPath: "video-wrappers/canalplus.js", + disabledKeyboardControls: KEYBOARD_CONTROLS.LIVE_SEEK, + }, + "https://*.canalplus.com/*": { + videoWrapperScriptPath: "video-wrappers/canalplus.js", + }, + }, + cbc: { "https://*.cbc.ca/*": { videoWrapperScriptPath: "video-wrappers/cbc.js", }, }, + cnbc: { + "https://*.cnbc.com/*": { + videoWrapperScriptPath: "video-wrappers/jwplayerWrapper.js", + }, + }, + + cpac: { + "https://*.cpac.ca/*": { + videoWrapperScriptPath: "video-wrappers/jwplayerWrapper.js", + }, + }, + + cspan: { + "https://*.c-span.org/*": { + videoWrapperScriptPath: "video-wrappers/jwplayerWrapper.js", + }, + }, + dailymotion: { "https://*.dailymotion.com/*": { videoWrapperScriptPath: "video-wrappers/dailymotion.js", @@ -105,6 +134,18 @@ let AVAILABLE_PIP_OVERRIDES; }, }, + fandom: { + "https://*.fandom.com/*": { + videoWrapperScriptPath: "video-wrappers/jwplayerWrapper.js", + }, + }, + + fastcompany: { + "https://*.fastcompany.com/*": { + videoWrapperScriptPath: "video-wrappers/jwplayerWrapper.js", + }, + }, + frontendMasters: { "https://*.frontendmasters.com/*": { videoWrapperScriptPath: "video-wrappers/videojsWrapper.js", @@ -117,6 +158,12 @@ let AVAILABLE_PIP_OVERRIDES; }, }, + fuse: { + "https://*.fuse.tv/*": { + videoWrapperScriptPath: "video-wrappers/jwplayerWrapper.js", + }, + }, + hbomax: { "https://play.hbomax.com/page/*": { policy: TOGGLE_POLICIES.HIDDEN }, "https://play.hbomax.com/player/*": { @@ -136,10 +183,34 @@ let AVAILABLE_PIP_OVERRIDES; }, }, + imdb: { + "https://*.imdb.com/*": { + videoWrapperScriptPath: "video-wrappers/jwplayerWrapper.js", + }, + }, + + indpendentuk: { + "https://*.independent.co.uk/*": { + videoWrapperScriptPath: "video-wrappers/jwplayerWrapper.js", + }, + }, + + indy100: { + "https://*.indy100.com/*": { + videoWrapperScriptPath: "video-wrappers/jwplayerWrapper.js", + }, + }, + instagram: { "https://www.instagram.com/*": { policy: TOGGLE_POLICIES.ONE_QUARTER }, }, + internetArchive: { + "https://*.archive.org/*": { + videoWrapperScriptPath: "video-wrappers/jwplayerWrapper.js", + }, + }, + laracasts: { "https://*.laracasts.com/*": { policy: TOGGLE_POLICIES.ONE_QUARTER }, }, @@ -149,12 +220,31 @@ let AVAILABLE_PIP_OVERRIDES; visibilityThreshold: 0.7, }, }, + + msnbc: { + "https://*.msnbc.com/*": { + videoWrapperScriptPath: "video-wrappers/jwplayerWrapper.js", + }, + }, + mxplayer: { "https://*.mxplayer.in/*": { videoWrapperScriptPath: "video-wrappers/videojsWrapper.js", }, }, + nbcnews: { + "https://*.nbcnews.com/*": { + videoWrapperScriptPath: "video-wrappers/jwplayerWrapper.js", + }, + }, + + nbcUniversal: { + "https://*.nbcuni.com/*": { + videoWrapperScriptPath: "video-wrappers/jwplayerWrapper.js", + }, + }, + nebula: { "https://*.nebula.app/*": { videoWrapperScriptPath: "video-wrappers/videojsWrapper.js", @@ -197,6 +287,17 @@ let AVAILABLE_PIP_OVERRIDES; }, }, + primeVideo: { + "https://*.primevideo.com/*": { + visibilityThreshold: 0.9, + videoWrapperScriptPath: "video-wrappers/primeVideo.js", + }, + "https://*.amazon.com/*": { + visibilityThreshold: 0.9, + videoWrapperScriptPath: "video-wrappers/primeVideo.js", + }, + }, + radiocanada: { "https://*.ici.radio-canada.ca/*": { videoWrapperScriptPath: "video-wrappers/radiocanada.js", @@ -207,18 +308,46 @@ let AVAILABLE_PIP_OVERRIDES; "https://*.reddit.com/*": { policy: TOGGLE_POLICIES.ONE_QUARTER }, }, + reuters: { + "https://*.reuters.com/*": { + videoWrapperScriptPath: "video-wrappers/jwplayerWrapper.js", + }, + }, + sonyliv: { "https://*.sonyliv.com/*": { videoWrapperScriptPath: "video-wrappers/sonyliv.js", }, }, + syfy: { + "https://*.syfy.com/*": { + videoWrapperScriptPath: "video-wrappers/jwplayerWrapper.js", + }, + }, + ted: { "https://*.ted.com/*": { showHiddenTextTracks: true, }, }, + time: { + "https://*.time.com/*": { + videoWrapperScriptPath: "video-wrappers/jwplayerWrapper.js", + }, + }, + + timvision: { + "https://*.timvision.it/TV/*": { + videoWrapperScriptPath: "video-wrappers/canalplus.js", + disabledKeyboardControls: KEYBOARD_CONTROLS.LIVE_SEEK, + }, + "https://*.timvision.it/*": { + videoWrapperScriptPath: "video-wrappers/canalplus.js", + }, + }, + tubi: { "https://*.tubitv.com/live*": { videoWrapperScriptPath: "video-wrappers/tubilive.js", @@ -256,6 +385,12 @@ let AVAILABLE_PIP_OVERRIDES; }, }, + univision: { + "https://*.univision.com/*": { + videoWrapperScriptPath: "video-wrappers/jwplayerWrapper.js", + }, + }, + viki: { "https://*.viki.com/*": { videoWrapperScriptPath: "video-wrappers/videojsWrapper.js", @@ -274,9 +409,9 @@ let AVAILABLE_PIP_OVERRIDES; }, }, - yahoofinance: { - "https://*.finance.yahoo.com/*": { - videoWrapperScriptPath: "video-wrappers/yahoo.js", + yahoo: { + "https://*.s.yimg.com/*": { + videoWrapperScriptPath: "video-wrappers/videojsWrapper.js", }, }, @@ -301,16 +436,5 @@ let AVAILABLE_PIP_OVERRIDES; videoWrapperScriptPath: "video-wrappers/washingtonpost.js", }, }, - - primeVideo: { - "https://*.primevideo.com/*": { - visibilityThreshold: 0.9, - videoWrapperScriptPath: "video-wrappers/primeVideo.js", - }, - "https://*.amazon.com/*": { - visibilityThreshold: 0.9, - videoWrapperScriptPath: "video-wrappers/primeVideo.js", - }, - }, }; } diff --git a/browser/extensions/pictureinpicture/moz.build b/browser/extensions/pictureinpicture/moz.build index 7cc77f9594..fbdefbeb1c 100644 --- a/browser/extensions/pictureinpicture/moz.build +++ b/browser/extensions/pictureinpicture/moz.build @@ -31,6 +31,7 @@ FINAL_TARGET_FILES.features["pictureinpicture@mozilla.org"]["video-wrappers"] += "video-wrappers/airmozilla.js",
"video-wrappers/arte.js",
"video-wrappers/bbc.js",
+ "video-wrappers/canalplus.js",
"video-wrappers/cbc.js",
"video-wrappers/dailymotion.js",
"video-wrappers/disneyplus.js",
@@ -38,6 +39,7 @@ FINAL_TARGET_FILES.features["pictureinpicture@mozilla.org"]["video-wrappers"] += "video-wrappers/hbomax.js",
"video-wrappers/hotstar.js",
"video-wrappers/hulu.js",
+ "video-wrappers/jwplayerWrapper.js",
"video-wrappers/mock-wrapper.js",
"video-wrappers/netflix.js",
"video-wrappers/nytimes.js",
@@ -52,7 +54,6 @@ FINAL_TARGET_FILES.features["pictureinpicture@mozilla.org"]["video-wrappers"] += "video-wrappers/videojsWrapper.js",
"video-wrappers/voot.js",
"video-wrappers/washingtonpost.js",
- "video-wrappers/yahoo.js",
"video-wrappers/youtube.js",
]
diff --git a/browser/extensions/pictureinpicture/video-wrappers/canalplus.js b/browser/extensions/pictureinpicture/video-wrappers/canalplus.js new file mode 100644 index 0000000000..3d725ef54a --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/canalplus.js @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +class PictureInPictureVideoWrapper { + isLive() { + let documentURI = document.documentURI; + return documentURI.includes("/live/") || documentURI.includes("/TV/"); + } + + getDuration(video) { + if (this.isLive(video)) { + return Infinity; + } + return video.duration; + } + + setCaptionContainerObserver(video, updateCaptionsFunction) { + let container = + document.querySelector(`[data-testid="playerRoot"]`) || + document.querySelector(`[player-root="true"]`); + + if (container) { + updateCaptionsFunction(""); + const callback = function (mutationsList) { + // eslint-disable-next-line no-unused-vars + for (const mutation of mutationsList) { + let text = container.querySelector( + ".rxp-texttrack-region" + )?.innerText; + if (!text) { + updateCaptionsFunction(""); + return; + } + + updateCaptionsFunction(text); + } + }; + + // immediately invoke the callback function to add subtitles to the PiP window + callback([1], null); + + let captionsObserver = new MutationObserver(callback); + + captionsObserver.observe(container, { + attributes: false, + childList: true, + subtree: true, + }); + } + } +} + +this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper; diff --git a/browser/extensions/pictureinpicture/video-wrappers/jwplayerWrapper.js b/browser/extensions/pictureinpicture/video-wrappers/jwplayerWrapper.js new file mode 100644 index 0000000000..37591c16f8 --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/jwplayerWrapper.js @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This wrapper supports multiple sites that use JWPlayer +class PictureInPictureVideoWrapper { + setCaptionContainerObserver(video, updateCaptionsFunction) { + let container = document.querySelector(".jw-captions"); + + if (container) { + updateCaptionsFunction(""); + + const callback = function () { + let text = container.innerText; + if (!text) { + updateCaptionsFunction(""); + return; + } + + updateCaptionsFunction(text); + }; + + // immediately invoke the callback function to add subtitles to the PiP window + callback(); + + let captionsObserver = new MutationObserver(callback); + + captionsObserver.observe(container, { + attributes: false, + childList: true, + subtree: true, + }); + } + } +} + +this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper; diff --git a/browser/extensions/pictureinpicture/video-wrappers/yahoo.js b/browser/extensions/pictureinpicture/video-wrappers/yahoo.js deleted file mode 100644 index 1dd932bc37..0000000000 --- a/browser/extensions/pictureinpicture/video-wrappers/yahoo.js +++ /dev/null @@ -1,38 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -"use strict"; - -class PictureInPictureVideoWrapper { - setCaptionContainerObserver(video, updateCaptionsFunction) { - let container = document.querySelector(".vp-main"); - - if (container) { - updateCaptionsFunction(""); - const callback = function () { - let text = container.querySelector(".vp-cc-element.vp-show")?.innerText; - - if (!text) { - updateCaptionsFunction(""); - return; - } - - updateCaptionsFunction(text); - }; - - // immediately invoke the callback function to add subtitles to the PiP window - callback([1], null); - - let captionsObserver = new MutationObserver(callback); - - captionsObserver.observe(container, { - attributes: false, - childList: true, - subtree: true, - }); - } - } -} - -this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper; |