Bug 1370768 - (Part 2) Add manage credit cards dialog. r=lchang draft
authorScott Wu <scottcwwu@gmail.com>
Wed, 23 Aug 2017 10:12:18 +0800
changeset 652715 29086d3f2630b5a16ffd00b1ebc52577e1129ad1
parent 652053 4de7e87e42fc0438c47b33a991eed3aeaf505c63
child 652716 6bf5fcf476d74d89bee0d3c7b2de17633e809511
push id76130
push userbmo:scwwu@mozilla.com
push dateFri, 25 Aug 2017 04:01:23 +0000
reviewerslchang
bugs1370768
milestone57.0a1
Bug 1370768 - (Part 2) Add manage credit cards dialog. r=lchang MozReview-Commit-ID: 6xl9HuDraIk
browser/base/content/test/static/browser_all_files_referenced.js
browser/extensions/formautofill/FormAutofillUtils.jsm
browser/extensions/formautofill/ProfileAutoCompleteResult.jsm
browser/extensions/formautofill/content/manageAddresses.xhtml
browser/extensions/formautofill/content/manageCreditCards.xhtml
browser/extensions/formautofill/content/manageDialog.css
browser/extensions/formautofill/content/manageDialog.js
browser/extensions/formautofill/locales/en-US/formautofill.properties
browser/extensions/formautofill/test/browser/browser_manageAddressesDialog.js
--- a/browser/base/content/test/static/browser_all_files_referenced.js
+++ b/browser/base/content/test/static/browser_all_files_referenced.js
@@ -126,18 +126,18 @@ var whitelist = [
   {file: "resource://gre/modules/Localization.jsm"},
 
   // Starting from here, files in the whitelist are bugs that need fixing.
   // Bug 1339420
   {file: "chrome://branding/content/icon128.png"},
   // Bug 1339424 (wontfix?)
   {file: "chrome://browser/locale/taskbar.properties",
    platforms: ["linux", "macosx"]},
-  // Bug 1370768 will reference this file
-  {file: "chrome://formautofill/content/editCreditCard.xhtml"},
+  // Bug 1370766 will reference this file
+  {file: "chrome://formautofill/content/manageCreditCards.xhtml"},
   // Bug 1316187
   {file: "chrome://global/content/customizeToolbar.xul"},
   // Bug 1343837
   {file: "chrome://global/content/findUtils.js"},
   // Bug 1343843
   {file: "chrome://global/content/url-classifier/unittests.xul"},
   // Bug 1348362
   {file: "chrome://global/skin/icons/warning-64.png", platforms: ["linux", "win"]},
--- a/browser/extensions/formautofill/FormAutofillUtils.jsm
+++ b/browser/extensions/formautofill/FormAutofillUtils.jsm
@@ -98,16 +98,23 @@ this.FormAutofillUtils = {
       return "";
     }
     return array
       .map(s => s ? s.trim() : "")
       .filter(s => s)
       .join(this.getAddressSeparator());
   },
 
+  fmtMaskedCreditCardLabel(maskedCCNum = "") {
+    return {
+      affix: "****",
+      label: maskedCCNum.replace(/^\**/, ""),
+    };
+  },
+
   defineLazyLogGetter(scope, logPrefix) {
     XPCOMUtils.defineLazyGetter(scope, "log", () => {
       let ConsoleAPI = Cu.import("resource://gre/modules/Console.jsm", {}).ConsoleAPI;
       return new ConsoleAPI({
         maxLogLevelPref: "extensions.formautofill.loglevel",
         prefix: logPrefix,
       });
     });
--- a/browser/extensions/formautofill/ProfileAutoCompleteResult.jsm
+++ b/browser/extensions/formautofill/ProfileAutoCompleteResult.jsm
@@ -269,23 +269,16 @@ class AddressResult extends ProfileAutoC
   }
 }
 
 class CreditCardResult extends ProfileAutoCompleteResult {
   constructor(...args) {
     super(...args);
   }
 
-  _fmtMaskedCreditCardLabel(maskedCCNum = "") {
-    return {
-      affix: "****",
-      label: maskedCCNum.replace(/^\**/, ""),
-    };
-  }
-
   _getSecondaryLabel(focusedFieldName, allFieldNames, profile) {
     const GROUP_FIELDS = {
       "cc-name": [
         "cc-name",
         "cc-given-name",
         "cc-additional-name",
         "cc-family-name",
       ],
@@ -315,17 +308,17 @@ class CreditCardResult extends ProfileAu
       }
 
       let matching = GROUP_FIELDS[currentFieldName] ?
         allFieldNames.some(fieldName => GROUP_FIELDS[currentFieldName].includes(fieldName)) :
         allFieldNames.includes(currentFieldName);
 
       if (matching) {
         if (currentFieldName == "cc-number") {
-          let {affix, label} = this._fmtMaskedCreditCardLabel(profile[currentFieldName]);
+          let {affix, label} = FormAutofillUtils.fmtMaskedCreditCardLabel(profile[currentFieldName]);
           return affix + label;
         }
         return profile[currentFieldName];
       }
     }
 
     return ""; // Nothing matched.
   }
