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