Bug 1569846: Add breach alert dismissal. r=MattN,fluent-reviewers,flod
☠☠ backed out by eb56d88d1b07 ☠ ☠
authorlesleynorton <lnorton@mozilla.com>
Wed, 14 Aug 2019 22:56:55 +0000
changeset 488165 e9acadba3a61509e29acee433753a8cec09e96a8
parent 488164 9c5269817d0a522126bafb7ebd1ac0858853f9a6
child 488166 7e5760b1988ce1c47ddcaf63750ca5801aa1da6f
push id36437
push userncsoregi@mozilla.com
push dateThu, 15 Aug 2019 19:33:18 +0000
treeherdermozilla-central@44aac6fc3352 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN, fluent-reviewers, flod
bugs1569846
milestone70.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 1569846: Add breach alert dismissal. r=MattN,fluent-reviewers,flod Differential Revision: https://phabricator.services.mozilla.com/D41034
browser/components/BrowserGlue.jsm
browser/components/aboutlogins/AboutLoginsChild.jsm
browser/components/aboutlogins/AboutLoginsParent.jsm
browser/components/aboutlogins/content/aboutLogins.html
browser/components/aboutlogins/content/components/login-item.css
browser/components/aboutlogins/content/components/login-item.js
browser/components/aboutlogins/content/components/login-list.js
browser/components/aboutlogins/tests/browser/browser.ini
browser/components/aboutlogins/tests/browser/browser_breachAlertDismissals.js
browser/components/aboutlogins/tests/browser/head.js
browser/components/aboutlogins/tests/chrome/test_login_item.html
browser/components/aboutlogins/tests/unit/test_getBreachesForLogins.js
browser/locales/en-US/browser/aboutLogins.ftl
toolkit/components/passwordmgr/LoginHelper.jsm
toolkit/components/passwordmgr/LoginStore.jsm
toolkit/components/passwordmgr/nsILoginManager.idl
toolkit/components/passwordmgr/storage-json.js
--- a/browser/components/BrowserGlue.jsm
+++ b/browser/components/BrowserGlue.jsm
@@ -130,16 +130,17 @@ let LEGACY_ACTORS = {
   AboutLogins: {
     child: {
       matches: ["about:logins", "about:logins?*"],
       module: "resource:///actors/AboutLoginsChild.jsm",
       events: {
         AboutLoginsCopyLoginDetail: { wantUntrusted: true },
         AboutLoginsCreateLogin: { wantUntrusted: true },
         AboutLoginsDeleteLogin: { wantUntrusted: true },
+        AboutLoginsDismissBreachAlert: { wantUntrusted: true },
         AboutLoginsImport: { wantUntrusted: true },
         AboutLoginsInit: { wantUntrusted: true },
         AboutLoginsOpenFAQ: { wantUntrusted: true },
         AboutLoginsOpenFeedback: { wantUntrusted: true },
         AboutLoginsOpenMobileAndroid: { wantUntrusted: true },
         AboutLoginsOpenMobileIos: { wantUntrusted: true },
         AboutLoginsOpenPreferences: { wantUntrusted: true },
         AboutLoginsOpenSite: { wantUntrusted: true },
@@ -631,16 +632,17 @@ const listeners = {
 
     "webrtc:UpdateGlobalIndicators": ["webrtcUI"],
     "webrtc:UpdatingIndicators": ["webrtcUI"],
   },
 
   mm: {
     "AboutLogins:CreateLogin": ["AboutLoginsParent"],
     "AboutLogins:DeleteLogin": ["AboutLoginsParent"],
+    "AboutLogins:DismissBreachAlert": ["AboutLoginsParent"],
     "AboutLogins:Import": ["AboutLoginsParent"],
     "AboutLogins:MasterPasswordRequest": ["AboutLoginsParent"],
     "AboutLogins:OpenFAQ": ["AboutLoginsParent"],
     "AboutLogins:OpenFeedback": ["AboutLoginsParent"],
     "AboutLogins:OpenPreferences": ["AboutLoginsParent"],
     "AboutLogins:OpenMobileAndroid": ["AboutLoginsParent"],
     "AboutLogins:OpenMobileIos": ["AboutLoginsParent"],
     "AboutLogins:OpenSite": ["AboutLoginsParent"],
--- a/browser/components/aboutlogins/AboutLoginsChild.jsm
+++ b/browser/components/aboutlogins/AboutLoginsChild.jsm
@@ -85,16 +85,22 @@ class AboutLoginsChild extends ActorChil
         break;
       }
       case "AboutLoginsDeleteLogin": {
         this.mm.sendAsyncMessage("AboutLogins:DeleteLogin", {
           login: event.detail,
         });
         break;
       }
+      case "AboutLoginsDismissBreachAlert": {
+        this.mm.sendAsyncMessage("AboutLogins:DismissBreachAlert", {
+          login: event.detail,
+        });
+        break;
+      }
       case "AboutLoginsImport": {
         this.mm.sendAsyncMessage("AboutLogins:Import");
         break;
       }
       case "AboutLoginsOpenFAQ": {
         this.mm.sendAsyncMessage("AboutLogins:OpenFAQ");
         break;
       }
--- a/browser/components/aboutlogins/AboutLoginsParent.jsm
+++ b/browser/components/aboutlogins/AboutLoginsParent.jsm
@@ -118,16 +118,31 @@ var AboutLoginsParent = {
         Services.logins.addLogin(LoginHelper.vanillaObjectToLogin(newLogin));
         break;
       }
       case "AboutLogins:DeleteLogin": {
         let login = LoginHelper.vanillaObjectToLogin(message.data.login);
         Services.logins.removeLogin(login);
         break;
       }
+      case "AboutLogins:DismissBreachAlert": {
+        const login = message.data.login;
+
+        await LoginHelper.recordBreachAlertDismissal(login.guid);
+        const logins = await this.getAllLogins();
+        const breachesByLoginGUID = await LoginHelper.getBreachesForLogins(
+          logins
+        );
+        const messageManager = message.target.messageManager;
+        messageManager.sendAsyncMessage(
+          "AboutLogins:UpdateBreaches",
+          breachesByLoginGUID
+        );
+        break;
+      }
       case "AboutLogins:SyncEnable": {
         message.target.ownerGlobal.gSync.openFxAEmailFirstPage(
           "password-manager"
         );
         break;
       }
       case "AboutLogins:SyncOptions": {
         message.target.ownerGlobal.gSync.openFxAManagePage("password-manager");
--- a/browser/components/aboutlogins/content/aboutLogins.html
+++ b/browser/components/aboutlogins/content/aboutLogins.html
@@ -132,16 +132,17 @@
 
     <template id="login-item-template">
       <link rel="stylesheet" href="chrome://global/skin/in-content/common.css">
       <link rel="stylesheet" href="chrome://browser/content/aboutlogins/common.css">
       <link rel="stylesheet" href="chrome://browser/content/aboutlogins/components/login-item.css">
       <div class="breach-alert">
         <span class="breach-alert-text" data-l10n-id="breach-alert-text"></span>
         <a class="breach-alert-link" data-l10n-id="breach-alert-link" href="#" rel="noopener noreferer" target="_blank"></a>
+        <button class="dismiss-breach-alert"></button>
       </div>
       <div class="header">
         <div class="login-item-favicon-wrapper">
           <img class="login-item-favicon" src="" alt=""/>
         </div>
         <h2 class="title">
           <span class="login-item-title"></span>
           <span class="new-login-title" data-l10n-id="login-item-new-login-title"></span>
--- a/browser/components/aboutlogins/content/components/login-item.css
+++ b/browser/components/aboutlogins/content/components/login-item.css
@@ -223,28 +223,51 @@ input[type="url"][readOnly]:hover:active
   box-shadow: 0 2px 8px 0 rgba(12,12,13,0.1);
   font-size: .9em;
   font-weight: 300;
   line-height: 1.4;
   padding-block: 12px;
   padding-inline-start: 36px;
   padding-inline-end: 92px;
   margin-block-end: 40px;
+  position: relative;
 }
 
 .breach-alert:dir(rtl) {
   background-position: right 10px top 10px;
 }
 
 a.breach-alert-link {
   color: inherit;
   text-decoration: underline;
   font-weight: 500;
 }
 
+.dismiss-breach-alert {
+  border: none;
+  padding: 0;
+  margin: 0;
+  position: absolute;
+  background-image: url("chrome://global/skin/icons/close.svg");
+  background-repeat: no-repeat;
+  background-size: contain;
+  min-height: 16px;
+  min-width: 16px;
+  -moz-context-properties: fill, fill-opacity;
+  fill-opacity: 0;
+  fill: var(--grey-90);
+  inset-inline-end: 12px;
+  inset-block-start: 12px
+}
+
+.dismiss-breach-alert,
+.dismiss-breach-alert:hover {
+  background-color: transparent;
+}
+
 @supports -moz-bool-pref("browser.in-content.dark-mode") {
   @media (prefers-color-scheme: dark) {
     :host {
       --reveal-checkbox-opacity: .8;
       --reveal-checkbox-opacity-hover: 1;
       --reveal-checkbox-opacity-active: .6;
       --success-color: #86DE74;
     }
--- a/browser/components/aboutlogins/content/components/login-item.js
+++ b/browser/components/aboutlogins/content/components/login-item.js
@@ -60,24 +60,28 @@ export default class LoginItem extends H
     this._faviconWrapper = this.shadowRoot.querySelector(
       ".login-item-favicon-wrapper"
     );
     this._title = this.shadowRoot.querySelector(".login-item-title");
     this._timeCreated = this.shadowRoot.querySelector(".time-created");
     this._timeChanged = this.shadowRoot.querySelector(".time-changed");
     this._timeUsed = this.shadowRoot.querySelector(".time-used");
     this._breachAlert = this.shadowRoot.querySelector(".breach-alert");
+    this._dismissBreachAlert = this.shadowRoot.querySelector(
+      ".dismiss-breach-alert"
+    );
 
     this.render();
 
     this._originInput.addEventListener("blur", this);
     this._cancelButton.addEventListener("click", this);
     this._copyPasswordButton.addEventListener("click", this);
     this._copyUsernameButton.addEventListener("click", this);
     this._deleteButton.addEventListener("click", this);
+    this._dismissBreachAlert.addEventListener("click", this);
     this._editButton.addEventListener("click", this);
     this._form.addEventListener("submit", this);
     this._openSiteButton.addEventListener("click", this);
     this._originInput.addEventListener("click", this);
     this._revealCheckbox.addEventListener("click", this);
     window.addEventListener("AboutLoginsInitialLoginSelected", this);
     window.addEventListener("AboutLoginsLoadInitialFavicon", this);
     window.addEventListener("AboutLoginsLoginSelected", this);
@@ -87,16 +91,20 @@ export default class LoginItem extends H
   async render() {
     this._breachAlert.hidden = true;
     if (this._breachesMap && this._breachesMap.has(this._login.guid)) {
       const breachDetails = this._breachesMap.get(this._login.guid);
       const breachAlertLink = this._breachAlert.querySelector(
         ".breach-alert-link"
       );
       breachAlertLink.href = breachDetails.breachAlertURL;
+      document.l10n.setAttributes(
+        this._dismissBreachAlert,
+        "breach-alert-dismiss"
+      );
       this._breachAlert.hidden = false;
     }
     document.l10n.setAttributes(this._timeCreated, "login-item-time-created", {
       timeCreated: this._login.timeCreated || "",
     });
     document.l10n.setAttributes(this._timeChanged, "login-item-time-changed", {
       timeChanged: this._login.timePasswordChanged || "",
     });
@@ -130,16 +138,25 @@ export default class LoginItem extends H
     await this._updatePasswordRevealState();
   }
 
   updateBreaches(breachesByLoginGUID) {
     this._breachesMap = breachesByLoginGUID;
     this.render();
   }
 
+  dismissBreachAlert() {
+    document.dispatchEvent(
+      new CustomEvent("AboutLoginsDismissBreachAlert", {
+        bubbles: true,
+        detail: this._login,
+      })
+    );
+  }
+
   async handleEvent(event) {
     switch (event.type) {
       case "AboutLoginsInitialLoginSelected": {
         this.setLogin(event.detail, { skipFocusChange: true });
         break;
       }
       case "AboutLoginsLoadInitialFavicon": {
         this.render();
@@ -246,16 +263,20 @@ export default class LoginItem extends H
               new CustomEvent("AboutLoginsDeleteLogin", {
                 bubbles: true,
                 detail: this._login,
               })
             );
           });
           return;
         }
+        if (classList.contains("dismiss-breach-alert")) {
+          this.dismissBreachAlert();
+          return;
+        }
         if (classList.contains("edit-button")) {
           this._toggleEditing();
 
           recordTelemetryEvent({ object: "existing_login", method: "edit" });
           return;
         }
         if (
           classList.contains("open-site-button") ||
--- a/browser/components/aboutlogins/content/components/login-list.js
+++ b/browser/components/aboutlogins/content/components/login-list.js
@@ -85,26 +85,25 @@ export default class LoginList extends H
       });
       fragment.appendChild(listItem);
     }
     this._list.appendChild(fragment);
 
     // Show, hide, and update state of the list items per the applied search filter.
     for (let guid of this._loginGuidsSortedOrder) {
       let { listItem } = this._logins[guid];
+
       if (guid == this._selectedGuid) {
         this._setListItemAsSelected(listItem);
       }
-      if (
+      listItem.classList.toggle(
+        "breached",
         this._breachesByLoginGUID &&
-        this._breachesByLoginGUID.has(listItem.dataset.guid)
-      ) {
-        listItem.classList.add("breached");
-      }
-
+          this._breachesByLoginGUID.has(listItem.dataset.guid)
+      );
       listItem.hidden = !visibleLoginGuids.has(listItem.dataset.guid);
     }
 
     let createLoginSelected =
       this._selectedGuid == null && Object.keys(this._logins).length > 0;
     this.classList.toggle("create-login-selected", createLoginSelected);
     this._createLoginButton.disabled = createLoginSelected;
 
--- a/browser/components/aboutlogins/tests/browser/browser.ini
+++ b/browser/components/aboutlogins/tests/browser/browser.ini
@@ -3,16 +3,17 @@ prefs =
   signon.management.page.enabled=true
 support-files =
   head.js
 
 # Run first so content events from previous tests won't trickle in.
 # Skip ASAN and debug since waiting for content events is already slow.
 [browser_aaa_eventTelemetry_run_first.js]
 skip-if = asan || debug
+[browser_breachAlertDismissals.js]
 [browser_confirmDeleteDialog.js]
 [browser_copyToClipboardButton.js]
 [browser_createLogin.js]
 [browser_deleteLogin.js]
 [browser_fxAccounts.js]
 [browser_loginListChanges.js]
 [browser_masterPassword.js]
 skip-if = (os == 'linux') # bug 1569789
new file mode 100644
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_breachAlertDismissals.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_BREACHES = [
+  {
+    AddedDate: "2019-12-20T23:56:26Z",
+    BreachDate: "2018-12-16",
+    Domain: "breached.com",
+    Name: "Breached",
+    PwnCount: 1643100,
+    DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"],
+    _status: "synced",
+    id: "047940fe-d2fd-4314-b636-b4a952ee0043",
+    last_modified: "1541615610052",
+    schema: "1541615609018",
+  },
+];
+
+add_task(async function setup() {
+  TEST_LOGIN3 = await addLogin(TEST_LOGIN3);
+  await BrowserTestUtils.openNewForegroundTab({
+    gBrowser,
+    url: "about:logins",
+  });
+  registerCleanupFunction(() => {
+    BrowserTestUtils.removeTab(gBrowser.selectedTab);
+    Services.logins.removeAllLogins();
+  });
+});
+
+add_task(async function test_show_login() {
+  let browser = gBrowser.selectedBrowser;
+  TEST_LOGIN3.timePasswordChanged = 12345;
+  let testBreaches = await LoginHelper.getBreachesForLogins(
+    [TEST_LOGIN3],
+    TEST_BREACHES
+  );
+  browser.messageManager.sendAsyncMessage(
+    "AboutLogins:UpdateBreaches",
+    testBreaches
+  );
+  await ContentTask.spawn(browser, TEST_LOGIN3, async () => {
+    let loginItem = Cu.waiveXrays(content.document.querySelector("login-item"));
+    let breachAlert = loginItem.shadowRoot.querySelector(".breach-alert");
+    let breachAlertVisible = await ContentTaskUtils.waitForCondition(() => {
+      return !breachAlert.hidden;
+    }, "Waiting for breach alert to be visible");
+    ok(
+      breachAlertVisible,
+      "Breach alert should be visible for a breached login."
+    );
+
+    let breachAlertDismissalButton = breachAlert.querySelector(
+      ".dismiss-breach-alert"
+    );
+    breachAlertDismissalButton.click();
+
+    let breachAlertDismissed = await ContentTaskUtils.waitForCondition(() => {
+      return breachAlert.hidden;
+    }, "Waiting for breach alert to be dismissed");
+    ok(
+      breachAlertDismissed,
+      "Breach alert should not be visible after alert dismissal."
+    );
+  });
+});
--- a/browser/components/aboutlogins/tests/browser/head.js
+++ b/browser/components/aboutlogins/tests/browser/head.js
@@ -1,16 +1,17 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 let nsLoginInfo = new Components.Constructor(
   "@mozilla.org/login-manager/loginInfo;1",
   Ci.nsILoginInfo,
   "init"
 );
+
 let TEST_LOGIN1 = new nsLoginInfo(
   "https://example.com/",
   "https://example.com/",
   null,
   "user1",
   "pass1",
   "username",
   "password"
@@ -20,16 +21,26 @@ let TEST_LOGIN2 = new nsLoginInfo(
   "https://2.example.com/",
   null,
   "user2",
   "pass2",
   "username",
   "password"
 );
 
+let TEST_LOGIN3 = new nsLoginInfo(
+  "https://breached.com/",
+  "https://breached.com/",
+  null,
+  "breachedLogin1",
+  "pass3",
+  "breachedLogin",
+  "password"
+);
+
 async function addLogin(login) {
   let storageChangedPromised = TestUtils.topicObserved(
     "passwordmgr-storage-changed",
     (_, data) => data == "addLogin"
   );
   login = Services.logins.addLogin(login);
   await storageChangedPromised;
   registerCleanupFunction(() => {
--- a/browser/components/aboutlogins/tests/chrome/test_login_item.html
+++ b/browser/components/aboutlogins/tests/chrome/test_login_item.html
@@ -96,27 +96,27 @@ add_task(async function test_set_login()
 
 add_task(async function test_update_breaches() {
   gLoginItem.setLogin(TEST_LOGIN_1);
   gLoginItem.updateBreaches(TEST_BREACHES_MAP);
   await asyncElementRendered();
 
   let correspondingBreach = TEST_BREACHES_MAP.get(gLoginItem._login.guid);
   let breachAlert = gLoginItem.shadowRoot.querySelector(".breach-alert");
-  ok(!breachAlert.hidden, "Breach alert should be visible");
+  ok(!isHidden(breachAlert), "Breach alert should be visible");
   is(breachAlert.querySelector(".breach-alert-link").href, correspondingBreach.breachAlertURL, "Breach alert link should be equal to the correspondingBreach.breachAlertURL.");
 });
 
 add_task(async function test_breach_alert_is_correctly_hidden() {
   gLoginItem.setLogin(TEST_LOGIN_2);
   gLoginItem.updateBreaches(TEST_BREACHES_MAP);
   await asyncElementRendered();
 
   let breachAlert = gLoginItem.shadowRoot.querySelector(".breach-alert");
-  ok(breachAlert.hidden, "Breach alert should not be visible on login without an associated breach.");
+  ok(isHidden(breachAlert), "Breach alert should not be visible on login without an associated breach.");
 });
 
 add_task(async function test_edit_login() {
   gLoginItem.setLogin(TEST_LOGIN_1);
   gLoginItem.shadowRoot.querySelector(".edit-button").click();
   await asyncElementRendered();
 
   ok(gLoginItem.dataset.editing, "loginItem should be in 'edit' mode");
--- a/browser/components/aboutlogins/tests/unit/test_getBreachesForLogins.js
+++ b/browser/components/aboutlogins/tests/unit/test_getBreachesForLogins.js
@@ -158,8 +158,46 @@ add_task(
     Assert.strictEqual(
       breachesByLoginGUID.size,
       0,
       "Should be 0 breached login: " +
         LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS.origin
     );
   }
 );
+
+add_task(
+  async function test_getBreachesForLogins_breachAlertHiddenAfterDismissal() {
+    BREACHED_LOGIN.guid = "{d2de5ac1-4de6-e544-a7af-1f75abcba92b}";
+
+    await Services.logins.initializationPromise;
+    const storageJSON =
+      Services.logins.wrappedJSObject._storage.wrappedJSObject;
+
+    storageJSON.recordBreachAlertDismissal(BREACHED_LOGIN.guid);
+
+    const breachesByLoginGUID = await LoginHelper.getBreachesForLogins(
+      [BREACHED_LOGIN, NOT_BREACHED_LOGIN],
+      TEST_BREACHES
+    );
+    Assert.strictEqual(
+      breachesByLoginGUID.size,
+      0,
+      "Should be 0 breached logins after dismissal: " + BREACHED_LOGIN.origin
+    );
+  }
+);
+
+add_task(async function test_getBreachesForLogins_newBreachAfterDismissal() {
+  TEST_BREACHES[0].AddedDate = new Date().toISOString();
+
+  const breachesByLoginGUID = await LoginHelper.getBreachesForLogins(
+    [BREACHED_LOGIN, NOT_BREACHED_LOGIN],
+    TEST_BREACHES
+  );
+
+  Assert.strictEqual(
+    breachesByLoginGUID.size,
+    1,
+    "Should be 1 breached login after new breach following the dismissal of a previous breach: " +
+      BREACHED_LOGIN.origin
+  );
+});
--- a/browser/locales/en-US/browser/aboutLogins.ftl
+++ b/browser/locales/en-US/browser/aboutLogins.ftl
@@ -125,8 +125,10 @@ confirm-delete-dialog-confirm-button = D
 confirm-discard-changes-dialog-title = Discard unsaved changes?
 confirm-discard-changes-dialog-message = All unsaved changes will be lost.
 confirm-discard-changes-dialog-confirm-button = Discard
 
 ## Breach Alert notification
 
 breach-alert-text = Passwords were leaked or stolen from this website since you last updated your login details. Change your password to protect your account.
 breach-alert-link = Learn more about this breach.
+breach-alert-dismiss = 
+    .title = Close this alert
--- a/toolkit/components/passwordmgr/LoginHelper.jsm
+++ b/toolkit/components/passwordmgr/LoginHelper.jsm
@@ -1108,16 +1108,24 @@ this.LoginHelper = {
       if (e.result == Cr.NS_ERROR_ABORT) {
         // If the user cancels the MP prompt then return no logins.
         return [];
       }
       throw e;
     }
   },
 
+  async recordBreachAlertDismissal(loginGuid) {
+    await Services.logins.initializationPromise;
+    const storageJSON =
+      Services.logins.wrappedJSObject._storage.wrappedJSObject;
+
+    return storageJSON.recordBreachAlertDismissal(loginGuid);
+  },
+
   async getBreachesForLogins(logins, breaches = null) {
     const breachesByLoginGUID = new Map();
     if (!breaches) {
       try {
         breaches = await RemoteSettings(
           REMOTE_SETTINGS_BREACHES_COLLECTION
         ).get();
       } catch (ex) {
@@ -1132,30 +1140,40 @@ this.LoginHelper = {
         throw ex;
       }
     }
     const BREACH_ALERT_URL = Services.prefs.getStringPref(
       "signon.management.page.breachAlertUrl"
     );
     const baseBreachAlertURL = new URL(BREACH_ALERT_URL);
 
+    await Services.logins.initializationPromise;
+    const storageJSON =
+      Services.logins.wrappedJSObject._storage.wrappedJSObject;
+    const dismissedBreachAlertsByLoginGUID = storageJSON.getBreachAlertDismissalsByLoginGUID();
+
     // Determine potentially breached logins by checking their origin and the last time
     // they were changed. It's important to note here that we are NOT considering the
     // username and password of that login.
     for (const login of logins) {
       const loginURI = Services.io.newURI(login.origin);
       for (const breach of breaches) {
         if (!breach.Domain) {
           continue;
         }
+        const breachDate = new Date(breach.BreachDate).getTime();
+        const breachAddedDate = new Date(breach.AddedDate).getTime();
         if (
           Services.eTLD.hasRootDomain(loginURI.host, breach.Domain) &&
           breach.hasOwnProperty("DataClasses") &&
           breach.DataClasses.includes("Passwords") &&
-          login.timePasswordChanged < new Date(breach.BreachDate).getTime()
+          login.timePasswordChanged < breachDate &&
+          (!dismissedBreachAlertsByLoginGUID[login.guid] ||
+            dismissedBreachAlertsByLoginGUID[login.guid]
+              .timeBreachAlertDismissed < breachAddedDate)
         ) {
           let breachAlertURL = new URL(breach.Name, baseBreachAlertURL);
           breach.breachAlertURL = breachAlertURL.href;
           breachesByLoginGUID.set(login.guid, breach);
         }
       }
     }
     return breachesByLoginGUID;
--- a/toolkit/components/passwordmgr/LoginStore.jsm
+++ b/toolkit/components/passwordmgr/LoginStore.jsm
@@ -93,13 +93,17 @@ LoginStore.prototype._dataPostProcessor 
     data.nextId = 1;
   }
 
   // Create any arrays that are not present in the saved file.
   if (!data.logins) {
     data.logins = [];
   }
 
+  if (!data.dismissedBreachAlertsByLoginGUID) {
+    data.dismissedBreachAlertsByLoginGUID = {};
+  }
+
   // Indicate that the current version of the code has touched the file.
   data.version = kDataVersion;
 
   return data;
 };
--- a/toolkit/components/passwordmgr/nsILoginManager.idl
+++ b/toolkit/components/passwordmgr/nsILoginManager.idl
@@ -29,16 +29,17 @@ interface nsILoginManager : nsISupports 
    *
    * Default values for the login's nsILoginMetaInfo properties will be
    * created. However, if the caller specifies non-default values, they will
    * be used instead.
    */
   nsILoginInfo addLogin(in nsILoginInfo aLogin);
 
 
+
   /**
    * Like addLogin, but asynchronous and for many logins.
    *
    * @param aLogins
    *        A JS Array of nsILoginInfos to add.
    * @return A promise which resolves with a JS Array of cloned logins with
    *         the guids set.
    *
--- a/toolkit/components/passwordmgr/storage-json.js
+++ b/toolkit/components/passwordmgr/storage-json.js
@@ -278,16 +278,33 @@ this.LoginManagerStorage_json.prototype 
         this._store.saveSoon();
         break;
       }
     }
 
     LoginHelper.notifyStorageChanged("modifyLogin", [oldStoredLogin, newLogin]);
   },
 
+  async recordBreachAlertDismissal(loginGUID) {
+    this._store.ensureDataReady();
+    const dismissedBreachAlertsByLoginGUID = this._store._data
+      .dismissedBreachAlertsByLoginGUID;
+
+    dismissedBreachAlertsByLoginGUID[loginGUID] = {
+      timeBreachAlertDismissed: new Date().getTime(),
+    };
+
+    return this._store.saveSoon();
+  },
+
+  getBreachAlertDismissalsByLoginGUID() {
+    this._store.ensureDataReady();
+    return this._store._data.dismissedBreachAlertsByLoginGUID;
+  },
+
   /**
    * @return {nsILoginInfo[]}
    */
   getAllLogins() {
     let [logins, ids] = this._searchLogins({});
 
     // decrypt entries for caller.
     logins = this._decryptLogins(logins);