diff options
Diffstat (limited to 'browser/extensions/formautofill')
29 files changed, 1101 insertions, 958 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.mjs index 467acbdd07..5371051e12 100644 --- a/browser/extensions/formautofill/content/editDialog.js +++ b/browser/extensions/formautofill/content/editDialog.mjs @@ -2,24 +2,27 @@ * 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"; +import { + getCurrentFormData, + canSubmitForm, +} from "chrome://formautofill/content/addressFormLayout.mjs"; -ChromeUtils.defineESModuleGetters(this, { +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 = formAutofillStorage.initialize(); + this._storageInitPromise = lazy.formAutofillStorage.initialize(); this._subStorageName = subStorageName; this._elements = elements; this._record = record; this.localizeDocument(); - window.addEventListener("DOMContentLoaded", this, { once: true }); + window.addEventListener("load", this, { once: true }); } async init() { @@ -28,7 +31,7 @@ class AutofillEditDialog { // 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")); + window.dispatchEvent(new CustomEvent("FormReadyForTests")); } /** @@ -38,7 +41,7 @@ class AutofillEditDialog { */ async getStorage() { await this._storageInitPromise; - return formAutofillStorage[this._subStorageName]; + return lazy.formAutofillStorage[this._subStorageName]; } /** @@ -63,7 +66,7 @@ class AutofillEditDialog { */ handleEvent(event) { switch (event.type) { - case "DOMContentLoaded": { + case "load": { this.init(); break; } @@ -139,7 +142,8 @@ class AutofillEditDialog { attachEventListeners() { window.addEventListener("keypress", this); window.addEventListener("contextmenu", this); - this._elements.controlsContainer.addEventListener("click", this); + this._elements.save.addEventListener("click", this); + this._elements.cancel.addEventListener("click", this); document.addEventListener("input", this); } @@ -148,17 +152,20 @@ class AutofillEditDialog { recordFormSubmit() { let method = this._record?.guid ? "edit" : "add"; - AutofillTelemetry.recordManageEvent(this.telemetryType, method); + lazy.AutofillTelemetry.recordManageEvent(this.telemetryType, method); } } -class EditAddressDialog extends AutofillEditDialog { - telemetryType = AutofillTelemetry.ADDRESS; +export class EditAddressDialog extends AutofillEditDialog { + telemetryType = lazy.AutofillTelemetry.ADDRESS; constructor(elements, record) { super("addresses", elements, record); if (record) { - AutofillTelemetry.recordManageEvent(this.telemetryType, "show_entry"); + lazy.AutofillTelemetry.recordManageEvent( + this.telemetryType, + "show_entry" + ); } } @@ -171,9 +178,19 @@ class EditAddressDialog extends AutofillEditDialog { } } + 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( - this._elements.fieldContainer.buildFormObject(), + getCurrentFormData(), this._record ? this._record.guid : null ); this.recordFormSubmit(); @@ -182,8 +199,8 @@ class EditAddressDialog extends AutofillEditDialog { } } -class EditCreditCardDialog extends AutofillEditDialog { - telemetryType = AutofillTelemetry.CREDIT_CARD; +export class EditCreditCardDialog extends AutofillEditDialog { + telemetryType = lazy.AutofillTelemetry.CREDIT_CARD; constructor(elements, record) { elements.fieldContainer._elements.billingAddress.disabled = true; @@ -193,7 +210,10 @@ class EditCreditCardDialog extends AutofillEditDialog { this._onCCNumberFieldBlur.bind(this) ); if (record) { - AutofillTelemetry.recordManageEvent(this.telemetryType, "show_entry"); + lazy.AutofillTelemetry.recordManageEvent( + this.telemetryType, + "show_entry" + ); } } 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.mjs index ad5cefbb15..bca0f48f40 100644 --- a/browser/extensions/formautofill/content/manageDialog.js +++ b/browser/extensions/formautofill/content/manageDialog.mjs @@ -2,10 +2,6 @@ * 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"; @@ -20,38 +16,44 @@ const { AutofillTelemetry } = ChromeUtils.importESModule( "resource://gre/modules/shared/AutofillTelemetry.sys.mjs" ); -ChromeUtils.defineESModuleGetters(this, { +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", }); -this.log = null; -ChromeUtils.defineLazyGetter(this, "log", () => - FormAutofill.defineLogGetter(this, "manageAddresses") +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 = formAutofillStorage.initialize(); + this._storageInitPromise = lazy.formAutofillStorage.initialize(); this._subStorageName = subStorageName; this._elements = elements; this._newRequest = false; this._isLoadingRecords = false; this.prefWin = window.opener; - window.addEventListener("DOMContentLoaded", this, { once: true }); + 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("FormReady")); + window.dispatchEvent(new CustomEvent("FormReadyForTests")); } uninit() { - log.debug("uninit"); + lazy.log.debug("uninit"); this.detachEventListeners(); this._elements = null; } @@ -72,7 +74,7 @@ class ManageRecords { */ async getStorage() { await this._storageInitPromise; - return formAutofillStorage[this._subStorageName]; + return lazy.formAutofillStorage[this._subStorageName]; } /** @@ -146,9 +148,9 @@ class ManageRecords { * Remove all existing record elements. */ clearRecordElements() { - let parent = this._elements.records; - while (parent.lastChild) { - parent.removeChild(parent.lastChild); + const parentElement = this._elements.records; + while (parentElement.lastChild) { + parentElement.removeChild(parentElement.lastChild); } } @@ -186,7 +188,7 @@ class ManageRecords { * @param {number} selectedCount */ updateButtonsStates(selectedCount) { - log.debug("updateButtonsStates:", selectedCount); + lazy.log.debug("updateButtonsStates:", selectedCount); if (selectedCount == 0) { this._elements.edit.setAttribute("disabled", "disabled"); this._elements.remove.setAttribute("disabled", "disabled"); @@ -209,7 +211,7 @@ class ManageRecords { */ handleEvent(event) { switch (event.type) { - case "DOMContentLoaded": { + case "load": { this.init(); break; } @@ -302,18 +304,33 @@ class ManageRecords { } } -class ManageAddresses extends ManageRecords { +export class ManageAddresses extends ManageRecords { telemetryType = AutofillTelemetry.ADDRESS; constructor(elements) { super("addresses", elements); elements.add.setAttribute( "search-l10n-ids", - FormAutofillUtils.EDIT_ADDRESS_L10N_IDS.join(",") + 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. * @@ -325,22 +342,23 @@ class ManageAddresses extends ManageRecords { // 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: FormAutofillUtils.getAddressLabel(address) }; + return { raw: lazy.FormAutofillUtils.getAddressLabel(address) }; } } -class ManageCreditCards extends ManageRecords { +export 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(",") + lazy.FormAutofillUtils.EDIT_CREDITCARD_L10N_IDS.join(",") ); this._isDecrypted = false; @@ -355,22 +373,24 @@ class ManageCreditCards extends ManageRecords { async openEditDialog(creditCard) { // Ask for reauth if user is trying to edit an existing credit card. if (creditCard) { - const promptMessage = FormAutofillUtils.reauthOSPromptMessage( + 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 loggedIn = await FormAutofillUtils.ensureLoggedIn(promptMessage); - if (!loggedIn.authenticated) { + 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 OSKeyStore.decrypt( + decryptedCCNumObj["cc-number"] = await lazy.OSKeyStore.decrypt( creditCard["cc-number-encrypted"] ); } catch (ex) { @@ -410,11 +430,11 @@ class ManageCreditCards extends ManageRecords { // 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 typeL10nId = lazy.CreditCard.getNetworkL10nId(type); const typeName = typeL10nId ? await document.l10n.formatValue(typeL10nId) : type ?? ""; // Unknown card type - return CreditCard.getLabelInfo({ + return lazy.CreditCard.getLabelInfo({ name: creditCard["cc-name"], number: creditCard["cc-number"], month: creditCard["cc-exp-month"], 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) { |