Bug 1548463 - Base page for HTML-based login manager. r=MattN,flod
authorJared Wein <jwein@mozilla.com>
Wed, 08 May 2019 19:17:02 +0000
changeset 473111 2f50e7dfbc564477f53313400d953cf2381e1cdd
parent 473110 790d1bff41400ac2266adb5340b8911e2251d6d3
child 473112 6632c97e250f79cc93b4a7223b1a3ae87f2b2676
push id113065
push useropoprus@mozilla.com
push dateThu, 09 May 2019 03:46:59 +0000
treeherdermozilla-inbound@34a824c75b7b [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN, flod
bugs1548463
milestone68.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 1548463 - Base page for HTML-based login manager. r=MattN,flod Differential Revision: https://phabricator.services.mozilla.com/D29624
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.ftl
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/jar.mn
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1742,16 +1742,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},
@@ -443,16 +460,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",
 });
@@ -521,16 +539,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,20 @@ 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 +147,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.originNoSuffix != 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.originNoSuffix != 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.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
new file mode 100644
--- /dev/null
+++ b/browser/components/aboutlogins/content/aboutLogins.html
@@ -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/. -->
+
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta http-equiv="Content-Security-Policy" content="default-src 'none'; object-src 'none'; script-src resource: chrome:; connect-src https:; img-src https: data: blob:; style-src 'unsafe-inline';"/>
+    <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,129 @@
+/* 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 = {};
+  }
+
+  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) {
+    if (!this.shadowRoot) {
+      return;
+    }
+
+    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,82 @@
+/* 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) {
+    if (!this.shadowRoot) {
+      return;
+    }
+
+    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',
--- a/browser/locales/jar.mn
+++ b/browser/locales/jar.mn
@@ -3,16 +3,17 @@
 # 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/.
 
 # Note: This file should only contain locale entries. All
 # override and resource entries should go to browser/base/jar.mn to avoid
 # having to create the same entry for each locale.
 
 [localization] @AB_CD@.jar:
+  browser/aboutLogins.ftl                          (../components/aboutlogins/content/aboutLogins.ftl)
   browser                                          (%browser/**/*.ftl)
 
 @AB_CD@.jar:
 % locale browser @AB_CD@ %locale/browser/
 # bookmarks.html is produced by LOCALIZED_GENERATED_FILES.
     locale/browser/bookmarks.html                  (bookmarks.html)
 
     locale/browser/accounts.properties             (%chrome/browser/accounts.properties)