@@ -343,17 +336,17 @@ class CreditCardResult extends ProfileAu
     // Skip results without a primary label.
     let labels = profiles.filter(profile => {
       return !!profile[focusedFieldName];
     }).map(profile => {
       let primaryAffix;
       let primary = profile[focusedFieldName];
 
       if (focusedFieldName == "cc-number") {
-        let {affix, label} = this._fmtMaskedCreditCardLabel(primary);
+        let {affix, label} = FormAutofillUtils.fmtMaskedCreditCardLabel(primary);
         primaryAffix = affix;
         primary = label;
       }
       return {
         primaryAffix,
         primary,
         secondary: this._getSecondaryLabel(focusedFieldName,
                                            allFieldNames,
--- a/browser/extensions/formautofill/content/manageAddresses.xhtml
+++ b/browser/extensions/formautofill/content/manageAddresses.xhtml
@@ -1,29 +1,35 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!-- 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/. -->
 <!DOCTYPE html>
 <html xmlns="http://www.w3.org/1999/xhtml">
 <head>
-  <title data-localization="manageDialogTitle"/>
+  <title data-localization="manageAddressesTitle"/>
   <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
   <link rel="stylesheet" href="chrome://formautofill/content/manageDialog.css" />
   <script src="chrome://formautofill/content/manageDialog.js"></script>
 </head>
 <body>
   <fieldset>
-    <legend data-localization="addressListHeader"/>
+    <legend data-localization="addressesListHeader"/>
     <select id="addresses" size="9" multiple="multiple"/>
   </fieldset>
   <div id="controls-container">
     <button id="remove" disabled="disabled" data-localization="remove"/>
     <button id="add" data-localization="add"/>
     <button id="edit" disabled="disabled" data-localization="edit"/>
   </div>
   <script type="application/javascript">
     "use strict";
-    // Localize strings before DOMContentLoaded to prevent flash
-    window.dialog.localizeDocument();
+    /* global ManageAddresses */
+    new ManageAddresses({
+      records: document.getElementById("addresses"),
+      controlsContainer: document.getElementById("controls-container"),
+      remove: document.getElementById("remove"),
+      add: document.getElementById("add"),
+      edit: document.getElementById("edit"),
+    });
   </script>
 </body>
 </html>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/formautofill/content/manageCreditCards.xhtml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- 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/. -->
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+  <title data-localization="manageCreditCardsTitle"/>
+  <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
+  <link rel="stylesheet" href="chrome://formautofill/content/manageDialog.css" />
+  <script src="chrome://formautofill/content/manageDialog.js"></script>
+</head>
+<body>
+  <fieldset>
+    <legend data-localization="creditCardsListHeader"/>
+    <select id="credit-cards" size="9" multiple="multiple"/>
+  </fieldset>
+  <div id="controls-container">
+    <button id="remove" disabled="disabled" data-localization="remove"/>
+    <button id="show-credit-cards" data-localization="showCreditCards"/>
+    <button id="add" data-localization="add"/>
+    <button id="edit" disabled="disabled" data-localization="edit"/>
+  </div>
+  <script type="application/javascript">
+    "use strict";
+    /* global ManageCreditCards */
+    new ManageCreditCards({
+      records: document.getElementById("credit-cards"),
+      controlsContainer: document.getElementById("controls-container"),
+      remove: document.getElementById("remove"),
+      showCreditCards: document.getElementById("show-credit-cards"),
+      add: document.getElementById("add"),
+      edit: document.getElementById("edit"),
+    });
+  </script>
+</body>
+</html>
--- a/browser/extensions/formautofill/content/manageDialog.css
+++ b/browser/extensions/formautofill/content/manageDialog.css
@@ -27,25 +27,27 @@ fieldset > legend {
   border-radius: 2px 2px 0 0;
   -moz-user-select: none;
 }
 
 option:nth-child(even) {
   background-color: -moz-oddtreerow;
 }
 
-#addresses {
+#addresses,
+#credit-cards {
   font-size: 0.85em;
   width: 100%;
   height: 16.6em;
   border-top: none;
   border-radius: 0 0 2px 2px;
 }
 
-#addresses > option {
+#addresses > option,
+#credit-cards > option {
   padding-inline-start: 0.7em;
 }
 
 #controls-container {
   flex: 0 1 100%;
   justify-content: end;
   font-size: 0.9em;
   margin-top: 1em;
@@ -53,9 +55,18 @@ option:nth-child(even) {
 
 #remove {
   margin-inline-start: 0;
   margin-inline-end: auto;
 }
 
 #edit {
   margin-inline-end: 0;
-}
\ No newline at end of file
+}
+
+#credit-cards > option::before {
+  content: "";
+  background: url("icon-credit-card-generic.svg") no-repeat;
+  float: left;
+  width: 16px;
+  height: 16px;
+  padding-inline-end: 10px;
+}
--- a/browser/extensions/formautofill/content/manageDialog.js
+++ b/browser/extensions/formautofill/content/manageDialog.js
@@ -1,161 +1,275 @@
 /* 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 {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 const EDIT_ADDRESS_URL = "chrome://formautofill/content/editAddress.xhtml";
+const EDIT_CREDIT_CARD_URL = "chrome://formautofill/content/editCreditCard.xhtml";
 const AUTOFILL_BUNDLE_URI = "chrome://formautofill/locale/formautofill.properties";
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://formautofill/FormAutofillUtils.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "profileStorage",
+                                  "resource://formautofill/ProfileStorage.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "MasterPassword",
+                                  "resource://formautofill/MasterPassword.jsm");
+
 this.log = null;
 FormAutofillUtils.defineLazyLogGetter(this, "manageAddresses");
 
-function ManageAddressDialog() {
-  this.prefWin = window.opener;
-  window.addEventListener("DOMContentLoaded", this, {once: true});
-}
-
-ManageAddressDialog.prototype = {
-  QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIObserver]),
+class ManageRecords {
+  constructor(subStorageName, elements) {
+    this._storageInitPromise = profileStorage.initialize();
+    this._subStorageName = subStorageName;
+    this._elements = elements;
+    this._records = [];
+    this.prefWin = window.opener;
+    this.localizeDocument();
+    window.addEventListener("DOMContentLoaded", this, {once: true});
+  }
 
-  _elements: {},
+  async init() {
+    await this.loadRecords();
+    this.attachEventListeners();
+    // For testing only: Notify when the dialog is ready for interaction
+    window.dispatchEvent(new CustomEvent("FormReady"));
+  }
 
-  /**
-   * Count the number of "formautofill-storage-changed" events epected to
-   * receive to prevent repeatedly loading addresses.
-   * @type {number}
-   */
-  _pendingChangeCount: 0,
+  uninit() {
+    log.debug("uninit");
+    this.detachEventListeners();
+    this._elements = null;
+  }
+
+  localizeDocument() {
+    FormAutofillUtils.localizeMarkup(AUTOFILL_BUNDLE_URI, document);
+  }
 
   /**
    * Get the selected options on the addresses element.
    *
    * @returns {array<DOMElement>}
    */
   get _selectedOptions() {
-    return Array.from(this._elements.addresses.selectedOptions);
-  },
-
-  init() {
-    this._elements = {
-      addresses: document.getElementById("addresses"),
-      controlsContainer: document.getElementById("controls-container"),
-      remove: document.getElementById("remove"),
-      add: document.getElementById("add"),
-      edit: document.getElementById("edit"),
-    };
-    this.attachEventListeners();
-  },
-
-  uninit() {
-    log.debug("uninit");
-    this.detachEventListeners();
-    this._elements = null;
-  },
-
-  localizeDocument() {
-    FormAutofillUtils.localizeMarkup(AUTOFILL_BUNDLE_URI, document);
-  },
+    return Array.from(this._elements.records.selectedOptions);
+  }
 
   /**
-   * Load addresses and render them.
-   *
-   * @returns {promise}
+   * Get storage and ensure it has been initialized.
+   * @returns {object}
    */
