/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /* exported ManageAddresses, ManageCreditCards */ "use strict"; const EDIT_ADDRESS_URL = "chrome://formautofill/content/editAddress.xhtml"; const EDIT_CREDIT_CARD_URL = "chrome://formautofill/content/editCreditCard.xhtml"; const { AppConstants } = ChromeUtils.importESModule( "resource://gre/modules/AppConstants.sys.mjs" ); const { FormAutofill } = ChromeUtils.importESModule( "resource://autofill/FormAutofill.sys.mjs" ); const { AutofillTelemetry } = ChromeUtils.importESModule( "resource://autofill/AutofillTelemetry.sys.mjs" ); ChromeUtils.defineESModuleGetters(this, { CreditCard: "resource://gre/modules/CreditCard.sys.mjs", FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", formAutofillStorage: "resource://autofill/FormAutofillStorage.sys.mjs", }); this.log = null; ChromeUtils.defineLazyGetter(this, "log", () => FormAutofill.defineLogGetter(this, "manageAddresses") ); class ManageRecords { constructor(subStorageName, elements) { this._storageInitPromise = formAutofillStorage.initialize(); this._subStorageName = subStorageName; this._elements = elements; this._newRequest = false; this._isLoadingRecords = false; this.prefWin = window.opener; window.addEventListener("DOMContentLoaded", this, { once: true }); } async init() { await this.loadRecords(); this.attachEventListeners(); // For testing only: Notify when the dialog is ready for interaction window.dispatchEvent(new CustomEvent("FormReady")); } uninit() { log.debug("uninit"); this.detachEventListeners(); this._elements = null; } /** * Get the selected options on the addresses element. * * @returns {Array} */ 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} 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} 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 promptMessage = FormAutofillUtils.reauthOSPromptMessage( "autofill-edit-payment-method-os-prompt-macos", "autofill-edit-payment-method-os-prompt-windows", "autofill-edit-payment-method-os-prompt-other" ); const loggedIn = await FormAutofillUtils.ensureLoggedIn(promptMessage); if (!loggedIn.authenticated) { return; } } let decryptedCCNumObj = {}; if (creditCard && creditCard["cc-number-encrypted"]) { try { decryptedCCNumObj["cc-number"] = await OSKeyStore.decrypt( creditCard["cc-number-encrypted"] ); } catch (ex) { if (ex.result == Cr.NS_ERROR_ABORT) { // User shouldn't be ask to reauth here, but it could happen. // Return here and skip opening the dialog. return; } // We've got ourselves a real error. // Recover from encryption error so the user gets a chance to re-enter // unencrypted credit card number. decryptedCCNumObj["cc-number"] = ""; console.error(ex); } } let decryptedCreditCard = Object.assign({}, creditCard, decryptedCCNumObj); this.prefWin.gSubDialog.open( EDIT_CREDIT_CARD_URL, { features: "resizable=no" }, { record: decryptedCreditCard, } ); } /** * Get credit card display label. It should display masked numbers and the * cardholder's name, separated by a comma. * * @param {object} creditCard * @returns {Promise} */ 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); } }