Bug 1189618 - Add a 'View Saved Logins' footer to the password manager autocomplete popup. r=MattN
authorprathiksha <prathikshaprasadsuman@gmail.com>
Mon, 25 Feb 2019 18:30:32 +0000
changeset 460949 271d947b5c4b84fbbbaf020655f680e21cd12ced
parent 460948 29c70d20ad43124e8540747edd6c5df50dd7d58b
child 460950 5e240b5794fcf316368e5027b52884a7aec0268c
push id35613
push usernerli@mozilla.com
push dateTue, 26 Feb 2019 03:52:35 +0000
treeherdermozilla-central@faec87a80ed1 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN
bugs1189618
milestone67.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 1189618 - Add a 'View Saved Logins' footer to the password manager autocomplete popup. r=MattN Add a 'View Saved Logins' footer to the password manager autocomplete popup. Differential Revision: https://phabricator.services.mozilla.com/D19602
browser/base/content/browser.css
modules/libpref/init/all.js
toolkit/components/passwordmgr/LoginHelper.jsm
toolkit/components/passwordmgr/LoginManager.jsm
toolkit/components/passwordmgr/LoginManagerContent.jsm
toolkit/components/passwordmgr/test/browser/browser.ini
toolkit/components/passwordmgr/test/browser/browser_autocomplete_footer.js
toolkit/content/widgets/autocomplete-richlistitem.js
toolkit/content/widgets/autocomplete.xml
toolkit/locales/en-US/chrome/passwordmgr/passwordmgr.properties
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -713,16 +713,29 @@ html|input.urlbar-input {
 
 #urlbar[actionoverride] > #urlbar-display-box,
 #urlbar:not([actiontype="switchtab"]):not([actiontype="extension"]) > #urlbar-display-box,
 #urlbar:not([actiontype="switchtab"]) > #urlbar-display-box > #switchtab,
 #urlbar:not([actiontype="extension"]) > #urlbar-display-box > #extension {
   display: none;
 }
 
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="loginsFooter"] {
+  color: var(--btn-text-color);
+  padding: 0 20px;
+  min-height: 40px;
+  border-top: 1px solid rgba(38,38,38,.15);
+  background-color: #EDEDED;
+}
+
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="loginsFooter"]:hover,
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="loginsFooter"][selected] {
+  background-color: #DCDCDE;
+}
+
 #PopupAutoComplete[firstresultstyle="insecureWarning"] {
   min-width: 200px;
 }
 
 #PopupAutoComplete > richlistbox > richlistitem[originaltype="insecureWarning"] {
   -moz-binding: none;
   height: auto;
 }
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -4668,16 +4668,17 @@ pref("signon.autologin.proxy",          
 pref("signon.formlessCapture.enabled",      true);
 pref("signon.privateBrowsingCapture.enabled", false);
 pref("signon.storeWhenAutocompleteOff",     true);
 pref("signon.debug",                        false);
 pref("signon.recipes.path",                 "chrome://passwordmgr/content/recipes.json");
 pref("signon.schemeUpgrades",               false);
 // This temporarily prevents the master password to reprompt for autocomplete.
 pref("signon.masterPasswordReprompt.timeout_ms", 900000); // 15 Minutes
+pref("signon.showAutoCompleteFooter", false);
 
 // Satchel (Form Manager) prefs
 pref("browser.formfill.debug",            false);
 pref("browser.formfill.enable",           true);
 pref("browser.formfill.expire_days",      180);
 pref("browser.formfill.agedWeight",       2);
 pref("browser.formfill.bucketSize",       1);
 pref("browser.formfill.maxTimeGroupings", 25);
--- a/toolkit/components/passwordmgr/LoginHelper.jsm
+++ b/toolkit/components/passwordmgr/LoginHelper.jsm
@@ -44,16 +44,17 @@ var LoginHelper = {
     this.enabled = Services.prefs.getBoolPref("signon.rememberSignons");
     this.formlessCaptureEnabled = Services.prefs.getBoolPref("signon.formlessCapture.enabled");
     this.insecureAutofill = Services.prefs.getBoolPref("signon.autofillForms.http");
     this.privateBrowsingCaptureEnabled =
       Services.prefs.getBoolPref("signon.privateBrowsingCapture.enabled");
 
     this.schemeUpgrades = Services.prefs.getBoolPref("signon.schemeUpgrades");
     this.storeWhenAutocompleteOff = Services.prefs.getBoolPref("signon.storeWhenAutocompleteOff");
+    this.showAutoCompleteFooter = Services.prefs.getBoolPref("signon.showAutoCompleteFooter");
   },
 
   createLogger(aLogPrefix) {
     let getMaxLogLevel = () => {
       return this.debug ? "Debug" : "Warn";
     };
 
     let logger;
--- a/toolkit/components/passwordmgr/LoginManager.jsm
+++ b/toolkit/components/passwordmgr/LoginManager.jsm
@@ -519,29 +519,31 @@ LoginManager.prototype = {
     //   reasons it's better to not load LMC at all for these sandboxed frames. Also, if the top-
     //   document is sandboxing a document, it probably doesn't want that sandboxed document to be
     //   able to affect the identity icon in the address bar by adding a password field.
     if (isSecure) {
       let form = LoginFormFactory.createFromField(aElement);
       isSecure = InsecurePasswordUtils.isFormSecure(form);
     }
     let isPasswordField = aElement.type == "password";
+    let hostname = aElement.ownerDocument.documentURIObject.host;
 
     let completeSearch = (autoCompleteLookupPromise, { logins, messageManager }) => {
       // If the search was canceled before we got our
       // results, don't bother reporting them.
       if (this._autoCompleteLookupPromise !== autoCompleteLookupPromise) {
         return;
       }
 
       this._autoCompleteLookupPromise = null;
       let results = new UserAutoCompleteResult(aSearchString, logins, {
         messageManager,
         isSecure,
         isPasswordField,
+        hostname,
       });
       aCallback.onSearchCompletion(results);
     };
 
     if (isNullPrincipal) {
       // Don't search login storage when the field has a null principal as we don't want to fill
       // logins for the `location` in this case.
       let acLookupPromise = this._autoCompleteLookupPromise = Promise.resolve({ logins: [] });
--- a/toolkit/components/passwordmgr/LoginManagerContent.jsm
+++ b/toolkit/components/passwordmgr/LoginManagerContent.jsm
@@ -1438,17 +1438,17 @@ var LoginManagerContent = {
         found: !!newPasswordField,
         disabled: newPasswordField && (newPasswordField.disabled || newPasswordField.readOnly),
       },
     };
   },
 };
 
 // nsIAutoCompleteResult implementation
-function UserAutoCompleteResult(aSearchString, matchingLogins, {isSecure, messageManager, isPasswordField}) {
+function UserAutoCompleteResult(aSearchString, matchingLogins, {isSecure, messageManager, isPasswordField, hostname}) {
   function loginSort(a, b) {
     var userA = a.username.toLowerCase();
     var userB = b.username.toLowerCase();
 
     if (userA < userB) {
       return -1;
     }
 
@@ -1467,24 +1467,26 @@ function UserAutoCompleteResult(aSearchS
         duplicates.add(login.username);
       }
       seen.add(login.username);
     }
     return duplicates;
   }
 
   this._showInsecureFieldWarning = (!isSecure && LoginHelper.showInsecureFieldWarning) ? 1 : 0;
+  this._showAutoCompleteFooter = LoginHelper.showAutoCompleteFooter ? 1 : 0;
   this.searchString = aSearchString;
   this.logins = matchingLogins.sort(loginSort);
-  this.matchCount = matchingLogins.length + this._showInsecureFieldWarning;
+  this.matchCount = matchingLogins.length + this._showInsecureFieldWarning + this._showAutoCompleteFooter;
   this._messageManager = messageManager;
   this._stringBundle = Services.strings.createBundle("chrome://passwordmgr/locale/passwordmgr.properties");
   this._dateAndTimeFormatter = new Services.intl.DateTimeFormat(undefined, { dateStyle: "medium" });
 
   this._isPasswordField = isPasswordField;
+  this._hostname = hostname;
 
   this._duplicateUsernames = findDuplicates(matchingLogins);
 
   if (this.matchCount > 0) {
     this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
     this.defaultIndex = 0;
   }
 }
@@ -1513,16 +1515,20 @@ UserAutoCompleteResult.prototype = {
     if (index < 0 || index >= this.matchCount) {
       throw new Error("Index out of range.");
     }
 
     if (this._showInsecureFieldWarning && index === 0) {
       return "";
     }
 
+    if (this._showAutoCompleteFooter && index === this.matchCount - 1) {
+      return "";
+    }
+
     let selectedLogin = this.logins[index - this._showInsecureFieldWarning];
 
     return this._isPasswordField ? selectedLogin.password : selectedLogin.username;
   },
 
   getLabelAt(index) {
     if (index < 0 || index >= this.matchCount) {
       throw new Error("Index out of range.");
@@ -1533,16 +1539,21 @@ UserAutoCompleteResult.prototype = {
         return this._stringBundle.formatStringFromName(key, formatArgs, formatArgs.length);
       }
       return this._stringBundle.GetStringFromName(key);
     };
 
     if (this._showInsecureFieldWarning && index === 0) {
       let learnMoreString = getLocalizedString("insecureFieldWarningLearnMore");
       return getLocalizedString("insecureFieldWarningDescription2", [learnMoreString]);
+    } else if (this._showAutoCompleteFooter && index === this.matchCount - 1) {
+      return JSON.stringify({
+        label: getLocalizedString("viewSavedLogins.label"),
+        hostname: this._hostname,
+      });
     }
 
     let login = this.logins[index - this._showInsecureFieldWarning];
     let username = login.username;
     // If login is empty or duplicated we want to append a modification date to it.
     if (!username || this._duplicateUsernames.has(username)) {
       if (!username) {
         username = getLocalizedString("noUsername");
@@ -1557,16 +1568,18 @@ UserAutoCompleteResult.prototype = {
 
   getCommentAt(index) {
     return "";
   },
 
   getStyleAt(index) {
     if (index == 0 && this._showInsecureFieldWarning) {
       return "insecureWarning";
+    } else if (this._showAutoCompleteFooter && index == this.matchCount - 1) {
+      return "loginsFooter";
     }
 
     return "login";
   },
 
   getImageAt(index) {
     return "";
   },
@@ -1579,20 +1592,26 @@ UserAutoCompleteResult.prototype = {
     if (index < 0 || index >= this.matchCount) {
       throw new Error("Index out of range.");
     }
 
     if (this._showInsecureFieldWarning && index === 0) {
       // Ignore the warning message item.
       return;
     }
+
     if (this._showInsecureFieldWarning) {
       index--;
     }
 
+    // The user cannot delete the autocomplete footer.
+    if (this._showAutoCompleteFooter && index === this.matchCount - 1) {
+      return;
+    }
+
     var [removedLogin] = this.logins.splice(index, 1);
 
     this.matchCount--;
     if (this.defaultIndex > this.logins.length) {
       this.defaultIndex--;
     }
 
     if (removeFromDB) {
--- a/toolkit/components/passwordmgr/test/browser/browser.ini
+++ b/toolkit/components/passwordmgr/test/browser/browser.ini
@@ -9,16 +9,17 @@ support-files =
   form_cross_origin_secure_action.html
   form_cross_origin_insecure_action.html
   head.js
   insecure_test.html
   insecure_test_subframe.html
   multiple_forms.html
   streamConverter_content.sjs
 
+[browser_autocomplete_footer.js]
 [browser_autocomplete_insecure_warning.js]
 skip-if = os == "linux" || os == "mac"  # Bug 1425879
 [browser_basicAuth_rateLimit.js]
 [browser_capture_doorhanger.js]
 skip-if = os == "linux" && debug # Bug 1334336
 support-files =
   subtst_notifications_1.html
   subtst_notifications_2.html
new file mode 100644
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_footer.js
@@ -0,0 +1,81 @@
+"use strict";
+
+const TEST_HOSTNAME = "https://example.com";
+const BASIC_FORM_PAGE_PATH = DIRECTORY_PATH + "form_basic.html";
+const PWMGR_DLG = "chrome://passwordmgr/content/passwordManager.xul";
+
+function loginList() {
+  return [
+    LoginTestUtils.testData.formLogin({
+      hostname: "https://example.com",
+      formSubmitURL: "https://example.com",
+      username: "username",
+      password: "password",
+    }),
+    LoginTestUtils.testData.formLogin({
+      hostname: "https://example.com",
+      formSubmitURL: "https://example.com",
+      username: "username2",
+      password: "password2",
+    }),
+  ];
+}
+
+/**
+ * Initialize logins and set prefs needed for the test.
+ */
+add_task(async function test_initialize() {
+  Services.prefs.setBoolPref("signon.showAutoCompleteFooter", true);
+  registerCleanupFunction(() => {
+    Services.prefs.clearUserPref("signon.showAutoCompleteFooter");
+  });
+
+  for (let login of loginList()) {
+    Services.logins.addLogin(login);
+  }
+});
+
+add_task(async function test_autocomplete_footer() {
+  let url = TEST_HOSTNAME + BASIC_FORM_PAGE_PATH;
+  await BrowserTestUtils.withNewTab({
+    gBrowser,
+    url,
+  }, async function(browser) {
+    let popup = document.getElementById("PopupAutoComplete");
+    ok(popup, "Got popup");
+
+    let promiseShown = BrowserTestUtils.waitForEvent(popup, "popupshown");
+
+    await SimpleTest.promiseFocus(browser);
+    info("content window focused");
+
+    // Focus the username field to open the popup.
+    await ContentTask.spawn(browser, null, function openAutocomplete() {
+      content.document.getElementById("form-basic-username").focus();
+    });
+
+    await promiseShown;
+    ok(promiseShown, "autocomplete shown");
+
+    let footer = document.getAnonymousElementByAttribute(popup, "originaltype", "loginsFooter");
+    ok(footer, "Got footer richlistitem");
+
+    await TestUtils.waitForCondition(() => {
+      return !EventUtils.isHidden(footer);
+    }, "Waiting for footer to become visible");
+
+    EventUtils.synthesizeMouseAtCenter(footer, {});
+    await TestUtils.waitForCondition(() => {
+      return Services.wm.getMostRecentWindow("Toolkit:PasswordManager") !== null;
+    }, "Waiting for the password manager dialog to open");
+    info("Login dialog was opened");
+
+    let window = Services.wm.getMostRecentWindow("Toolkit:PasswordManager");
+    await TestUtils.waitForCondition(() => {
+      return window.document.getElementById("filter").value == "example.com";
+    }, "Waiting for the search string to filter logins");
+
+    window.close();
+    popup.hidePopup();
+  });
+});
--- a/toolkit/content/widgets/autocomplete-richlistitem.js
+++ b/toolkit/content/widgets/autocomplete-richlistitem.js
@@ -3,16 +3,19 @@
   * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 // This is loaded into all XUL windows. Wrap in a block to prevent
 // leaking to window scope.
 {
 const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
+ChromeUtils.defineModuleGetter(this, "LoginHelper",
+                               "resource://gre/modules/LoginHelper.jsm");
+
 
 MozElements.MozAutocompleteRichlistitem = class MozAutocompleteRichlistitem extends MozElements.MozRichlistitem {
   constructor() {
     super();
 
     /**
      * This overrides listitem's mousedown handler because we want to set the
      * selected item even when the shift or accel keys are pressed.
@@ -971,16 +974,44 @@ class MozAutocompleteRichlistitemInsecur
   /**
    * Override _getSearchTokens to have the Learn More text emphasized
    */
   _getSearchTokens(aSearch) {
     return [this._learnMoreString.toLowerCase()];
   }
 }
 
+class MozAutocompleteRichlistitemLoginsFooter extends MozElements.MozAutocompleteRichlistitem {
+  constructor() {
+    super();
+
+    function handleEvent(event) {
+      if (event.button != 0) {
+        return;
+      }
+
+      LoginHelper.openPasswordManager(this.ownerGlobal, this._data.hostname);
+    }
+
+    this.addEventListener("click", handleEvent);
+  }
+
+  get _data() {
+    return JSON.parse(this.getAttribute("ac-value"));
+  }
+
+  _adjustAcItem() {
+    this._titleText.textContent = this._data.label;
+  }
+}
+
 customElements.define("autocomplete-richlistitem", MozElements.MozAutocompleteRichlistitem, {
   extends: "richlistitem",
 });
 
 customElements.define("autocomplete-richlistitem-insecure-warning", MozAutocompleteRichlistitemInsecureWarning, {
   extends: "richlistitem",
 });
+
+customElements.define("autocomplete-richlistitem-logins-footer", MozAutocompleteRichlistitemLoginsFooter, {
+  extends: "richlistitem",
+});
 }
--- a/toolkit/content/widgets/autocomplete.xml
+++ b/toolkit/content/widgets/autocomplete.xml
@@ -1044,16 +1044,17 @@
               // The styles on the list which have different <content> structure and overrided
               // _adjustAcItem() are unreusable.
               const UNREUSEABLE_STYLES = [
                 "autofill-profile",
                 "autofill-footer",
                 "autofill-clear-button",
                 "autofill-insecureWarning",
                 "insecureWarning",
+                "loginsFooter",
               ];
               // Reuse the item when its style is exactly equal to the previous style or
               // neither of their style are in the UNREUSEABLE_STYLES.
               reusable = originalType === style ||
                 !(UNREUSEABLE_STYLES.includes(style) || UNREUSEABLE_STYLES.includes(originalType));
             }
 
             // If no reusable item available, then create a new item.
@@ -1070,16 +1071,19 @@
                   options = { is: "autocomplete-profile-listitem-clear-button" };
                   break;
                 case "autofill-insecureWarning":
                   options = { is: "autocomplete-creditcard-insecure-field" };
                   break;
                 case "insecureWarning":
                   options = { is: "autocomplete-richlistitem-insecure-warning" };
                   break;
+                case "loginsFooter":
+                  options = { is: "autocomplete-richlistitem-logins-footer" };
+                  break;
                 default:
                   options = { is: "autocomplete-richlistitem" };
               }
               item = document.createXULElement("richlistitem", options);
               item.className = "autocomplete-richlistitem";
             }
 
             item.setAttribute("dir", this.style.direction);
--- a/toolkit/locales/en-US/chrome/passwordmgr/passwordmgr.properties
+++ b/toolkit/locales/en-US/chrome/passwordmgr/passwordmgr.properties
@@ -65,16 +65,20 @@ duplicateLoginTitle=Login already exists
 duplicateLogin=A duplicate login already exists.
 
 # LOCALIZATION NOTE (insecureFieldWarningDescription2, insecureFieldWarningDescription3):
 # %1$S will contain insecureFieldWarningLearnMore and look like a link to indicate that clicking will open a tab with support information.
 insecureFieldWarningDescription2 = This connection is not secure. Logins entered here could be compromised. %1$S
 insecureFieldWarningDescription3 = Logins entered here could be compromised. %1$S
 insecureFieldWarningLearnMore = Learn More
 
+# LOCALIZATION NOTE (viewSavedLogins.label):
+# This label is used in the footer of login autocomplete menus.
+viewSavedLogins.label= View Saved Logins
+
 # LOCALIZATION NOTE (removeAll, removeAllShown):
 # removeAll and removeAllShown are both used on the same one button,
 # never displayed together and can share the same accesskey.
 # When only partial sites are shown as a result of keyword search,
 # removeAllShown is displayed as button label.
 # removeAll is displayed when no keyword search and all sites are shown.
 removeAll.label=Remove All
 removeAll.accesskey=A