-  loadAddresses() {
-    return this.getRecords({collectionName: "addresses"}).then(addresses => {
-      log.debug("addresses:", addresses);
-      // Sort by last modified time starting with most recent
-      addresses.sort((a, b) => b.timeLastModified - a.timeLastModified);
-      this.renderAddressElements(addresses);
-      this.updateButtonsStates(this._selectedOptions.length);
-    });
-  },
+  async getStorage() {
+    await this._storageInitPromise;
+    return profileStorage[this._subStorageName];
+  }
 
   /**
-   * Get records from storage.
-   *
-   * @private
-   * @param  {Object} data
-   *         Parameters for querying the corresponding result.
-   * @param  {string} data.collectionName
-   *         The name used to specify which collection to retrieve records.
-   * @param  {string} data.searchString
-   *         The typed string for filtering out the matched records.
-   * @param  {string} data.info
-   *         The input autocomplete property's information.
-   * @returns {Promise}
-   *          Promise that resolves when addresses returned from parent process.
+   * Load records and render them.
    */
-  getRecords(data) {
-    return new Promise(resolve => {
-      Services.cpmm.addMessageListener("FormAutofill:Records", function getResult(result) {
-        Services.cpmm.removeMessageListener("FormAutofill:Records", getResult);
-        resolve(result.data);
-      });
-      Services.cpmm.sendAsyncMessage("FormAutofill:GetRecords", data);
-    });
-  },
+  async loadRecords() {
+    let storage = await this.getStorage();
+    let records = storage.getAll();
+    // Sort by last modified time starting with most recent
+    records.sort((a, b) => b.timeLastModified - a.timeLastModified);
+    await this.renderRecordElements(records);
+    this.updateButtonsStates(this._selectedOptions.length);
+    // For testing only: Notify when records are loaded
+    this._elements.records.dispatchEvent(new CustomEvent("RecordsLoaded"));
+  }
 
   /**
-   * Render the addresses onto the page while maintaining selected options if
+   * Render the records onto the page while maintaining selected options if
    * they still exist.
    *
-   * @param  {array<object>} addresses
+   * @param  {array<object>} records
    */
