Source code

Revision control

Copy as Markdown

Other Tools

/* 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("login-breach-alert");
this._vulnerableAlert = this.shadowRoot.querySelector(
"login-vulnerable-password-alert"
);
this._passwordWarning = this.shadowRoot.querySelector("password-warning");
this._originWarning = this.shadowRoot.querySelector("origin-warning");
this.render();
this._cancelButton.addEventListener("click", e =>
this.handleCancelEvent(e)
);
window.addEventListener("keydown", e => this.handleKeydown(e));
// TODO: Using the addEventListener to listen for clicks and pass the event handler due to a CSP error.
// This will be fixed as login-item itself is converted into a lit component. We will then be able to use the onclick
// prop of login-command-button as seen in the example below (functionality works and passes tests).
// this._editButton.onClick = e => this.handleEditEvent(e);
this._editButton.addEventListener("click", e => this.handleEditEvent(e));
this._copyPasswordButton.addEventListener("click", e =>
this.handleCopyPasswordClick(e)
);
this._copyUsernameButton.addEventListener("click", e =>
this.handleCopyUsernameClick(e)
);
this._deleteButton.addEventListener("click", e =>
this.handleDeleteEvent(e)
);
this._errorMessageLink.addEventListener("click", e =>
this.handleDuplicateErrorGuid(e)
);
this._form.addEventListener("submit", e => this.handleInputSubmit(e));
this._originInput.addEventListener("blur", e => this.addHTTPSPrefix(e));
this._originInput.addEventListener("click", e =>
this.handleOriginInputClick(e)
);
this._originInput.addEventListener(
"mousedown",
e => this.handleInputMousedown(e),
true
);
this._originInput.addEventListener("auxclick", e =>
this.handleInputAuxclick(e)
);
this._originDisplayInput.addEventListener("click", e =>
this.handleOriginInputClick(e)
);
this._revealCheckbox.addEventListener("click", e =>
this.handleRevealPasswordClick(e)
);
this._passwordInput.addEventListener("focus", e =>
this.handlePasswordDisplayFocus(e)
);
this._passwordInput.addEventListener("blur", e =>
this.dataset.editing
? this.handleEditPasswordInputBlur(e)
: this.addHTTPSPrefix(e)
);
this._passwordDisplayInput.addEventListener("focus", e =>
this.handlePasswordDisplayFocus(e)
);
this._passwordDisplayInput.addEventListener("blur", e =>
this.handlePasswordDisplayBlur(e)
);
window.addEventListener("AboutLoginsInitialLoginSelected", e =>
this.handleAboutLoginsInitial(e)
);
window.addEventListener("AboutLoginsLoginSelected", e =>
this.handleAboutLoginsLoginSelected(e)
);
window.addEventListener("AboutLoginsShowBlankLogin", e =>
this.handleAboutLoginsShowBlankLogin(e)
);
window.addEventListener("AboutLoginsRemaskPassword", e =>
this.handleAboutLoginsRemaskPassword(e)
);
}
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);
const breachTimestamp = new Date(breachDetails.BreachDate ?? 0).getTime();
this.#updateBreachAlert(this._login.origin, breachTimestamp);
}
this._vulnerableAlert.hidden =
!this._vulnerableLoginsMap ||
!this._vulnerableLoginsMap.has(this._login.guid) ||
!this._breachAlert.hidden;
if (!this._vulnerableAlert.hidden) {
this.#updateVulnerablePasswordAlert(this._login.origin);
}
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"
: "about-logins-login-item-save-changes-button"
);
this._updatePasswordRevealState();
this._updateOriginDisplayState();
this.#updateTimeline();
this.#updatePasswordMessage();
}
#updateTimeline() {
let timeline = this.shadowRoot.querySelector("login-timeline");
timeline.hidden = !this._login.guid;
const createdTime = {
actionId: "login-item-timeline-action-created",
time: this._login.timeCreated,
};
const lastUpdatedTime = {
actionId: "login-item-timeline-action-updated",
time: this._login.timePasswordChanged,
};
const lastUsedTime = {
actionId: "login-item-timeline-action-used",
time: this._login.timeLastUsed,
};
timeline.history =
this._login.timeCreated == this._login.timePasswordChanged
? [createdTime, lastUsedTime]
: [createdTime, lastUpdatedTime, lastUsedTime];
}
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 handleKeydown(e) {
// The below handleKeydown will be cleaned up when Bug 1848785 lands.
if (e.key === "Escape" && this.dataset.editing) {
this.handleCancelEvent();
} else if (e.altKey && e.key === "Enter" && !this.dataset.editing) {
this.handleEditEvent();
} else if (e.altKey && (e.key === "Backspace" || e.key === "Delete")) {
this.handleDeleteEvent();
}
}
async handlePasswordDisplayFocus(e) {
// TODO(Bug 1838494): Remove this if block
// This is a temporary fix until Bug 1750072 lands
const focusFromCheckbox = e && e.relatedTarget === this._revealCheckbox;
const isEditingMode = this.dataset.editing || this.dataset.isNewLogin;
if (focusFromCheckbox && isEditingMode) {
this._passwordInput.type = this._revealCheckbox.checked
? "text"
: "password";
return;
}
this._revealCheckbox.checked = !!this.dataset.editing;
this._updatePasswordRevealState();
}
async addHTTPSPrefix(e) {
// TODO(Bug 1838494): Remove this if block
// This is a temporary fix until Bug 1750072 lands
const focusCheckboxNext = e && e.relatedTarget === this._revealCheckbox;
if (focusCheckboxNext) {
return;
}
// 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;
}
}
async handlePasswordDisplayBlur(e) {
// TODO(Bug 1838494): Remove this if block
// This is a temporary fix until Bug 1750072 lands
const focusCheckboxNext = e && e.relatedTarget === this._revealCheckbox;
if (focusCheckboxNext) {
return;
}
this._revealCheckbox.checked = !!this.dataset.editing;
this._updatePasswordRevealState();
this.addHTTPSPrefix();
}
async handleEditPasswordInputBlur(e) {
// TODO(Bug 1838494): Remove this if block
// This is a temporary fix until Bug 1750072 lands
const focusCheckboxNext = e && e.relatedTarget === this._revealCheckbox;
if (focusCheckboxNext) {
return;
}
this._revealCheckbox.checked = false;
this._updatePasswordRevealState();
this.addHTTPSPrefix();
}
async handleRevealPasswordClick() {
// 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 });
}
async handleCancelEvent(_e) {
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();
});
}
}
async handleCopyPasswordClick({ currentTarget }) {
let primaryPasswordAuth = await promptForPrimaryPassword(
"about-logins-copy-password-os-auth-dialog-message"
);
if (!primaryPasswordAuth) {
return;
}
currentTarget.dataset.copied = true;
currentTarget.copiedText = true;
currentTarget.disabled = true;
let propertyToCopy = this._login.password;
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) {
this._copyUsernameButton.copiedText = false;
this._copyUsernameButton.disabled = false;
delete this._copyUsernameButton.dataset.copied;
}
clearTimeout(this._copyUsernameTimeoutId);
clearTimeout(this._copyPasswordTimeoutId);
let timeoutId = setTimeout(() => {
currentTarget.disabled = false;
currentTarget.copiedText = false;
delete currentTarget.dataset.copied;
}, LoginItem.COPY_BUTTON_RESET_TIMEOUT);
this._copyPasswordTimeoutId = timeoutId;
this._recordTelemetryEvent({
object: "password",
method: "copy",
});
}
async handleCopyUsernameClick({ currentTarget }) {
currentTarget.dataset.copied = true;
currentTarget.copiedText = true;
currentTarget.disabled = true;
let propertyToCopy = this._login.username;
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) {
this._copyPasswordButton.copiedText = false;
this._copyPasswordButton.disabled = false;
delete this._copyPasswordButton.dataset.copied;
}
clearTimeout(this._copyUsernameTimeoutId);
clearTimeout(this._copyPasswordTimeoutId);
let timeoutId = setTimeout(() => {
currentTarget.disabled = false;
currentTarget.copiedText = false;
delete currentTarget.dataset.copied;
}, LoginItem.COPY_BUTTON_RESET_TIMEOUT);
this._copyUsernameTimeoutId = timeoutId;
this._recordTelemetryEvent({
object: "username",
method: "copy",
});
}
async handleDeleteEvent() {
this.showConfirmationDialog("delete", () => {
document.dispatchEvent(
new CustomEvent("AboutLoginsDeleteLogin", {
bubbles: true,
detail: this._login,
})
);
});
}
async handleEditEvent() {
let primaryPasswordAuth = await promptForPrimaryPassword(
"about-logins-edit-login-os-auth-dialog-message2"
);
if (!primaryPasswordAuth) {
return;
}
this._toggleEditing();
this.render();
this._recordTelemetryEvent({
object: "existing_login",
method: "edit",
});
}
async handleAlertLearnMoreClick({ currentTarget }) {
if (currentTarget.closest(".vulnerable-alert")) {
this._recordTelemetryEvent({
object: "existing_login",
method: "learn_more_vuln",
});
}
}
async handleOriginInputClick() {
this._handleOriginClick();
}
async handleDuplicateErrorGuid({ currentTarget }) {
let existingDuplicateLogin = {
guid: currentTarget.dataset.errorGuid,
};
window.dispatchEvent(
new CustomEvent("AboutLoginsLoginSelected", {
detail: existingDuplicateLogin,
cancelable: true,
})
);
}
async handleInputSubmit(event) {
// 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",
});
this._toggleEditing(false);
this.render();
} else {
document.dispatchEvent(
new CustomEvent("AboutLoginsCreateLogin", {
bubbles: true,
detail: loginUpdates,
})
);
this._recordTelemetryEvent({ object: "new_login", method: "save" });
}
}
async handleInputAuxclick({ button }) {
if (button == 1) {
this._handleOriginClick();
}
}
async handleInputMousedown(event) {
// No AutoScroll when middle clicking on origin input.
if (event.currentTarget == this._originInput && event.button == 1) {
event.preventDefault();
}
}
async handleAboutLoginsInitial({ detail }) {
this.setLogin(detail, { skipFocusChange: true });
}
async handleAboutLoginsLoginSelected(event) {
this.#confirmPendingChangesOnEvent(event, event.detail);
}
async handleAboutLoginsShowBlankLogin(event) {
this.#confirmPendingChangesOnEvent(event, {});
}
async handleAboutLoginsRemaskPassword() {
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 });
}
/**
* 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-delete-dialog-title",
message: "about-logins-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 currentTarget of [
this._copyUsernameButton,
this._copyPasswordButton,
]) {
currentTarget.disabled = false;
this._copyPasswordButton.copiedText = false;
this._copyUsernameButton.copiedText = false;
delete currentTarget.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");
this._revealCheckbox.hidden = true;
} else {
this._passwordDisplayInput.setAttribute("tabindex", -1);
this._revealCheckbox.hidden = false;
}
// 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);
}
}
// TODO(Bug 1838182): This is glue code to make lit component work
// Once login-item itself is a lit component, this method is going to be deleted
// in favour of updating the props themselves.
// NOTE: Adding this method here instead of login-alert because this file will be
// refactored soon.
#updateBreachAlert(hostname, date) {
this._breachAlert.hostname = hostname;
this._breachAlert.date = date;
}
#updateVulnerablePasswordAlert(hostname) {
this._vulnerableAlert.hostname = hostname;
}
#updatePasswordMessage() {
this._passwordWarning.isNewLogin = this.dataset.isNewLogin;
this._passwordWarning.webTitle = this._login.title;
}
}
customElements.define("login-item", LoginItem);