firefox-desktop/browser/extensions/formautofill/content/manageDialog.js
2023-01-04 19:48:44 +01:00

472 lines
13 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* exported ManageAddresses, ManageCreditCards */
"use strict";
const EDIT_ADDRESS_URL = "chrome://formautofill/content/editAddress.xhtml";
const EDIT_CREDIT_CARD_URL =
"chrome://formautofill/content/editCreditCard.xhtml";
const { AppConstants } = ChromeUtils.importESModule(
"resource://gre/modules/AppConstants.sys.mjs"
);
const { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
);
const { FormAutofill } = ChromeUtils.import(
"resource://autofill/FormAutofill.jsm"
);
const { AutofillTelemetry } = ChromeUtils.import(
"resource://autofill/AutofillTelemetry.jsm"
);
ChromeUtils.defineESModuleGetters(this, {
CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
});
ChromeUtils.defineModuleGetter(
this,
"formAutofillStorage",
"resource://autofill/FormAutofillStorage.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"FormAutofillUtils",
"resource://autofill/FormAutofillUtils.jsm"
);
const lazy = {};
XPCOMUtils.defineLazyGetter(
lazy,
"l10n",
() =>
new Localization([
"browser/preferences/formAutofill.ftl",
"branding/brand.ftl",
])
);
this.log = null;
XPCOMUtils.defineLazyGetter(this, "log", () =>
FormAutofill.defineLogGetter(this, "manageAddresses")
);
class ManageRecords {
constructor(subStorageName, elements) {
this._storageInitPromise = formAutofillStorage.initialize();
this._subStorageName = subStorageName;
this._elements = elements;
this._newRequest = false;
this._isLoadingRecords = false;
this.prefWin = window.opener;
window.addEventListener("DOMContentLoaded", this, { once: true });
}
async init() {
await this.loadRecords();
this.attachEventListeners();
// For testing only: Notify when the dialog is ready for interaction
window.dispatchEvent(new CustomEvent("FormReady"));
}
uninit() {
log.debug("uninit");
this.detachEventListeners();
this._elements = null;
}
/**
* Get the selected options on the addresses element.
*
* @returns {Array<DOMElement>}
*/
get _selectedOptions() {
return Array.from(this._elements.records.selectedOptions);
}
/**
* Get storage and ensure it has been initialized.
*
* @returns {object}
*/
async getStorage() {
await this._storageInitPromise;
return formAutofillStorage[this._subStorageName];
}
/**
* Load records and render them. This function is a wrapper for _loadRecords
* to ensure any reentrant will be handled well.
*/
async loadRecords() {
// This function can be early returned when there is any reentrant happends.
// "_newRequest" needs to be set to ensure all changes will be applied.
if (this._isLoadingRecords) {
this._newRequest = true;
return;
}
this._isLoadingRecords = true;
await this._loadRecords();
// _loadRecords should be invoked again if there is any multiple entrant
// during running _loadRecords(). This step ensures that the latest request
// still is applied.
while (this._newRequest) {
this._newRequest = false;
await this._loadRecords();
}
this._isLoadingRecords = false;
// For testing only: Notify when records are loaded
this._elements.records.dispatchEvent(new CustomEvent("RecordsLoaded"));
}
async _loadRecords() {
let storage = await this.getStorage();
let records = await storage.getAll();
// Sort by last used time starting with most recent
records.sort((a, b) => {
let aLastUsed = a.timeLastUsed || a.timeLastModified;
let bLastUsed = b.timeLastUsed || b.timeLastModified;
return bLastUsed - aLastUsed;
});
await this.renderRecordElements(records);
this.updateButtonsStates(this._selectedOptions.length);
}
/**
* Render the records onto the page while maintaining selected options if
* they still exist.
*
* @param {Array<object>} records
*/
async renderRecordElements(records) {
let selectedGuids = this._selectedOptions.map(option => option.value);
this.clearRecordElements();
for (let record of records) {
let { id, args, raw } = await this.getLabelInfo(record);
let option = new Option(
raw ?? "",
record.guid,
false,
selectedGuids.includes(record.guid)
);
if (id) {
document.l10n.setAttributes(option, id, args);
}
option.record = record;
this._elements.records.appendChild(option);
}
}
/**
* Remove all existing record elements.
*/
clearRecordElements() {
let parent = this._elements.records;
while (parent.lastChild) {
parent.removeChild(parent.lastChild);
}
}
/**
* Remove records by selected options.
*
* @param {Array<DOMElement>} options
*/
async removeRecords(options) {
let storage = await this.getStorage();
// Pause listening to storage change event to avoid triggering `loadRecords`
// when removing records
Services.obs.removeObserver(this, "formautofill-storage-changed");
for (let option of options) {
storage.remove(option.value);
option.remove();
}
this.updateButtonsStates(this._selectedOptions);
// Resume listening to storage change event
Services.obs.addObserver(this, "formautofill-storage-changed");
// For testing only: notify record(s) has been removed
this._elements.records.dispatchEvent(new CustomEvent("RecordsRemoved"));
for (let i = 0; i < options.length; i++) {
AutofillTelemetry.recordManageEvent(this.telemetryType, "delete");
}
}
/**
* Enable/disable the Edit and Remove buttons based on number of selected
* options.
*
* @param {number} selectedCount
*/
updateButtonsStates(selectedCount) {
log.debug("updateButtonsStates:", selectedCount);
if (selectedCount == 0) {
this._elements.edit.setAttribute("disabled", "disabled");
this._elements.remove.setAttribute("disabled", "disabled");
} else if (selectedCount == 1) {
this._elements.edit.removeAttribute("disabled");
this._elements.remove.removeAttribute("disabled");
} else if (selectedCount > 1) {
this._elements.edit.setAttribute("disabled", "disabled");
this._elements.remove.removeAttribute("disabled");
}
}
/**
* Handle events
*
* @param {DOMEvent} event
*/
handleEvent(event) {
switch (event.type) {
case "DOMContentLoaded": {
this.init();
break;
}
case "click": {
this.handleClick(event);
break;
}
case "change": {
this.updateButtonsStates(this._selectedOptions.length);
break;
}
case "unload": {
this.uninit();
break;
}
case "keypress": {
this.handleKeyPress(event);
break;
}
case "contextmenu": {
event.preventDefault();
break;
}
}
}
/**
* Handle click events
*
* @param {DOMEvent} event
*/
handleClick(event) {
if (event.target == this._elements.remove) {
this.removeRecords(this._selectedOptions);
} else if (event.target == this._elements.add) {
this.openEditDialog();
} else if (
event.target == this._elements.edit ||
(event.target.parentNode == this._elements.records && event.detail > 1)
) {
this.openEditDialog(this._selectedOptions[0].record);
}
}
/**
* Handle key press events
*
* @param {DOMEvent} event
*/
handleKeyPress(event) {
if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) {
window.close();
}
if (event.keyCode == KeyEvent.DOM_VK_DELETE) {
this.removeRecords(this._selectedOptions);
}
}
observe(subject, topic, data) {
switch (topic) {
case "formautofill-storage-changed": {
this.loadRecords();
}
}
}
/**
* Attach event listener
*/
attachEventListeners() {
window.addEventListener("unload", this, { once: true });
window.addEventListener("keypress", this);
window.addEventListener("contextmenu", this);
this._elements.records.addEventListener("change", this);
this._elements.records.addEventListener("click", this);
this._elements.controlsContainer.addEventListener("click", this);
Services.obs.addObserver(this, "formautofill-storage-changed");
}
/**
* Remove event listener
*/
detachEventListeners() {
window.removeEventListener("keypress", this);
window.removeEventListener("contextmenu", this);
this._elements.records.removeEventListener("change", this);
this._elements.records.removeEventListener("click", this);
this._elements.controlsContainer.removeEventListener("click", this);
Services.obs.removeObserver(this, "formautofill-storage-changed");
}
}
class ManageAddresses extends ManageRecords {
telemetryType = AutofillTelemetry.ADDRESS;
constructor(elements) {
super("addresses", elements);
elements.add.setAttribute(
"search-l10n-ids",
FormAutofillUtils.EDIT_ADDRESS_L10N_IDS.join(",")
);
AutofillTelemetry.recordManageEvent(this.telemetryType, "show");
}
/**
* Open the edit address dialog to create/edit an address.
*
* @param {object} address [optional]
*/
openEditDialog(address) {
this.prefWin.gSubDialog.open(EDIT_ADDRESS_URL, undefined, {
record: address,
// Don't validate in preferences since it's fine for fields to be missing
// for autofill purposes. For PaymentRequest addresses get more validation.
noValidate: true,
});
}
getLabelInfo(address) {
return { raw: FormAutofillUtils.getAddressLabel(address) };
}
}
class ManageCreditCards extends ManageRecords {
telemetryType = AutofillTelemetry.CREDIT_CARD;
constructor(elements) {
super("creditCards", elements);
elements.add.setAttribute(
"search-l10n-ids",
FormAutofillUtils.EDIT_CREDITCARD_L10N_IDS.join(",")
);
this._isDecrypted = false;
AutofillTelemetry.recordManageEvent(this.telemetryType, "show");
}
/**
* Open the edit address dialog to create/edit a credit card.
*
* @param {object} creditCard [optional]
*/
async openEditDialog(creditCard) {
// Ask for reauth if user is trying to edit an existing credit card.
if (creditCard) {
const reauthPasswordPromptMessage = await lazy.l10n.formatValue(
"autofill-edit-card-password-prompt"
);
const loggedIn = await FormAutofillUtils.ensureLoggedIn(
reauthPasswordPromptMessage
);
if (!loggedIn.authenticated) {
return;
}
}
let decryptedCCNumObj = {};
if (creditCard && creditCard["cc-number-encrypted"]) {
try {
decryptedCCNumObj["cc-number"] = await OSKeyStore.decrypt(
creditCard["cc-number-encrypted"]
);
} catch (ex) {
if (ex.result == Cr.NS_ERROR_ABORT) {
// User shouldn't be ask to reauth here, but it could happen.
// Return here and skip opening the dialog.
return;
}
// We've got ourselves a real error.
// Recover from encryption error so the user gets a chance to re-enter
// unencrypted credit card number.
decryptedCCNumObj["cc-number"] = "";
console.error(ex);
}
}
let decryptedCreditCard = Object.assign({}, creditCard, decryptedCCNumObj);
this.prefWin.gSubDialog.open(
EDIT_CREDIT_CARD_URL,
{ features: "resizable=no" },
{
record: decryptedCreditCard,
}
);
}
/**
* Get credit card display label. It should display masked numbers and the
* cardholder's name, separated by a comma.
*
* @param {object} creditCard
* @returns {Promise<string>}
*/
async getLabelInfo(creditCard) {
// The card type is displayed visually using an image. For a11y, we need
// to expose it as text. We do this using aria-label. However,
// aria-label overrides the text content, so we must include that also.
// Since the text content is generated by Fluent, aria-label must be
// generated by Fluent also.
const type = creditCard["cc-type"];
const typeL10nId = CreditCard.getNetworkL10nId(type);
const typeName = typeL10nId
? await document.l10n.formatValue(typeL10nId)
: type ?? ""; // Unknown card type
return CreditCard.getLabelInfo({
name: creditCard["cc-name"],
number: creditCard["cc-number"],
month: creditCard["cc-exp-month"],
year: creditCard["cc-exp-year"],
type: typeName,
});
}
async renderRecordElements(records) {
// Revert back to encrypted form when re-rendering happens
this._isDecrypted = false;
// Display third-party card icons when possible
this._elements.records.classList.toggle(
"branded",
AppConstants.MOZILLA_OFFICIAL
);
await super.renderRecordElements(records);
let options = this._elements.records.options;
for (let option of options) {
let record = option.record;
if (record && record["cc-type"]) {
option.setAttribute("cc-type", record["cc-type"]);
} else {
option.removeAttribute("cc-type");
}
}
}
updateButtonsStates(selectedCount) {
super.updateButtonsStates(selectedCount);
}
handleClick(event) {
super.handleClick(event);
}
}