summaryrefslogtreecommitdiffstats
path: root/browser/components/aboutlogins/content/components/login-item.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/aboutlogins/content/components/login-item.mjs')
-rw-r--r--browser/components/aboutlogins/content/components/login-item.mjs952
1 files changed, 952 insertions, 0 deletions
diff --git a/browser/components/aboutlogins/content/components/login-item.mjs b/browser/components/aboutlogins/content/components/login-item.mjs
new file mode 100644
index 0000000000..35bca46163
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-item.mjs
@@ -0,0 +1,952 @@
+/* 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 {
+ CONCEALED_PASSWORD_TEXT,
+ recordTelemetryEvent,
+ promptForPrimaryPassword,
+} from "../aboutLoginsUtils.mjs";
+
+export default class LoginItem extends HTMLElement {
+ /**
+ * The number of milliseconds to display the "Copied" success message
+ * before reverting to the normal "Copy" button.
+ */
+ static get COPY_BUTTON_RESET_TIMEOUT() {
+ return 5000;
+ }
+
+ constructor() {
+ super();
+ this._login = {};
+ this._error = null;
+ this._copyUsernameTimeoutId = 0;
+ this._copyPasswordTimeoutId = 0;
+ }
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ this.render();
+ return;
+ }
+
+ let loginItemTemplate = document.querySelector("#login-item-template");
+ let shadowRoot = this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(shadowRoot);
+ shadowRoot.appendChild(loginItemTemplate.content.cloneNode(true));
+
+ this._cancelButton = this.shadowRoot.querySelector(".cancel-button");
+ this._confirmDeleteDialog = document.querySelector("confirm-delete-dialog");
+ this._copyPasswordButton = this.shadowRoot.querySelector(
+ ".copy-password-button"
+ );
+ this._copyUsernameButton = this.shadowRoot.querySelector(
+ ".copy-username-button"
+ );
+ this._deleteButton = this.shadowRoot.querySelector(".delete-button");
+ this._editButton = this.shadowRoot.querySelector(".edit-button");
+ this._errorMessage = this.shadowRoot.querySelector(".error-message");
+ this._errorMessageLink = this._errorMessage.querySelector(
+ ".error-message-link"
+ );
+ this._errorMessageText = this._errorMessage.querySelector(
+ ".error-message-text"
+ );
+ this._form = this.shadowRoot.querySelector("form");
+ this._originInput = this.shadowRoot.querySelector("input[name='origin']");
+ this._originDisplayInput =
+ this.shadowRoot.querySelector("a[name='origin']");
+ this._usernameInput = this.shadowRoot.querySelector(
+ "input[name='username']"
+ );
+ // type=password field for display which only ever contains spaces the correct
+ // length of the password.
+ this._passwordDisplayInput = this.shadowRoot.querySelector(
+ "input.password-display"
+ );
+ // type=text field for editing the password with the actual password value.
+ this._passwordInput = this.shadowRoot.querySelector(
+ "input[name='password']"
+ );
+ this._revealCheckbox = this.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ this._saveChangesButton = this.shadowRoot.querySelector(
+ ".save-changes-button"
+ );
+ this._favicon = this.shadowRoot.querySelector(".login-item-favicon");
+ this._title = this.shadowRoot.querySelector(".login-item-title");
+ this._breachAlert = this.shadowRoot.querySelector(".breach-alert");
+ this._breachAlertLink = this._breachAlert.querySelector(".alert-link");
+ this._breachAlertDate = this._breachAlert.querySelector(".alert-date");
+ this._vulnerableAlert = this.shadowRoot.querySelector(".vulnerable-alert");
+ this._vulnerableAlertLink =
+ this._vulnerableAlert.querySelector(".alert-link");
+ this._vulnerableAlertLearnMoreLink = this._vulnerableAlert.querySelector(
+ ".alert-learn-more-link"
+ );
+
+ this.render();
+
+ this._cancelButton.addEventListener("click", this);
+ this._copyPasswordButton.addEventListener("click", this);
+ this._copyUsernameButton.addEventListener("click", this);
+ this._deleteButton.addEventListener("click", this);
+ this._editButton.addEventListener("click", this);
+ this._errorMessageLink.addEventListener("click", this);
+ this._form.addEventListener("submit", this);
+ this._originInput.addEventListener("blur", this);
+ this._originInput.addEventListener("click", this);
+ this._originInput.addEventListener("mousedown", this, true);
+ this._originInput.addEventListener("auxclick", this);
+ this._originDisplayInput.addEventListener("click", this);
+ this._revealCheckbox.addEventListener("click", this);
+ this._vulnerableAlertLearnMoreLink.addEventListener("click", this);
+
+ this._passwordInput.addEventListener("focus", this);
+ this._passwordInput.addEventListener("blur", this);
+ this._passwordDisplayInput.addEventListener("focus", this);
+ this._passwordDisplayInput.addEventListener("blur", this);
+
+ window.addEventListener("AboutLoginsInitialLoginSelected", this);
+ window.addEventListener("AboutLoginsLoginSelected", this);
+ window.addEventListener("AboutLoginsShowBlankLogin", this);
+ window.addEventListener("AboutLoginsRemaskPassword", this);
+ }
+
+ focus() {
+ if (!this._editButton.disabled) {
+ this._editButton.focus();
+ } else if (!this._deleteButton.disabled) {
+ this._deleteButton.focus();
+ } else {
+ this._originInput.focus();
+ }
+ }
+
+ async render(
+ { onlyUpdateErrorsAndAlerts } = { onlyUpdateErrorsAndAlerts: false }
+ ) {
+ if (this._error) {
+ if (this._error.errorMessage.includes("This login already exists")) {
+ document.l10n.setAttributes(
+ this._errorMessageLink,
+ "about-logins-error-message-duplicate-login-with-link",
+ {
+ loginTitle: this._error.login.title,
+ }
+ );
+ this._errorMessageLink.dataset.errorGuid =
+ this._error.existingLoginGuid;
+ this._errorMessageText.hidden = true;
+ this._errorMessageLink.hidden = false;
+ } else {
+ this._errorMessageText.hidden = false;
+ this._errorMessageLink.hidden = true;
+ }
+ }
+ this._errorMessage.hidden = !this._error;
+
+ this._breachAlert.hidden =
+ !this._breachesMap || !this._breachesMap.has(this._login.guid);
+ if (!this._breachAlert.hidden) {
+ const breachDetails = this._breachesMap.get(this._login.guid);
+ this._breachAlertLink.href = this._login.origin;
+ document.l10n.setAttributes(
+ this._breachAlertLink,
+ "about-logins-breach-alert-link",
+ { hostname: this._login.displayOrigin }
+ );
+ if (breachDetails.BreachDate) {
+ let breachDate = new Date(breachDetails.BreachDate);
+ this._breachAlertDate.hidden = isNaN(breachDate);
+ if (!isNaN(breachDate)) {
+ document.l10n.setAttributes(
+ this._breachAlertDate,
+ "about-logins-breach-alert-date",
+ {
+ date: breachDate.getTime(),
+ }
+ );
+ }
+ }
+ }
+ this._vulnerableAlert.hidden =
+ !this._vulnerableLoginsMap ||
+ !this._vulnerableLoginsMap.has(this._login.guid) ||
+ !this._breachAlert.hidden;
+ if (!this._vulnerableAlert.hidden) {
+ this._vulnerableAlertLink.href = this._login.origin;
+ document.l10n.setAttributes(
+ this._vulnerableAlertLink,
+ "about-logins-vulnerable-alert-link",
+ {
+ hostname: this._login.displayOrigin,
+ }
+ );
+ this._vulnerableAlertLearnMoreLink.setAttribute(
+ "href",
+ window.AboutLoginsUtils.supportBaseURL + "lockwise-alerts"
+ );
+ }
+ if (onlyUpdateErrorsAndAlerts) {
+ return;
+ }
+
+ this._favicon.src = `page-icon:${this._login.origin}`;
+ this._title.textContent = this._login.title;
+ this._title.title = this._login.title;
+ this._originInput.defaultValue = this._login.origin || "";
+ if (this._login.origin) {
+ // Creates anchor element with origin URL
+ this._originDisplayInput.href = this._login.origin || "";
+ this._originDisplayInput.innerText = this._login.origin || "";
+ }
+ this._usernameInput.defaultValue = this._login.username || "";
+ if (this._login.password) {
+ // We use .value instead of .defaultValue since the latter updates the
+ // content attribute making the password easily viewable with Inspect
+ // Element even when Primary Password is enabled. This is only run when
+ // the password is non-empty since setting the field to an empty value
+ // would mark the field as 'dirty' for form validation and thus trigger
+ // the error styling since the password field is 'required'.
+ // This element is only in the document while unmasked or editing.
+ this._passwordInput.value = this._login.password;
+
+ // In masked non-edit mode we use a different "display" element to render
+ // the masked password so that one cannot simply remove/change
+ // @type=password to reveal the real password.
+ this._passwordDisplayInput.value = CONCEALED_PASSWORD_TEXT;
+ }
+
+ if (this.dataset.editing) {
+ this._usernameInput.removeAttribute("data-l10n-id");
+ this._usernameInput.placeholder = "";
+ } else {
+ document.l10n.setAttributes(
+ this._usernameInput,
+ "about-logins-login-item-username"
+ );
+ }
+ this._copyUsernameButton.disabled = !this._login.username;
+ document.l10n.setAttributes(
+ this._saveChangesButton,
+ this.dataset.isNewLogin
+ ? "login-item-save-new-button"
+ : "login-item-save-changes-button"
+ );
+ this._updatePasswordRevealState();
+ this._updateOriginDisplayState();
+ this.#updateTimeline();
+ }
+
+ #updateTimeline() {
+ let timeline = this.shadowRoot.querySelector("login-timeline");
+ timeline.hidden = !this._login.guid;
+ timeline.history = [
+ {
+ actionId: "login-item-timeline-action-created",
+ time: this._login.timeCreated,
+ },
+ {
+ actionId: "login-item-timeline-action-updated",
+ time: this._login.timePasswordChanged,
+ },
+ {
+ actionId: "login-item-timeline-action-used",
+ time: this._login.timeLastUsed,
+ },
+ ];
+ }
+
+ setBreaches(breachesByLoginGUID) {
+ this._internalSetMonitorData("_breachesMap", breachesByLoginGUID);
+ }
+
+ updateBreaches(breachesByLoginGUID) {
+ this._internalUpdateMonitorData("_breachesMap", breachesByLoginGUID);
+ }
+
+ setVulnerableLogins(vulnerableLoginsByLoginGUID) {
+ this._internalSetMonitorData(
+ "_vulnerableLoginsMap",
+ vulnerableLoginsByLoginGUID
+ );
+ }
+
+ updateVulnerableLogins(vulnerableLoginsByLoginGUID) {
+ this._internalUpdateMonitorData(
+ "_vulnerableLoginsMap",
+ vulnerableLoginsByLoginGUID
+ );
+ }
+
+ _internalSetMonitorData(internalMemberName, mapByLoginGUID) {
+ this[internalMemberName] = mapByLoginGUID;
+ this.render({ onlyUpdateErrorsAndAlerts: true });
+ }
+
+ _internalUpdateMonitorData(internalMemberName, mapByLoginGUID) {
+ if (!this[internalMemberName]) {
+ this[internalMemberName] = new Map();
+ }
+ for (const [guid, data] of [...mapByLoginGUID]) {
+ if (data) {
+ this[internalMemberName].set(guid, data);
+ } else {
+ this[internalMemberName].delete(guid);
+ }
+ }
+ this._internalSetMonitorData(internalMemberName, this[internalMemberName]);
+ }
+
+ showLoginItemError(error) {
+ this._error = error;
+ this.render();
+ }
+
+ async handleEvent(event) {
+ switch (event.type) {
+ case "AboutLoginsInitialLoginSelected": {
+ this.setLogin(event.detail, { skipFocusChange: true });
+ break;
+ }
+ case "AboutLoginsLoginSelected": {
+ this.confirmPendingChangesOnEvent(event, event.detail);
+ break;
+ }
+ case "AboutLoginsShowBlankLogin": {
+ this.confirmPendingChangesOnEvent(event, {});
+ break;
+ }
+ case "auxclick": {
+ if (event.button == 1) {
+ this._handleOriginClick();
+ }
+ break;
+ }
+ case "blur": {
+ // TODO(Bug 1838494): Remove this if block
+ // This is a temporary fix until Bug 1750072 lands
+ const focusCheckboxNext = event.relatedTarget === this._revealCheckbox;
+ if (focusCheckboxNext) {
+ return;
+ }
+
+ if (this.dataset.editing && event.target === this._passwordInput) {
+ this._revealCheckbox.checked = false;
+ this._updatePasswordRevealState();
+ }
+
+ if (event.target === this._passwordDisplayInput) {
+ this._revealCheckbox.checked = !!this.dataset.editing;
+ this._updatePasswordRevealState();
+ }
+
+ // Add https:// prefix if one was not provided.
+ let originValue = this._originInput.value.trim();
+ if (!originValue) {
+ return;
+ }
+
+ if (!originValue.match(/:\/\//)) {
+ this._originInput.value = "https://" + originValue;
+ }
+
+ break;
+ }
+ case "focus": {
+ // TODO(Bug 1838494): Remove this if block
+ // This is a temporary fix until Bug 1750072 lands
+ const focusFromCheckbox = event.relatedTarget === this._revealCheckbox;
+ const isEditingMode = this.dataset.editing || this.dataset.isNewLogin;
+ if (focusFromCheckbox && isEditingMode) {
+ this._passwordInput.type = this._revealCheckbox.checked
+ ? "text"
+ : "password";
+ return;
+ }
+
+ if (event.target === this._passwordDisplayInput) {
+ this._revealCheckbox.checked = !!this.dataset.editing;
+ this._updatePasswordRevealState();
+ }
+
+ break;
+ }
+ case "click": {
+ let classList = event.currentTarget.classList;
+ if (classList.contains("reveal-password-checkbox")) {
+ // TODO(Bug 1838494): Remove this if block
+ // This is a temporary fix until Bug 1750072 lands
+ if (this.dataset.editing || this.dataset.isNewLogin) {
+ this._passwordDisplayInput.replaceWith(this._passwordInput);
+ this._passwordInput.type = "text";
+ this._passwordInput.focus();
+ return;
+ }
+ // We prompt for the primary password when entering edit mode already.
+ if (this._revealCheckbox.checked && !this.dataset.editing) {
+ let primaryPasswordAuth = await promptForPrimaryPassword(
+ "about-logins-reveal-password-os-auth-dialog-message"
+ );
+ if (!primaryPasswordAuth) {
+ this._revealCheckbox.checked = false;
+ return;
+ }
+ }
+ this._updatePasswordRevealState();
+
+ let method = this._revealCheckbox.checked ? "show" : "hide";
+ this._recordTelemetryEvent({ object: "password", method });
+ return;
+ }
+
+ if (classList.contains("cancel-button")) {
+ let wasExistingLogin = !!this._login.guid;
+ if (wasExistingLogin) {
+ if (this.hasPendingChanges()) {
+ this.showConfirmationDialog("discard-changes", () => {
+ this.setLogin(this._login);
+ });
+ } else {
+ this.setLogin(this._login);
+ }
+ } else if (!this.hasPendingChanges()) {
+ window.dispatchEvent(new CustomEvent("AboutLoginsClearSelection"));
+ this._recordTelemetryEvent({
+ object: "new_login",
+ method: "cancel",
+ });
+
+ this.setLogin(this._login, { skipFocusChange: true });
+ this._toggleEditing(false);
+ this.render();
+ } else {
+ this.showConfirmationDialog("discard-changes", () => {
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsClearSelection")
+ );
+
+ this.setLogin({}, { skipFocusChange: true });
+ this._toggleEditing(false);
+ this.render();
+ });
+ }
+
+ return;
+ }
+ if (
+ classList.contains("copy-password-button") ||
+ classList.contains("copy-username-button")
+ ) {
+ let copyButton = event.currentTarget;
+ let otherCopyButton =
+ copyButton == this._copyPasswordButton
+ ? this._copyUsernameButton
+ : this._copyPasswordButton;
+ if (copyButton.dataset.copyLoginProperty == "password") {
+ let primaryPasswordAuth = await promptForPrimaryPassword(
+ "about-logins-copy-password-os-auth-dialog-message"
+ );
+ if (!primaryPasswordAuth) {
+ return;
+ }
+ }
+
+ copyButton.disabled = true;
+ copyButton.dataset.copied = true;
+ let propertyToCopy =
+ this._login[copyButton.dataset.copyLoginProperty];
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsCopyLoginDetail", {
+ bubbles: true,
+ detail: propertyToCopy,
+ })
+ );
+ // If there is no username, this must be triggered by the password button,
+ // don't enable otherCopyButton (username copy button) in this case.
+ if (this._login.username) {
+ otherCopyButton.disabled = false;
+ delete otherCopyButton.dataset.copied;
+ }
+ clearTimeout(this._copyUsernameTimeoutId);
+ clearTimeout(this._copyPasswordTimeoutId);
+ let timeoutId = setTimeout(() => {
+ copyButton.disabled = false;
+ delete copyButton.dataset.copied;
+ }, LoginItem.COPY_BUTTON_RESET_TIMEOUT);
+ if (copyButton.dataset.copyLoginProperty == "password") {
+ this._copyPasswordTimeoutId = timeoutId;
+ } else {
+ this._copyUsernameTimeoutId = timeoutId;
+ }
+
+ this._recordTelemetryEvent({
+ object: copyButton.dataset.telemetryObject,
+ method: "copy",
+ });
+ return;
+ }
+ if (classList.contains("delete-button")) {
+ this.showConfirmationDialog("delete", () => {
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsDeleteLogin", {
+ bubbles: true,
+ detail: this._login,
+ })
+ );
+ });
+ return;
+ }
+ if (classList.contains("edit-button")) {
+ let primaryPasswordAuth = await promptForPrimaryPassword(
+ "about-logins-edit-login-os-auth-dialog-message"
+ );
+ if (!primaryPasswordAuth) {
+ return;
+ }
+
+ this._toggleEditing();
+ this.render();
+
+ this._recordTelemetryEvent({
+ object: "existing_login",
+ method: "edit",
+ });
+ return;
+ }
+ if (
+ event.target.dataset.l10nName == "duplicate-link" &&
+ event.currentTarget.dataset.errorGuid
+ ) {
+ let existingDuplicateLogin = {
+ guid: event.currentTarget.dataset.errorGuid,
+ };
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsLoginSelected", {
+ detail: existingDuplicateLogin,
+ cancelable: true,
+ })
+ );
+ return;
+ }
+ if (classList.contains("origin-input")) {
+ this._handleOriginClick();
+ }
+ if (classList.contains("alert-learn-more-link")) {
+ if (event.currentTarget.closest(".vulnerable-alert")) {
+ this._recordTelemetryEvent({
+ object: "existing_login",
+ method: "learn_more_vuln",
+ });
+ }
+ }
+ break;
+ }
+ case "submit": {
+ // Prevent page navigation form submit behavior.
+ event.preventDefault();
+ if (!this._isFormValid({ reportErrors: true })) {
+ return;
+ }
+ if (!this.hasPendingChanges()) {
+ this._toggleEditing(false);
+ this.render();
+ return;
+ }
+ let loginUpdates = this._loginFromForm();
+ if (this._login.guid) {
+ loginUpdates.guid = this._login.guid;
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsUpdateLogin", {
+ bubbles: true,
+ detail: loginUpdates,
+ })
+ );
+
+ this._recordTelemetryEvent({
+ object: "existing_login",
+ method: "save",
+ });
+ } else {
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsCreateLogin", {
+ bubbles: true,
+ detail: loginUpdates,
+ })
+ );
+
+ this._recordTelemetryEvent({ object: "new_login", method: "save" });
+ }
+ break;
+ }
+ case "mousedown": {
+ // No AutoScroll when middle clicking on origin input.
+ if (event.currentTarget == this._originInput && event.button == 1) {
+ event.preventDefault();
+ }
+ break;
+ }
+ case "AboutLoginsRemaskPassword": {
+ if (this._revealCheckbox.checked && !this.dataset.editing) {
+ this._revealCheckbox.checked = false;
+ }
+ this._updatePasswordRevealState();
+ let method = this._revealCheckbox.checked ? "show" : "hide";
+ this._recordTelemetryEvent({ object: "password", method });
+ break;
+ }
+ }
+ }
+
+ /**
+ * Helper to show the "Discard changes" confirmation dialog and delay the
+ * received event after confirmation.
+ * @param {object} event The event to be delayed.
+ * @param {object} login The login to be shown on confirmation.
+ */
+ confirmPendingChangesOnEvent(event, login) {
+ if (this.hasPendingChanges()) {
+ event.preventDefault();
+ this.showConfirmationDialog("discard-changes", () => {
+ // Clear any pending changes
+ this.setLogin(login);
+
+ window.dispatchEvent(
+ new CustomEvent(event.type, {
+ detail: login,
+ cancelable: false,
+ })
+ );
+ });
+ } else {
+ this.setLogin(login, { skipFocusChange: true });
+ }
+ }
+
+ /**
+ * Shows a confirmation dialog.
+ * @param {string} type The type of confirmation dialog to display.
+ * @param {boolean} onConfirm Optional, the function to execute when the confirm button is clicked.
+ */
+ showConfirmationDialog(type, onConfirm = () => {}) {
+ const dialog = document.querySelector("confirmation-dialog");
+ let options;
+ switch (type) {
+ case "delete": {
+ options = {
+ title: "about-logins-confirm-remove-dialog-title",
+ message: "confirm-delete-dialog-message",
+ confirmButtonLabel:
+ "about-logins-confirm-remove-dialog-confirm-button",
+ };
+ break;
+ }
+ case "discard-changes": {
+ options = {
+ title: "confirm-discard-changes-dialog-title",
+ message: "confirm-discard-changes-dialog-message",
+ confirmButtonLabel: "confirm-discard-changes-dialog-confirm-button",
+ };
+ break;
+ }
+ }
+ let wasExistingLogin = !!this._login.guid;
+ let method = type == "delete" ? "delete" : "cancel";
+ let dialogPromise = dialog.show(options);
+ dialogPromise.then(
+ () => {
+ try {
+ onConfirm();
+ } catch (ex) {}
+ this._recordTelemetryEvent({
+ object: wasExistingLogin ? "existing_login" : "new_login",
+ method,
+ });
+ },
+ () => {}
+ );
+ return dialogPromise;
+ }
+
+ hasPendingChanges() {
+ let valuesChanged = !window.AboutLoginsUtils.doLoginsMatch(
+ Object.assign({ username: "", password: "", origin: "" }, this._login),
+ this._loginFromForm()
+ );
+
+ return this.dataset.editing && valuesChanged;
+ }
+
+ resetForm() {
+ // If the password input (which uses HTML form validation) wasn't connected,
+ // append it to the form so it gets included in the reset, specifically for
+ // .value and the dirty state for validation.
+ let wasConnected = this._passwordInput.isConnected;
+ if (!wasConnected) {
+ this._revealCheckbox.insertAdjacentElement(
+ "beforebegin",
+ this._passwordInput
+ );
+ }
+
+ this._form.reset();
+ if (!wasConnected) {
+ this._passwordInput.remove();
+ }
+ }
+
+ /**
+ * @param {login} login The login that should be displayed. The login object is
+ * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo.
+ * @param {boolean} skipFocusChange Optional, if present and set to true, the Edit button of the
+ * login will not get focus automatically. This is used to prevent
+ * stealing focus from the search filter upon page load.
+ */
+ setLogin(login, { skipFocusChange } = {}) {
+ this._login = login;
+ this._error = null;
+
+ this.resetForm();
+
+ if (login.guid) {
+ delete this.dataset.isNewLogin;
+ } else {
+ this.dataset.isNewLogin = true;
+ }
+ document.documentElement.classList.toggle("login-selected", login.guid);
+ this._toggleEditing(!login.guid);
+
+ this._revealCheckbox.checked = false;
+
+ clearTimeout(this._copyUsernameTimeoutId);
+ clearTimeout(this._copyPasswordTimeoutId);
+ for (let copyButton of [
+ this._copyUsernameButton,
+ this._copyPasswordButton,
+ ]) {
+ copyButton.disabled = false;
+ delete copyButton.dataset.copied;
+ }
+
+ if (!skipFocusChange) {
+ this._editButton.focus();
+ }
+ this.render();
+ }
+
+ /**
+ * Updates the view if the login argument matches the login currently
+ * displayed.
+ *
+ * @param {login} login The login that was added to storage. The login object is
+ * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo.
+ */
+ loginAdded(login) {
+ if (
+ this._login.guid ||
+ !window.AboutLoginsUtils.doLoginsMatch(login, this._loginFromForm())
+ ) {
+ return;
+ }
+
+ this.setLogin(login);
+ this.dispatchEvent(
+ new CustomEvent("AboutLoginsLoginSelected", {
+ bubbles: true,
+ composed: true,
+ detail: login,
+ })
+ );
+ }
+
+ /**
+ * Updates the view if the login argument matches the login currently
+ * displayed.
+ *
+ * @param {login} login The login that was modified in storage. The login object is
+ * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo.
+ */
+ loginModified(login) {
+ if (this._login.guid != login.guid) {
+ return;
+ }
+
+ let valuesChanged =
+ this.dataset.editing &&
+ !window.AboutLoginsUtils.doLoginsMatch(login, this._loginFromForm());
+ if (valuesChanged) {
+ this.showConfirmationDialog("discard-changes", () => {
+ this.setLogin(login);
+ });
+ } else {
+ this.setLogin(login);
+ }
+ }
+
+ /**
+ * Clears the displayed login if the argument matches the currently
+ * displayed login.
+ *
+ * @param {login} login The login that was removed from storage. The login object is
+ * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo.
+ */
+ loginRemoved(login) {
+ if (login.guid != this._login.guid) {
+ return;
+ }
+
+ this.setLogin({}, { skipFocusChange: true });
+ this._toggleEditing(false);
+ }
+
+ _handleOriginClick() {
+ this._recordTelemetryEvent({
+ object: "existing_login",
+ method: "open_site",
+ });
+ }
+
+ /**
+ * Checks that the edit/new-login form has valid values present for their
+ * respective required fields.
+ *
+ * @param {boolean} reportErrors If true, validation errors will be reported
+ * to the user.
+ */
+ _isFormValid({ reportErrors } = {}) {
+ let fields = [this._passwordInput];
+ if (this.dataset.isNewLogin) {
+ fields.push(this._originInput);
+ }
+ let valid = true;
+ // Check validity on all required fields so each field will get :invalid styling
+ // if applicable.
+ for (let field of fields) {
+ if (reportErrors) {
+ valid &= field.reportValidity();
+ } else {
+ valid &= field.checkValidity();
+ }
+ }
+ return valid;
+ }
+
+ _loginFromForm() {
+ return Object.assign({}, this._login, {
+ username: this._usernameInput.value.trim(),
+ password: this._passwordInput.value,
+ origin:
+ window.AboutLoginsUtils.getLoginOrigin(this._originInput.value) || "",
+ });
+ }
+
+ _recordTelemetryEvent(eventObject) {
+ // Breach alerts have higher priority than vulnerable logins, the
+ // following conditionals must reflect this priority.
+ const extra = eventObject.hasOwnProperty("extra") ? eventObject.extra : {};
+ if (this._breachesMap && this._breachesMap.has(this._login.guid)) {
+ Object.assign(extra, { breached: "true" });
+ eventObject.extra = extra;
+ } else if (
+ this._vulnerableLoginsMap &&
+ this._vulnerableLoginsMap.has(this._login.guid)
+ ) {
+ Object.assign(extra, { vulnerable: "true" });
+ eventObject.extra = extra;
+ }
+ recordTelemetryEvent(eventObject);
+ }
+
+ /**
+ * Toggles the login-item view from editing to non-editing mode.
+ *
+ * @param {boolean} force When true puts the form in 'edit' mode, otherwise
+ * puts the form in read-only mode.
+ */
+ _toggleEditing(force) {
+ let shouldEdit = force !== undefined ? force : !this.dataset.editing;
+
+ if (!shouldEdit) {
+ delete this.dataset.isNewLogin;
+ }
+
+ // Reset cursor to the start of the input for long text names.
+ this._usernameInput.scrollLeft = 0;
+
+ if (shouldEdit) {
+ this._passwordInput.style.removeProperty("width");
+ } else {
+ // Need to set a shorter width than -moz-available so the reveal checkbox
+ // will still appear next to the password.
+ this._passwordInput.style.width =
+ (this._login.password || "").length + "ch";
+ }
+
+ this._deleteButton.disabled = this.dataset.isNewLogin;
+ this._editButton.disabled = shouldEdit;
+ let inputTabIndex = shouldEdit ? 0 : -1;
+ this._originInput.readOnly = !this.dataset.isNewLogin;
+ this._originInput.tabIndex = inputTabIndex;
+ this._usernameInput.readOnly = !shouldEdit;
+ this._usernameInput.tabIndex = inputTabIndex;
+ this._passwordInput.readOnly = !shouldEdit;
+ this._passwordInput.tabIndex = inputTabIndex;
+ if (shouldEdit) {
+ this.dataset.editing = true;
+ this._usernameInput.focus();
+ this._usernameInput.select();
+ } else {
+ delete this.dataset.editing;
+ // Only reset the reveal checkbox when exiting 'edit' mode
+ this._revealCheckbox.checked = false;
+ }
+ }
+
+ _updatePasswordRevealState() {
+ if (
+ window.AboutLoginsUtils &&
+ window.AboutLoginsUtils.passwordRevealVisible === false
+ ) {
+ this._revealCheckbox.hidden = true;
+ }
+
+ let { checked } = this._revealCheckbox;
+ let inputType = checked ? "text" : "password";
+ this._passwordInput.type = inputType;
+
+ if (this.dataset.editing) {
+ this._passwordDisplayInput.removeAttribute("tabindex");
+ } else {
+ this._passwordDisplayInput.setAttribute("tabindex", -1);
+ }
+
+ // Swap which <input> is in the document depending on whether we need the
+ // real .value (which means that the primary password was already entered,
+ // if applicable)
+ if (checked || this.dataset.isNewLogin) {
+ this._passwordDisplayInput.replaceWith(this._passwordInput);
+
+ // Focus the input if it hasn't been already.
+ if (this.dataset.editing && inputType === "text") {
+ this._passwordInput.focus();
+ }
+ } else {
+ this._passwordInput.replaceWith(this._passwordDisplayInput);
+ }
+ }
+
+ _updateOriginDisplayState() {
+ // Switches between the origin input and anchor tag depending
+ // if a new login is being created.
+ if (this.dataset.isNewLogin) {
+ this._originDisplayInput.replaceWith(this._originInput);
+ this._originInput.focus();
+ } else {
+ this._originInput.replaceWith(this._originDisplayInput);
+ }
+ }
+}
+customElements.define("login-item", LoginItem);