diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /toolkit/components/formautofill/default | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/formautofill/default')
-rw-r--r-- | toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs | 1410 | ||||
-rw-r--r-- | toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs | 106 |
2 files changed, 1516 insertions, 0 deletions
diff --git a/toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs b/toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs new file mode 100644 index 0000000000..ecf787137e --- /dev/null +++ b/toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs @@ -0,0 +1,1410 @@ +/* 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/. */ + +/* + * Implements doorhanger singleton that wraps up the PopupNotifications and handles + * the doorhager UI for formautofill related features. + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; +import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; + +import { AutofillTelemetry } from "resource://autofill/AutofillTelemetry.sys.mjs"; +import { showConfirmation } from "resource://gre/modules/FillHelpers.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CreditCard: "resource://gre/modules/CreditCard.sys.mjs", + formAutofillStorage: "resource://autofill/FormAutofillStorage.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "log", () => + FormAutofill.defineLogGetter(lazy, "FormAutofillPrompter") +); + +const l10n = new Localization( + [ + "browser/preferences/formAutofill.ftl", + "toolkit/formautofill/formAutofill.ftl", + "branding/brand.ftl", + ], + true +); + +const { ENABLED_AUTOFILL_CREDITCARDS_PREF } = FormAutofill; + +let CONTENT = {}; + +/** + * `AutofillDoorhanger` provides a base for both address capture and credit card + * capture doorhanger notifications. It handles the UI generation and logic + * related to displaying the doorhanger, + * + * The UI data sourced from the `CONTENT` variable is used for rendering. Derived classes + * should override the `render()` method to customize the layout. + */ +export class AutofillDoorhanger { + /** + * Constructs an instance of the `AutofillDoorhanger` class. + * + * @param {object} browser The browser where the doorhanger will be displayed. + * @param {object} oldRecord The old record that can be merged with the new record + * @param {object} newRecord The new record submitted by users + */ + static headerClass = "address-capture-header"; + static descriptionClass = "address-capture-description"; + static contentClass = "address-capture-content"; + static menuButtonId = "address-capture-menu-button"; + + static preferenceURL = null; + static learnMoreURL = null; + + constructor(browser, oldRecord, newRecord, flowId) { + this.browser = browser; + this.oldRecord = oldRecord ?? {}; + this.newRecord = newRecord; + this.flowId = flowId; + } + + get ui() { + return CONTENT[this.constructor.name]; + } + + // PopupNotification appends a "-notification" suffix to the id to avoid + // id conflict. + get notificationId() { + return this.ui.id + "-notification"; + } + + // The popup notification element + get panel() { + return this.browser.ownerDocument.getElementById(this.notificationId); + } + + get doc() { + return this.browser.ownerDocument; + } + + get chromeWin() { + return this.browser.ownerGlobal; + } + + /* + * An autofill doorhanger consists 3 parts - header, description, and content + * The content part contains customized UI layout for this doorhanger + */ + + // The container of the header part + static header(panel) { + return panel.querySelector(`.${AutofillDoorhanger.headerClass}`); + } + get header() { + return AutofillDoorhanger.header(this.panel); + } + + // The container of the description part + static description(panel) { + return panel.querySelector(`.${AutofillDoorhanger.descriptionClass}`); + } + get description() { + return AutofillDoorhanger.description(this.panel); + } + + // The container of the content part + static content(panel) { + return panel.querySelector(`.${AutofillDoorhanger.contentClass}`); + } + get content() { + return AutofillDoorhanger.content(this.panel); + } + + static menuButton(panel) { + return panel.querySelector(`#${AutofillDoorhanger.menuButtonId}`); + } + get menuButton() { + return AutofillDoorhanger.menuButton(this.panel); + } + + static menuPopup(panel) { + return AutofillDoorhanger.menuButton(panel).querySelector( + `.toolbar-menupopup` + ); + } + get menuPopup() { + return AutofillDoorhanger.menuPopup(this.panel); + } + + static preferenceButton(panel) { + return AutofillDoorhanger.menuButton(panel).querySelector( + `[data-l10n-id=address-capture-manage-address-button]` + ); + } + static learnMoreButton(panel) { + return AutofillDoorhanger.menuButton(panel).querySelector( + `[data-l10n-id=address-capture-learn-more-button]` + ); + } + + get preferenceURL() { + return this.constructor.preferenceURL; + } + get learnMoreURL() { + return this.constructor.learnMoreURL; + } + + onMenuItemClick(evt) { + AutofillTelemetry.recordDoorhangerClicked( + this.constructor.telemetryType, + evt, + this.constructor.telemetryObject, + this.flowId + ); + + if (evt == "open-pref") { + this.browser.ownerGlobal.openPreferences(this.preferenceURL); + } else if (evt == "learn-more") { + const url = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + this.learnMoreURL; + this.browser.ownerGlobal.openWebLinkIn(url, "tab", { + relatedToCurrent: true, + }); + } + } + + // Build the doorhanger markup + render() { + this.renderHeader(); + + this.renderDescription(); + + // doorhanger specific content + this.renderContent(); + } + + renderHeader() { + // Render the header text + const text = this.header.querySelector(`p`); + this.doc.l10n.setAttributes(text, this.ui.header.l10nId); + + // Render the menu button + if (!this.ui.menu?.length || AutofillDoorhanger.menuButton(this.panel)) { + return; + } + + const button = this.doc.createElement("button"); + button.setAttribute("id", AutofillDoorhanger.menuButtonId); + button.setAttribute("class", "address-capture-icon-button"); + this.doc.l10n.setAttributes(button, "address-capture-open-menu-button"); + + const menupopup = this.doc.createXULElement("menupopup"); + menupopup.setAttribute("id", AutofillDoorhanger.menuButtonId); + menupopup.setAttribute("class", "toolbar-menupopup"); + + for (const [index, element] of this.ui.menu.entries()) { + const menuitem = this.doc.createXULElement("menuitem"); + this.doc.l10n.setAttributes(menuitem, element.l10nId); + /* eslint-disable mozilla/balanced-listeners */ + menuitem.addEventListener("command", event => { + event.stopPropagation(); + this.onMenuItemClick(element.evt); + }); + menupopup.appendChild(menuitem); + + if (index != this.ui.menu.length - 1) { + menupopup.appendChild(this.doc.createXULElement("menuseparator")); + } + } + + button.appendChild(menupopup); + /* eslint-disable mozilla/balanced-listeners */ + button.addEventListener("click", event => { + event.stopPropagation(); + menupopup.openPopup(button, "after_start"); + }); + this.header.appendChild(button); + } + + renderDescription() { + if (this.ui.description?.l10nId) { + const text = this.description.querySelector(`p`); + this.doc.l10n.setAttributes(text, this.ui.description.l10nId); + this.description?.setAttribute("style", ""); + } else { + this.description?.setAttribute("style", "display:none"); + } + } + + onEventCallback(state) { + lazy.log.debug(`Doorhanger receives event callback: ${state}`); + + if (state == "showing") { + this.render(); + } + } + + async show() { + AutofillTelemetry.recordDoorhangerShown( + this.constructor.telemetryType, + this.constructor.telemetryObject, + this.flowId + ); + + let options = { + ...this.ui.options, + eventCallback: state => this.onEventCallback(state), + }; + + this.#setAnchor(); + + return new Promise(resolve => { + this.resolve = resolve; + this.chromeWin.PopupNotifications.show( + this.browser, + this.ui.id, + this.getNotificationHeader?.() ?? "", + this.ui.anchor.id, + ...this.#createActions(), + options + ); + }); + } + + /** + * Closes the doorhanger with a given action. + * This method is specifically intended for closing the doorhanger in scenarios + * other than clicking the main or secondary buttons. + */ + closeDoorhanger(action) { + this.resolve(action); + const notification = this.chromeWin.PopupNotifications.getNotification( + this.ui.id, + this.browser + ); + if (notification) { + this.chromeWin.PopupNotifications.remove(notification); + } + } + + /** + * Create an image element for notification anchor if it doesn't already exist. + */ + #setAnchor() { + let anchor = this.doc.getElementById(this.ui.anchor.id); + if (!anchor) { + // Icon shown on URL bar + anchor = this.doc.createXULElement("image"); + anchor.id = this.ui.anchor.id; + anchor.setAttribute("src", this.ui.anchor.URL); + anchor.classList.add("notification-anchor-icon"); + anchor.setAttribute("role", "button"); + anchor.setAttribute("tooltiptext", this.ui.anchor.tooltiptext); + + const popupBox = this.doc.getElementById("notification-popup-box"); + popupBox.appendChild(anchor); + } + } + + /** + * Generate the main action and secondary actions from content parameters and + * promise resolve. + */ + #createActions() { + function getLabelAndAccessKey(param) { + const msg = l10n.formatMessagesSync([{ id: param.l10nId }])[0]; + return { + label: msg.attributes.find(x => x.name == "label").value, + accessKey: msg.attributes.find(x => x.name == "accessKey").value, + dismiss: param.dismiss, + }; + } + + const mainActionParams = this.ui.footer.mainAction; + const secondaryActionParams = this.ui.footer.secondaryActions; + + const callback = () => { + AutofillTelemetry.recordDoorhangerClicked( + this.constructor.telemetryType, + mainActionParams.callbackState, + this.constructor.telemetryObject, + this.flowId + ); + + this.resolve(mainActionParams.callbackState); + }; + + const mainAction = { + ...getLabelAndAccessKey(mainActionParams), + callback, + }; + + let secondaryActions = []; + for (const params of secondaryActionParams) { + const callback = () => { + AutofillTelemetry.recordDoorhangerClicked( + this.constructor.telemetryType, + params.callbackState, + this.constructor.telemetryObject, + this.flowId + ); + + this.resolve(params.callbackState); + }; + + secondaryActions.push({ + ...getLabelAndAccessKey(params), + callback, + }); + } + + return [mainAction, secondaryActions]; + } +} + +export class AddressSaveDoorhanger extends AutofillDoorhanger { + static preferenceURL = "privacy-address-autofill"; + static learnMoreURL = "automatically-fill-your-address-web-forms"; + static editButtonId = "address-capture-edit-address-button"; + + static telemetryType = AutofillTelemetry.ADDRESS; + static telemetryObject = "capture_doorhanger"; + + constructor(browser, oldRecord, newRecord, flowId) { + super(browser, oldRecord, newRecord, flowId); + } + + static editButton(panel) { + return panel.querySelector(`#${AddressSaveDoorhanger.editButtonId}`); + } + get editButton() { + return AddressSaveDoorhanger.editButton(this.panel); + } + + /** + * Formats a line by comparing the old and the new address field and returns an array of + * <span> elements that represents the formatted line. + * + * @param {Array<Array<string>>} datalist An array of pairs, where each pair contains old and new data. + * @param {boolean} showDiff True to format the text line that highlight the diff part. + * + * @returns {Array<HTMLSpanElement>} An array of formatted text elements. + */ + #formatLine(datalist, showDiff) { + const createSpan = (text, style = null) => { + let s; + + if (showDiff) { + if (style == "remove") { + s = this.doc.createElement("del"); + s.setAttribute("class", "address-update-text-diff-removed"); + } else if (style == "add") { + s = this.doc.createElement("mark"); + s.setAttribute("class", "address-update-text-diff-added"); + } else { + s = this.doc.createElement("span"); + } + } else { + s = this.doc.createElement("span"); + } + s.textContent = text; + return s; + }; + + let spans = []; + let previousField; + for (const [field, oldData, newData] of datalist) { + if (!oldData && !newData) { + continue; + } + + // Always add a whitespace between field data that we put in the same line. + // Ex. first-name: John, family-name: Doe becomes + // "John Doe" + if (spans.length) { + if (previousField == "address-level2" && field == "address-level1") { + spans.push(createSpan(", ")); + } else { + spans.push(createSpan(" ")); + } + } + + if (!oldData) { + spans.push(createSpan(newData, "add")); + } else if (!newData || oldData == newData) { + // The same + spans.push(createSpan(oldData)); + } else if (newData.startsWith(oldData)) { + // Have the same prefix + const diff = newData.slice(oldData.length).trim(); + spans.push(createSpan(newData.slice(0, newData.length - diff.length))); + spans.push(createSpan(diff, "add")); + } else if (newData.endsWith(oldData)) { + // Have the same suffix + const diff = newData.slice(0, newData.length - oldData.length).trim(); + spans.push(createSpan(diff, "add")); + spans.push(createSpan(newData.slice(diff.length))); + } else { + spans.push(createSpan(oldData, "remove")); + spans.push(createSpan(" ")); + spans.push(createSpan(newData, "add")); + } + + previousField = field; + } + + return spans; + } + + #formatTextByAddressCategory(fieldName) { + let data = []; + switch (fieldName) { + case "street-address": + data = [ + [ + fieldName, + FormAutofillUtils.toOneLineAddress( + this.oldRecord["street-address"] + ), + FormAutofillUtils.toOneLineAddress( + this.newRecord["street-address"] + ), + ], + ]; + break; + case "address": + data = ["address-level2", "address-level1", "postal-code"].map( + field => [field, this.oldRecord[field], this.newRecord[field]] + ); + break; + case "name": + case "country": + case "tel": + case "email": + case "organization": + data = [ + [fieldName, this.oldRecord[fieldName], this.newRecord[fieldName]], + ]; + break; + } + + const showDiff = !!Object.keys(this.oldRecord).length; + return this.#formatLine(data, showDiff); + } + + renderDescription() { + if (lazy.formAutofillStorage.addresses.isEmpty()) { + super.renderDescription(); + } else { + this.description?.setAttribute("style", "display:none"); + } + } + + renderContent() { + this.content.replaceChildren(); + + // Each section contains address fields that are grouped together while displaying + // the doorhanger. + for (const { imgClass, categories } of this.ui.content.sections) { + // Add all the address fields that are in the same category + let texts = []; + categories.forEach(category => { + const line = this.#formatTextByAddressCategory(category); + if (line.length) { + texts.push(line); + } + }); + + // If there is no data for this section, just ignore it. + if (!texts.length) { + continue; + } + + const section = this.doc.createElement("div"); + section.setAttribute("class", "address-save-update-row-container"); + + // Add image icon for this section + //const img = this.doc.createElement("img"); + const img = this.doc.createXULElement("image"); + img.setAttribute("class", imgClass); + section.appendChild(img); + + // Each line is consisted of multiple <span> to form diff style texts + const lineContainer = this.doc.createElement("div"); + for (const spans of texts) { + const p = this.doc.createElement("p"); + spans.forEach(span => p.appendChild(span)); + lineContainer.appendChild(p); + } + section.appendChild(lineContainer); + + this.content.appendChild(section); + + // Put the edit address button in the first section + if (!AddressSaveDoorhanger.editButton(this.panel)) { + const button = this.doc.createElement("button"); + button.setAttribute("id", AddressSaveDoorhanger.editButtonId); + button.setAttribute("class", "address-capture-icon-button"); + this.doc.l10n.setAttributes( + button, + "address-capture-edit-address-button" + ); + + // The element will be removed after the popup is closed + /* eslint-disable mozilla/balanced-listeners */ + button.addEventListener("click", event => { + event.stopPropagation(); + this.closeDoorhanger("edit-address"); + }); + section.appendChild(button); + } + } + } + + // The record to be saved by this doorhanger + recordToSave() { + return this.newRecord; + } +} + +/** + * Address Update doorhanger and Address Save doorhanger have the same implementation. + * The only difference is UI. + */ +export class AddressUpdateDoorhanger extends AddressSaveDoorhanger { + static telemetryObject = "update_doorhanger"; +} + +export class AddressEditDoorhanger extends AutofillDoorhanger { + static telemetryType = AutofillTelemetry.ADDRESS; + static telemetryObject = "edit_doorhanger"; + + constructor(browser, record, flowId) { + // Address edit dialog doesn't have "old" record + super(browser, null, record, flowId); + + this.country = record.country || FormAutofill.DEFAULT_REGION; + } + + // Address edit doorhanger changes layout according to the country + #layout = null; + get layout() { + if (this.#layout?.country != this.country) { + this.#layout = FormAutofillUtils.getFormFormat(this.country); + } + return this.#layout; + } + + get country() { + return this.newRecord.country; + } + + set country(c) { + if (this.newRecord.country == c) { + return; + } + + // `recordToSave` only contains the latest data the current country support. + // For example, if a country doesn't have `address-level2`, `recordToSave` + // will not have the address field. + // `newRecord` is where we keep all the data regardless what the country is. + // Merge `recordToSave` to `newRecord` before switching country to keep + // `newRecord` update-to-date. + this.newRecord = Object.assign(this.newRecord, this.recordToSave()); + + // The layout of the address edit doorhanger should be changed when the + // country is changed. + this.#buildCountrySpecificAddressFields(); + } + + renderContent() { + this.content.replaceChildren(); + + this.#buildAddressFields(this.content, this.ui.content.fixedFields); + + this.#buildCountrySpecificAddressFields(); + } + + // Put address fields that should be in the same line together. + // Determined by the `newLine` property that is defined in libaddressinput + #buildAddressFields(container, fields) { + const createRowContainer = () => { + const div = this.doc.createElement("div"); + div.setAttribute("class", "address-edit-row-container"); + container.appendChild(div); + return div; + }; + + let row = null; + let createRow = true; + for (const { fieldId, newLine } of fields) { + if (createRow) { + row = createRowContainer(); + } + row.appendChild(this.#createInputField(fieldId)); + createRow = newLine; + } + } + + #buildCountrySpecificAddressFields() { + const fixedFieldIds = this.ui.content.fixedFields.map(f => f.fieldId); + let container = this.doc.getElementById( + "country-specific-fields-container" + ); + if (container) { + // Country-specific fields might be rebuilt after users update the country + // field, so if the container already exists, we remove all its childern and + // then rebuild it. + container.replaceChildren(); + } else { + container = this.doc.createElement("div"); + container.setAttribute("id", "country-specific-fields-container"); + + // Find where to insert country-specific fields + const nth = fixedFieldIds.indexOf( + this.ui.content.countrySpecificFieldsBefore + ); + this.content.insertBefore(container, this.content.children[nth]); + } + + this.#buildAddressFields( + container, + // Filter out fields that are always displayed + this.layout.fieldsOrder.filter(f => !fixedFieldIds.includes(f.fieldId)) + ); + } + + #buildCountryMenupopup() { + const menupopup = this.doc.createXULElement("menupopup"); + + let menuitem = this.doc.createXULElement("menuitem"); + menuitem.setAttribute("value", ""); + menupopup.appendChild(menuitem); + + const countries = [...FormAutofill.countries.entries()].sort((e1, e2) => + e1[1].localeCompare(e2[1]) + ); + for (const [country] of countries) { + const countryName = Services.intl.getRegionDisplayNames(undefined, [ + country.toLowerCase(), + ]); + menuitem = this.doc.createXULElement("menuitem"); + menuitem.setAttribute("label", countryName); + menuitem.setAttribute("value", country); + menupopup.appendChild(menuitem); + } + + return menupopup; + } + + #buildAddressLevel1Menupopup() { + const menupopup = this.doc.createXULElement("menupopup"); + + let menuitem = this.doc.createXULElement("menuitem"); + menuitem.setAttribute("value", ""); + menupopup.appendChild(menuitem); + + for (const [regionCode, regionName] of this.layout.addressLevel1Options) { + menuitem = this.doc.createXULElement("menuitem"); + menuitem.setAttribute("label", regionCode); + menuitem.setAttribute("value", regionName); + menupopup.appendChild(menuitem); + } + + return menupopup; + } + + /** + * Creates an input field with a label and attaches it to a container element. + * The type of the input field is determined by the `fieldName`. + * + * @param {string} fieldName The name of the address field + */ + #createInputField(fieldName) { + const div = this.doc.createElement("div"); + div.setAttribute("class", "address-edit-input-container"); + + const inputId = AddressEditDoorhanger.getInputId(fieldName); + const label = this.doc.createElement("label"); + label.setAttribute("for", inputId); + + switch (fieldName) { + case "address-level1": + this.doc.l10n.setAttributes(label, this.layout.addressLevel1L10nId); + break; + case "address-level2": + this.doc.l10n.setAttributes(label, this.layout.addressLevel2L10nId); + break; + case "address-level3": + this.doc.l10n.setAttributes(label, this.layout.addressLevel3L10nId); + break; + case "postal-code": + this.doc.l10n.setAttributes(label, this.layout.postalCodeL10nId); + break; + case "country": + // workaround because `autofill-address-country` is already defined + this.doc.l10n.setAttributes( + label, + `autofill-address-${fieldName}-only` + ); + break; + default: + this.doc.l10n.setAttributes(label, `autofill-address-${fieldName}`); + break; + } + div.appendChild(label); + + let input; + let popup; + if ("street-address".includes(fieldName)) { + input = this.doc.createElement("textarea"); + input.setAttribute("rows", 3); + } else if (fieldName == "country") { + input = this.doc.createXULElement("menulist"); + popup = this.#buildCountryMenupopup(); + popup.addEventListener("popuphidden", e => e.stopPropagation()); + input.appendChild(popup); + + // The element will be removed after the popup is closed + /* eslint-disable mozilla/balanced-listeners */ + input.addEventListener("command", event => { + event.stopPropagation(); + this.country = input.selectedItem.value; + }); + } else if ( + fieldName == "address-level1" && + this.layout.addressLevel1Options + ) { + input = this.doc.createXULElement("menulist"); + popup = this.#buildAddressLevel1Menupopup(); + popup.addEventListener("popuphidden", e => e.stopPropagation()); + input.appendChild(popup); + } else { + input = this.doc.createElement("input"); + } + + input.setAttribute("id", inputId); + + const value = this.newRecord[fieldName] ?? ""; + if (popup) { + const menuitem = Array.from(popup.childNodes).find( + item => + item.label.toLowerCase() === value?.toLowerCase() || + item.value.toLowerCase() === value?.toLowerCase() + ); + input.selectedItem = menuitem; + } else { + input.value = value; + } + + div.appendChild(input); + + return div; + } + + /* + * This method generates a unique input ID using the field name of the address field. + * + * @param {string} fieldName The name of the address field + */ + static getInputId(fieldName) { + return `address-edit-${fieldName}-input`; + } + + /* + * Return a regular expression that matches the ID pattern generated by getInputId. + */ + static #getInputIdMatchRegexp() { + const regex = /^address-edit-(.+)-input$/; + return regex; + } + + /** + * Collects data from all visible address field inputs within the doorhanger. + * Since address fields may vary by country, only fields present for the + * current country's address format are included in the record. + */ + recordToSave() { + let record = {}; + const regex = AddressEditDoorhanger.#getInputIdMatchRegexp(); + const elements = this.panel.querySelectorAll("input, textarea, menulist"); + for (const element of elements) { + const match = element.id.match(regex); + if (match && match[1]) { + record[match[1]] = element.value; + } + } + return record; + } + + onEventCallback(state) { + super.onEventCallback(state); + + // Close the edit address doorhanger when it has been dismissed. + if (state == "dismissed") { + this.closeDoorhanger("cancel"); + } + } +} + +export class CreditCardSaveDoorhanger extends AutofillDoorhanger { + static contentClass = "credit-card-capture-content"; + + static telemetryType = AutofillTelemetry.CREDIT_CARD; + static telemetryObject = "capture_doorhanger"; + + static spotlightURL = "about:preferences#privacy-credit-card-autofill"; + + constructor(browser, oldRecord, newRecord, flowId) { + super(browser, oldRecord, newRecord, flowId); + } + + /** + * We have not yet sync address and credit card design. After syncing, + * we should be able to use the same "class" + */ + static content(panel) { + return panel.querySelector(`.${CreditCardSaveDoorhanger.contentClass}`); + } + get content() { + return CreditCardSaveDoorhanger.content(this.panel); + } + + addCheckboxListener() { + if (!this.ui.options.checkbox) { + return; + } + + const { checkbox } = this.panel; + if (checkbox && !checkbox.hidden) { + checkbox.addEventListener("command", event => { + let { secondaryButton, menubutton } = + event.target.closest("popupnotification"); + let checked = event.target.checked; + Services.prefs.setBoolPref("services.sync.engine.creditcards", checked); + secondaryButton.disabled = checked; + menubutton.disabled = checked; + lazy.log.debug("Set creditCard sync to", checked); + }); + } + } + + removeCheckboxListener() { + if (!this.ui.options.checkbox) { + return; + } + + const { checkbox } = this.panel; + + if (checkbox && !checkbox.hidden) { + checkbox.removeEventListener( + "command", + this.ui.options.checkbox.callback + ); + } + } + + appendDescription() { + const docFragment = this.doc.createDocumentFragment(); + + const label = this.doc.createXULElement("label"); + this.doc.l10n.setAttributes(label, this.ui.description.l10nId); + docFragment.appendChild(label); + + const descriptionWrapper = this.doc.createXULElement("hbox"); + descriptionWrapper.className = "desc-message-box"; + + const number = + this.newRecord["cc-number"] || this.newRecord["cc-number-decrypted"]; + const name = this.newRecord["cc-name"]; + const network = lazy.CreditCard.getType(number); + + const descriptionIcon = lazy.CreditCard.getCreditCardLogo(network); + if (descriptionIcon) { + const icon = this.doc.createXULElement("image"); + if ( + typeof descriptionIcon == "string" && + (descriptionIcon.includes("cc-logo") || + descriptionIcon.includes("icon-credit")) + ) { + icon.setAttribute("src", descriptionIcon); + } + descriptionWrapper.appendChild(icon); + } + + const description = this.doc.createXULElement("description"); + description.textContent = + `${lazy.CreditCard.getMaskedNumber(number)}` + (name ? `, ${name}` : ``); + + descriptionWrapper.appendChild(description); + docFragment.appendChild(descriptionWrapper); + + this.content.appendChild(docFragment); + } + + appendPrivacyPanelLink() { + const privacyLinkElement = this.doc.createXULElement("label", { + is: "text-link", + }); + privacyLinkElement.setAttribute("useoriginprincipal", true); + privacyLinkElement.setAttribute( + "href", + CreditCardSaveDoorhanger.spotlightURL || + "about:preferences#privacy-form-autofill" + ); + + const linkId = `autofill-options-link${ + AppConstants.platform == "macosx" ? "-osx" : "" + }`; + this.doc.l10n.setAttributes(privacyLinkElement, linkId); + + this.content.appendChild(privacyLinkElement); + } + + // TODO: Currently, the header and description are unused. Align + // these with the address doorhanger's implementation during + // the credit card doorhanger redesign. + getNotificationHeader() { + return l10n.formatValueSync(this.ui.header.l10nId); + } + + renderHeader() { + // Not implement + } + + renderDescription() { + // Not implement + } + + renderContent() { + this.content.replaceChildren(); + + this.appendDescription(); + + this.appendPrivacyPanelLink(); + } + + onEventCallback(state) { + super.onEventCallback(state); + + if (state == "removed" || state == "dismissed") { + this.removeCheckboxListener(); + } else if (state == "shown") { + this.addCheckboxListener(); + } + } + + // The record to be saved by this doorhanger + recordToSave() { + return this.newRecord; + } +} + +export class CreditCardUpdateDoorhanger extends CreditCardSaveDoorhanger { + static telemetryType = AutofillTelemetry.CREDIT_CARD; + static telemetryObject = "update_doorhanger"; + + constructor(browser, oldRecord, newRecord, flowId) { + super(browser, oldRecord, newRecord, flowId); + } +} + +CONTENT = { + [AddressSaveDoorhanger.name]: { + id: "address-save-update", + anchor: { + id: "autofill-address-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: l10n.formatValueSync("autofill-message-tooltip"), + }, + header: { + l10nId: "address-capture-save-doorhanger-header", + }, + description: { + l10nId: "address-capture-save-doorhanger-description", + }, + menu: [ + { + l10nId: "address-capture-manage-address-button", + evt: "open-pref", + }, + { + l10nId: "address-capture-learn-more-button", + evt: "learn-more", + }, + ], + content: { + // We divide address data into two sections to display in the Address Save Doorhanger. + sections: [ + { + imgClass: "address-capture-img-address", + categories: [ + "name", + "organization", + "street-address", + "address", + "country", + ], + }, + { + imgClass: "address-capture-img-email", + categories: ["email", "tel"], + }, + ], + }, + footer: { + mainAction: { + l10nId: "address-capture-save-button", + callbackState: "create", + }, + secondaryActions: [ + { + l10nId: "address-capture-not-now-button", + callbackState: "cancel", + }, + ], + }, + options: { + autofocus: true, + persistWhileVisible: true, + hideClose: true, + }, + }, + + [AddressUpdateDoorhanger.name]: { + id: "address-save-update", + anchor: { + id: "autofill-address-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: l10n.formatValueSync("autofill-message-tooltip"), + }, + header: { + l10nId: "address-capture-update-doorhanger-header", + }, + menu: [ + { + l10nId: "address-capture-manage-address-button", + evt: "open-pref", + }, + { + l10nId: "address-capture-learn-more-button", + evt: "learn-more", + }, + ], + content: { + // Addresses fields are categoried into two sections, each section + // has its own icon + sections: [ + { + imgClass: "address-capture-img-address", + categories: [ + "name", + "organization", + "street-address", + "address", + "country", + ], + }, + { + imgClass: "address-capture-img-email", + categories: ["email", "tel"], + }, + ], + }, + footer: { + mainAction: { + l10nId: "address-capture-update-button", + callbackState: "update", + }, + secondaryActions: [ + { + l10nId: "address-capture-not-now-button", + callbackState: "cancel", + }, + ], + }, + options: { + autofocus: true, + persistWhileVisible: true, + hideClose: true, + }, + }, + + [AddressEditDoorhanger.name]: { + id: "address-edit", + anchor: { + id: "autofill-address-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: l10n.formatValueSync("autofill-message-tooltip"), + }, + header: { + l10nId: "address-capture-edit-doorhanger-header", + }, + menu: null, + content: { + // We start by organizing the fields in a specific order: + // name, organization, and country are fixed and come first. + // These are followed by country-specific fields, which are + // laid out differently for each country (as referenced from libaddressinput). + // Finally, we place the telephone and email fields at the end. + countrySpecificFieldsBefore: "tel", + fixedFields: [ + { fieldId: "name", newLine: true }, + { fieldId: "organization", newLine: true }, + { fieldId: "country", newLine: true }, + { fieldId: "tel", newLine: false }, + { fieldId: "email", newLine: true }, + ], + }, + footer: { + mainAction: { + l10nId: "address-capture-save-button", + callbackState: "save", + }, + secondaryActions: [ + { + l10nId: "address-capture-cancel-button", + callbackState: "cancel", + dismiss: true, + }, + ], + }, + options: { + autofocus: true, + persistWhileVisible: true, + hideClose: true, + }, + }, + + [CreditCardSaveDoorhanger.name]: { + id: "credit-card-save-update", + anchor: { + id: "autofill-credit-card-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: l10n.formatValueSync("autofill-message-tooltip"), + }, + header: { + l10nId: "credit-card-save-doorhanger-header", + }, + description: { + l10nId: "credit-card-save-doorhanger-description", + }, + content: {}, + footer: { + mainAction: { + l10nId: "credit-card-capture-save-button", + callbackState: "create", + }, + secondaryActions: [ + { + l10nId: "credit-card-capture-cancel-button", + callbackState: "cancel", + }, + { + l10nId: "credit-card-capture-never-save-button", + callbackState: "disable", + }, + ], + }, + options: { + persistWhileVisible: true, + popupIconURL: "chrome://formautofill/content/icon-credit-card.svg", + hideClose: true, + + checkbox: { + get checked() { + return Services.prefs.getBoolPref("services.sync.engine.creditcards"); + }, + get label() { + // Only set the label when the fallowing conditions existed: + // - sync account is set + // - credit card sync is disabled + // - credit card sync is available + // otherwise return null label to hide checkbox. + return Services.prefs.prefHasUserValue("services.sync.username") && + !Services.prefs.getBoolPref("services.sync.engine.creditcards") && + Services.prefs.getBoolPref( + "services.sync.engine.creditcards.available" + ) + ? l10n.formatValueSync( + "credit-card-doorhanger-credit-cards-sync-checkbox" + ) + : null; + }, + }, + }, + }, + + [CreditCardUpdateDoorhanger.name]: { + id: "credit-card-save-update", + anchor: { + id: "autofill-credit-card-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: l10n.formatValueSync("autofill-message-tooltip"), + }, + header: { + l10nId: "credit-card-update-doorhanger-header", + }, + description: { + l10nId: "credit-card-update-doorhanger-description", + }, + content: {}, + footer: { + mainAction: { + l10nId: "credit-card-capture-update-button", + callbackState: "update", + }, + secondaryActions: [ + { + l10nId: "credit-card-capture-save-new-button", + callbackState: "create", + }, + ], + }, + options: { + persistWhileVisible: true, + popupIconURL: "chrome://formautofill/content/icon-credit-card.svg", + hideClose: true, + }, + }, +}; + +export let FormAutofillPrompter = { + async promptToSaveCreditCard( + browser, + storage, + flowId, + { oldRecord, newRecord } + ) { + const showUpdateDoorhanger = !!Object.keys(oldRecord).length; + + const { ownerGlobal: win } = browser; + win.MozXULElement.insertFTLIfNeeded( + "toolkit/formautofill/formAutofill.ftl" + ); + + let action; + const doorhanger = showUpdateDoorhanger + ? new CreditCardUpdateDoorhanger(browser, oldRecord, newRecord, flowId) + : new CreditCardSaveDoorhanger(browser, oldRecord, newRecord, flowId); + action = await doorhanger.show(); + + lazy.log.debug(`Doorhanger action is ${action}`); + + if (action == "cancel") { + return; + } else if (action == "disable") { + Services.prefs.setBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF, false); + return; + } + + if (!(await FormAutofillUtils.ensureLoggedIn()).authenticated) { + lazy.log.warn("User canceled encryption login"); + return; + } + + this._updateStorageAfterInteractWithPrompt( + browser, + storage, + "credit-card", + action == "update" ? oldRecord : null, + doorhanger.recordToSave() + ); + }, + + /** + * Show save or update address doorhanger + * + * @param {Element<browser>} browser Browser to show the save/update address prompt + * @param {object} storage Address storage + * @param {string} flowId Unique GUID to record a series of the same user action + * @param {object} options + * @param {object} [options.oldRecord] Record to be merged + * @param {object} [options.newRecord] Record with more information + */ + async promptToSaveAddress( + browser, + storage, + flowId, + { oldRecord, newRecord } + ) { + const showUpdateDoorhanger = !!Object.keys(oldRecord).length; + + lazy.log.debug( + `Show the ${showUpdateDoorhanger ? "update" : "save"} address doorhanger` + ); + + const { ownerGlobal: win } = browser; + await win.ensureCustomElements("moz-support-link"); + win.MozXULElement.insertFTLIfNeeded( + "toolkit/formautofill/formAutofill.ftl" + ); + // address-autofill-* are defined in browser/preferences now + win.MozXULElement.insertFTLIfNeeded("browser/preferences/formAutofill.ftl"); + + let doorhanger; + let action; + while (true) { + doorhanger = showUpdateDoorhanger + ? new AddressUpdateDoorhanger(browser, oldRecord, newRecord, flowId) + : new AddressSaveDoorhanger(browser, oldRecord, newRecord, flowId); + action = await doorhanger.show(); + + if (action == "edit-address") { + doorhanger = new AddressEditDoorhanger( + browser, + { ...oldRecord, ...newRecord }, + flowId + ); + action = await doorhanger.show(); + + // If users cancel the edit address doorhanger, show the save/update + // doorhanger again. + if (action == "cancel") { + continue; + } + } + + break; + } + + lazy.log.debug(`Doorhanger action is ${action}`); + + if (action == "cancel") { + return; + } + + this._updateStorageAfterInteractWithPrompt( + browser, + storage, + "address", + showUpdateDoorhanger ? oldRecord : null, + doorhanger.recordToSave() + ); + }, + + // TODO: Simplify the code after integrating credit card prompt to use AutofillDoorhanger + async _updateStorageAfterInteractWithPrompt( + browser, + storage, + type, + oldRecord, + newRecord + ) { + let changedGUID = null; + if (oldRecord) { + changedGUID = oldRecord.guid; + await storage.update(changedGUID, newRecord, true); + } else { + changedGUID = await storage.add(newRecord); + } + storage.notifyUsed(changedGUID); + + const hintId = `confirmation-hint-${type}-${ + oldRecord ? "updated" : "created" + }`; + showConfirmation(browser, hintId); + }, +}; diff --git a/toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs b/toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs new file mode 100644 index 0000000000..1f323998c3 --- /dev/null +++ b/toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs @@ -0,0 +1,106 @@ +/* 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/. */ + +/* + * Implements an interface of the storage of Form Autofill. + */ + +// We expose a singleton from this module. Some tests may import the +// constructor via a backstage pass. +import { + AddressesBase, + CreditCardsBase, + FormAutofillStorageBase, +} from "resource://autofill/FormAutofillStorageBase.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CreditCard: "resource://gre/modules/CreditCard.sys.mjs", + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", + OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", +}); + +const PROFILE_JSON_FILE_NAME = "autofill-profiles.json"; + +class Addresses extends AddressesBase {} + +class CreditCards extends CreditCardsBase { + constructor(store) { + super(store); + } + + async _encryptNumber(creditCard) { + if (!("cc-number-encrypted" in creditCard)) { + if ("cc-number" in creditCard) { + let ccNumber = creditCard["cc-number"]; + if (lazy.CreditCard.isValidNumber(ccNumber)) { + creditCard["cc-number"] = + lazy.CreditCard.getLongMaskedNumber(ccNumber); + } else { + // Credit card numbers can be entered on versions of Firefox that don't validate + // the number and then synced to this version of Firefox. Therefore, mask the + // full number if the number is invalid on this version. + creditCard["cc-number"] = "*".repeat(ccNumber.length); + } + creditCard["cc-number-encrypted"] = await lazy.OSKeyStore.encrypt( + ccNumber + ); + } else { + creditCard["cc-number-encrypted"] = ""; + } + } + } +} + +export class FormAutofillStorage extends FormAutofillStorageBase { + constructor(path) { + super(path); + } + + getAddresses() { + if (!this._addresses) { + this._store.ensureDataReady(); + this._addresses = new Addresses(this._store); + } + return this._addresses; + } + + getCreditCards() { + if (!this._creditCards) { + this._store.ensureDataReady(); + this._creditCards = new CreditCards(this._store); + } + return this._creditCards; + } + + /** + * Loads the profile data from file to memory. + * + * @returns {JSONFile} + * The JSONFile store. + */ + _initializeStore() { + return new lazy.JSONFile({ + path: this._path, + dataPostProcessor: this._dataPostProcessor.bind(this), + }); + } + + _dataPostProcessor(data) { + data.version = this.version; + if (!data.addresses) { + data.addresses = []; + } + if (!data.creditCards) { + data.creditCards = []; + } + return data; + } +} + +// The singleton exposed by this module. +export const formAutofillStorage = new FormAutofillStorage( + PathUtils.join(PathUtils.profileDir, PROFILE_JSON_FILE_NAME) +); |