-  renderAddressElements(addresses) {
+  async renderRecordElements(records) {
     let selectedGuids = this._selectedOptions.map(option => option.value);
-    this.clearAddressElements();
-    for (let address of addresses) {
-      let option = new Option(this.getAddressLabel(address),
-                              address.guid,
+    this.clearRecordElements();
+    for (let record of records) {
+      let option = new Option(await this.getLabel(record),
+                              record.guid,
                               false,
-                              selectedGuids.includes(address.guid));
-      option.address = address;
-      this._elements.addresses.appendChild(option);
+                              selectedGuids.includes(record.guid));
+      option.record = record;
+      this._elements.records.appendChild(option);
     }
-  },
+  }
 
   /**
-   * Remove all existing address elements.
+   * Remove all existing record elements.
    */
-  clearAddressElements() {
-    let parent = this._elements.addresses;
+  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();
+    }
+
+    // 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"));
+  }
+
+  /**
+   * 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");
+    }
+  }
 
   /**
-   * Remove addresses by guids.
-   * Keep track of the number of "formautofill-storage-changed" events to
-   * ignore before loading addresses.
+   * 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;
+      }
+    }
+  }
+
+  /**
+   * 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  {array<string>} guids
+   * @param  {DOMEvent} event
+   */
+  handleKeyPress(event) {
+    if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) {
+      window.close();
+    }
+  }
+
+  observe(subject, topic, data) {
+    switch (topic) {
+      case "formautofill-storage-changed": {
+        this.loadRecords();
+      }
+    }
+  }
+
+  /**
+   * Attach event listener
    */
