None draft
authorpulselistener
Fri, 03 May 2019 06:53:53 +0000
changeset 1979486 9accc8333f73173b52429f3254be10002394d8c4
parent 1979411 083106d8fc7407c880a3a044c83d4e15e5961063
child 1979487 a3f7f1a4ce53f7300f5e3033d898c8e5a0730c3e
push id356206
push userreviewbot
push dateFri, 03 May 2019 06:56:04 +0000
treeherdertry@a3f7f1a4ce53 [default view] [failures only]
milestone68.0a1
None Differential Diff: PHID-DIFF-pgoipdyujgkvztmnqzmg
browser/app/profile/firefox.js
browser/components/BrowserGlue.jsm
browser/components/about/AboutRedirector.cpp
browser/components/about/AboutRedirector.h
browser/components/about/components.conf
browser/components/aboutlogins/AboutLoginsChild.jsm
browser/components/aboutlogins/AboutLoginsParent.jsm
browser/components/aboutlogins/content/aboutLogins.html
browser/components/aboutlogins/content/aboutLogins.js
browser/components/aboutlogins/content/components/login-item.js
browser/components/aboutlogins/content/components/login-list-item.js
browser/components/aboutlogins/content/components/login-list.js
browser/components/aboutlogins/jar.mn
browser/components/aboutlogins/moz.build
browser/components/moz.build
browser/locales/en-US/browser/aboutLogins.ftl
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1739,16 +1739,17 @@ pref("dom.mozBrowserFramesEnabled", true
 pref("extensions.pocket.api", "api.getpocket.com");
 pref("extensions.pocket.enabled", true);
 pref("extensions.pocket.oAuthConsumerKey", "40249-e88c401e1b1f2242d9e441c4");
 pref("extensions.pocket.site", "getpocket.com");
 
 pref("signon.schemeUpgrades", true);
 pref("signon.privateBrowsingCapture.enabled", true);
 pref("signon.showAutoCompleteFooter", true);
+pref("signon.management.page.enabled", false);
 
 // Enable the "Simplify Page" feature in Print Preview. This feature
 // is disabled by default in toolkit.
 pref("print.use_simplify_page", true);
 
 // Space separated list of URLS that are allowed to send objects (instead of
 // only strings) through webchannels. This list is duplicated in mobile/android/app/mobile.js
 pref("webchannel.allowObject.urlWhitelist", "https://content.cdn.mozilla.net https://support.mozilla.org https://install.mozilla.org");
--- a/browser/components/BrowserGlue.jsm
+++ b/browser/components/BrowserGlue.jsm
@@ -25,16 +25,33 @@ let ACTORS = {
       moduleURI: "resource:///actors/SubframeCrashChild.jsm",
     },
 
     allFrames: true,
   },
 };
 
 let LEGACY_ACTORS = {
+  AboutLogins: {
+    child: {
+      matches: ["about:logins"],
+      module: "resource:///actors/AboutLoginsChild.jsm",
+      events: {
+        "AboutLoginsDeleteLogin": {wantUntrusted: true},
+        "AboutLoginsInit": {wantUntrusted: true},
+      },
+      messages: [
+        "AboutLogins:AllLogins",
+        "AboutLogins:LoginAdded",
+        "AboutLogins:LoginModified",
+        "AboutLogins:LoginRemoved",
+      ],
+    },
+  },
+
   AboutReader: {
     child: {
       module: "resource:///actors/AboutReaderChild.jsm",
       group: "browsers",
       events: {
         "AboutReaderContentLoaded": {wantUntrusted: true},
         "DOMContentLoaded": {},
         "pageshow": {mozSystemGroup: true},
@@ -444,16 +461,17 @@ XPCOMUtils.defineLazyModuleGetters(this,
   UIState: "resource://services-sync/UIState.jsm",
   UITour: "resource:///modules/UITour.jsm",
   WebChannel: "resource://gre/modules/WebChannel.jsm",
   WindowsRegistry: "resource://gre/modules/WindowsRegistry.jsm",
 });
 
 // eslint-disable-next-line no-unused-vars
 XPCOMUtils.defineLazyModuleGetters(this, {
+  AboutLoginsParent: "resource:///modules/AboutLoginsParent.jsm",
   AsyncPrefs: "resource://gre/modules/AsyncPrefs.jsm",
   ContentClick: "resource:///modules/ContentClick.jsm",
   FormValidationHandler: "resource:///modules/FormValidationHandler.jsm",
   LoginManagerParent: "resource://gre/modules/LoginManagerParent.jsm",
   PictureInPicture: "resource://gre/modules/PictureInPicture.jsm",
   ReaderParent: "resource:///modules/ReaderParent.jsm",
   RemotePrompt: "resource:///modules/RemotePrompt.jsm",
 });
@@ -522,16 +540,18 @@ const listeners = {
     "AsyncPrefs:ResetPref": ["AsyncPrefs"],
     // PLEASE KEEP THIS LIST IN SYNC WITH THE LISTENERS ADDED IN AsyncPrefs.init
 
     "webrtc:UpdateGlobalIndicators": ["webrtcUI"],
     "webrtc:UpdatingIndicators": ["webrtcUI"],
   },
 
   mm: {
+    "AboutLogins:DeleteLogin": ["AboutLoginsParent"],
+    "AboutLogins:Subscribe": ["AboutLoginsParent"],
     "Content:Click": ["ContentClick"],
     "ContentSearch": ["ContentSearch"],
     "FormValidation:ShowPopup": ["FormValidationHandler"],
     "FormValidation:HidePopup": ["FormValidationHandler"],
     "PictureInPicture:Request": ["PictureInPicture"],
     "PictureInPicture:Close": ["PictureInPicture"],
     "PictureInPicture:Playing": ["PictureInPicture"],
     "PictureInPicture:Paused": ["PictureInPicture"],
--- a/browser/components/about/AboutRedirector.cpp
+++ b/browser/components/about/AboutRedirector.cpp
@@ -17,16 +17,17 @@
 #include "nsServiceManagerUtils.h"
 
 namespace mozilla {
 namespace browser {
 
 NS_IMPL_ISUPPORTS(AboutRedirector, nsIAboutModule)
 
 bool AboutRedirector::sNewTabPageEnabled = false;
+bool AboutRedirector::sAboutLoginsEnabled = false;
 
 static const uint32_t ACTIVITY_STREAM_FLAGS =
     nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::ENABLE_INDEXED_DB |
     nsIAboutModule::URI_MUST_LOAD_IN_CHILD |
     nsIAboutModule::URI_CAN_LOAD_IN_PRIVILEGED_CHILD |
     nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT;
 
 struct RedirEntry {
@@ -51,16 +52,21 @@ static const RedirEntry kRedirMap[] = {
     {"certerror", "chrome://browser/content/aboutNetError.xhtml",
      nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT |
          nsIAboutModule::URI_CAN_LOAD_IN_CHILD | nsIAboutModule::ALLOW_SCRIPT |
          nsIAboutModule::HIDE_FROM_ABOUTABOUT},
     {"config", "chrome://browser/content/aboutconfig/aboutconfig.html", 0},
     {"framecrashed", "chrome://browser/content/aboutFrameCrashed.html",
      nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT |
          nsIAboutModule::HIDE_FROM_ABOUTABOUT},
+    {"logins", "chrome://browser/content/aboutlogins/aboutLogins.html",
+     nsIAboutModule::ALLOW_SCRIPT |
+         nsIAboutModule::URI_MUST_LOAD_IN_CHILD |
+         nsIAboutModule::URI_CAN_LOAD_IN_PRIVILEGED_CHILD |
+         nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT},
     {"tabcrashed", "chrome://browser/content/aboutTabCrashed.xhtml",
      nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT |
          nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::HIDE_FROM_ABOUTABOUT},
     {"policies", "chrome://browser/content/policies/aboutPolicies.xhtml",
      nsIAboutModule::ALLOW_SCRIPT},
     {"privatebrowsing", "chrome://browser/content/aboutPrivateBrowsing.xhtml",
      nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT |
          nsIAboutModule::URI_MUST_LOAD_IN_CHILD | nsIAboutModule::ALLOW_SCRIPT},
@@ -142,31 +148,42 @@ AboutRedirector::NewChannel(nsIURI* aURI
 
   static bool sNTPEnabledCacheInited = false;
   if (!sNTPEnabledCacheInited) {
     Preferences::AddBoolVarCache(&AboutRedirector::sNewTabPageEnabled,
                                  "browser.newtabpage.enabled");
     sNTPEnabledCacheInited = true;
   }
 
+  static bool sAboutLoginsCacheInited = false;
+  if (!sAboutLoginsCacheInited) {
+    Preferences::AddBoolVarCache(&AboutRedirector::sAboutLoginsEnabled,
+                                 "signon.management.page.enabled");
+    sAboutLoginsCacheInited = true;
+  }
+
   for (auto& redir : kRedirMap) {
     if (!strcmp(path.get(), redir.id)) {
       nsAutoCString url;
 
       // Let the aboutNewTabService decide where to redirect for about:home and
       // enabled about:newtab. Disabledx about:newtab page uses fallback.
       if (path.EqualsLiteral("home") ||
           (sNewTabPageEnabled && path.EqualsLiteral("newtab"))) {
         nsCOMPtr<nsIAboutNewTabService> aboutNewTabService =
             do_GetService("@mozilla.org/browser/aboutnewtab-service;1", &rv);
         NS_ENSURE_SUCCESS(rv, rv);
         rv = aboutNewTabService->GetDefaultURL(url);
         NS_ENSURE_SUCCESS(rv, rv);
       }
 
+      if (!sAboutLoginsEnabled && path.EqualsLiteral("logins")) {
+        return NS_ERROR_NOT_AVAILABLE;
+      }
+
       if (path.EqualsLiteral("welcome")) {
         nsCOMPtr<nsIAboutNewTabService> aboutNewTabService =
             do_GetService("@mozilla.org/browser/aboutnewtab-service;1", &rv);
         NS_ENSURE_SUCCESS(rv, rv);
         rv = aboutNewTabService->GetWelcomeURL(url);
         NS_ENSURE_SUCCESS(rv, rv);
       }
 
--- a/browser/components/about/AboutRedirector.h
+++ b/browser/components/about/AboutRedirector.h
@@ -19,15 +19,16 @@ class AboutRedirector : public nsIAboutM
   AboutRedirector() {}
 
   static nsresult Create(nsISupports* aOuter, REFNSIID aIID, void** aResult);
 
  protected:
   virtual ~AboutRedirector() {}
 
  private:
+  static bool sAboutLoginsEnabled;
   static bool sNewTabPageEnabled;
 };
 
 }  // namespace browser
 }  // namespace mozilla
 
 #endif  // AboutRedirector_h__
--- a/browser/components/about/components.conf
+++ b/browser/components/about/components.conf
@@ -6,16 +6,17 @@
 
 pages = [
     'blocked',
     'certerror',
     'downloads',
     'framecrashed',
     'home',
     'library',
+    'logins',
     'newinstall',
     'newtab',
     'pocket-saved',
     'pocket-signup',
     'policies',
     'preferences',
     'privatebrowsing',
     'reader',
new file mode 100644
--- /dev/null
+++ b/browser/components/aboutlogins/AboutLoginsChild.jsm
@@ -0,0 +1,60 @@
+/* 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/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["AboutLoginsChild"];
+
+const {ActorChild} = ChromeUtils.import("resource://gre/modules/ActorChild.jsm");
+const {LoginHelper} = ChromeUtils.import("resource://gre/modules/LoginHelper.jsm");
+
+class AboutLoginsChild extends ActorChild {
+  handleEvent(event) {
+    switch (event.type) {
+      case "AboutLoginsInit": {
+        this.mm.sendAsyncMessage("AboutLogins:Subscribe");
+
+        let waivedContent = Cu.waiveXrays(this.content);
+        let AboutLoginsUtils = {
+          doLoginsMatch(loginA, loginB) {
+            return LoginHelper.doLoginsMatch(loginA, loginB, {});
+          },
+        };
+        waivedContent.AboutLoginsUtils = Cu.cloneInto(AboutLoginsUtils, waivedContent, {
+          cloneFunctions: true,
+        });
+        break;
+      }
+      case "AboutLoginsDeleteLogin": {
+        this.mm.sendAsyncMessage("AboutLogins:DeleteLogin", {login: event.detail});
+        break;
+      }
+    }
+  }
+
+  receiveMessage(message) {
+    switch (message.name) {
+      case "AboutLogins:AllLogins":
+        this.sendToContent("AllLogins", message.data);
+        break;
+      case "AboutLogins:LoginAdded":
+        this.sendToContent("LoginAdded", message.data);
+        break;
+      case "AboutLogins:LoginModified":
+        this.sendToContent("LoginModified", message.data);
+        break;
+      case "AboutLogins:LoginRemoved":
+        this.sendToContent("LoginRemoved", message.data);
+        break;
+    }
+  }
+
+  sendToContent(messageType, detail) {
+    let message = Object.assign({messageType}, {value: detail});
+    let event = new this.content.CustomEvent("AboutLoginsChromeToContent", {
+      detail: Cu.cloneInto(message, this.content),
+    });
+    this.content.dispatchEvent(event);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/components/aboutlogins/AboutLoginsParent.jsm
@@ -0,0 +1,114 @@
+/* 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/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["AboutLoginsParent"];
+
+ChromeUtils.defineModuleGetter(this, "LoginHelper",
+                               "resource://gre/modules/LoginHelper.jsm");
+ChromeUtils.defineModuleGetter(this, "Services",
+                               "resource://gre/modules/Services.jsm");
+
+const ABOUT_LOGINS_ORIGIN = "about:logins";
+
+const isValidLogin = login => {
+  return !(login.hostname || "").startsWith("chrome://");
+};
+
+const convertSubjectToLogin = subject => {
+    subject.QueryInterface(Ci.nsILoginMetaInfo).QueryInterface(Ci.nsILoginInfo);
+    const login = LoginHelper.loginToVanillaObject(subject);
+    if (!isValidLogin(login)) {
+      return null;
+    }
+    return login;
+};
+
+var AboutLoginsParent = {
+  _subscribers: new WeakSet(),
+
+  // Listeners are added in BrowserGlue.jsm
+  receiveMessage(message) {
+    // Only respond to messages sent from about:logins.
+    if (message.target.contentPrincipal.origin != ABOUT_LOGINS_ORIGIN) {
+      return;
+    }
+
+    switch (message.name) {
+      case "AboutLogins:DeleteLogin": {
+        let login = LoginHelper.vanillaObjectToLogin(message.data.login);
+        Services.logins.removeLogin(login);
+        break;
+      }
+      case "AboutLogins:Subscribe": {
+        if (!ChromeUtils.nondeterministicGetWeakSetKeys(this._subscribers).length) {
+          Services.obs.addObserver(this, "passwordmgr-storage-changed");
+        }
+        this._subscribers.add(message.target);
+
+        let messageManager = message.target.messageManager;
+        messageManager.sendAsyncMessage("AboutLogins:AllLogins", this.getAllLogins());
+        break;
+      }
+    }
+  },
+
+  observe(subject, topic, type) {
+    if (!ChromeUtils.nondeterministicGetWeakSetKeys(this._subscribers).length) {
+      Services.obs.removeObserver(this, "passwordmgr-storage-changed");
+      return;
+    }
+
+    switch (type) {
+      case "addLogin": {
+        const login = convertSubjectToLogin(subject);
+        if (!login) {
+          return;
+        }
+        this.messageSubscribers("AboutLogins:LoginAdded", login);
+        break;
+      }
+      case "modifyLogin": {
+        subject.QueryInterface(Ci.nsIArrayExtensions);
+        const login = convertSubjectToLogin(subject.GetElementAt(1));
+        if (!login) {
+          return;
+        }
+        this.messageSubscribers("AboutLogins:LoginModified", login);
+        break;
+      }
+      case "removeLogin": {
+        const login = convertSubjectToLogin(subject);
+        if (!login) {
+          return;
+        }
+        this.messageSubscribers("AboutLogins:LoginRemoved", login);
+      }
+      default: {
+        break;
+      }
+    }
+  },
+
+  messageSubscribers(name, details) {
+    let subscribers = ChromeUtils.nondeterministicGetWeakSetKeys(this._subscribers);
+    for (let subscriber of subscribers) {
+      if (subscriber.contentPrincipal.origin != ABOUT_LOGINS_ORIGIN) {
+        this._subscribers.delete(subscriber);
+        continue;
+      }
+      try {
+        subscriber.messageManager.sendAsyncMessage(name, details);
+      } catch (ex) {}
+    }
+  },
+
+  getAllLogins() {
+    return Services.logins
+                   .getAllLogins()
+                   .filter(isValidLogin)
+                   .map(LoginHelper.loginToVanillaObject);
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/components/aboutlogins/content/aboutLogins.html
@@ -0,0 +1,59 @@
+<!-- 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>
+  <head>
+    <meta charset="utf-8">
+    <title data-l10n-id="about-logins-page-title"></title>
+    <link rel="localization" href="browser/aboutLogins.ftl">
+    <script defer="defer" src="chrome://browser/content/aboutlogins/components/login-item.js"></script>
+    <script defer="defer" src="chrome://browser/content/aboutlogins/components/login-list.js"></script>
+    <script defer="defer" src="chrome://browser/content/aboutlogins/components/login-list-item.js"></script>
+    <script defer="defer" src="chrome://browser/content/aboutlogins/aboutLogins.js"></script>
+  </head>
+  <body>
+    <login-list data-l10n-id="login-list"
+                data-l10n-attrs="login-list-header"></login-list>
+    <login-item data-l10n-id="login-item"
+                data-l10n-attrs="login-item-hostname, login-item-password, login-item-username, login-item-time-created, login-item-delete"></login-item>
+
+    <template id="login-list-template">
+      <h2></h2>
+      <pre>
+      </pre>
+    </template>
+
+    <template id="login-list-item-template">
+      <style>
+        :host(.selected) {
+          font-weight: bold;
+        }
+      </style>
+      <span class="login-list-item-hostname"></span>
+      <span class="login-list-item-username"></span>
+    </template>
+
+    <template id="login-item-template">
+      <h2 data-l10n-id="login-item-header"></h2>
+      <label>
+        <span class="hostname-label"></span>
+        <input name="hostname"/>
+      </label>
+      <label>
+        <span class="username-label"></span>
+        <input name="username"/>
+      </label>
+      <label>
+        <span class="password-label"></span>
+        <input type="password" name="password"/>
+      </label>
+      <p>
+        <span class="time-created-label"></span>
+        <span class="time-created"></span>
+      </p>
+      <button class="delete-button"></button>
+    </template>
+  </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/components/aboutlogins/content/aboutLogins.js
@@ -0,0 +1,36 @@
+/* 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/. */
+
+let gElements = {};
+
+document.addEventListener("DOMContentLoaded", () => {
+  gElements.loginList = document.querySelector("login-list");
+  gElements.loginItem = document.querySelector("login-item");
+
+  document.dispatchEvent(new CustomEvent("AboutLoginsInit", {bubbles: true}));
+}, {once: true});
+
+window.addEventListener("AboutLoginsChromeToContent", event => {
+  switch (event.detail.messageType) {
+    case "AllLogins": {
+      gElements.loginList.setLogins(event.detail.value);
+      break;
+    }
+    case "LoginAdded": {
+      gElements.loginList.loginAdded(event.detail.value);
+      gElements.loginItem.loginAdded(event.detail.value);
+      break;
+    }
+    case "LoginModified": {
+      gElements.loginList.loginModified(event.detail.value);
+      gElements.loginItem.loginModified(event.detail.value);
+      break;
+    }
+    case "LoginRemoved": {
+      gElements.loginList.loginRemoved(event.detail.value);
+      gElements.loginItem.loginRemoved(event.detail.value);
+      break;
+    }
+  }
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-item.js
@@ -0,0 +1,126 @@
+/* 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/. */
+
+class LoginItem extends HTMLElement {
+  constructor() {
+    super();
+    this._login = {};
+    this._elements = {};
+  }
+
+  connectedCallback() {
+    if (this.children.length) {
+      this.render();
+      return;
+    }
+
+    let loginItemTemplate = document.querySelector("#login-item-template");
+    this.attachShadow({mode: "open"})
+        .appendChild(loginItemTemplate.content.cloneNode(true));
+
+    let deleteButton = this.shadowRoot.querySelector(".delete-button");
+    deleteButton.addEventListener("click", this);
+
+    window.addEventListener("AboutLoginsLoginSelected", this);
+
+    this.render();
+  }
+
+  static get observedAttributes() {
+    return [
+      "login-item-delete",
+      "login-item-hostname",
+      "login-item-password",
+      "login-item-time-created",
+      "login-item-username",
+    ];
+  }
+
+  /* Fluent doesn't handle localizing into Shadow DOM yet so strings
+     need to get reflected in to their targeted element. */
+  attributeChangedCallback(attr, oldValue, newValue) {
+    switch (attr) {
+      case "login-item-delete":
+        this.shadowRoot.querySelector(".delete-button").textContent = newValue;
+        break;
+      case "login-item-hostname":
+        this.shadowRoot.querySelector(".hostname-label").textContent = newValue;
+        break;
+      case "login-item-password":
+        this.shadowRoot.querySelector(".password-label").textContent = newValue;
+        break;
+      case "login-item-time-created":
+        this.shadowRoot.querySelector(".time-created-label").textContent = newValue;
+        break;
+      case "login-item-username":
+        this.shadowRoot.querySelector(".username-label").textContent = newValue;
+        break;
+    }
+  }
+
+  render() {
+    this.shadowRoot.querySelector("input[name='hostname']").value = this._login.hostname || "";
+    this.shadowRoot.querySelector("input[name='username']").value = this._login.username || "";
+    this.shadowRoot.querySelector("input[name='password']").value = this._login.password || "";
+    this.shadowRoot.querySelector(".time-created").textContent = this._login.timeCreated || "";
+  }
+
+  handleEvent(event) {
+    switch (event.type) {
+      case "AboutLoginsLoginSelected": {
+        this._login = event.detail;
+        this.render();
+        break;
+      }
+      case "click": {
+        if (event.target.classList.contains("delete-button")) {
+          document.dispatchEvent(new CustomEvent("AboutLoginsDeleteLogin", {
+            bubbles: true,
+            detail: this._login,
+          }));
+        }
+        break;
+      }
+    }
+  }
+
+  loginAdded(login) {
+    if (!this._login.guid) {
+      let tempLogin = {
+        username: this.shadowRoot.querySelector("input[name='username']").value,
+        formSubmitURL: "", // Use the wildcard since the user doesn't supply it.
+        hostname: this.shadowRoot.querySelector("input[name='hostname']").value,
+        password: this.shadowRoot.querySelector("input[name='password']").value,
+      };
+      // Need to use LoginHelper.doLoginsMatch() to see if the login
+      // that was added is the login that was being edited, so we
+      // can update time-created, etc.
+      if (window.AboutLoginsUtils.doLoginsMatch(tempLogin, login)) {
+        this._login = login;
+        this.render();
+      }
+    } else if (login.guid == this._login.guid) {
+      this._login = login;
+      this.render();
+    }
+  }
+
+  loginModified(login) {
+    if (login.guid != this._login.guid) {
+      return;
+    }
+
+    this._login = login;
+    this.render();
+  }
+
+  loginRemoved(login) {
+    if (login.guid != this._login.guid) {
+      return;
+    }
+    this._login = {};
+    this.render();
+  }
+}
+customElements.define("login-item", LoginItem);
new file mode 100644
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-list-item.js
@@ -0,0 +1,58 @@
+/* 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/. */
+
+class LoginListItem extends HTMLElement {
+  constructor(login) {
+    super();
+    this._login = login;
+    this._selected = false;
+  }
+
+  connectedCallback() {
+    if (this.children.length) {
+      this.render();
+      return;
+    }
+
+    let loginListItemTemplate = document.querySelector("#login-list-item-template");
+    this.attachShadow({mode: "open"})
+        .appendChild(loginListItemTemplate.content.cloneNode(true));
+    this.render();
+
+    this.addEventListener("click", this);
+    window.addEventListener("AboutLoginsLoginSelected", this);
+  }
+
+  render() {
+    this.classList.toggle("selected", this._selected);
+    this.setAttribute("guid", this._login.guid);
+    this.shadowRoot.querySelector(".login-list-item-hostname").textContent = this._login.hostname;
+    this.shadowRoot.querySelector(".login-list-item-username").textContent = this._login.username;
+  }
+
+  handleEvent(event) {
+    switch (event.type) {
+      case "AboutLoginsLoginSelected": {
+        if (this._selected != (event.detail.guid == this._login.guid)) {
+          this._selected = event.detail.guid == this._login.guid;
+          this.render();
+        }
+        break;
+      }
+      case "click": {
+        this.dispatchEvent(new CustomEvent("AboutLoginsLoginSelected", {
+          bubbles: true,
+          composed: true,
+          detail: this._login,
+        }));
+      }
+    }
+  }
+
+  update(login) {
+    this._login = login;
+    this.render();
+  }
+}
+customElements.define("login-list-item", LoginListItem);
new file mode 100644
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-list.js
@@ -0,0 +1,79 @@
+/* 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/. */
+
+/* globals LoginListItem */
+
+class LoginList extends HTMLElement {
+  constructor() {
+    super();
+
+    this._logins = [];
+  }
+
+  connectedCallback() {
+    if (this.children.length) {
+      return;
+    }
+    let loginListTemplate = document.querySelector("#login-list-template");
+    this.attachShadow({mode: "open"})
+        .appendChild(loginListTemplate.content.cloneNode(true));
+    this.render();
+  }
+
+  render() {
+    let pre = this.shadowRoot.querySelector("pre");
+    for (let login of this._logins) {
+      pre.append(new LoginListItem(login));
+    }
+  }
+
+  static get observedAttributes() {
+    return ["login-list-header"];
+  }
+
+  /* Fluent doesn't handle localizing into Shadow DOM yet so strings
+     need to get reflected in to their targeted element. */
+  attributeChangedCallback(attr, oldValue, newValue) {
+    switch (attr) {
+      case "login-list-header":
+        this.shadowRoot.querySelector("h2").textContent = newValue;
+        break;
+    }
+  }
+
+  setLogins(logins) {
+    let pre = this.shadowRoot.querySelector("pre");
+    pre.textContent = "";
+    this._logins = logins;
+    this.render();
+  }
+
+  loginAdded(login) {
+    this._logins.push(login);
+    let pre = this.shadowRoot.querySelector("pre");
+    pre.append(new LoginListItem(login));
+  }
+
+  loginModified(login) {
+    let pre = this.shadowRoot.querySelector("pre");
+    for (let loginListItem of pre.children) {
+      if (loginListItem.getAttribute("guid") == login.guid) {
+        loginListItem.update(login);
+        break;
+      }
+    }
+  }
+
+  loginRemoved(login) {
+    this._logins = this._logins.filter(l => l.guid != login.guid);
+    let pre = this.shadowRoot.querySelector("pre");
+    for (let loginListItem of pre.children) {
+      if (loginListItem.getAttribute("guid") == login.guid) {
+        loginListItem.remove();
+        break;
+      }
+    }
+  }
+}
+customElements.define("login-list", LoginList);
new file mode 100644
--- /dev/null
+++ b/browser/components/aboutlogins/jar.mn
@@ -0,0 +1,10 @@
+# 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/.
+
+browser.jar:
+  content/browser/aboutlogins/components/login-item.js         (content/components/login-item.js)
+  content/browser/aboutlogins/components/login-list.js         (content/components/login-list.js)
+  content/browser/aboutlogins/components/login-list-item.js    (content/components/login-list-item.js)
+  content/browser/aboutlogins/aboutLogins.js    (content/aboutLogins.js)
+  content/browser/aboutlogins/aboutLogins.html  (content/aboutLogins.html)
new file mode 100644
--- /dev/null
+++ b/browser/components/aboutlogins/moz.build
@@ -0,0 +1,18 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ['jar.mn']
+
+with Files('**'):
+    BUG_COMPONENT = ('Toolkit', 'Password Manager')
+
+EXTRA_JS_MODULES += [
+    'AboutLoginsParent.jsm',
+]
+
+FINAL_TARGET_FILES.actors += [
+    'AboutLoginsChild.jsm',
+]
--- a/browser/components/moz.build
+++ b/browser/components/moz.build
@@ -26,16 +26,17 @@ with Files("safebrowsing/**"):
     BUG_COMPONENT = ("Toolkit", "Safe Browsing")
 
 with Files('controlcenter/**'):
     BUG_COMPONENT = ('Firefox', 'General')
 
 
 DIRS += [
     'about',
+    'aboutlogins',
     'attribution',
     'contextualidentity',
     'customizableui',
     'dirprovider',
     'downloads',
     'enterprisepolicies',
     'extensions',
     'library',
new file mode 100644
--- /dev/null
+++ b/browser/locales/en-US/browser/aboutLogins.ftl
@@ -0,0 +1,23 @@
+# 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/.
+
+### This file is not in a locales directory to prevent it from
+### being translated as the feature is still in heavy development
+### and strings are likely to change often.
+
+### Fluent isn't translating elements in the shadow DOM so the translated strings
+### need to be applied to the composed node where they can be moved to the proper
+### descendant after translation.
+
+about-logins-page-title = Login Manager
+
+login-list =
+  .login-list-header = Logins
+
+login-item =
+  .login-item-hostname = Hostname
+  .login-item-password = Password
+  .login-item-username = Username
+  .login-item-time-created = Time Created
+  .login-item-delete = Delete