summaryrefslogtreecommitdiffstats
path: root/toolkit/components/passwordmgr/LoginManagerPrompter.sys.mjs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
commit36d22d82aa202bb199967e9512281e9a53db42c9 (patch)
tree105e8c98ddea1c1e4784a60a5a6410fa416be2de /toolkit/components/passwordmgr/LoginManagerPrompter.sys.mjs
parentInitial commit. (diff)
downloadfirefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz
firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/passwordmgr/LoginManagerPrompter.sys.mjs')
-rw-r--r--toolkit/components/passwordmgr/LoginManagerPrompter.sys.mjs1116
1 files changed, 1116 insertions, 0 deletions
diff --git a/toolkit/components/passwordmgr/LoginManagerPrompter.sys.mjs b/toolkit/components/passwordmgr/LoginManagerPrompter.sys.mjs
new file mode 100644
index 0000000000..e1b1a85c75
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginManagerPrompter.sys.mjs
@@ -0,0 +1,1116 @@
+/* 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/. */
+
+import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+/* eslint-disable block-scoped-var, no-var */
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "usernameAutocompleteSearch",
+ "@mozilla.org/autocomplete/search;1?name=login-doorhanger-username",
+ "nsIAutoCompleteSimpleSearch"
+);
+
+XPCOMUtils.defineLazyGetter(lazy, "strBundle", () => {
+ return Services.strings.createBundle(
+ "chrome://passwordmgr/locale/passwordmgr.properties"
+ );
+});
+
+const LoginInfo = Components.Constructor(
+ "@mozilla.org/login-manager/loginInfo;1",
+ "nsILoginInfo",
+ "init"
+);
+
+/**
+ * The maximum age of the password in ms (using `timePasswordChanged`) whereby
+ * a user can toggle the password visibility in a doorhanger to add a username to
+ * a saved login.
+ */
+const VISIBILITY_TOGGLE_MAX_PW_AGE_MS = 2 * 60 * 1000; // 2 minutes
+
+/**
+ * Constants for password prompt telemetry.
+ */
+const PROMPT_DISPLAYED = 0;
+const PROMPT_ADD_OR_UPDATE = 1;
+const PROMPT_NOTNOW_OR_DONTUPDATE = 2;
+const PROMPT_NEVER = 3;
+const PROMPT_DELETE = 3;
+
+/**
+ * The minimum age of a doorhanger in ms before it will get removed after a locationchange
+ */
+const NOTIFICATION_TIMEOUT_MS = 10 * 1000; // 10 seconds
+
+/**
+ * The minimum age of an attention-requiring dismissed doorhanger in ms
+ * before it will get removed after a locationchange
+ */
+const ATTENTION_NOTIFICATION_TIMEOUT_MS = 60 * 1000; // 1 minute
+
+function autocompleteSelected(popup) {
+ let doc = popup.ownerDocument;
+ let nameField = doc.getElementById("password-notification-username");
+ let passwordField = doc.getElementById("password-notification-password");
+
+ let activeElement = nameField.ownerDocument.activeElement;
+ if (activeElement == nameField) {
+ popup.onUsernameSelect();
+ } else if (activeElement == passwordField) {
+ popup.onPasswordSelect();
+ }
+}
+
+const observer = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+
+ // nsIObserver
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "autocomplete-did-enter-text": {
+ let input = subject.QueryInterface(Ci.nsIAutoCompleteInput);
+ autocompleteSelected(input.popupElement);
+ break;
+ }
+ }
+ },
+};
+
+/**
+ * Implements interfaces for prompting the user to enter/save/change login info
+ * found in HTML forms.
+ */
+export class LoginManagerPrompter {
+ get classID() {
+ return Components.ID("{c47ff942-9678-44a5-bc9b-05e0d676c79c}");
+ }
+
+ get QueryInterface() {
+ return ChromeUtils.generateQI(["nsILoginManagerPrompter"]);
+ }
+
+ /**
+ * Called when we detect a password or username that is not yet saved as
+ * an existing login.
+ *
+ * @param {Element} aBrowser
+ * The browser element that the request came from.
+ * @param {nsILoginInfo} aLogin
+ * The new login from the page form.
+ * @param {boolean} [dismissed = false]
+ * If the prompt should be automatically dismissed on being shown.
+ * @param {boolean} [notifySaved = false]
+ * Whether the notification should indicate that a login has been saved
+ * @param {string} [autoSavedLoginGuid = ""]
+ * A guid value for the old login to be removed if the changes match it
+ * to a different login
+ * @param {object?} possibleValues
+ * Contains values from anything that we think, but are not sure, might be
+ * a username or password. Has two properties, 'usernames' and 'passwords'.
+ * @param {Set<String>} possibleValues.usernames
+ * @param {Set<String>} possibleValues.passwords
+ */
+ promptToSavePassword(
+ aBrowser,
+ aLogin,
+ dismissed = false,
+ notifySaved = false,
+ autoFilledLoginGuid = "",
+ possibleValues = undefined
+ ) {
+ lazy.log.debug("Prompting user to save login.");
+ let inPrivateBrowsing = PrivateBrowsingUtils.isBrowserPrivate(aBrowser);
+ let notification = LoginManagerPrompter._showLoginCaptureDoorhanger(
+ aBrowser,
+ aLogin,
+ "password-save",
+ {
+ dismissed: inPrivateBrowsing || dismissed,
+ extraAttr: notifySaved ? "attention" : "",
+ },
+ possibleValues,
+ {
+ notifySaved,
+ autoFilledLoginGuid,
+ }
+ );
+ Services.obs.notifyObservers(aLogin, "passwordmgr-prompt-save");
+
+ return {
+ dismiss() {
+ let { PopupNotifications } = aBrowser.ownerGlobal.wrappedJSObject;
+ PopupNotifications.remove(notification);
+ },
+ };
+ }
+
+ /**
+ * Displays the PopupNotifications.sys.mjs doorhanger for password save or change.
+ *
+ * @param {Element} browser
+ * The browser to show the doorhanger on.
+ * @param {nsILoginInfo} login
+ * Login to save or change. For changes, this login should contain the
+ * new password and/or username
+ * @param {string} type
+ * This is "password-save" or "password-change" depending on the
+ * original notification type. This is used for telemetry and tests.
+ * @param {object} showOptions
+ * Options to pass along to PopupNotifications.show().
+ * @param {bool} [options.notifySaved = false]
+ * Whether to indicate to the user that the login was already saved.
+ * @param {string} [options.messageStringID = undefined]
+ * An optional string ID to override the default message.
+ * @param {string} [options.autoSavedLoginGuid = ""]
+ * A string guid value for the auto-saved login to be removed if the changes
+ * match it to a different login
+ * @param {string} [options.autoFilledLoginGuid = ""]
+ * A string guid value for the autofilled login
+ * @param {object?} possibleValues
+ * Contains values from anything that we think, but are not sure, might be
+ * a username or password. Has two properties, 'usernames' and 'passwords'.
+ * @param {Set<String>} possibleValues.usernames
+ * @param {Set<String>} possibleValues.passwords
+ */
+ static _showLoginCaptureDoorhanger(
+ browser,
+ login,
+ type,
+ showOptions = {},
+ possibleValues = undefined,
+ {
+ notifySaved = false,
+ messageStringID,
+ autoSavedLoginGuid = "",
+ autoFilledLoginGuid = "",
+ } = {}
+ ) {
+ lazy.log.debug(
+ `Got autoSavedLoginGuid: ${autoSavedLoginGuid} and autoFilledLoginGuid ${autoFilledLoginGuid}.`
+ );
+
+ let saveMsgNames = {
+ prompt: login.username === "" ? "saveLoginMsgNoUser2" : "saveLoginMsg2",
+ buttonLabel: "saveLoginButtonAllow.label",
+ buttonAccessKey: "saveLoginButtonAllow.accesskey",
+ secondaryButtonLabel: "saveLoginButtonDeny.label",
+ secondaryButtonAccessKey: "saveLoginButtonDeny.accesskey",
+ };
+
+ let changeMsgNames = {
+ prompt:
+ login.username === "" ? "updateLoginMsgNoUser3" : "updateLoginMsg3",
+ buttonLabel: "updateLoginButtonText",
+ buttonAccessKey: "updateLoginButtonAccessKey",
+ secondaryButtonLabel: "updateLoginButtonDeny.label",
+ secondaryButtonAccessKey: "updateLoginButtonDeny.accesskey",
+ };
+
+ let initialMsgNames =
+ type == "password-save" ? saveMsgNames : changeMsgNames;
+
+ if (messageStringID) {
+ changeMsgNames.prompt = messageStringID;
+ }
+
+ let host = this._getShortDisplayHost(login.origin);
+ let promptMsg =
+ type == "password-save"
+ ? this._getLocalizedString(saveMsgNames.prompt, [host])
+ : this._getLocalizedString(changeMsgNames.prompt, [host]);
+
+ let histogramName =
+ type == "password-save"
+ ? "PWMGR_PROMPT_REMEMBER_ACTION"
+ : "PWMGR_PROMPT_UPDATE_ACTION";
+ let histogram = Services.telemetry.getHistogramById(histogramName);
+
+ let chromeDoc = browser.ownerDocument;
+ let currentNotification;
+
+ let wasModifiedEvent = {
+ // Values are mutated
+ did_edit_un: "false",
+ did_select_un: "false",
+ did_edit_pw: "false",
+ did_select_pw: "false",
+ };
+
+ let updateButtonStatus = element => {
+ let mainActionButton = element.button;
+ // Disable the main button inside the menu-button if the password field is empty.
+ if (!login.password.length) {
+ mainActionButton.setAttribute("disabled", true);
+ chromeDoc
+ .getElementById("password-notification-password")
+ .classList.add("popup-notification-invalid-input");
+ } else {
+ mainActionButton.removeAttribute("disabled");
+ chromeDoc
+ .getElementById("password-notification-password")
+ .classList.remove("popup-notification-invalid-input");
+ }
+ };
+
+ let updateButtonLabel = () => {
+ if (!currentNotification) {
+ console.error("updateButtonLabel, no currentNotification");
+ }
+ let foundLogins = lazy.LoginHelper.searchLoginsWithObject({
+ formActionOrigin: login.formActionOrigin,
+ origin: login.origin,
+ httpRealm: login.httpRealm,
+ schemeUpgrades: lazy.LoginHelper.schemeUpgrades,
+ });
+
+ let logins = this._filterUpdatableLogins(
+ login,
+ foundLogins,
+ autoSavedLoginGuid
+ );
+ let msgNames = !logins.length ? saveMsgNames : changeMsgNames;
+
+ // Update the label based on whether this will be a new login or not.
+ let label = this._getLocalizedString(msgNames.buttonLabel);
+ let accessKey = this._getLocalizedString(msgNames.buttonAccessKey);
+
+ // Update the labels for the next time the panel is opened.
+ currentNotification.mainAction.label = label;
+ currentNotification.mainAction.accessKey = accessKey;
+
+ // Update the labels in real time if the notification is displayed.
+ let element = [...currentNotification.owner.panel.childNodes].find(
+ n => n.notification == currentNotification
+ );
+ if (element) {
+ element.setAttribute("buttonlabel", label);
+ element.setAttribute("buttonaccesskey", accessKey);
+ updateButtonStatus(element);
+ }
+ };
+
+ let writeDataToUI = () => {
+ let nameField = chromeDoc.getElementById(
+ "password-notification-username"
+ );
+
+ nameField.placeholder = usernamePlaceholder;
+ nameField.value = login.username;
+
+ let toggleCheckbox = chromeDoc.getElementById(
+ "password-notification-visibilityToggle"
+ );
+ toggleCheckbox.removeAttribute("checked");
+ let passwordField = chromeDoc.getElementById(
+ "password-notification-password"
+ );
+ // Ensure the type is reset so the field is masked.
+ passwordField.type = "password";
+ passwordField.value = login.password;
+
+ updateButtonLabel();
+ };
+
+ let readDataFromUI = () => {
+ login.username = chromeDoc.getElementById(
+ "password-notification-username"
+ ).value;
+ login.password = chromeDoc.getElementById(
+ "password-notification-password"
+ ).value;
+ };
+
+ let onInput = () => {
+ readDataFromUI();
+ updateButtonLabel();
+ };
+
+ let onUsernameInput = () => {
+ wasModifiedEvent.did_edit_un = "true";
+ wasModifiedEvent.did_select_un = "false";
+ onInput();
+ };
+
+ let onUsernameSelect = () => {
+ wasModifiedEvent.did_edit_un = "false";
+ wasModifiedEvent.did_select_un = "true";
+ };
+
+ let onPasswordInput = () => {
+ wasModifiedEvent.did_edit_pw = "true";
+ wasModifiedEvent.did_select_pw = "false";
+ onInput();
+ };
+
+ let onPasswordSelect = () => {
+ wasModifiedEvent.did_edit_pw = "false";
+ wasModifiedEvent.did_select_pw = "true";
+ };
+
+ let onKeyUp = e => {
+ if (e.key == "Enter") {
+ e.target.closest("popupnotification").button.doCommand();
+ }
+ };
+
+ let onVisibilityToggle = commandEvent => {
+ let passwordField = chromeDoc.getElementById(
+ "password-notification-password"
+ );
+ // Gets the caret position before changing the type of the textbox
+ let selectionStart = passwordField.selectionStart;
+ let selectionEnd = passwordField.selectionEnd;
+ passwordField.setAttribute(
+ "type",
+ commandEvent.target.checked ? "" : "password"
+ );
+ if (!passwordField.hasAttribute("focused")) {
+ return;
+ }
+ passwordField.selectionStart = selectionStart;
+ passwordField.selectionEnd = selectionEnd;
+ };
+
+ let togglePopup = event => {
+ event.target.parentElement
+ .getElementsByClassName("ac-has-end-icon")[0]
+ .toggleHistoryPopup();
+ };
+
+ let persistData = () => {
+ let foundLogins = lazy.LoginHelper.searchLoginsWithObject({
+ formActionOrigin: login.formActionOrigin,
+ origin: login.origin,
+ httpRealm: login.httpRealm,
+ schemeUpgrades: lazy.LoginHelper.schemeUpgrades,
+ });
+
+ let logins = this._filterUpdatableLogins(
+ login,
+ foundLogins,
+ autoSavedLoginGuid
+ );
+ let resolveBy = ["scheme", "timePasswordChanged"];
+ logins = lazy.LoginHelper.dedupeLogins(
+ logins,
+ ["username"],
+ resolveBy,
+ login.origin
+ );
+ // sort exact username matches to the top
+ logins.sort(l => (l.username == login.username ? -1 : 1));
+
+ lazy.log.debug(`Matched ${logins.length} logins.`);
+
+ let loginToRemove;
+ let loginToUpdate = logins.shift();
+
+ if (logins.length && logins[0].guid == autoSavedLoginGuid) {
+ loginToRemove = logins.shift();
+ }
+ if (logins.length) {
+ lazy.log.warn(
+ "persistData:",
+ logins.length,
+ "other updatable logins!",
+ logins.map(l => l.guid),
+ "loginToUpdate:",
+ loginToUpdate && loginToUpdate.guid,
+ "loginToRemove:",
+ loginToRemove && loginToRemove.guid
+ );
+ // Proceed with updating the login with the best username match rather
+ // than returning and losing the edit.
+ }
+
+ if (!loginToUpdate) {
+ // Create a new login, don't update an original.
+ // The original login we have been provided with might have its own
+ // metadata, but we don't want it propagated to the newly created one.
+ Services.logins.addLogin(
+ new LoginInfo(
+ login.origin,
+ login.formActionOrigin,
+ login.httpRealm,
+ login.username,
+ login.password,
+ login.usernameField,
+ login.passwordField
+ )
+ );
+ } else if (
+ loginToUpdate.password == login.password &&
+ loginToUpdate.username == login.username
+ ) {
+ // We only want to touch the login's use count and last used time.
+ lazy.log.debug(`Touch matched login: ${loginToUpdate.guid}.`);
+ Services.logins.recordPasswordUse(
+ loginToUpdate,
+ PrivateBrowsingUtils.isBrowserPrivate(browser),
+ loginToUpdate.username ? "form_password" : "form_login",
+ !!autoFilledLoginGuid
+ );
+ } else {
+ lazy.log.debug(`Update matched login: ${loginToUpdate.guid}.`);
+ this._updateLogin(loginToUpdate, login);
+ // notify that this auto-saved login has been merged
+ if (loginToRemove && loginToRemove.guid == autoSavedLoginGuid) {
+ Services.obs.notifyObservers(
+ loginToRemove,
+ "passwordmgr-autosaved-login-merged"
+ );
+ }
+ }
+
+ if (loginToRemove) {
+ lazy.log.debug(`Removing login ${loginToRemove.guid}.`);
+ Services.logins.removeLogin(loginToRemove);
+ }
+ };
+
+ // The main action is the "Save" or "Update" button.
+ let mainAction = {
+ label: this._getLocalizedString(initialMsgNames.buttonLabel),
+ accessKey: this._getLocalizedString(initialMsgNames.buttonAccessKey),
+ callback: () => {
+ readDataFromUI();
+ if (
+ type == "password-save" &&
+ !Services.policies.isAllowed("removeMasterPassword")
+ ) {
+ if (!lazy.LoginHelper.isPrimaryPasswordSet()) {
+ browser.ownerGlobal.openDialog(
+ "chrome://mozapps/content/preferences/changemp.xhtml",
+ "",
+ "centerscreen,chrome,modal,titlebar"
+ );
+ if (!lazy.LoginHelper.isPrimaryPasswordSet()) {
+ return;
+ }
+ }
+ }
+ histogram.add(PROMPT_ADD_OR_UPDATE);
+ if (histogramName == "PWMGR_PROMPT_REMEMBER_ACTION") {
+ Services.obs.notifyObservers(browser, "LoginStats:NewSavedPassword");
+ } else if (histogramName == "PWMGR_PROMPT_UPDATE_ACTION") {
+ Services.obs.notifyObservers(browser, "LoginStats:LoginUpdateSaved");
+ } else {
+ throw new Error("Unknown histogram");
+ }
+
+ let eventObject;
+ if (type == "password-change") {
+ eventObject = "update";
+ } else if (type == "password-save") {
+ eventObject = "save";
+ } else {
+ throw new Error(
+ `Unexpected doorhanger type. Expected either 'password-save' or 'password-change', got ${type}`
+ );
+ }
+
+ Services.telemetry.recordEvent(
+ "pwmgr",
+ "doorhanger_submitted",
+ eventObject,
+ null,
+ wasModifiedEvent
+ );
+
+ persistData();
+ Services.obs.notifyObservers(
+ null,
+ "weave:telemetry:histogram",
+ histogramName
+ );
+ browser.focus();
+ },
+ };
+
+ let secondaryActions = [
+ {
+ label: this._getLocalizedString(initialMsgNames.secondaryButtonLabel),
+ accessKey: this._getLocalizedString(
+ initialMsgNames.secondaryButtonAccessKey
+ ),
+ callback: () => {
+ histogram.add(PROMPT_NOTNOW_OR_DONTUPDATE);
+ Services.obs.notifyObservers(
+ null,
+ "weave:telemetry:histogram",
+ histogramName
+ );
+ browser.focus();
+ },
+ },
+ ];
+ // Include a "Never for this site" button when saving a new password.
+ if (type == "password-save") {
+ secondaryActions.push({
+ label: this._getLocalizedString("saveLoginButtonNever.label"),
+ accessKey: this._getLocalizedString("saveLoginButtonNever.accesskey"),
+ callback: () => {
+ histogram.add(PROMPT_NEVER);
+ Services.obs.notifyObservers(
+ null,
+ "weave:telemetry:histogram",
+ histogramName
+ );
+ Services.logins.setLoginSavingEnabled(login.origin, false);
+ browser.focus();
+ },
+ });
+ }
+
+ // Include a "Delete this login" button when updating an existing password
+ if (type == "password-change") {
+ secondaryActions.push({
+ label: this._getLocalizedString("updateLoginButtonDelete.label"),
+ accessKey: this._getLocalizedString(
+ "updateLoginButtonDelete.accesskey"
+ ),
+ callback: async () => {
+ histogram.add(PROMPT_DELETE);
+ Services.obs.notifyObservers(
+ null,
+ "weave:telemetry:histogram",
+ histogramName
+ );
+ const matchingLogins = await Services.logins.searchLoginsAsync({
+ guid: login.guid,
+ origin: login.origin,
+ });
+ Services.logins.removeLogin(matchingLogins[0]);
+ browser.focus();
+ // The "password-notification-icon" and "notification-icon-box" are hidden
+ // at this point, so approximate the location with the next closest,
+ // visible icon as the anchor.
+ const anchor = browser.ownerDocument.getElementById("identity-icon");
+ lazy.log.debug("Showing the ConfirmationHint");
+ anchor.ownerGlobal.ConfirmationHint.show(
+ anchor,
+ "confirmation-hint-login-removed"
+ );
+ },
+ });
+ }
+
+ let usernamePlaceholder = this._getLocalizedString("noUsernamePlaceholder");
+ let togglePasswordLabel = this._getLocalizedString("togglePasswordLabel");
+ let togglePasswordAccessKey = this._getLocalizedString(
+ "togglePasswordAccessKey2"
+ );
+
+ // .wrappedJSObject needed here -- see bug 422974 comment 5.
+ let { PopupNotifications } = browser.ownerGlobal.wrappedJSObject;
+
+ let notificationID = "password";
+ // keep attention notifications around for longer after a locationchange
+ const timeoutMs =
+ showOptions.dismissed && showOptions.extraAttr == "attention"
+ ? ATTENTION_NOTIFICATION_TIMEOUT_MS
+ : NOTIFICATION_TIMEOUT_MS;
+
+ let options = Object.assign(
+ {
+ timeout: Date.now() + timeoutMs,
+ persistWhileVisible: true,
+ passwordNotificationType: type,
+ hideClose: true,
+ eventCallback(topic) {
+ switch (topic) {
+ case "showing":
+ lazy.log.debug("showing");
+ currentNotification = this;
+
+ // Record the first time this instance of the doorhanger is shown.
+ if (!this.timeShown) {
+ histogram.add(PROMPT_DISPLAYED);
+ Services.obs.notifyObservers(
+ null,
+ "weave:telemetry:histogram",
+ histogramName
+ );
+ }
+
+ chromeDoc
+ .getElementById("password-notification-password")
+ .removeAttribute("focused");
+ chromeDoc
+ .getElementById("password-notification-username")
+ .removeAttribute("focused");
+ chromeDoc
+ .getElementById("password-notification-username")
+ .addEventListener("input", onUsernameInput);
+ chromeDoc
+ .getElementById("password-notification-username")
+ .addEventListener("keyup", onKeyUp);
+ chromeDoc
+ .getElementById("password-notification-password")
+ .addEventListener("keyup", onKeyUp);
+ chromeDoc
+ .getElementById("password-notification-password")
+ .addEventListener("input", onPasswordInput);
+ chromeDoc
+ .getElementById("password-notification-username-dropmarker")
+ .addEventListener("click", togglePopup);
+
+ LoginManagerPrompter._getUsernameSuggestions(
+ login,
+ possibleValues?.usernames
+ ).then(usernameSuggestions => {
+ let dropmarker = chromeDoc?.getElementById(
+ "password-notification-username-dropmarker"
+ );
+ if (dropmarker) {
+ dropmarker.hidden = !usernameSuggestions.length;
+ }
+
+ let usernameField = chromeDoc?.getElementById(
+ "password-notification-username"
+ );
+ if (usernameField) {
+ usernameField.classList.toggle(
+ "ac-has-end-icon",
+ !!usernameSuggestions.length
+ );
+ }
+ });
+
+ let toggleBtn = chromeDoc.getElementById(
+ "password-notification-visibilityToggle"
+ );
+
+ if (
+ Services.prefs.getBoolPref(
+ "signon.rememberSignons.visibilityToggle"
+ )
+ ) {
+ toggleBtn.addEventListener("command", onVisibilityToggle);
+ toggleBtn.setAttribute("label", togglePasswordLabel);
+ toggleBtn.setAttribute("accesskey", togglePasswordAccessKey);
+
+ let hideToggle =
+ lazy.LoginHelper.isPrimaryPasswordSet() ||
+ // Don't show the toggle when the login was autofilled
+ !!autoFilledLoginGuid ||
+ // Dismissed-by-default prompts should still show the toggle.
+ (this.timeShown && this.wasDismissed) ||
+ // If we are only adding a username then the password is
+ // one that is already saved and we don't want to reveal
+ // it as the submitter of this form may not be the account
+ // owner, they may just be using the saved password.
+ (messageStringID == "updateLoginMsgAddUsername2" &&
+ login.timePasswordChanged <
+ Date.now() - VISIBILITY_TOGGLE_MAX_PW_AGE_MS);
+ toggleBtn.hidden = hideToggle;
+ }
+
+ let popup = chromeDoc.getElementById("PopupAutoComplete");
+ popup.onUsernameSelect = onUsernameSelect;
+ popup.onPasswordSelect = onPasswordSelect;
+
+ LoginManagerPrompter._setUsernameAutocomplete(
+ login,
+ possibleValues?.usernames
+ );
+
+ break;
+ case "shown": {
+ lazy.log.debug("shown");
+ writeDataToUI();
+ let anchorIcon = this.anchorElement;
+ if (anchorIcon && this.options.extraAttr == "attention") {
+ anchorIcon.removeAttribute("extraAttr");
+ delete this.options.extraAttr;
+ }
+ break;
+ }
+ case "dismissed":
+ // Note that this can run after `showing` but before `shown` upon tab switch.
+ this.wasDismissed = true;
+ // Fall through.
+ case "removed": {
+ // Note that this can run after `showing` and `shown` for the
+ // notification it's replacing.
+ lazy.log.debug(topic);
+ currentNotification = null;
+
+ let usernameField = chromeDoc.getElementById(
+ "password-notification-username"
+ );
+ usernameField.removeEventListener("input", onUsernameInput);
+ usernameField.removeEventListener("keyup", onKeyUp);
+ let passwordField = chromeDoc.getElementById(
+ "password-notification-password"
+ );
+ passwordField.removeEventListener("input", onPasswordInput);
+ passwordField.removeEventListener("keyup", onKeyUp);
+ passwordField.removeEventListener("command", onVisibilityToggle);
+ chromeDoc
+ .getElementById("password-notification-username-dropmarker")
+ .removeEventListener("click", togglePopup);
+ break;
+ }
+ }
+ return false;
+ },
+ },
+ showOptions
+ );
+
+ let notification = PopupNotifications.show(
+ browser,
+ notificationID,
+ promptMsg,
+ "password-notification-icon",
+ mainAction,
+ secondaryActions,
+ options
+ );
+
+ if (notifySaved) {
+ let anchor = notification.anchorElement;
+ lazy.log.debug("Showing the ConfirmationHint.");
+ anchor.ownerGlobal.ConfirmationHint.show(
+ anchor,
+ "confirmation-hint-password-saved"
+ );
+ }
+
+ return notification;
+ }
+
+ /**
+ * Called when we think we detect a password or username change for
+ * an existing login, when the form being submitted contains multiple
+ * password fields.
+ *
+ * @param {Element} aBrowser
+ * The browser element that the request came from.
+ * @param {nsILoginInfo} aOldLogin
+ * The old login we may want to update.
+ * @param {nsILoginInfo} aNewLogin
+ * The new login from the page form.
+ * @param {boolean} [dismissed = false]
+ * If the prompt should be automatically dismissed on being shown.
+ * @param {boolean} [notifySaved = false]
+ * Whether the notification should indicate that a login has been saved
+ * @param {string} [autoSavedLoginGuid = ""]
+ * A guid value for the old login to be removed if the changes match it
+ * to a different login
+ * @param {object?} possibleValues
+ * Contains values from anything that we think, but are not sure, might be
+ * a username or password. Has two properties, 'usernames' and 'passwords'.
+ * @param {Set<String>} possibleValues.usernames
+ * @param {Set<String>} possibleValues.passwords
+ */
+ promptToChangePassword(
+ aBrowser,
+ aOldLogin,
+ aNewLogin,
+ dismissed = false,
+ notifySaved = false,
+ autoSavedLoginGuid = "",
+ autoFilledLoginGuid = "",
+ possibleValues = undefined
+ ) {
+ let login = aOldLogin.clone();
+ login.origin = aNewLogin.origin;
+ login.formActionOrigin = aNewLogin.formActionOrigin;
+ login.password = aNewLogin.password;
+ login.username = aNewLogin.username;
+
+ let messageStringID;
+ if (
+ aOldLogin.username === "" &&
+ login.username !== "" &&
+ login.password == aOldLogin.password
+ ) {
+ // If the saved password matches the password we're prompting with then we
+ // are only prompting to let the user add a username since there was one in
+ // the form. Change the message so the purpose of the prompt is clearer.
+ messageStringID = "updateLoginMsgAddUsername2";
+ }
+
+ let notification = LoginManagerPrompter._showLoginCaptureDoorhanger(
+ aBrowser,
+ login,
+ "password-change",
+ {
+ dismissed,
+ extraAttr: notifySaved ? "attention" : "",
+ },
+ possibleValues,
+ {
+ notifySaved,
+ messageStringID,
+ autoSavedLoginGuid,
+ autoFilledLoginGuid,
+ }
+ );
+
+ let oldGUID = aOldLogin.QueryInterface(Ci.nsILoginMetaInfo).guid;
+ Services.obs.notifyObservers(
+ aNewLogin,
+ "passwordmgr-prompt-change",
+ oldGUID
+ );
+
+ return {
+ dismiss() {
+ let { PopupNotifications } = aBrowser.ownerGlobal.wrappedJSObject;
+ PopupNotifications.remove(notification);
+ },
+ };
+ }
+
+ /**
+ * Called when we detect a password change in a form submission, but we
+ * don't know which existing login (username) it's for. Asks the user
+ * to select a username and confirm the password change.
+ *
+ * Note: The caller doesn't know the username for aNewLogin, so this
+ * function fills in .username and .usernameField with the values
+ * from the login selected by the user.
+ */
+ promptToChangePasswordWithUsernames(browser, logins, aNewLogin) {
+ lazy.log.debug(
+ `Prompting user to change passowrd for username with count: ${logins.length}.`
+ );
+
+ var usernames = logins.map(
+ l => l.username || LoginManagerPrompter._getLocalizedString("noUsername")
+ );
+ var dialogText =
+ LoginManagerPrompter._getLocalizedString("userSelectText2");
+ var dialogTitle = LoginManagerPrompter._getLocalizedString(
+ "passwordChangeTitle"
+ );
+ var selectedIndex = { value: null };
+
+ // If user selects ok, outparam.value is set to the index
+ // of the selected username.
+ var ok = Services.prompt.select(
+ browser.ownerGlobal,
+ dialogTitle,
+ dialogText,
+ usernames,
+ selectedIndex
+ );
+ if (ok) {
+ // Now that we know which login to use, modify its password.
+ var selectedLogin = logins[selectedIndex.value];
+ lazy.log.debug(`Updating password for origin: ${aNewLogin.origin}.`);
+ var newLoginWithUsername = Cc[
+ "@mozilla.org/login-manager/loginInfo;1"
+ ].createInstance(Ci.nsILoginInfo);
+ newLoginWithUsername.init(
+ aNewLogin.origin,
+ aNewLogin.formActionOrigin,
+ aNewLogin.httpRealm,
+ selectedLogin.username,
+ aNewLogin.password,
+ selectedLogin.usernameField,
+ aNewLogin.passwordField
+ );
+ LoginManagerPrompter._updateLogin(selectedLogin, newLoginWithUsername);
+ }
+ }
+
+ /* ---------- Internal Methods ---------- */
+
+ /**
+ * Helper method to update and persist an existing nsILoginInfo object with new property values.
+ */
+ static _updateLogin(login, aNewLogin) {
+ var now = Date.now();
+ var propBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
+ Ci.nsIWritablePropertyBag
+ );
+ propBag.setProperty("formActionOrigin", aNewLogin.formActionOrigin);
+ propBag.setProperty("origin", aNewLogin.origin);
+ propBag.setProperty("password", aNewLogin.password);
+ propBag.setProperty("username", aNewLogin.username);
+ // Explicitly set the password change time here (even though it would
+ // be changed automatically), to ensure that it's exactly the same
+ // value as timeLastUsed.
+ propBag.setProperty("timePasswordChanged", now);
+ propBag.setProperty("timeLastUsed", now);
+ propBag.setProperty("timesUsedIncrement", 1);
+ // Note that we don't call `recordPasswordUse` so telemetry won't record a
+ // use in this case though that is normally correct since we would instead
+ // record the save/update in a separate probe and recording it in both would
+ // be wrong.
+ Services.logins.modifyLogin(login, propBag);
+ }
+
+ /**
+ * Can be called as:
+ * _getLocalizedString("key1");
+ * _getLocalizedString("key2", ["arg1"]);
+ * _getLocalizedString("key3", ["arg1", "arg2"]);
+ * (etc)
+ *
+ * Returns the localized string for the specified key,
+ * formatted if required.
+ *
+ */
+ static _getLocalizedString(key, formatArgs) {
+ if (formatArgs) {
+ return lazy.strBundle.formatStringFromName(key, formatArgs);
+ }
+ return lazy.strBundle.GetStringFromName(key);
+ }
+
+ /**
+ * Converts a login's origin field to a short string for
+ * prompting purposes. Eg, "http://foo.com" --> "foo.com", or
+ * "ftp://www.site.co.uk" --> "site.co.uk".
+ */
+ static _getShortDisplayHost(aURIString) {
+ var displayHost;
+
+ var idnService = Cc["@mozilla.org/network/idn-service;1"].getService(
+ Ci.nsIIDNService
+ );
+ try {
+ var uri = Services.io.newURI(aURIString);
+ var baseDomain = Services.eTLD.getBaseDomain(uri);
+ displayHost = idnService.convertToDisplayIDN(baseDomain, {});
+ } catch (e) {
+ lazy.log.warn(`Couldn't process supplied URIString: ${aURIString}`);
+ }
+
+ if (!displayHost) {
+ displayHost = aURIString;
+ }
+
+ return displayHost;
+ }
+
+ /**
+ * This function looks for existing logins that can be updated
+ * to match a submitted login, instead of creating a new one.
+ *
+ * Given a login and a loginList, it filters the login list
+ * to find every login with either:
+ * - the same username as aLogin
+ * - the same password as aLogin and an empty username
+ * so the user can add a username.
+ * - the same guid as the given login when it has an empty username
+ *
+ * @param {nsILoginInfo} aLogin
+ * login to use as filter.
+ * @param {nsILoginInfo[]} aLoginList
+ * Array of logins to filter.
+ * @param {String} includeGUID
+ * guid value for login that not be filtered out
+ * @returns {nsILoginInfo[]} the filtered array of logins.
+ */
+ static _filterUpdatableLogins(aLogin, aLoginList, includeGUID) {
+ return aLoginList.filter(
+ l =>
+ l.username == aLogin.username ||
+ (l.password == aLogin.password && !l.username) ||
+ (includeGUID && includeGUID == l.guid)
+ );
+ }
+
+ /**
+ * Set the values that will be used the next time the username autocomplete popup is opened.
+ *
+ * @param {nsILoginInfo} login - used only for its information about the current domain.
+ * @param {Set<String>?} possibleUsernames - values that we believe may be new/changed login usernames.
+ */
+ static async _setUsernameAutocomplete(login, possibleUsernames = new Set()) {
+ let result = Cc["@mozilla.org/autocomplete/simple-result;1"].createInstance(
+ Ci.nsIAutoCompleteSimpleResult
+ );
+ result.setDefaultIndex(0);
+
+ let usernames = await this._getUsernameSuggestions(
+ login,
+ possibleUsernames
+ );
+ for (let { text, style } of usernames) {
+ let value = text;
+ let comment = "";
+ let image = "";
+ let _style = style;
+ result.appendMatch(value, comment, image, _style);
+ }
+
+ if (usernames.length) {
+ result.setSearchResult(Ci.nsIAutoCompleteResult.RESULT_SUCCESS);
+ } else {
+ result.setSearchResult(Ci.nsIAutoCompleteResult.RESULT_NOMATCH);
+ }
+
+ lazy.usernameAutocompleteSearch.overrideNextResult(result);
+ }
+
+ /**
+ * @param {nsILoginInfo} login - used only for its information about the current domain.
+ * @param {Set<String>?} possibleUsernames - values that we believe may be new/changed login usernames.
+ *
+ * @returns {object[]} an ordered list of usernames to be used the next time the username autocomplete popup is opened.
+ */
+ static async _getUsernameSuggestions(login, possibleUsernames = new Set()) {
+ if (!Services.prefs.getBoolPref("signon.capture.inputChanges.enabled")) {
+ return [];
+ }
+
+ // Don't reprompt for Primary Password, as we already prompted at least once
+ // to show the doorhanger if it is locked
+ if (!Services.logins.isLoggedIn) {
+ return [];
+ }
+
+ let baseDomainLogins = await Services.logins.searchLoginsAsync({
+ origin: login.origin,
+ schemeUpgrades: lazy.LoginHelper.schemeUpgrades,
+ acceptDifferentSubdomains: true,
+ });
+
+ let saved = baseDomainLogins.map(login => {
+ return { text: login.username, style: "login" };
+ });
+ let possible = [...possibleUsernames].map(username => {
+ return { text: username, style: "possible-username" };
+ });
+
+ return possible
+ .concat(saved)
+ .reduce((acc, next) => {
+ let alreadyInAcc =
+ acc.findIndex(entry => entry.text == next.text) != -1;
+ if (!alreadyInAcc) {
+ acc.push(next);
+ } else if (next.style == "possible-username") {
+ let existingIndex = acc.findIndex(entry => entry.text == next.text);
+ acc[existingIndex] = next;
+ }
+ return acc;
+ }, [])
+ .filter(suggestion => !!suggestion.text);
+ }
+}
+
+// Add this observer once for the process.
+Services.obs.addObserver(observer, "autocomplete-did-enter-text");
+
+XPCOMUtils.defineLazyGetter(lazy, "log", () => {
+ return lazy.LoginHelper.createLogger("LoginManagerPrompter");
+});