-  removeAddresses(guids) {
-    this._pendingChangeCount += guids.length - 1;
-    Services.cpmm.sendAsyncMessage("FormAutofill:RemoveAddresses", {guids});
-  },
+  attachEventListeners() {
+    window.addEventListener("unload", this, {once: true});
+    window.addEventListener("keypress", 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);
+    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 {
+  constructor(elements) {
+    super("addresses", elements);
+  }
+
+  /**
+   * Open the edit address dialog to create/edit an address.
+   *
+   * @param  {object} address [optional]
+   */
+  openEditDialog(address) {
+    this.prefWin.gSubDialog.open(EDIT_ADDRESS_URL, null, address);
+  }
 
   /**
    * Get address display label. It should display up to two pieces of
    * information, separated by a comma.
    *
    * @param  {object} address
    * @returns {string}
    */
-  getAddressLabel(address) {
+  getLabel(address) {
     // TODO: Implement a smarter way for deciding what to display
     //       as option text. Possibly improve the algorithm in
     //       ProfileAutoCompleteResult.jsm and reuse it here.
     const fieldOrder = [
       "name",
       "-moz-street-address-one-line",  // Street address
       "address-level2",  // City/Town
       "organization",    // Company or organization name
@@ -177,134 +291,75 @@ ManageAddressDialog.prototype = {
       if (string) {
         parts.push(string);
       }
       if (parts.length == 2) {
         break;
       }
     }
     return parts.join(", ");
-  },
+  }
+}
 
-  /**
-   * Open the edit address dialog to create/edit an address.
-   *
-   * @param  {object} address [optional]
-   */
-  openEditDialog(address) {
-    this.prefWin.gSubDialog.open(EDIT_ADDRESS_URL, null, address);
-  },
+class ManageCreditCards extends ManageRecords {
+  constructor(elements) {
+    super("creditCards", elements);
+    this.hasMasterPassword = MasterPassword.isEnabled;
+    if (this.hasMasterPassword) {
+      elements.showCreditCards.setAttribute("hidden", true);
+    }
+  }
 
   /**
-   * Enable/disable the Edit and Remove buttons based on number of selected
-   * options.
+   * Open the edit address dialog to create/edit a credit card.
    *
-   * @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
+   * @param  {object} creditCard [optional]
    */
-  handleEvent(event) {
-    switch (event.type) {
-      case "DOMContentLoaded": {
-        this.init();
-        this.loadAddresses();
-        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;
-      }
+  async openEditDialog(creditCard) {
+    // If master password is set, ask for password if user is trying to edit an
+    // existing credit card.
+    if (!this.hasMasterPassword || !creditCard || await MasterPassword.prompt()) {
+      this.prefWin.gSubDialog.open(EDIT_CREDIT_CARD_URL, null, creditCard);
     }
-  },
+  }
 
   /**
-   * Handle click events
+   * Get credit card display label. It should display masked numbers and the
+   * cardholder's name, separated by a comma. If `showCreditCards` is set to
+   * true, decrypted credit card numbers are shown instead.
    *
-   * @param  {DOMEvent} event
+   * @param  {object} creditCard
+   * @param  {boolean} showCreditCards [optional]
+   * @returns {string}
    */
-  handleClick(event) {
-    if (event.target == this._elements.remove) {
-      this.removeAddresses(this._selectedOptions.map(option => option.value));
-    } else if (event.target == this._elements.add) {
-      this.openEditDialog();
-    } else if (event.target == this._elements.edit ||
-               event.target.parentNode == this._elements.addresses && event.detail > 1) {
-      this.openEditDialog(this._selectedOptions[0].address);
+  async getLabel(creditCard, showCreditCards = false) {
+    let parts = [];
+    if (creditCard["cc-number"]) {
+      let ccLabel;
+      if (showCreditCards) {
+        ccLabel = await MasterPassword.decrypt(creditCard["cc-number-encrypted"]);
+      } else {
+        let {affix, label} = FormAutofillUtils.fmtMaskedCreditCardLabel(creditCard["cc-number"]);
+        ccLabel = `${affix} ${label}`;
+      }
+      parts.push(ccLabel);
     }
-  },
-
-  /**
-   * Handle key press events
-   *
-   * @param  {DOMEvent} event
-   */
-  handleKeyPress(event) {
-    if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) {
-      window.close();
+    if (creditCard["cc-name"]) {
+      parts.push(creditCard["cc-name"]);
     }
-  },
+    return parts.join(", ");
+  }
 
-  observe(subject, topic, data) {
-    switch (topic) {
-      case "formautofill-storage-changed": {
-        if (this._pendingChangeCount) {
-          this._pendingChangeCount -= 1;
-          return;
-        }
-        this.loadAddresses();
-      }
+  async decryptOptions(options) {
+    for (let option of options) {
+      option.text = await this.getLabel(option.record, true);
     }
-  },
+    // For testing only: Notify when credit cards have been decrypted
+    this._elements.records.dispatchEvent(new CustomEvent("OptionsDecrypted"));
+  }
 
-  /**
-   * Attach event listener
-   */
-  attachEventListeners() {
-    window.addEventListener("unload", this, {once: true});
-    window.addEventListener("keypress", this);
-    this._elements.addresses.addEventListener("change", this);
-    this._elements.addresses.addEventListener("click", this);
-    this._elements.controlsContainer.addEventListener("click", this);
-    Services.obs.addObserver(this, "formautofill-storage-changed");
-  },
-
-  /**
-   * Remove event listener
-   */
-  detachEventListeners() {
-    window.removeEventListener("keypress", this);
-    this._elements.addresses.removeEventListener("change", this);
-    this._elements.addresses.removeEventListener("click", this);
-    this._elements.controlsContainer.removeEventListener("click", this);
-    Services.obs.removeObserver(this, "formautofill-storage-changed");
-  },
-};
-
-window.dialog = new ManageAddressDialog();
+  handleClick(event) {
+    if (event.target == this._elements.showCreditCards) {
+      this.decryptOptions(this._elements.records.options);
+    }
+    super.handleClick(event);
+  }
+}
--- a/browser/extensions/formautofill/locales/en-US/formautofill.properties
+++ b/browser/extensions/formautofill/locales/en-US/formautofill.properties
@@ -28,18 +28,21 @@ category.email = email
 fieldNameSeparator = ,\u0020
 # LOCALIZATION NOTE (phishingWarningMessage, phishingWarningMessage2): The warning
 # text that is displayed for informing users what categories are about to be filled.
 # "%S" will be replaced with a list generated from the pre-defined categories.
 # The text would be e.g. Also fill company, phone, email
 phishingWarningMessage = Also autofills %S
 phishingWarningMessage2 = Autofills %S
 
-manageDialogTitle = Saved Addresses
-addressListHeader = Addresses
+manageAddressesTitle = Saved Addresses
+manageCreditCardsTitle = Saved Credit Cards
+addressesListHeader = Addresses
+creditCardsListHeader = Credit Cards
+showCreditCards = Show Credit Cards
 remove = Remove
 add = Add…
 edit = Edit…
 
 addNewAddressTitle = Add New Address
 editAddressTitle = Edit Address
 givenName = First Name
 additionalName = Middle Name
--- a/browser/extensions/formautofill/test/browser/browser_manageAddressesDialog.js
+++ b/browser/extensions/formautofill/test/browser/browser_manageAddressesDialog.js
@@ -1,98 +1,88 @@
 "use strict";
 
 const TEST_SELECTORS = {
-  selAddresses: "#addresses",
+  selRecords: "#addresses",
   btnRemove: "#remove",
   btnAdd: "#add",
   btnEdit: "#edit",
 };
 
 const DIALOG_SIZE = "width=600,height=400";
 
-function waitForRecords() {
-  return new Promise(resolve => {
-    Services.cpmm.addMessageListener("FormAutofill:Records", function getResult(result) {
-      Services.cpmm.removeMessageListener("FormAutofill:Records", getResult);
-      // Wait for the next tick for elements to get rendered.
-      SimpleTest.executeSoon(resolve.bind(null, result.data));
-    });
-  });
-}
-
 add_task(async function test_manageAddressesInitialState() {
   await BrowserTestUtils.withNewTab({gBrowser, url: MANAGE_ADDRESSES_DIALOG_URL}, async function(browser) {
     await ContentTask.spawn(browser, TEST_SELECTORS, (args) => {
-      let selAddresses = content.document.querySelector(args.selAddresses);
+      let selRecords = content.document.querySelector(args.selRecords);
       let btnRemove = content.document.querySelector(args.btnRemove);
       let btnEdit = content.document.querySelector(args.btnEdit);
       let btnAdd = content.document.querySelector(args.btnAdd);
 
-      is(selAddresses.length, 0, "No address");
+      is(selRecords.length, 0, "No address");
       is(btnAdd.disabled, false, "Add button enabled");
       is(btnRemove.disabled, true, "Remove button disabled");
       is(btnEdit.disabled, true, "Edit button disabled");
     });
   });
 });
 
 add_task(async function test_cancelManageAddressDialogWithESC() {
   await new Promise(resolve => {
     let win = window.openDialog(MANAGE_ADDRESSES_DIALOG_URL);
-    win.addEventListener("load", () => {
+    win.addEventListener("FormReady", () => {
       win.addEventListener("unload", () => {
         ok(true, "Manage addresses dialog is closed with ESC key");
         resolve();
       }, {once: true});
       EventUtils.synthesizeKey("VK_ESCAPE", {}, win);
     }, {once: true});
   });
 });
 
 add_task(async function test_removingSingleAndMultipleAddresses() {
   await saveAddress(TEST_ADDRESS_1);
   await saveAddress(TEST_ADDRESS_2);
   await saveAddress(TEST_ADDRESS_3);
 
   let win = window.openDialog(MANAGE_ADDRESSES_DIALOG_URL, null, DIALOG_SIZE);
-  await waitForRecords();
+  await BrowserTestUtils.waitForEvent(win, "FormReady");
 
-  let selAddresses = win.document.querySelector(TEST_SELECTORS.selAddresses);
+  let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords);
   let btnRemove = win.document.querySelector(TEST_SELECTORS.btnRemove);
   let btnEdit = win.document.querySelector(TEST_SELECTORS.btnEdit);
 
-  is(selAddresses.length, 3, "Three addresses");
+  is(selRecords.length, 3, "Three addresses");
 
-  EventUtils.synthesizeMouseAtCenter(selAddresses.children[0], {}, win);
+  EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win);
   is(btnRemove.disabled, false, "Remove button enabled");
   is(btnEdit.disabled, false, "Edit button enabled");
   EventUtils.synthesizeMouseAtCenter(btnRemove, {}, win);
-  await waitForRecords();
-  is(selAddresses.length, 2, "Two addresses left");
+  await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved");
+  is(selRecords.length, 2, "Two addresses left");
 
-  EventUtils.synthesizeMouseAtCenter(selAddresses.children[0], {}, win);
-  EventUtils.synthesizeMouseAtCenter(selAddresses.children[1],
+  EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win);
+  EventUtils.synthesizeMouseAtCenter(selRecords.children[1],
                                      {shiftKey: true}, win);
   is(btnEdit.disabled, true, "Edit button disabled when multi-select");
 
   EventUtils.synthesizeMouseAtCenter(btnRemove, {}, win);
-  await waitForRecords();
-  is(selAddresses.length, 0, "All addresses are removed");
+  await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved");
+  is(selRecords.length, 0, "All addresses are removed");
 
   win.close();
 });
 
 add_task(async function test_addressesDialogWatchesStorageChanges() {
   let win = window.openDialog(MANAGE_ADDRESSES_DIALOG_URL, null, DIALOG_SIZE);
-  await waitForRecords();
+  await BrowserTestUtils.waitForEvent(win, "FormReady");
 
-  let selAddresses = win.document.querySelector(TEST_SELECTORS.selAddresses);
+  let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords);
 
   await saveAddress(TEST_ADDRESS_1);
-  let addresses = await waitForRecords();
-  is(selAddresses.length, 1, "One address is shown");
+  await BrowserTestUtils.waitForEvent(selRecords, "RecordsLoaded");
+  is(selRecords.length, 1, "One address is shown");
 
-  await removeAddresses([addresses[0].guid]);
-  await waitForRecords();
-  is(selAddresses.length, 0, "Address is removed");
+  await removeAddresses([selRecords.options[0].value]);
+  await BrowserTestUtils.waitForEvent(selRecords, "RecordsLoaded");
+  is(selRecords.length, 0, "Address is removed");
   win.close();
 });