Bug 1370768 - (Part 2) Add manage credit cards dialog. r=lchang
authorScott Wu <scottcwwu@gmail.com>
Wed, 23 Aug 2017 10:12:18 +0800
changeset 428772 8b7f29c9028da2302845ec32886a29d59cd9fcc6
parent 428771 52168d31f429433dec30f77627f44e9044d65b45
child 428773 5ec1cb93ebc8cc386bad169a319aec96abccbec9
push id1567
push userjlorenzo@mozilla.com
push dateThu, 02 Nov 2017 12:36:05 +0000
treeherdermozilla-release@e512c14a0406 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerslchang
bugs1370768
milestone57.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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
@@ -109,16 +109,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
@@ -274,23 +274,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",
       ],
@@ -320,17 +313,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.
   }
@@ -348,17 +341,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();
 });