From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- .../content/components/confirmation-dialog.css | 71 ++ .../content/components/confirmation-dialog.mjs | 105 +++ .../content/components/fxaccounts-button.css | 80 ++ .../content/components/fxaccounts-button.mjs | 83 ++ .../content/components/generic-dialog.css | 65 ++ .../content/components/generic-dialog.mjs | 63 ++ .../content/components/import-details-row.mjs | 60 ++ .../content/components/import-error-dialog.css | 28 + .../content/components/import-error-dialog.mjs | 59 ++ .../content/components/import-summary-dialog.css | 42 + .../content/components/import-summary-dialog.mjs | 72 ++ .../content/components/login-filter.css | 29 + .../content/components/login-filter.mjs | 99 +++ .../aboutlogins/content/components/login-intro.css | 27 + .../aboutlogins/content/components/login-intro.mjs | 67 ++ .../aboutlogins/content/components/login-item.css | 444 ++++++++++ .../aboutlogins/content/components/login-item.mjs | 952 +++++++++++++++++++++ .../content/components/login-list-item.mjs | 81 ++ .../content/components/login-list-section.mjs | 34 + .../aboutlogins/content/components/login-list.css | 202 +++++ .../aboutlogins/content/components/login-list.mjs | 912 ++++++++++++++++++++ .../content/components/login-timeline.mjs | 137 +++ .../aboutlogins/content/components/menu-button.css | 93 ++ .../aboutlogins/content/components/menu-button.mjs | 183 ++++ .../content/components/remove-logins-dialog.css | 102 +++ .../content/components/remove-logins-dialog.mjs | 117 +++ 26 files changed, 4207 insertions(+) create mode 100644 browser/components/aboutlogins/content/components/confirmation-dialog.css create mode 100644 browser/components/aboutlogins/content/components/confirmation-dialog.mjs create mode 100644 browser/components/aboutlogins/content/components/fxaccounts-button.css create mode 100644 browser/components/aboutlogins/content/components/fxaccounts-button.mjs create mode 100644 browser/components/aboutlogins/content/components/generic-dialog.css create mode 100644 browser/components/aboutlogins/content/components/generic-dialog.mjs create mode 100644 browser/components/aboutlogins/content/components/import-details-row.mjs create mode 100644 browser/components/aboutlogins/content/components/import-error-dialog.css create mode 100644 browser/components/aboutlogins/content/components/import-error-dialog.mjs create mode 100644 browser/components/aboutlogins/content/components/import-summary-dialog.css create mode 100644 browser/components/aboutlogins/content/components/import-summary-dialog.mjs create mode 100644 browser/components/aboutlogins/content/components/login-filter.css create mode 100644 browser/components/aboutlogins/content/components/login-filter.mjs create mode 100644 browser/components/aboutlogins/content/components/login-intro.css create mode 100644 browser/components/aboutlogins/content/components/login-intro.mjs create mode 100644 browser/components/aboutlogins/content/components/login-item.css create mode 100644 browser/components/aboutlogins/content/components/login-item.mjs create mode 100644 browser/components/aboutlogins/content/components/login-list-item.mjs create mode 100644 browser/components/aboutlogins/content/components/login-list-section.mjs create mode 100644 browser/components/aboutlogins/content/components/login-list.css create mode 100644 browser/components/aboutlogins/content/components/login-list.mjs create mode 100644 browser/components/aboutlogins/content/components/login-timeline.mjs create mode 100644 browser/components/aboutlogins/content/components/menu-button.css create mode 100644 browser/components/aboutlogins/content/components/menu-button.mjs create mode 100644 browser/components/aboutlogins/content/components/remove-logins-dialog.css create mode 100644 browser/components/aboutlogins/content/components/remove-logins-dialog.mjs (limited to 'browser/components/aboutlogins/content/components') diff --git a/browser/components/aboutlogins/content/components/confirmation-dialog.css b/browser/components/aboutlogins/content/components/confirmation-dialog.css new file mode 100644 index 0000000000..bdd1e23c58 --- /dev/null +++ b/browser/components/aboutlogins/content/components/confirmation-dialog.css @@ -0,0 +1,71 @@ +/* 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/. */ + + .overlay { + position: fixed; + z-index: 1; + inset: 0; + /* TODO: this color is used in the about:preferences overlay, but + why isn't it declared as a variable? */ + background-color: rgba(0,0,0,0.5); + display: flex; +} + +.container { + z-index: 2; + position: relative; + display: flex; + flex-direction: column; + min-width: 250px; + max-width: 500px; + min-height: 200px; + margin: auto; + background: var(--in-content-page-background); + color: var(--in-content-page-color); + box-shadow: var(--shadow-30); + /* show a border in high contrast mode */ + outline: 1px solid transparent; +} + +.title { + font-size: 1.5em; + font-weight: normal; + user-select: none; + margin: 0; +} + +.message { + color: var(--text-color-deemphasized); + margin-bottom: 0; +} + +.dismiss-button { + position: absolute; + top: 0; + inset-inline-end: 0; + min-width: 20px; + min-height: 20px; + margin: 16px; + padding: 0; + line-height: 0; +} + +.dismiss-icon { + -moz-context-properties: fill; + fill: currentColor; +} + +.warning-icon { + -moz-context-properties: fill; + fill: currentColor; + width: 40px; + height: 40px; + margin: 16px; +} + +.content, +.buttons { + text-align: center; + padding: 16px 32px; +} diff --git a/browser/components/aboutlogins/content/components/confirmation-dialog.mjs b/browser/components/aboutlogins/content/components/confirmation-dialog.mjs new file mode 100644 index 0000000000..91a9c3a9d7 --- /dev/null +++ b/browser/components/aboutlogins/content/components/confirmation-dialog.mjs @@ -0,0 +1,105 @@ +/* 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 { setKeyboardAccessForNonDialogElements } from "../aboutLoginsUtils.mjs"; + +export default class ConfirmationDialog extends HTMLElement { + constructor() { + super(); + this._promise = null; + } + + connectedCallback() { + if (this.shadowRoot) { + return; + } + let template = document.querySelector("#confirmation-dialog-template"); + let shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + shadowRoot.appendChild(template.content.cloneNode(true)); + + this._buttons = this.shadowRoot.querySelector(".buttons"); + this._cancelButton = this.shadowRoot.querySelector(".cancel-button"); + this._confirmButton = this.shadowRoot.querySelector(".confirm-button"); + this._dismissButton = this.shadowRoot.querySelector(".dismiss-button"); + this._message = this.shadowRoot.querySelector(".message"); + this._overlay = this.shadowRoot.querySelector(".overlay"); + this._title = this.shadowRoot.querySelector(".title"); + } + + handleEvent(event) { + switch (event.type) { + case "keydown": + if (event.repeat) { + // Prevent repeat keypresses from accidentally confirming the + // dialog since the confirmation button is focused by default. + event.preventDefault(); + return; + } + if (event.key === "Escape" && !event.defaultPrevented) { + this.onCancel(); + } + break; + case "click": + if ( + event.target.classList.contains("cancel-button") || + event.currentTarget.classList.contains("dismiss-button") || + event.target.classList.contains("overlay") + ) { + this.onCancel(); + } else if (event.target.classList.contains("confirm-button")) { + this.onConfirm(); + } + } + } + + hide() { + setKeyboardAccessForNonDialogElements(true); + this._cancelButton.removeEventListener("click", this); + this._confirmButton.removeEventListener("click", this); + this._dismissButton.removeEventListener("click", this); + this._overlay.removeEventListener("click", this); + window.removeEventListener("keydown", this); + + this.hidden = true; + } + + show({ title, message, confirmButtonLabel }) { + setKeyboardAccessForNonDialogElements(false); + this.hidden = false; + + document.l10n.setAttributes(this._title, title); + document.l10n.setAttributes(this._message, message); + document.l10n.setAttributes(this._confirmButton, confirmButtonLabel); + + this._cancelButton.addEventListener("click", this); + this._confirmButton.addEventListener("click", this); + this._dismissButton.addEventListener("click", this); + this._overlay.addEventListener("click", this); + window.addEventListener("keydown", this); + + // For speed-of-use, focus the confirm button when the + // dialog loads. Showing the dialog itself provides enough + // of a buffer for accidental deletions. + this._confirmButton.focus(); + + this._promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + + return this._promise; + } + + onCancel() { + this._reject(); + this.hide(); + } + + onConfirm() { + this._resolve(); + this.hide(); + } +} +customElements.define("confirmation-dialog", ConfirmationDialog); diff --git a/browser/components/aboutlogins/content/components/fxaccounts-button.css b/browser/components/aboutlogins/content/components/fxaccounts-button.css new file mode 100644 index 0000000000..2e2ef7f080 --- /dev/null +++ b/browser/components/aboutlogins/content/components/fxaccounts-button.css @@ -0,0 +1,80 @@ +/* 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/. */ + +.logged-out-view, +.logged-in-view { + display: flex; + align-items: center; +} + +.fxaccounts-extra-text { + /* Only show at most 3 lines of text to limit the + text from overflowing the header. */ + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + + color: var(--text-color-deemphasized); + text-align: end; +} + +.fxaccounts-extra-text, +.fxaccount-email, +.fxaccounts-enable-button { + font-size: 13px; +} + +@media (max-width: 830px) { + .fxaccounts-extra-text, + .fxaccount-email { + display: none; + } +} + +.fxaccount-avatar, +.fxaccounts-enable-button { + margin-inline-start: 9px; +} + +.fxaccounts-enable-button { + min-width: 120px; + padding-inline: 16px; + /* See bug 1626764: The width of button could go lesser than 120px in small window size which could wrap the texts into two lines in systems with different default fonts */ + flex-shrink: 0; +} + +.fxaccounts-avatar-button { + cursor: pointer; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.fxaccount-email { + vertical-align: middle; +} + +.fxaccount-avatar { + display: inline-block; + vertical-align: middle; + background-image: var(--avatar-url, + url(chrome://browser/skin/fxa/avatar-color.svg)); + background-position: center; + background-repeat: no-repeat; + background-size: cover; + border-radius: 1000px; + width: 32px; + height: 32px; +} + +@media not (prefers-contrast) { + .fxaccounts-avatar-button:hover { + background-color: transparent !important; + } + + .fxaccounts-avatar-button:hover > .fxaccount-email { + text-decoration: underline; + } +} diff --git a/browser/components/aboutlogins/content/components/fxaccounts-button.mjs b/browser/components/aboutlogins/content/components/fxaccounts-button.mjs new file mode 100644 index 0000000000..d39969d726 --- /dev/null +++ b/browser/components/aboutlogins/content/components/fxaccounts-button.mjs @@ -0,0 +1,83 @@ +/* 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/. */ + +export default class FxAccountsButton extends HTMLElement { + connectedCallback() { + if (this.shadowRoot) { + return; + } + let template = document.querySelector("#fxaccounts-button-template"); + let shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + shadowRoot.appendChild(template.content.cloneNode(true)); + + this._avatarButton = shadowRoot.querySelector(".fxaccounts-avatar-button"); + this._extraText = shadowRoot.querySelector(".fxaccounts-extra-text"); + this._enableButton = shadowRoot.querySelector(".fxaccounts-enable-button"); + this._loggedOutView = shadowRoot.querySelector(".logged-out-view"); + this._loggedInView = shadowRoot.querySelector(".logged-in-view"); + this._emailText = shadowRoot.querySelector(".fxaccount-email"); + + this._avatarButton.addEventListener("click", this); + this._enableButton.addEventListener("click", this); + + this.render(); + } + + handleEvent(event) { + if (event.currentTarget == this._avatarButton) { + document.dispatchEvent( + new CustomEvent("AboutLoginsSyncOptions", { + bubbles: true, + }) + ); + return; + } + if (event.target == this._enableButton) { + document.dispatchEvent( + new CustomEvent("AboutLoginsSyncEnable", { + bubbles: true, + }) + ); + } + } + + render() { + this._loggedOutView.hidden = !!this._loggedIn; + this._loggedInView.hidden = !this._loggedIn; + this._emailText.textContent = this._email; + if (this._avatarURL) { + this._avatarButton.style.setProperty( + "--avatar-url", + `url(${this._avatarURL})` + ); + } else { + let defaultAvatar = "chrome://browser/skin/fxa/avatar-color.svg"; + this._avatarButton.style.setProperty( + "--avatar-url", + `url(${defaultAvatar})` + ); + } + } + + /** + * + * @param {object} state + * loggedIn: {Boolean} FxAccount authentication + * status. + * email: {String} Email address used with FxAccount. Must + * be empty if `loggedIn` is false. + * avatarURL: {String} URL of account avatar. Must + * be empty if `loggedIn` is false. + */ + updateState(state) { + this.hidden = !state.fxAccountsEnabled; + this._loggedIn = state.loggedIn; + this._email = state.email; + this._avatarURL = state.avatarURL; + + this.render(); + } +} +customElements.define("fxaccounts-button", FxAccountsButton); diff --git a/browser/components/aboutlogins/content/components/generic-dialog.css b/browser/components/aboutlogins/content/components/generic-dialog.css new file mode 100644 index 0000000000..d8fbbfe93c --- /dev/null +++ b/browser/components/aboutlogins/content/components/generic-dialog.css @@ -0,0 +1,65 @@ +/* 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/. */ + +.overlay { + position: fixed; + z-index: 1; + inset: 0; + /* TODO: this color is used in the about:preferences overlay, but + why isn't it declared as a variable? */ + background-color: rgba(0,0,0,0.5); + display: flex; +} + +.container { + z-index: 2; + position: relative; + display: grid; + grid-template-columns: 37px auto; + grid-template-rows: 32px auto 50px; + grid-gap: 5px; + align-items: center; + width: 580px; + height: 290px; + padding: 50px 50px 20px; + margin: auto; + background-color: var(--in-content-page-background); + color: var(--in-content-page-color); + box-shadow: var(--shadow-30); + /* show a border in high contrast mode */ + outline: 1px solid transparent; +} + +::slotted([slot="dialog-icon"]) { + width: 32px; + height: 32px; + -moz-context-properties: fill; + fill: currentColor; +} + +::slotted([slot="dialog-title"]) { + font-size: 2.2em; + font-weight: 300; + user-select: none; + margin: 0; +} + +::slotted([slot="content"]) { + grid-column-start: 2; + align-self: baseline; + margin-top: 16px; + line-height: 1.4em; +} + +::slotted([slot="buttons"]) { + grid-column: 1 / 4; + grid-row-start: 3; + border-top: 1px solid var(--in-content-border-color); + padding-top: 12px; +} + +.dialog-body { + padding-block: 40px 16px; + padding-inline: 45px 32px; +} diff --git a/browser/components/aboutlogins/content/components/generic-dialog.mjs b/browser/components/aboutlogins/content/components/generic-dialog.mjs new file mode 100644 index 0000000000..8d9ddc9d36 --- /dev/null +++ b/browser/components/aboutlogins/content/components/generic-dialog.mjs @@ -0,0 +1,63 @@ +/* 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 { + setKeyboardAccessForNonDialogElements, + initDialog, +} from "../aboutLoginsUtils.mjs"; + +export default class GenericDialog extends HTMLElement { + constructor() { + super(); + this._promise = null; + } + + connectedCallback() { + if (this.shadowRoot) { + return; + } + const shadowRoot = initDialog(this, "#generic-dialog-template"); + this._dismissButton = this.querySelector(".dismiss-button"); + this._overlay = shadowRoot.querySelector(".overlay"); + } + + handleEvent(event) { + switch (event.type) { + case "keydown": + if (event.key === "Escape" && !event.defaultPrevented) { + this.hide(); + } + break; + case "click": + if ( + event.currentTarget.classList.contains("dismiss-button") || + event.target.classList.contains("overlay") + ) { + this.hide(); + } + } + } + + show() { + setKeyboardAccessForNonDialogElements(false); + this.hidden = false; + this.parentNode.host.hidden = false; + + this._dismissButton.addEventListener("click", this); + this._overlay.addEventListener("click", this); + window.addEventListener("keydown", this); + } + + hide() { + setKeyboardAccessForNonDialogElements(true); + this._dismissButton.removeEventListener("click", this); + this._overlay.removeEventListener("click", this); + window.removeEventListener("keydown", this); + + this.hidden = true; + this.parentNode.host.hidden = true; + } +} + +customElements.define("generic-dialog", GenericDialog); diff --git a/browser/components/aboutlogins/content/components/import-details-row.mjs b/browser/components/aboutlogins/content/components/import-details-row.mjs new file mode 100644 index 0000000000..13fe40da59 --- /dev/null +++ b/browser/components/aboutlogins/content/components/import-details-row.mjs @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const resultToUiData = { + no_change: { + message: "about-logins-import-report-row-description-no-change", + }, + modified: { + message: "about-logins-import-report-row-description-modified", + }, + added: { + message: "about-logins-import-report-row-description-added", + }, + error: { + message: "about-logins-import-report-row-description-error", + isError: true, + }, + error_multiple_values: { + message: "about-logins-import-report-row-description-error-multiple-values", + isError: true, + }, + error_missing_field: { + message: "about-logins-import-report-row-description-error-missing-field", + isError: true, + }, +}; + +export default class ImportDetailsRow extends HTMLElement { + constructor(number, reportRow) { + super(); + this._login = reportRow; + + let rowElement = document + .querySelector("#import-details-row-template") + .content.cloneNode(true); + + const uiData = resultToUiData[reportRow.result]; + if (uiData.isError) { + this.classList.add("error"); + } + const rowCount = rowElement.querySelector(".row-count"); + const rowDetails = rowElement.querySelector(".row-details"); + while (rowElement.childNodes.length) { + this.appendChild(rowElement.childNodes[0]); + } + document.l10n.connectRoot(this); + document.l10n.setAttributes( + rowCount, + "about-logins-import-report-row-index", + { + number, + } + ); + document.l10n.setAttributes(rowDetails, uiData.message, { + field: reportRow.field_name, + }); + } +} +customElements.define("import-details-row", ImportDetailsRow); diff --git a/browser/components/aboutlogins/content/components/import-error-dialog.css b/browser/components/aboutlogins/content/components/import-error-dialog.css new file mode 100644 index 0000000000..6fc2e945e4 --- /dev/null +++ b/browser/components/aboutlogins/content/components/import-error-dialog.css @@ -0,0 +1,28 @@ +/* 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/. */ + +.content { + display: flex; + flex-direction: column; + grid-area: 2 / 2 / 3 / 4; + align-items: flex-start; +} + +.error-title { + font-weight: 600; + margin-top: 20px; +} + +.no-logins { + margin-top: 25px; +} + +.error-learn-more-link { + font-weight: 600; +} + +.warning-icon { + -moz-context-properties: fill; + fill: #FFBF00; +} diff --git a/browser/components/aboutlogins/content/components/import-error-dialog.mjs b/browser/components/aboutlogins/content/components/import-error-dialog.mjs new file mode 100644 index 0000000000..31ad29512f --- /dev/null +++ b/browser/components/aboutlogins/content/components/import-error-dialog.mjs @@ -0,0 +1,59 @@ +/* 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 { initDialog } from "../aboutLoginsUtils.mjs"; + +export default class ImportErrorDialog extends HTMLElement { + constructor() { + super(); + this._promise = null; + this._errorMessages = {}; + this._errorMessages.CONFLICTING_VALUES_ERROR = { + title: "about-logins-import-dialog-error-conflicting-values-title", + description: + "about-logins-import-dialog-error-conflicting-values-description", + }; + this._errorMessages.FILE_FORMAT_ERROR = { + title: "about-logins-import-dialog-error-file-format-title", + description: "about-logins-import-dialog-error-file-format-description", + }; + this._errorMessages.FILE_PERMISSIONS_ERROR = { + title: "about-logins-import-dialog-error-file-permission-title", + description: + "about-logins-import-dialog-error-file-permission-description", + }; + this._errorMessages.UNABLE_TO_READ_ERROR = { + title: "about-logins-import-dialog-error-unable-to-read-title", + description: + "about-logins-import-dialog-error-unable-to-read-description", + }; + } + + connectedCallback() { + if (this.shadowRoot) { + return; + } + const shadowRoot = initDialog(this, "#import-error-dialog-template"); + this._titleElement = shadowRoot.querySelector(".error-title"); + this._descriptionElement = shadowRoot.querySelector(".error-description"); + this._genericDialog = this.shadowRoot.querySelector("generic-dialog"); + this._focusedElement = this.shadowRoot.querySelector("a"); + const tryImportAgain = this.shadowRoot.querySelector(".try-import-again"); + tryImportAgain.addEventListener("click", () => { + this._genericDialog.hide(); + document.dispatchEvent( + new CustomEvent("AboutLoginsImportFromFile", { bubbles: true }) + ); + }); + } + + show(errorType) { + const { title, description } = this._errorMessages[errorType]; + document.l10n.setAttributes(this._titleElement, title); + document.l10n.setAttributes(this._descriptionElement, description); + this._genericDialog.show(); + window.AboutLoginsUtils.setFocus(this._focusedElement); + } +} +customElements.define("import-error-dialog", ImportErrorDialog); diff --git a/browser/components/aboutlogins/content/components/import-summary-dialog.css b/browser/components/aboutlogins/content/components/import-summary-dialog.css new file mode 100644 index 0000000000..20dd987958 --- /dev/null +++ b/browser/components/aboutlogins/content/components/import-summary-dialog.css @@ -0,0 +1,42 @@ +/* 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/. */ + +.content { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.import-summary { + display: grid; + grid-template-columns: max-content max-content max-content; +} + +.import-summary > * > span { + margin-block: 0 2px; + margin-inline: 0 10px; +} + +.import-items-row { + grid-column: 1 / 4; + display: grid; + grid-template-columns: subgrid; +} + +.result-count { + text-align: end; + font-weight: bold; +} + +.result-meta { + font-style: italic; +} +.import-items-errors .result-meta { + color: var(--dialog-warning-text-color); +} + +.open-detailed-report { + margin-block-start: 30px; + font-weight: 600; +} diff --git a/browser/components/aboutlogins/content/components/import-summary-dialog.mjs b/browser/components/aboutlogins/content/components/import-summary-dialog.mjs new file mode 100644 index 0000000000..76d19b0190 --- /dev/null +++ b/browser/components/aboutlogins/content/components/import-summary-dialog.mjs @@ -0,0 +1,72 @@ +/* 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 { initDialog } from "../aboutLoginsUtils.mjs"; + +export default class ImportSummaryDialog extends HTMLElement { + constructor() { + super(); + this._promise = null; + } + + connectedCallback() { + if (this.shadowRoot) { + return; + } + initDialog(this, "#import-summary-dialog-template"); + this._added = this.shadowRoot.querySelector(".import-items-added"); + this._modified = this.shadowRoot.querySelector(".import-items-modified"); + this._noChange = this.shadowRoot.querySelector(".import-items-no-change"); + this._error = this.shadowRoot.querySelector(".import-items-errors"); + this._genericDialog = this.shadowRoot.querySelector("generic-dialog"); + } + + show({ logins }) { + const report = { + added: 0, + modified: 0, + no_change: 0, + error: 0, + }; + for (let loginRow of logins) { + if (loginRow.result.includes("error")) { + report.error++; + } else { + report[loginRow.result]++; + } + } + this._updateCount( + report.added, + this._added, + "about-logins-import-dialog-items-added" + ); + this._updateCount( + report.modified, + this._modified, + "about-logins-import-dialog-items-modified" + ); + this._updateCount( + report.no_change, + this._noChange, + "about-logins-import-dialog-items-no-change" + ); + this._updateCount( + report.error, + this._error, + "about-logins-import-dialog-items-error" + ); + this._noChange.querySelector(".result-meta").hidden = + report.no_change === 0; + this._error.querySelector(".result-meta").hidden = report.error === 0; + this._genericDialog.show(); + window.AboutLoginsUtils.setFocus(this._genericDialog._dismissButton); + } + + _updateCount(count, component, message) { + if (count != document.l10n.getAttributes(component).args.count) { + document.l10n.setAttributes(component, message, { count }); + } + } +} +customElements.define("import-summary-dialog", ImportSummaryDialog); diff --git a/browser/components/aboutlogins/content/components/login-filter.css b/browser/components/aboutlogins/content/components/login-filter.css new file mode 100644 index 0000000000..f7db0e6770 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-filter.css @@ -0,0 +1,29 @@ +/* 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/. */ + +.filter[type="text"] { + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + fill-opacity: 0.4; + background-image: url("chrome://global/skin/icons/search-glass.svg"); + background-position: 8px center; + background-repeat: no-repeat; + background-size: 16px; + text-align: match-parent; + width: 100%; + margin: 0; + box-sizing: border-box; + padding-block: 6px; +} + +:host(:dir(ltr)) .filter { + /* We use separate RTL rules over logical properties since we want the visual direction + to be independent from the user input direction */ + padding-left: 32px; +} + +:host(:dir(rtl)) .filter { + background-position-x: right 8px; + padding-right: 32px; +} diff --git a/browser/components/aboutlogins/content/components/login-filter.mjs b/browser/components/aboutlogins/content/components/login-filter.mjs new file mode 100644 index 0000000000..e5b89327d6 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-filter.mjs @@ -0,0 +1,99 @@ +/* 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 { recordTelemetryEvent } from "../aboutLoginsUtils.mjs"; + +export default class LoginFilter extends HTMLElement { + get #loginList() { + return document.querySelector("login-list"); + } + + connectedCallback() { + if (this.shadowRoot) { + return; + } + + let loginFilterTemplate = document.querySelector("#login-filter-template"); + let shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + shadowRoot.appendChild(loginFilterTemplate.content.cloneNode(true)); + + this._input = this.shadowRoot.querySelector("input"); + + this.addEventListener("input", this); + this._input.addEventListener("keydown", this); + window.addEventListener("AboutLoginsFilterLogins", this); + } + + focus() { + this._input.focus(); + } + + handleEvent(event) { + switch (event.type) { + case "AboutLoginsFilterLogins": + this.#filterLogins(event.detail); + break; + case "input": + this.#input(event.originalTarget.value); + break; + case "keydown": + this.#keyDown(event); + break; + } + } + + #filterLogins(filterText) { + if (this.value != filterText) { + this.value = filterText; + } + } + + #input(value) { + this._dispatchFilterEvent(value); + } + + #keyDown(e) { + switch (e.code) { + case "ArrowUp": + e.preventDefault(); + this.#loginList.selectPrevious(); + break; + case "ArrowDown": + e.preventDefault(); + this.#loginList.selectNext(); + break; + case "Escape": + e.preventDefault(); + this.value = ""; + break; + case "Enter": + e.preventDefault(); + this.#loginList.clickSelected(); + break; + } + } + + get value() { + return this._input.value; + } + + set value(val) { + this._input.value = val; + this._dispatchFilterEvent(val); + } + + _dispatchFilterEvent(value) { + this.dispatchEvent( + new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + composed: true, + detail: value, + }) + ); + + recordTelemetryEvent({ object: "list", method: "filter" }); + } +} +customElements.define("login-filter", LoginFilter); diff --git a/browser/components/aboutlogins/content/components/login-intro.css b/browser/components/aboutlogins/content/components/login-intro.css new file mode 100644 index 0000000000..3c5ecdc577 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-intro.css @@ -0,0 +1,27 @@ +/* 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/. */ + +:host { + padding: 60px; + display: flex; + flex-direction: column; + align-items: center; +} + +.heading { + font-size: 1.5em; +} + +section { + line-height: 2; +} + +.description { + font-weight: 600; + margin-bottom: 0; +} + +.illustration.logged-in { + opacity: .5; +} diff --git a/browser/components/aboutlogins/content/components/login-intro.mjs b/browser/components/aboutlogins/content/components/login-intro.mjs new file mode 100644 index 0000000000..682ddb32d8 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-intro.mjs @@ -0,0 +1,67 @@ +/* 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/. */ + +export default class LoginIntro extends HTMLElement { + connectedCallback() { + if (this.shadowRoot) { + return; + } + + let loginIntroTemplate = document.querySelector("#login-intro-template"); + let shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + shadowRoot.appendChild(loginIntroTemplate.content.cloneNode(true)); + } + + focus() { + let helpLink = this.shadowRoot.querySelector(".intro-help-link"); + helpLink.focus(); + } + + handleEvent(event) { + if ( + event.currentTarget.classList.contains("intro-import-text") && + event.target.localName == "a" + ) { + let eventName = + event.target.dataset.l10nName == "import-file-link" + ? "AboutLoginsImportFromFile" + : "AboutLoginsImportFromBrowser"; + document.dispatchEvent( + new CustomEvent(eventName, { + bubbles: true, + }) + ); + } + event.preventDefault(); + } + + updateState(syncState) { + let l10nId = syncState.loggedIn + ? "about-logins-login-intro-heading-logged-in" + : "about-logins-login-intro-heading-logged-out2"; + document.l10n.setAttributes( + this.shadowRoot.querySelector(".heading"), + l10nId + ); + + this.shadowRoot + .querySelector(".illustration") + .classList.toggle("logged-in", syncState.loggedIn); + let supportURL = + window.AboutLoginsUtils.supportBaseURL + + "password-manager-remember-delete-edit-logins"; + this.shadowRoot + .querySelector(".intro-help-link") + .setAttribute("href", supportURL); + + let importClass = window.AboutLoginsUtils.fileImportEnabled + ? ".intro-import-text.file-import" + : ".intro-import-text.no-file-import"; + let importText = this.shadowRoot.querySelector(importClass); + importText.addEventListener("click", this); + importText.hidden = !window.AboutLoginsUtils.importVisible; + } +} +customElements.define("login-intro", LoginIntro); diff --git a/browser/components/aboutlogins/content/components/login-item.css b/browser/components/aboutlogins/content/components/login-item.css new file mode 100644 index 0000000000..e11cb01700 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-item.css @@ -0,0 +1,444 @@ +/* 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/. */ + + :host { + overflow: hidden; + + --reveal-checkbox-opacity: .8; + --reveal-checkbox-opacity-hover: .6; + --reveal-checkbox-opacity-active: 1; + --success-color: #2AC3A2; + --edit-delete-button-color: #4a4a4f; +} + +/* Only overwrite the deemphasized text color in non-dark mode. */ +@media not (prefers-color-scheme: dark) { + :host { + --text-color-deemphasized: #737373; + } +} + +@media (prefers-color-scheme: dark) { + :host { + --reveal-checkbox-opacity: .8; + --reveal-checkbox-opacity-hover: 1; + --reveal-checkbox-opacity-active: .6; + --success-color: #54FFBD; + --edit-delete-button-color: #cfcfd1; + } +} + +.container { + overflow: auto; + padding: 0 40px; + box-sizing: border-box; + height: 100%; +} + +@media (max-width: 830px) { + .container { + padding-inline: 20px; + } +} + +.column { + min-height: 100%; + max-width: 700px; + display: flex; + flex-direction: column; +} + +button { + min-width: 100px; +} + +form { + flex-grow: 1; +} + +:host([data-editing]) .edit-button, +:host([data-editing]) .copy-button, +:host([data-is-new-login]) .delete-button, +:host([data-is-new-login]) .origin-saved-value, +:host([data-is-new-login]) login-timeline, +:host([data-is-new-login]) .login-item-title, +:host(:not([data-is-new-login])) .new-login-title, +:host(:not([data-editing])) .form-actions-row { + display: none; +} + +input[type="password"], +input[type="text"], +input[type="url"] { + text-align: match-parent !important; /* override `all: unset` in the rule below */ +} + +:host(:not([data-editing])) input[type="password"]:read-only, +input[type="text"]:read-only, +input[type="url"]:read-only { + all: unset; + font-size: 1.1em; + display: inline-block; + background-color: transparent !important; /* override common.inc.css */ + text-overflow: ellipsis; + overflow: hidden; + width: 100%; +} + +/* We can't use `margin-inline-start` here because we force + * the input to have dir="ltr", so we set the margin manually + * using the parent element's directionality. */ +.detail-cell:dir(ltr) input:not([type="checkbox"]) { + margin-left: 0; +} + +.detail-cell:dir(rtl) input:not([type="checkbox"]) { + margin-right: 0; +} + +.save-changes-button { + margin-inline-start: 0; /* Align the button on the start side */ +} + +.header { + display: flex; + align-items: center; + margin-bottom: 40px; +} + +.title { + margin-block: 0; + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.delete-button, +.edit-button { + color: var(--edit-delete-button-color); + background-repeat: no-repeat; + background-position: 8px; + -moz-context-properties: fill; + fill: currentColor; + min-width: auto; + /* See bug 1627164: In CJK locales, line break could happen in any letter of the button. The fix here is to explicitly specify flex property so that the button couldn't grow or shrink. */ + flex: 0 0 auto; +} + +.delete-button:dir(rtl), +.edit-button:dir(rtl) { + background-position-x: right 8px; +} + +.delete-button { + background-image: url("chrome://global/skin/icons/delete.svg"); + padding-inline-start: 30px; /* 8px on each side, and 14px for icon width */ +} + +.edit-button { + background-image: url("chrome://global/skin/icons/edit.svg"); + padding-inline-start: 32px; /* 8px on each side, and 16px for icon width */ +} + +input[type="url"]:read-only { + color: var(--in-content-link-color) !important; + cursor: pointer; +} + +input[type="url"]:read-only:hover { + color: var(--in-content-link-color-hover) !important; + text-decoration: underline; +} + +input[type="url"]:read-only:hover:active { + color: var(--in-content-link-color-active) !important; +} + +input[type = "url"]:focus:not(:-moz-ui-invalid):invalid ~ .tooltip-container { + display: block; +} + +input[type = "url"]:focus:-moz-ui-invalid:not(:placeholder-shown) ~ .tooltip-container { + display: block; +} + +.tooltip-container { + display: none; + position: absolute; + inset-inline-start: 315px; + width: 232px; + box-shadow: 2px 2px 10px 1px rgba(0,0,0,0.18); + top: 0; +} + +.tooltip-message { + margin: 0; + font-size: 14px; +} + +.arrow-box { + position: relative; + padding: 12px; + background-color: var(--in-content-box-background); + border: 1px solid var(--in-content-border-color); + border-radius: 4px; +} + +.arrow-box::before, +.arrow-box::after { + inset-inline-end: 100%; + top: 40px; /* This allows the arrow to stay in the correct position, even if the text length is changed */ + border: solid transparent; + content: ""; + height: 0; + width: 0; + position: absolute; + pointer-events: none; +} + +.arrow-box::after { + border-inline-end-color: var(--in-content-box-background); + border-width: 10px; + margin-top: -10px; +} +.arrow-box::before { + border-inline-end-color: var(--in-content-border-color); + border-width: 11px; + margin-top: -11px; +} + +.reveal-password-wrapper { + display: flex; + align-items: center; + justify-content: space-between; +} + +.detail-grid { + display: grid; + grid-template-columns: minmax(240px, max-content) auto; + grid-template-rows: auto; + column-gap: 20px; + row-gap: 40px; + justify-content: start; +} + +:host([data-editing]) .detail-grid { + grid-template-columns: auto; +} + +.detail-grid > .detail-row:not([hidden]) { + display: contents; +} + +.detail-grid > .detail-row > .detail-cell { + grid-column: 1; +} + +.detail-grid > .detail-row > .copy-button { + grid-column: 2; + margin-inline-start: 0; /* Reset button's margin so it doesn't affect the overall grid's width */ + justify-self: start; + align-self: end; +} + +.detail-row { + display: flex; + position: relative; /* Allows for the hint message to be positioned correctly */ +} + +.detail-grid, +.detail-row, +.form-actions-row { + margin-bottom: 40px; +} + +.detail-cell { + flex-grow: 1; + min-width: 0; /* Allow long passwords to collapse down to flex item width */ +} + +.field-label { + display: block; + font-size: smaller; + color: var(--text-color-deemphasized); + margin-bottom: 8px; +} + +moz-button-group, +:host([data-editing]) .detail-cell input:read-write:not([type="checkbox"]), +:host([data-editing]) input[type="password"]:read-only { + width: 298px; + box-sizing: border-box; +} + +.copy-button { + margin-bottom: 0; /* Align button at the bottom of the row */ +} + +.copy-button:not([data-copied]) .copied-button-text, +.copy-button[data-copied] .copy-button-text { + display: none; +} + +.copy-button[data-copied] { + color: var(--success-color) !important; /* override common.css */ + background-color: transparent; + opacity: 1; /* override common.css fading out disabled buttons */ +} + +.copy-button[data-copied]:-moz-focusring { + outline-width: 0; + box-shadow: none; +} + +.copied-button-text { + background-image: url(chrome://global/skin/icons/check.svg); + background-repeat: no-repeat; + -moz-context-properties: fill; + fill: currentColor; + padding-inline-start: 22px; +} + +.copied-button-text:dir(rtl) { + background-position-x: right; +} + +input.password-display, +input[name="password"] { + font-family: monospace !important; /* override `all: unset` in the rule above */ +} + +.reveal-password-checkbox { + /* !important is needed to override common.css styling for checkboxes */ + background-color: transparent !important; + border-width: 0 !important; + background-image: url("resource://gre-resources/password.svg") !important; + margin-inline: 10px 0 !important; + cursor: pointer; + -moz-context-properties: fill; + fill: currentColor !important; + color: inherit !important; + opacity: var(--reveal-checkbox-opacity); + flex-shrink: 0; +} + +.reveal-password-checkbox:hover { + opacity: var(--reveal-checkbox-opacity-hover); +} + +.reveal-password-checkbox:hover:active { + opacity: var(--reveal-checkbox-opacity-active); +} + +.reveal-password-checkbox:checked { + background-image: url("resource://gre-resources/password-hide.svg") !important; +} + +.login-item-favicon { + margin-inline-end: 12px; + height: 24px; + width: 24px; + flex-shrink: 0; + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + fill-opacity: 0.75; +} + +.vulnerable-alert, +.breach-alert { + border-radius: 4px; + border: 1px solid var(--in-content-border-color); + box-shadow: 0 2px 8px 0 var(--grey-90-a10); + font-size: .9em; + line-height: 1.4; + padding-block: 12px; + padding-inline: 64px 32px; + margin-block-end: 40px; + position: relative; +} + +.breach-alert { + background-color: var(--red-70); + color: #fff; +} + +.vulnerable-alert { + background-color: var(--in-content-box-background); + color: var(--in-content-text-color); +} + +.alert-title { + font-size: 22px; + font-weight: normal; + line-height: 1em; + margin-block: 0 12px; +} + +.alert-date { + display: block; + font-weight: 600; +} + +.alert-link:visited, +.alert-link { + font-weight: 600; + overflow-wrap: anywhere; +} + +.breach-alert > .alert-link:visited, +.breach-alert > .alert-link { + color: inherit; + text-decoration: underline; +} + +.alert-icon { + position: absolute; + inset-block-start: 16px; + inset-inline-start: 32px; + -moz-context-properties: fill; + fill: currentColor; + width: 24px; +} + +.alert-learn-more-link:hover, +.alert-learn-more-link:visited, +.alert-learn-more-link { + position: absolute; + inset-block-start: 16px; + inset-inline-end: 32px; + color: inherit; + font-size: 13px; +} + +.vulnerable-alert > .alert-learn-more-link { + color: var(--text-color-deemphasized); +} + +.error-message { + color: #fff; + background-color: var(--red-60); + border: 1px solid transparent; + padding-block: 6px; + display: inline-block; + padding-inline: 32px 16px; + background-image: url("chrome://global/skin/icons/warning.svg"); + background-repeat: no-repeat; + background-position: left 10px center; + -moz-context-properties: fill; + fill: currentColor; + margin-bottom: 38px; +} + +.error-message:dir(rtl) { + background-position-x: right 10px; +} + +.error-message-link > a, +.error-message-link > a:hover, +.error-message-link > a:hover:active { + color: currentColor; + text-decoration: underline; + font-weight: 600; +} 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 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); diff --git a/browser/components/aboutlogins/content/components/login-list-item.mjs b/browser/components/aboutlogins/content/components/login-list-item.mjs new file mode 100644 index 0000000000..2fe37c6b12 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-list-item.mjs @@ -0,0 +1,81 @@ +/* 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/. */ + +/** + * LoginListItemFactory is used instead of the "login-list-item" custom element + * since there may be 100s of login-list-items on about:logins and each + * custom element carries with it significant overhead when used in large + * numbers. + */ +export default class LoginListItemFactory { + static create(login) { + let template = document.querySelector("#login-list-item-template"); + let fragment = template.content.cloneNode(true); + let listItem = fragment.firstElementChild; + + LoginListItemFactory.update(listItem, login); + + return listItem; + } + + static update(listItem, login) { + let title = listItem.querySelector(".title"); + let username = listItem.querySelector(".username"); + let alertIcon = listItem.querySelector(".alert-icon"); + + const favicon = listItem.querySelector(".favicon"); + favicon.src = `page-icon:${login.origin}`; + + if (!login.guid) { + listItem.id = "new-login-list-item"; + document.l10n.setAttributes(title, "login-list-item-title-new-login"); + document.l10n.setAttributes( + username, + "login-list-item-subtitle-new-login" + ); + return; + } + + // Prepend the ID with a string since IDs must not begin with a number. + if (!listItem.id) { + listItem.id = "lli-" + login.guid; + listItem.dataset.guid = login.guid; + } + if (title.textContent != login.title) { + title.textContent = login.title; + } + + let trimmedUsernameValue = login.username.trim(); + if (trimmedUsernameValue) { + if (username.textContent != trimmedUsernameValue) { + username.removeAttribute("data-l10n-id"); + username.textContent = trimmedUsernameValue; + } + } else { + document.l10n.setAttributes( + username, + "login-list-item-subtitle-missing-username" + ); + } + + if (listItem.classList.contains("breached")) { + alertIcon.src = + "chrome://browser/content/aboutlogins/icons/breached-website.svg"; + document.l10n.setAttributes( + alertIcon, + "about-logins-list-item-breach-icon" + ); + } else if (listItem.classList.contains("vulnerable")) { + alertIcon.src = + "chrome://browser/content/aboutlogins/icons/vulnerable-password.svg"; + + document.l10n.setAttributes( + alertIcon, + "about-logins-list-item-vulnerable-password-icon" + ); + } else { + alertIcon.src = ""; + } + } +} diff --git a/browser/components/aboutlogins/content/components/login-list-section.mjs b/browser/components/aboutlogins/content/components/login-list-section.mjs new file mode 100644 index 0000000000..5495f55e28 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-list-section.mjs @@ -0,0 +1,34 @@ +/* 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/. */ + +export default class LoginListHeaderFactory { + static ID_PREFIX = "id-"; + + static create(header) { + let template = document.querySelector("#login-list-section-template"); + let fragment = template.content.cloneNode(true); + let sectionItem = fragment.firstElementChild; + + this.update(sectionItem, header); + + return sectionItem; + } + + static update(headerItem, header) { + let headerElement = headerItem.querySelector(".login-list-header"); + if (header) { + if (header.startsWith(this.ID_PREFIX)) { + document.l10n.setAttributes( + headerElement, + header.substring(this.ID_PREFIX.length) + ); + } else { + headerElement.textContent = header; + } + headerElement.hidden = false; + } else { + headerElement.hidden = true; + } + } +} diff --git a/browser/components/aboutlogins/content/components/login-list.css b/browser/components/aboutlogins/content/components/login-list.css new file mode 100644 index 0000000000..b58af780a1 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-list.css @@ -0,0 +1,202 @@ +/* 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/. */ + +:host { + border-inline-end: 1px solid var(--in-content-border-color); + background-color: var(--in-content-box-background); + display: flex; + flex-direction: column; + overflow: auto; +} + +.meta { + display: flex; + align-items: center; + padding: 0 16px 16px; + border-bottom: 1px solid var(--in-content-border-color); + background-color: var(--in-content-box-background); + color: var(--text-color-deemphasized); + font-size: 0.8em; +} + +.meta > label > span { + margin-inline-end: 2px; +} + +#login-sort { + background-color: transparent; + margin: 0; + padding-inline: 0 16px; + min-height: initial; + font: inherit; + font-weight: 600; + color: var(--in-content-text-color) !important; +} + +#login-sort:hover:not([disabled]) { + background-color: var(--in-content-button-background); +} + +#login-sort > option { + font-weight: normal; +} + +.count { + flex-grow: 1; + text-align: end; + margin-inline-start: 18px; +} + +.container { + display: contents; +} + +.listHeader { + display: flex; + justify-content: center; + align-content: center; + gap: 16px; + padding: 16px; +} + +:host(.no-logins) .empty-search-message, +:host(:not(.empty-search)) .empty-search-message, +:host(.empty-search:not(.create-login-selected)) ol, +:host(.no-logins:not(.create-login-selected)) ol, +:host(:not(.no-logins)) .intro, +:host(.create-login-selected) .intro, +:host(.create-login-selected) .empty-search-message { + display: none; +} + +:host(:not(.initialized)) .count, +:host(:not(.initialized)) .empty-search-message { + visibility: hidden; +} + +.empty-search-message, +.intro { + text-align: center; + padding: 1em; + max-width: 50ch; /* This should be kept in sync with login-list-item username and title max-width */ + flex-grow: 1; + border-bottom: 1px solid var(--in-content-border-color); +} + +.empty-search-message span, +.intro span { + font-size: 0.85em; +} + +ol { + outline-offset: var(--in-content-focus-outline-inset); + margin-block: 0; + padding-inline-start: 0; + overflow: hidden auto; + flex-grow: 1; + scroll-padding-top: 24px; /* there is the section header that is sticky to the top */ +} + +.create-login-button { + margin: 0; + min-width: auto; + background-repeat: no-repeat; + background-image: url("chrome://global/skin/icons/plus.svg"); + background-position: center; + -moz-context-properties: fill; + fill: currentColor; +} + +.login-list-item { + display: flex; + align-items: center; + padding-block: 10px; + padding-inline: 12px 18px; + border-inline-start: 4px solid transparent; + user-select: none; +} + +.login-list-header { + display: block; + position: sticky; + top: 0; + font-size: .85em; + font-weight: 600; + padding: 4px 16px; + border-bottom: 1px solid var(--in-content-border-color); + background-color: var(--in-content-box-background); + margin-block-start: 2px; + margin-inline: 2px; +} + +.login-list-item:not(.selected):hover { + background-color: var(--in-content-button-background-hover); + color: var(--in-content-button-text-color-hover); +} + +.login-list-item:not(.selected):hover:active { + background-color: var(--in-content-button-background-active); + color: var(--in-content-button-text-color-active); +} + +.login-list-item.keyboard-selected { + border-inline-start-color: var(--in-content-border-color); + background-color: var(--in-content-button-background-hover); +} + +.login-list-item.selected { + border-inline-start-color: var(--in-content-accent-color); + background-color: var(--in-content-page-background); +} + +.login-list-item.selected .title { + font-weight: 600; +} + +.labels { + flex-grow: 1; + overflow: hidden; + min-height: 40px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.title, +.username { + display: block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.favicon { + height: 16px; + width: 16px; + margin-inline-end: 12px; + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + fill-opacity: 0.8; +} + +.username { + font-size: 0.85em; + color: var(--text-color-deemphasized); +} + +.alert-icon { + min-width: 16px; + width: 16px; + margin-inline-start: 12px; + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + fill-opacity: 0.75; +} + +@media not (prefers-contrast) { + .breached > .alert-icon { + fill: var(--red-60); + fill-opacity: 1; + } +} diff --git a/browser/components/aboutlogins/content/components/login-list.mjs b/browser/components/aboutlogins/content/components/login-list.mjs new file mode 100644 index 0000000000..2af1a12a7a --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-list.mjs @@ -0,0 +1,912 @@ +/* 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 LoginListItemFactory from "./login-list-item.mjs"; +import LoginListSectionFactory from "./login-list-section.mjs"; +import { recordTelemetryEvent } from "../aboutLoginsUtils.mjs"; + +const collator = new Intl.Collator(); +const monthFormatter = new Intl.DateTimeFormat(undefined, { month: "long" }); +const yearMonthFormatter = new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "long", +}); +const dayDuration = 24 * 60 * 60_000; +const sortFnOptions = { + name: (a, b) => collator.compare(a.title, b.title), + "name-reverse": (a, b) => collator.compare(b.title, a.title), + username: (a, b) => collator.compare(a.username, b.username), + "username-reverse": (a, b) => collator.compare(b.username, a.username), + "last-used": (a, b) => a.timeLastUsed < b.timeLastUsed, + "last-changed": (a, b) => a.timePasswordChanged < b.timePasswordChanged, + alerts: (a, b, breachesByLoginGUID, vulnerableLoginsByLoginGUID) => { + const aIsBreached = breachesByLoginGUID && breachesByLoginGUID.has(a.guid); + const bIsBreached = breachesByLoginGUID && breachesByLoginGUID.has(b.guid); + const aIsVulnerable = + vulnerableLoginsByLoginGUID && vulnerableLoginsByLoginGUID.has(a.guid); + const bIsVulnerable = + vulnerableLoginsByLoginGUID && vulnerableLoginsByLoginGUID.has(b.guid); + + if ((aIsBreached && !bIsBreached) || (aIsVulnerable && !bIsVulnerable)) { + return -1; + } + + if ((!aIsBreached && bIsBreached) || (!aIsVulnerable && bIsVulnerable)) { + return 1; + } + return sortFnOptions.name(a, b); + }, +}; + +const headersFnOptions = { + // TODO: name should use the ICU API, see Bug 1592834 + // name: l => + // l.title.length && letterRegExp.test(l.title[0]) + // ? l.title[0].toUpperCase() + // : "#", + // "name-reverse": l => headersFnOptions.name(l), + name: () => "", + "name-reverse": () => "", + username: () => "", + "username-reverse": () => "", + "last-used": l => headerFromDate(l.timeLastUsed), + "last-changed": l => headerFromDate(l.timePasswordChanged), + alerts: (l, breachesByLoginGUID, vulnerableLoginsByLoginGUID) => { + const isBreached = breachesByLoginGUID && breachesByLoginGUID.has(l.guid); + const isVulnerable = + vulnerableLoginsByLoginGUID && vulnerableLoginsByLoginGUID.has(l.guid); + if (isBreached) { + return ( + LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-breach" + ); + } else if (isVulnerable) { + return ( + LoginListSectionFactory.ID_PREFIX + + "about-logins-list-section-vulnerable" + ); + } + return ( + LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-nothing" + ); + }, +}; + +function headerFromDate(timestamp) { + let now = new Date(); + now.setHours(0, 0, 0, 0); // reset to start of day + let date = new Date(timestamp); + + if (now < date) { + return ( + LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-today" + ); + } else if (now - dayDuration < date) { + return ( + LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-yesterday" + ); + } else if (now - 7 * dayDuration < date) { + return LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-week"; + } else if (now.getFullYear() == date.getFullYear()) { + return monthFormatter.format(date); + } else if (now.getFullYear() - 1 == date.getFullYear()) { + return yearMonthFormatter.format(date); + } + return String(date.getFullYear()); +} + +export default class LoginList extends HTMLElement { + // An array of login GUIDs, stored in sorted order. + _loginGuidsSortedOrder = []; + // A map of login GUID -> {login, listItem}. + _logins = {}; + // A map of section header -> sectionItem + _sections = {}; + _filter = ""; + _selectedGuid = null; + _blankLoginListItem = LoginListItemFactory.create({}); + + constructor() { + super(); + this._blankLoginListItem.hidden = true; + } + + connectedCallback() { + if (this.shadowRoot) { + return; + } + let loginListTemplate = document.querySelector("#login-list-template"); + let shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + shadowRoot.appendChild(loginListTemplate.content.cloneNode(true)); + + this._count = shadowRoot.querySelector(".count"); + this._createLoginButton = shadowRoot.querySelector(".create-login-button"); + this._list = shadowRoot.querySelector("ol"); + this._list.appendChild(this._blankLoginListItem); + this._sortSelect = shadowRoot.querySelector("#login-sort"); + + this.render(); + + this.shadowRoot + .getElementById("login-sort") + .addEventListener("change", this); + window.addEventListener("AboutLoginsClearSelection", this); + window.addEventListener("AboutLoginsFilterLogins", this); + window.addEventListener("AboutLoginsInitialLoginSelected", this); + window.addEventListener("AboutLoginsLoginSelected", this); + window.addEventListener("AboutLoginsShowBlankLogin", this); + this._list.addEventListener("click", this); + this.addEventListener("keydown", this); + this.addEventListener("keyup", this); + this._createLoginButton.addEventListener("click", this); + } + + get #activeDescendant() { + const activeDescendantId = this._list.getAttribute("aria-activedescendant"); + let activeDescendant = + activeDescendantId && this.shadowRoot.getElementById(activeDescendantId); + + return activeDescendant; + } + + selectLoginByDomainOrGuid(searchParam) { + this._preselectLogin = searchParam; + } + + render() { + let visibleLoginGuids = this._applyFilter(); + this.#updateVisibleLoginCount( + visibleLoginGuids.size, + this._loginGuidsSortedOrder.length + ); + this.classList.toggle("empty-search", !visibleLoginGuids.size); + document.documentElement.classList.toggle( + "empty-search", + this._filter && !visibleLoginGuids.size + ); + this._sortSelect.disabled = !visibleLoginGuids.size; + + // Add all of the logins that are not in the DOM yet. + let fragment = document.createDocumentFragment(); + for (let guid of this._loginGuidsSortedOrder) { + if (this._logins[guid].listItem) { + continue; + } + let login = this._logins[guid].login; + let listItem = LoginListItemFactory.create(login); + this._logins[login.guid] = Object.assign(this._logins[login.guid], { + listItem, + }); + 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, login } = this._logins[guid]; + + if (guid == this._selectedGuid) { + this._setListItemAsSelected(listItem); + } + listItem.classList.toggle( + "breached", + !!this._breachesByLoginGUID && + this._breachesByLoginGUID.has(listItem.dataset.guid) + ); + listItem.classList.toggle( + "vulnerable", + !!this._vulnerableLoginsByLoginGUID && + this._vulnerableLoginsByLoginGUID.has(listItem.dataset.guid) && + !listItem.classList.contains("breached") + ); + if ( + listItem.classList.contains("breached") || + listItem.classList.contains("vulnerable") + ) { + LoginListItemFactory.update(listItem, login); + } + listItem.hidden = !visibleLoginGuids.has(listItem.dataset.guid); + } + + let sectionsKey = Object.keys(this._sections); + for (let sectionKey of sectionsKey) { + this._sections[sectionKey]._inUse = false; + } + + if (this._loginGuidsSortedOrder.length) { + let section = null; + let currentHeader = null; + // Re-arrange the login-list-items according to their sort and + // create / re-arrange sections + for (let i = this._loginGuidsSortedOrder.length - 1; i >= 0; i--) { + let guid = this._loginGuidsSortedOrder[i]; + let { listItem, _header } = this._logins[guid]; + + if (!listItem.hidden) { + if (currentHeader != _header) { + section = this.renderSectionHeader((currentHeader = _header)); + } + + section.insertBefore( + listItem, + section.firstElementChild.nextElementSibling + ); + } + } + } + + for (let sectionKey of sectionsKey) { + let section = this._sections[sectionKey]; + if (section._inUse) { + continue; + } + + section.hidden = true; + } + + let activeDescendant = this.#activeDescendant; + if (!activeDescendant || activeDescendant.hidden) { + let visibleListItem = this._list.querySelector( + ".login-list-item:not([hidden])" + ); + if (visibleListItem) { + this._list.setAttribute("aria-activedescendant", visibleListItem.id); + } + } + + if ( + this._sortSelect.namedItem("alerts").hidden && + ((this._breachesByLoginGUID && + this._loginGuidsSortedOrder.some(loginGuid => + this._breachesByLoginGUID.has(loginGuid) + )) || + (this._vulnerableLoginsByLoginGUID && + this._loginGuidsSortedOrder.some(loginGuid => + this._vulnerableLoginsByLoginGUID.has(loginGuid) + ))) + ) { + // Make available the "alerts" option but don't change the + // selected sort so the user's current task isn't interrupted. + this._sortSelect.namedItem("alerts").hidden = false; + } + } + + renderSectionHeader(header) { + let section = this._sections[header]; + if (!section) { + section = this._sections[header] = LoginListSectionFactory.create(header); + } + + this._list.insertBefore( + section, + this._blankLoginListItem.nextElementSibling + ); + + section._inUse = true; + section.hidden = false; + return section; + } + + handleEvent(event) { + switch (event.type) { + case "click": { + if (event.originalTarget == this._createLoginButton) { + window.dispatchEvent( + new CustomEvent("AboutLoginsShowBlankLogin", { + cancelable: true, + }) + ); + recordTelemetryEvent({ object: "new_login", method: "new" }); + return; + } + + let listItem = event.originalTarget.closest(".login-list-item"); + if (!listItem || !listItem.dataset.guid) { + return; + } + + let { login } = this._logins[listItem.dataset.guid]; + this.dispatchEvent( + new CustomEvent("AboutLoginsLoginSelected", { + bubbles: true, + composed: true, + cancelable: true, // allow calling preventDefault() on event + detail: login, + }) + ); + + let extra = {}; + if (listItem.classList.contains("breached")) { + extra = { breached: "true" }; + } else if (listItem.classList.contains("vulnerable")) { + extra = { vulnerable: "true" }; + } + recordTelemetryEvent({ + object: "existing_login", + method: "select", + extra, + }); + break; + } + case "change": { + this._applyHeaders(); + this._applySortAndScrollToTop(); + const extra = { sort_key: this._sortSelect.value }; + recordTelemetryEvent({ object: "list", method: "sort", extra }); + document.dispatchEvent( + new CustomEvent("AboutLoginsSortChanged", { + bubbles: true, + detail: this._sortSelect.value, + }) + ); + break; + } + case "AboutLoginsClearSelection": { + if (!this._loginGuidsSortedOrder.length) { + this._createLoginButton.disabled = false; + this.classList.remove("create-login-selected"); + return; + } + + let firstVisibleListItem = this._list.querySelector( + ".login-list-item[data-guid]:not([hidden])" + ); + let newlySelectedLogin; + if (firstVisibleListItem) { + newlySelectedLogin = + this._logins[firstVisibleListItem.dataset.guid].login; + } else { + // Clear the filter if all items have been filtered out. + this.classList.remove("create-login-selected"); + this._createLoginButton.disabled = false; + window.dispatchEvent( + new CustomEvent("AboutLoginsFilterLogins", { + detail: "", + }) + ); + newlySelectedLogin = + this._logins[this._loginGuidsSortedOrder[0]].login; + } + + // Select the first visible login after any possible filter is applied. + window.dispatchEvent( + new CustomEvent("AboutLoginsLoginSelected", { + detail: newlySelectedLogin, + cancelable: true, + }) + ); + break; + } + case "AboutLoginsFilterLogins": { + this._filter = event.detail.toLocaleLowerCase(); + this.render(); + break; + } + case "AboutLoginsInitialLoginSelected": + case "AboutLoginsLoginSelected": { + if (event.defaultPrevented || this._selectedGuid == event.detail.guid) { + return; + } + + // XXX If an AboutLoginsLoginSelected event is received that doesn't contain + // the full login object, re-dispatch the event with the full login object since + // only the login-list knows the full details of each login object. + if ( + Object.keys(event.detail).length == 1 && + event.detail.hasOwnProperty("guid") + ) { + window.dispatchEvent( + new CustomEvent("AboutLoginsLoginSelected", { + detail: this._logins[event.detail.guid].login, + cancelable: true, + }) + ); + return; + } + + let listItem = this._list.querySelector( + `.login-list-item[data-guid="${event.detail.guid}"]` + ); + if (listItem) { + this._setListItemAsSelected(listItem); + } else { + this.render(); + } + break; + } + case "AboutLoginsShowBlankLogin": { + if (!event.defaultPrevented) { + this._selectedGuid = null; + this._setListItemAsSelected(this._blankLoginListItem); + } + break; + } + case "keyup": + case "keydown": { + if (event.type == "keydown") { + if ( + this.shadowRoot.activeElement && + this.shadowRoot.activeElement.closest("ol") && + (event.key == " " || + event.key == "ArrowUp" || + event.key == "ArrowDown") + ) { + // Since Space, ArrowUp and ArrowDown will perform actions, prevent + // them from also scrolling the list. + event.preventDefault(); + } + } + + this._handleKeyboardNavWithinList(event); + break; + } + } + } + + /** + * @param {login[]} logins An array of logins used for displaying in the list. + */ + setLogins(logins) { + this._loginGuidsSortedOrder = []; + this._logins = logins.reduce((map, login) => { + this._loginGuidsSortedOrder.push(login.guid); + map[login.guid] = { login }; + return map; + }, {}); + this._sections = {}; + this._applyHeaders(); + this._applySort(); + this._list.textContent = ""; + this._list.appendChild(this._blankLoginListItem); + this.render(); + + if (!this._selectedGuid || !this._logins[this._selectedGuid]) { + this._selectFirstVisibleLogin(); + } + } + + /** + * @param {Map} breachesByLoginGUID A Map of breaches by login GUIDs used + * for displaying breached login indicators. + */ + setBreaches(breachesByLoginGUID) { + this._internalSetMonitorData("_breachesByLoginGUID", breachesByLoginGUID); + } + + /** + * @param {Map} breachesByLoginGUID A Map of breaches by login GUIDs that + * should be added to the local cache of + * breaches. + */ + updateBreaches(breachesByLoginGUID) { + this._internalUpdateMonitorData( + "_breachesByLoginGUID", + breachesByLoginGUID + ); + } + + setVulnerableLogins(vulnerableLoginsByLoginGUID) { + this._internalSetMonitorData( + "_vulnerableLoginsByLoginGUID", + vulnerableLoginsByLoginGUID + ); + } + + updateVulnerableLogins(vulnerableLoginsByLoginGUID) { + this._internalUpdateMonitorData( + "_vulnerableLoginsByLoginGUID", + vulnerableLoginsByLoginGUID + ); + } + + _internalSetMonitorData( + internalMemberName, + mapByLoginGUID, + updateSortAndSelectedLogin = true + ) { + this[internalMemberName] = mapByLoginGUID; + if (this[internalMemberName].size) { + for (let [loginGuid] of mapByLoginGUID) { + if (this._logins[loginGuid]) { + let { login, listItem } = this._logins[loginGuid]; + LoginListItemFactory.update(listItem, login); + } + } + if (updateSortAndSelectedLogin) { + const alertsSortOptionElement = this._sortSelect.namedItem("alerts"); + alertsSortOptionElement.hidden = false; + this._sortSelect.selectedIndex = alertsSortOptionElement.index; + this._applyHeaders(); + this._applySortAndScrollToTop(); + this._selectFirstVisibleLogin(); + } + } + this.render(); + } + + _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], + false + ); + } + + setSortDirection(sortDirection) { + // The 'alerts' sort becomes visible when there are known alerts. + // Don't restore to the 'alerts' sort if there are no alerts to show. + if ( + sortDirection == "alerts" && + this._sortSelect.namedItem("alerts").hidden + ) { + return; + } + this._sortSelect.value = sortDirection; + this._applyHeaders(); + this._applySortAndScrollToTop(); + this._selectFirstVisibleLogin(); + } + + /** + * @param {login} login A login that was added to storage. + */ + loginAdded(login) { + this._logins[login.guid] = { login }; + this._loginGuidsSortedOrder.push(login.guid); + this._applyHeaders(false); + this._applySort(); + + // Add the list item and update any other related state that may pertain + // to the list item such as breach alerts. + this.render(); + + if ( + this.classList.contains("no-logins") && + !this.classList.contains("create-login-selected") + ) { + this._selectFirstVisibleLogin(); + } + } + + /** + * @param {login} login A login that was modified in storage. The related + * login-list-item will get updated. + */ + loginModified(login) { + this._logins[login.guid] = Object.assign(this._logins[login.guid], { + login, + _header: null, // reset header + }); + this._applyHeaders(false); + this._applySort(); + let loginObject = this._logins[login.guid]; + LoginListItemFactory.update(loginObject.listItem, login); + + // Update any other related state that may pertain to the list item + // such as breach alerts that may or may not now apply. + this.render(); + } + + /** + * @param {login} login A login that was removed from storage. The related + * login-list-item will get removed. The login object + * is a plain JS object representation of + * nsILoginInfo/nsILoginMetaInfo. + */ + loginRemoved(login) { + // Update the selected list item to the previous item in the list + // if one exists, otherwise the next item. If no logins remain + // the login-intro or empty-search text will be shown instead of the login-list. + if (this._selectedGuid == login.guid) { + let visibleListItems = this._list.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ); + if (visibleListItems.length > 1) { + let index = [...visibleListItems].findIndex(listItem => { + return listItem.dataset.guid == login.guid; + }); + let newlySelectedIndex = index > 0 ? index - 1 : index + 1; + let newlySelectedLogin = + this._logins[visibleListItems[newlySelectedIndex].dataset.guid].login; + window.dispatchEvent( + new CustomEvent("AboutLoginsLoginSelected", { + detail: newlySelectedLogin, + cancelable: true, + }) + ); + } + } + + this._logins[login.guid].listItem.remove(); + delete this._logins[login.guid]; + this._loginGuidsSortedOrder = this._loginGuidsSortedOrder.filter(guid => { + return guid != login.guid; + }); + + // Render the login-list to update the search result count and show the + // empty-search message if needed. + this.render(); + } + + /** + * @returns {Set} Set of login guids that match the filter. + */ + _applyFilter() { + let matchingLoginGuids; + if (this._filter) { + matchingLoginGuids = new Set( + this._loginGuidsSortedOrder.filter(guid => { + let { login } = this._logins[guid]; + return ( + login.origin.toLocaleLowerCase().includes(this._filter) || + (!!login.httpRealm && + login.httpRealm.toLocaleLowerCase().includes(this._filter)) || + login.username.toLocaleLowerCase().includes(this._filter) || + login.password.toLocaleLowerCase().includes(this._filter) + ); + }) + ); + } else { + matchingLoginGuids = new Set([...this._loginGuidsSortedOrder]); + } + + return matchingLoginGuids; + } + + _applySort() { + const sort = this._sortSelect.value; + this._loginGuidsSortedOrder = this._loginGuidsSortedOrder.sort((a, b) => { + let loginA = this._logins[a].login; + let loginB = this._logins[b].login; + return sortFnOptions[sort]( + loginA, + loginB, + this._breachesByLoginGUID, + this._vulnerableLoginsByLoginGUID + ); + }); + } + + _applyHeaders(updateAll = true) { + let headerFn = headersFnOptions[this._sortSelect.value]; + for (let guid of this._loginGuidsSortedOrder) { + let login = this._logins[guid]; + if (updateAll || !login._header) { + login._header = headerFn( + login.login, + this._breachesByLoginGUID, + this._vulnerableLoginsByLoginGUID + ); + } + } + } + + _applySortAndScrollToTop() { + this._applySort(); + this.render(); + this._list.scrollTop = 0; + } + + #updateVisibleLoginCount(count, total) { + const args = document.l10n.getAttributes(this._count).args; + if (count != args.count || total != args.total) { + document.l10n.setAttributes( + this._count, + count == total ? "login-list-count" : "login-list-filtered-count", + { count, total } + ); + } + } + + #findPreviousItem(item) { + let previousItem = item; + do { + previousItem = + (previousItem.tagName == "SECTION" + ? previousItem.lastElementChild + : previousItem.previousElementSibling) || + (previousItem.parentElement.tagName == "SECTION" && + previousItem.parentElement.previousElementSibling); + } while ( + previousItem && + (previousItem.hidden || + !previousItem.classList.contains("login-list-item")) + ); + + return previousItem; + } + + #findNextItem(item) { + let nextItem = item; + do { + nextItem = + (nextItem.tagName == "SECTION" + ? nextItem.firstElementChild.nextElementSibling + : nextItem.nextElementSibling) || + (nextItem.parentElement.tagName == "SECTION" && + nextItem.parentElement.nextElementSibling); + } while ( + nextItem && + (nextItem.hidden || !nextItem.classList.contains("login-list-item")) + ); + + return nextItem; + } + + #pickByDirection(ltr, rtl) { + return document.dir == "ltr" ? ltr : rtl; + } + + //TODO May be we can use this fn in render(), but logic is different a bit + get #activeDescendantForSelection() { + let activeDescendant = this.#activeDescendant; + if ( + !activeDescendant || + activeDescendant.hidden || + !activeDescendant.classList.contains("login-list-item") + ) { + activeDescendant = + this._list.querySelector(".login-list-item[data-guid]:not([hidden])") || + this._list.firstElementChild; + } + + return activeDescendant; + } + + _handleKeyboardNavWithinList(event) { + if (this._list != this.shadowRoot.activeElement) { + return; + } + + let command = null; + + switch (event.type) { + case "keyup": + switch (event.key) { + case " ": + case "Enter": + command = "click"; + break; + } + break; + case "keydown": + switch (event.key) { + case "ArrowDown": + command = "next"; + break; + case "ArrowLeft": + command = this.#pickByDirection("previous", "next"); + break; + case "ArrowRight": + command = this.#pickByDirection("next", "previous"); + break; + case "ArrowUp": + command = "previous"; + break; + } + break; + } + + if (command) { + event.preventDefault(); + + switch (command) { + case "click": + this.clickSelected(); + break; + case "next": + this.selectNext(); + break; + case "previous": + this.selectPrevious(); + break; + } + } + } + + clickSelected() { + this.#activeDescendantForSelection?.click(); + } + + selectNext() { + const activeDescendant = this.#activeDescendantForSelection; + if (activeDescendant) { + this.#moveSelection( + activeDescendant, + this.#findNextItem(activeDescendant) + ); + } + } + + selectPrevious() { + const activeDescendant = this.#activeDescendantForSelection; + if (activeDescendant) { + this.#moveSelection( + activeDescendant, + this.#findPreviousItem(activeDescendant) + ); + } + } + + #moveSelection(from, to) { + if (to) { + this._list.setAttribute("aria-activedescendant", to.id); + from?.classList.remove("keyboard-selected"); + to.classList.add("keyboard-selected"); + to.scrollIntoView({ block: "nearest" }); + this.clickSelected(); + } + } + + /** + * Selects the first visible login as part of the initial load of the page, + * which will bypass any focus changes that occur during manual login + * selection. + */ + _selectFirstVisibleLogin() { + const visibleLoginsGuids = this._applyFilter(); + let selectedLoginGuid = + this._loginGuidsSortedOrder.find(guid => guid === this._preselectLogin) ?? + this.findLoginGuidFromDomain(this._preselectLogin) ?? + this._loginGuidsSortedOrder[0]; + + selectedLoginGuid = [ + selectedLoginGuid, + ...this._loginGuidsSortedOrder, + ].find(guid => visibleLoginsGuids.has(guid)); + + if (selectedLoginGuid && this._logins[selectedLoginGuid]) { + let { login } = this._logins[selectedLoginGuid]; + window.dispatchEvent( + new CustomEvent("AboutLoginsInitialLoginSelected", { + detail: login, + }) + ); + this.updateSelectedLocationHash(selectedLoginGuid); + } + } + + _setListItemAsSelected(listItem) { + let oldSelectedItem = this._list.querySelector(".selected"); + if (oldSelectedItem) { + oldSelectedItem.classList.remove("selected"); + oldSelectedItem.removeAttribute("aria-selected"); + } + this.classList.toggle("create-login-selected", !listItem.dataset.guid); + this._blankLoginListItem.hidden = !!listItem.dataset.guid; + this._createLoginButton.disabled = !listItem.dataset.guid; + listItem.classList.add("selected"); + listItem.setAttribute("aria-selected", "true"); + this._list.setAttribute("aria-activedescendant", listItem.id); + this._selectedGuid = listItem.dataset.guid; + this.updateSelectedLocationHash(this._selectedGuid); + // Scroll item into view if it isn't visible + listItem.scrollIntoView({ block: "nearest" }); + } + + updateSelectedLocationHash(guid) { + window.location.hash = `#${encodeURIComponent(guid)}`; + } + + findLoginGuidFromDomain(domain) { + for (let guid of this._loginGuidsSortedOrder) { + let login = this._logins[guid].login; + if (login.hostname === domain) { + return guid; + } + } + return null; + } +} +customElements.define("login-list", LoginList); diff --git a/browser/components/aboutlogins/content/components/login-timeline.mjs b/browser/components/aboutlogins/content/components/login-timeline.mjs new file mode 100644 index 0000000000..52d053e999 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-timeline.mjs @@ -0,0 +1,137 @@ +/* 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 { + styleMap, + classMap, + html, + css, +} from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +export default class Timeline extends MozLitElement { + static get properties() { + return { + history: { type: Array }, + }; + } + + constructor() { + super(); + this.history = []; + } + + static styles = css` + .timeline { + display: grid; + grid-template-rows: 24px auto auto; + font-size: smaller; + color: var(--text-color-deemphasized); + padding-inline-start: 0px; + text-align: center; + } + + .timeline.empty { + display: none; + } + + .timeline > svg { + grid-row: 1 / 1; + fill: var(--in-content-box-background); + } + + .timeline > .line { + height: 2px; + justify-self: stretch; + align-self: center; + background-color: var(--in-content-border-color); + grid-row: 1; + } + + .timeline > .line:nth-child(1) { + grid-column: 1; + width: 50%; + justify-self: flex-end; + } + + .timeline > .line:nth-child(2) { + grid-column: 2/-2; + } + + .timeline > .line:nth-child(3) { + grid-column: -2; + width: 50%; + justify-self: flex-start; + } + + .timeline > .point { + width: 24px; + height: 24px; + stroke: var(--in-content-border-color); + stroke-width: 30px; + justify-self: center; + } + + .timeline > .date { + grid-row: 2; + padding: 4px 8px; + } + + .timeline > .action { + grid-row: 3; + } + `; + + render() { + this.history = this.history.filter(historyPoint => historyPoint.time); + this.history.sort((a, b) => a.time - b.time); + let columns = "auto"; + + // Add each history event to the timeline + let points = this.history.map((entry, index) => { + if (index > 0) { + // add a gap between previous point and current one + columns += ` ${entry.time - this.history[index - 1].time}fr auto`; + } + + let columnNumber = 2 * index + 1; + let styles = styleMap({ gridColumn: columnNumber }); + return html` + + + +
+
+ `; + }); + + return html` +
+
+
+
+ ${points} +
+ `; + } +} + +customElements.define("login-timeline", Timeline); diff --git a/browser/components/aboutlogins/content/components/menu-button.css b/browser/components/aboutlogins/content/components/menu-button.css new file mode 100644 index 0000000000..57e26676b3 --- /dev/null +++ b/browser/components/aboutlogins/content/components/menu-button.css @@ -0,0 +1,93 @@ +/* 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/. */ + +:host { + position: relative; +} + +.menu-button { + background-image: url("chrome://global/skin/icons/more.svg"); + background-repeat: no-repeat; + background-position: center; + -moz-context-properties: fill; + fill: currentColor; + width: 30px; + min-width: 30px; + margin: 0; +} + +.menu { + position: absolute; + inset-inline-end: 0; + margin: 0; + padding: 5px 0; + background-color: var(--in-content-box-background); + border: 1px solid var(--in-content-box-border-color); + border-radius: 4px; + box-shadow: var(--shadow-30); + min-width: max-content; + list-style-type: none; + display: flex; + flex-direction: column; + /* Show on top of .breach-alert which is also positioned */ + z-index: 1; + font: menu; +} + +.menuitem-button { + padding: 4px 8px; + /* 32px = 8px (padding) + 16px (icon) + 8px (padding) */ + padding-inline-start: 32px; + background-repeat: no-repeat; + background-position: left 8px center; + background-size: 16px; + -moz-context-properties: fill; + fill: currentColor; + + /* Override common.inc.css properties */ + margin: 0; + border: 0; + border-radius: 0; + text-align: start; + min-height: initial; + font: inherit; +} + +.menuitem-button:dir(rtl) { + background-position-x: right 8px; +} + +.menuitem-button:focus-visible { + outline-offset: var(--in-content-focus-outline-inset); +} + +.menuitem-separator { + border-top-width: 1px; + margin-block: 5px; + width: 100%; +} + +.menuitem-help { + background-image: url("chrome://global/skin/icons/help.svg"); +} + +.menuitem-import-browser { + background-image: url("chrome://browser/skin/import.svg"); +} + +.menuitem-import-file { + background-image: url("chrome://browser/skin/import.svg"); +} + +.menuitem-export { + background-image: url("chrome://browser/skin/save.svg"); +} + +.menuitem-remove-all-logins { + background-image: url("chrome://global/skin/icons/delete.svg"); +} + +.menuitem-preferences { + background-image: url("chrome://global/skin/icons/settings.svg"); +} diff --git a/browser/components/aboutlogins/content/components/menu-button.mjs b/browser/components/aboutlogins/content/components/menu-button.mjs new file mode 100644 index 0000000000..bb69b711c9 --- /dev/null +++ b/browser/components/aboutlogins/content/components/menu-button.mjs @@ -0,0 +1,183 @@ +/* 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/. */ + +export default class MenuButton extends HTMLElement { + connectedCallback() { + if (this.shadowRoot) { + return; + } + + let MenuButtonTemplate = document.querySelector("#menu-button-template"); + let shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + shadowRoot.appendChild(MenuButtonTemplate.content.cloneNode(true)); + + for (let menuitem of this.shadowRoot.querySelectorAll( + ".menuitem-button[data-supported-platforms]" + )) { + let supportedPlatforms = menuitem.dataset.supportedPlatforms + .split(",") + .map(platform => platform.trim()); + if (supportedPlatforms.includes(navigator.platform)) { + menuitem.hidden = false; + } + } + + this._menu = this.shadowRoot.querySelector(".menu"); + this._menuButton = this.shadowRoot.querySelector(".menu-button"); + + this._menuButton.addEventListener("click", this); + document.addEventListener("keydown", this, true); + } + + handleEvent(event) { + switch (event.type) { + case "blur": { + if (event.explicitOriginalTarget) { + let node = event.explicitOriginalTarget; + if (node.nodeType == Node.TEXT_NODE) { + node = node.parentElement; + } + if (node.closest(".menu") == this._menu) { + // Only hide the menu if focus has left the menu-button. + return; + } + } + this._hideMenu(); + break; + } + case "click": { + // Skip the catch-all event listener if it was the menu-button + // that was clicked on. + if ( + event.currentTarget == document.documentElement && + event.target == this && + event.originalTarget == this._menuButton + ) { + return; + } + + if (event.originalTarget == this._menuButton) { + this._toggleMenu(); + if (!this._menu.hidden) { + this._menuButton.focus(); + } + return; + } + + let classList = event.originalTarget.classList; + if (classList.contains("menuitem-button")) { + let eventName = event.originalTarget.dataset.eventName; + const linkTrackingSource = "Elipsis_Menu"; + document.dispatchEvent( + new CustomEvent(eventName, { + bubbles: true, + detail: linkTrackingSource, + }) + ); + + // Bug 1645365: Only hide the menu when the buttons are clicked + // So that the menu isn't closed when non-buttons (e.g. separators, paddings) are clicked + this._hideMenu(); + } + + // Explicitly close menu at the catch-all click event (i.e. a click outside of the menu) + if ( + !this._menu.contains(event.originalTarget) && + !this._menuButton.contains(event.originalTarget) + ) { + this._hideMenu(); + } + + break; + } + case "keydown": { + this._handleKeyDown(event); + } + } + } + + _handleKeyDown(event) { + if (event.key == "Enter" && event.originalTarget == this._menuButton) { + event.preventDefault(); + this._toggleMenu(); + this._focusSuccessor(true); + } else if (event.key == "Escape" && !this._menu.hidden) { + this._hideMenu(); + this._menuButton.focus(); + } else if ( + (event.key == "ArrowDown" || event.key == "ArrowUp") && + !this._menu.hidden + ) { + event.preventDefault(); + this._focusSuccessor(event.key == "ArrowDown"); + } + } + + _focusSuccessor(next = true) { + let items = this._menu.querySelectorAll(".menuitem-button:not([hidden])"); + let firstItem = items[0]; + let lastItem = items[items.length - 1]; + + let activeItem = this.shadowRoot.activeElement; + let activeItemIndex = [...items].indexOf(activeItem); + + let successor = null; + + if (next) { + if (!activeItem || activeItem === lastItem) { + successor = firstItem; + } else { + successor = items[activeItemIndex + 1]; + } + } else if (activeItem === this._menuButton || activeItem === firstItem) { + successor = lastItem; + } else { + successor = items[activeItemIndex - 1]; + } + + if (this._menu.hidden) { + this._showMenu(); + } + if (successor.disabled) { + if (next) { + successor = items[activeItemIndex + 2]; + } else { + successor = items[activeItemIndex - 2]; + } + } + window.AboutLoginsUtils.setFocus(successor); + } + + _hideMenu() { + this._menu.hidden = true; + + this.removeEventListener("blur", this); + document.documentElement.removeEventListener("click", this, true); + } + + _showMenu() { + this._menu.querySelector(".menuitem-import-file").hidden = + !window.AboutLoginsUtils.fileImportEnabled; + + this._menu.hidden = false; + + // Event listeners to close the menu + this.addEventListener("blur", this); + document.documentElement.addEventListener("click", this, true); + } + + /** + * Toggles the visibility of the menu. + */ + _toggleMenu() { + let wasHidden = this._menu.hidden; + if (wasHidden) { + this._showMenu(); + } else { + this._hideMenu(); + } + } +} +customElements.define("menu-button", MenuButton); diff --git a/browser/components/aboutlogins/content/components/remove-logins-dialog.css b/browser/components/aboutlogins/content/components/remove-logins-dialog.css new file mode 100644 index 0000000000..160ca47d03 --- /dev/null +++ b/browser/components/aboutlogins/content/components/remove-logins-dialog.css @@ -0,0 +1,102 @@ +/* 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/. */ + + .overlay { + position: fixed; + z-index: 1; + inset: 0; + /* TODO: this color is used in the about:preferences overlay, but + why isn't it declared as a variable? */ + background-color: rgba(0,0,0,0.5); + display: flex; +} + +.container { + z-index: 2; + position: relative; + display: flex; + flex-direction: column; + min-width: 300px; + max-width: 660px; + min-height: 200px; + margin: auto; + background-color: var(--in-content-page-background); + color: var(--in-content-text-color); + box-shadow: var(--shadow-30); + /* show a border in high contrast mode */ + outline: 1px solid transparent; +} + +.title { + grid-area: 1 / 2 / 2 / 8; +} + +.message { + font-weight: 600; + grid-area: 2 / 2 / 3 / 8; + font-size: 1.25em; +} + +.checkbox-text { + font-size: 1.25em; +} + +.dismiss-button { + position: absolute; + top: 0; + inset-inline-end: 0; + min-width: 20px; + min-height: 20px; + margin: 16px; + padding: 0; + line-height: 0; +} + +.dismiss-icon { + -moz-context-properties: fill; + fill: currentColor; +} + +.warning-icon { + -moz-context-properties: fill; + fill: currentColor; + width: 32px; + height: 32px; + margin: 8px; +} + +.content, +.buttons { + padding: 36px 48px; + padding-bottom: 24px; +} + +.content { + display: grid; + grid-template-columns: 0.5fr 1fr 1fr 1fr 1fr 1fr 1fr; + grid-template-rows: 0.5fr 0.5fr 0.5fr; +} + +.checkbox-wrapper { + grid-area: 3 / 2 / 4 / 8; + align-self: first baseline; + justify-self: start; +} + +.warning-icon { + grid-area: 1 / 1 / 2 / 2; +} + +.checkbox { + grid-area: 3 / 2 / 4 / 8; + font-size: 1.1em; + align-self: center; +} + +.buttons { + padding-block: 16px 32px; + padding-inline: 48px 0; + border-top: 1px solid var(--in-content-border-color); + margin-inline: 48px; +} diff --git a/browser/components/aboutlogins/content/components/remove-logins-dialog.mjs b/browser/components/aboutlogins/content/components/remove-logins-dialog.mjs new file mode 100644 index 0000000000..94cd6ef13e --- /dev/null +++ b/browser/components/aboutlogins/content/components/remove-logins-dialog.mjs @@ -0,0 +1,117 @@ +/* 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 { setKeyboardAccessForNonDialogElements } from "../aboutLoginsUtils.mjs"; + +export default class RemoveLoginsDialog extends HTMLElement { + constructor() { + super(); + this._promise = null; + } + + connectedCallback() { + if (this.shadowRoot) { + return; + } + let template = document.querySelector("#remove-logins-dialog-template"); + let shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + shadowRoot.appendChild(template.content.cloneNode(true)); + + this._buttons = this.shadowRoot.querySelector(".buttons"); + this._cancelButton = this.shadowRoot.querySelector(".cancel-button"); + this._confirmButton = this.shadowRoot.querySelector(".confirm-button"); + this._dismissButton = this.shadowRoot.querySelector(".dismiss-button"); + this._message = this.shadowRoot.querySelector(".message"); + this._overlay = this.shadowRoot.querySelector(".overlay"); + this._title = this.shadowRoot.querySelector(".title"); + this._checkbox = this.shadowRoot.querySelector(".checkbox"); + this._checkboxLabel = this.shadowRoot.querySelector(".checkbox-text"); + } + + handleEvent(event) { + switch (event.type) { + case "keydown": + if (event.key === "Escape" && !event.defaultPrevented) { + this.onCancel(); + } + break; + case "click": + if ( + event.target.classList.contains("cancel-button") || + event.currentTarget.classList.contains("dismiss-button") || + event.target.classList.contains("overlay") + ) { + this.onCancel(); + } else if (event.target.classList.contains("confirm-button")) { + this.onConfirm(); + } else if (event.target.classList.contains("checkbox")) { + this._confirmButton.disabled = !this._checkbox.checked; + } + } + } + + hide() { + setKeyboardAccessForNonDialogElements(true); + this._cancelButton.removeEventListener("click", this); + this._confirmButton.removeEventListener("click", this); + this._dismissButton.removeEventListener("click", this); + this._overlay.removeEventListener("click", this); + this._checkbox.removeEventListener("click", this); + window.removeEventListener("keydown", this); + + this._checkbox.checked = false; + + this.hidden = true; + } + + show({ title, message, confirmButtonLabel, confirmCheckboxLabel, count }) { + setKeyboardAccessForNonDialogElements(false); + this.hidden = false; + + document.l10n.setAttributes(this._title, title, { + count, + }); + document.l10n.setAttributes(this._message, message, { + count, + }); + document.l10n.setAttributes(this._confirmButton, confirmButtonLabel, { + count, + }); + document.l10n.setAttributes(this._checkboxLabel, confirmCheckboxLabel, { + count, + }); + + this._checkbox.addEventListener("click", this); + this._cancelButton.addEventListener("click", this); + this._confirmButton.addEventListener("click", this); + this._dismissButton.addEventListener("click", this); + this._overlay.addEventListener("click", this); + window.addEventListener("keydown", this); + + this._confirmButton.disabled = true; + // For speed-of-use, focus the confirmation checkbox when the dialog loads. + // Introducing this checkbox provides enough of a buffer for accidental deletions. + this._checkbox.focus(); + + this._promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + + return this._promise; + } + + onCancel() { + this._reject(); + this.hide(); + } + + onConfirm() { + this._resolve(); + this.hide(); + } +} + +customElements.define("remove-logins-dialog", RemoveLoginsDialog); -- cgit v1.2.3