summaryrefslogtreecommitdiffstats
path: root/browser/extensions/formautofill/content/manageDialog.js
diff options
context:
space:
mode:
Diffstat (limited to 'browser/extensions/formautofill/content/manageDialog.js')
-rw-r--r--browser/extensions/formautofill/content/manageDialog.js472
1 files changed, 472 insertions, 0 deletions
diff --git a/browser/extensions/formautofill/content/manageDialog.js b/browser/extensions/formautofill/content/manageDialog.js
new file mode 100644
index 0000000000..ed585c351b
--- /dev/null
+++ b/browser/extensions/formautofill/content/manageDialog.js
@@ -0,0 +1,472 @@
+/* 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);
+ }
+}