diff options
Diffstat (limited to 'browser/components/aboutlogins/content/components')
43 files changed, 5426 insertions, 0 deletions
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..49c3188f6f --- /dev/null +++ b/browser/components/aboutlogins/content/components/confirmation-dialog.css @@ -0,0 +1,73 @@ +/* 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; + user-select: none; +} + +.warning-icon { + -moz-context-properties: fill; + fill: currentColor; + user-select: none; + 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..a6d136ff70 --- /dev/null +++ b/browser/components/aboutlogins/content/components/fxaccounts-button.css @@ -0,0 +1,74 @@ +/* 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; + text-align: end; +} + +@media (max-width: 830px) { + .fxaccounts-extra-text, + .fxaccount-email { + display: none; + } +} + +.fxaccount-avatar, +.fxaccounts-enable-button { + font-size: var(--font-size-small); + 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 { + font-size: var(--font-size-small); + 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..897ef5da20 --- /dev/null +++ b/browser/components/aboutlogins/content/components/generic-dialog.css @@ -0,0 +1,64 @@ +/* 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; + 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..8b6fe269a3 --- /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-change2", + }, + modified: { + message: "about-logins-import-report-row-description-modified2", + }, + added: { + message: "about-logins-import-report-row-description-added2", + }, + 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..3b84338527 --- /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-added2" + ); + this._updateCount( + report.modified, + this._modified, + "about-logins-import-dialog-items-modified2" + ); + this._updateCount( + report.no_change, + this._noChange, + "about-logins-import-dialog-items-no-change2" + ); + 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/input-field/input-field.css b/browser/components/aboutlogins/content/components/input-field/input-field.css new file mode 100644 index 0000000000..0e65b7f1fc --- /dev/null +++ b/browser/components/aboutlogins/content/components/input-field/input-field.css @@ -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/. */ + +:host { + display: grid; + grid-template-areas: "label label" "input actions"; + align-items: center; + font-family: monospace; +} + +label { + grid-area: label; + display: block; + color: var(--in-content-page-color); + margin-bottom: 8px; +} + +/** input.input-field needed to override margin in themes/osx/global/in-content/common.css */ +input.input-field { + grid-area: input; + margin: 0; +} + + +.input-field:read-only { + all: unset; + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; +} + +.reveal-password-button { + grid-area: actions; + width: 32px; + height: 32px; + min-width: 0; + background: url("chrome://browser/content/aboutlogins/icons/password.svg") center no-repeat; + cursor: pointer; + -moz-context-properties: fill; + fill: currentColor; + color: inherit; + opacity: 0.8; +} + +.reveal-password-button.revealed { + background-image: url("chrome://browser/content/aboutlogins/icons/password-hide.svg"); +} + +/** button.reveal-password-button needed to override --in-content-button-background-hover in common-shared.css **/ +button.reveal-password-button:hover { + opacity: 0.6; + background-color: transparent; +} + +/** button.reveal-password-button needed to override --in-content-button-background-active in common-shared.css **/ +button.reveal-password-button:hover:active { + opacity: 1; + background-color: transparent; +} diff --git a/browser/components/aboutlogins/content/components/input-field/input-field.mjs b/browser/components/aboutlogins/content/components/input-field/input-field.mjs new file mode 100644 index 0000000000..dd65f167fe --- /dev/null +++ b/browser/components/aboutlogins/content/components/input-field/input-field.mjs @@ -0,0 +1,32 @@ +/* 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 { html, ifDefined } from "chrome://global/content/vendor/lit.all.mjs"; + +export const stylesTemplate = () => + html` + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link + rel="stylesheet" + href="chrome://browser/content/aboutlogins/components/input-field/input-field.css" + /> + `; + +export const editableFieldTemplate = ({ + type, + value, + inputId, + disabled, + onFocus, + onBlur, +}) => + html`<input + class="input-field" + data-l10n-id=${ifDefined(inputId)} + type=${type} + value=${value} + ?disabled=${disabled} + @focus=${onFocus} + @blur=${onBlur} + />`; diff --git a/browser/components/aboutlogins/content/components/input-field/input-field.stories.mjs b/browser/components/aboutlogins/content/components/input-field/input-field.stories.mjs new file mode 100644 index 0000000000..99dfa83738 --- /dev/null +++ b/browser/components/aboutlogins/content/components/input-field/input-field.stories.mjs @@ -0,0 +1,133 @@ +/* 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/. */ + +// eslint-disable-next-line import/no-unresolved +import { html } from "lit.all.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "./login-password-field.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "./login-username-field.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "./login-origin-field.mjs"; + +export default { + title: "Domain-specific UI Widgets/Credential Management/Input Fields", +}; + +window.MozXULElement.insertFTLIfNeeded("browser/aboutLogins.ftl"); + +export const LoginUsernameField = ({ value, readonly }) => { + return html` + <div style="max-width: 500px"> + <login-username-field .value=${value} .readonly=${readonly}> + </login-username-field> + </div> + `; +}; + +LoginUsernameField.argTypes = { + value: { + control: "text", + defaultValue: "username", + }, + readonly: { + control: "boolean", + defaultValue: false, + }, +}; + +export const LoginOriginField = ({ value, readonly }) => { + return html` + <div style="max-width: 500px"> + <login-origin-field .value=${value} .readonly=${readonly}> + </login-origin-field> + </div> + `; +}; + +LoginOriginField.argTypes = { + value: { + control: "text", + defaultValue: "https://example.com", + }, + readonly: { + control: "boolean", + defaultValue: false, + }, +}; + +export const LoginPasswordField = ({ + readonly, + visible, + value = "longpassword".repeat(2), +}) => { + return html` + <div style="max-width: 500px"> + <login-password-field + .value=${value} + .readonly=${readonly} + .visible=${visible} + .onPasswordVisible=${() => alert("auth...")} + > + </login-password-field> + </div> + `; +}; + +LoginPasswordField.argTypes = { + readonly: { + control: "boolean", + defaultValue: true, + }, + visible: { + control: "boolean", + defaultValue: false, + }, +}; + +export const LoginPasswordFieldDisplayMode = ({ + visible, + value = "longpassword".repeat(2), +}) => { + return html` + <div style="max-width: 500px"> + <login-password-field + .value=${value} + .readonly=${true} + .visible=${visible} + > + </login-password-field> + </div> + `; +}; + +LoginPasswordFieldDisplayMode.argTypes = { + visible: { + control: "boolean", + defaultValue: false, + }, +}; + +export const LoginPasswordFieldEditMode = ({ + visible, + value = "longpassword".repeat(2), +}) => { + return html` + <div style="max-width: 500px"> + <login-password-field + .value=${value} + .readonly=${false} + .visible=${visible} + > + </login-password-field> + </div> + `; +}; + +LoginPasswordFieldEditMode.argTypes = { + visible: { + control: "boolean", + defaultValue: false, + }, +}; diff --git a/browser/components/aboutlogins/content/components/input-field/login-origin-field.mjs b/browser/components/aboutlogins/content/components/input-field/login-origin-field.mjs new file mode 100644 index 0000000000..a6170eae5f --- /dev/null +++ b/browser/components/aboutlogins/content/components/input-field/login-origin-field.mjs @@ -0,0 +1,43 @@ +/* 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 { html } from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; +import { editableFieldTemplate, stylesTemplate } from "./input-field.mjs"; + +class LoginOriginField extends MozLitElement { + static properties = { + value: { type: String, reflect: true }, + readonly: { type: Boolean, reflect: true }, + }; + + get readonlyTemplate() { + return html` + <a + class="origin-input" + dir="auto" + target="_blank" + rel="noreferrer" + name="origin" + href=${this.value} + > + ${this.value} + </a> + `; + } + + render() { + return html` + ${stylesTemplate()} + <label class="field-label" data-l10n-id="login-item-origin-label"></label> + ${this.readonly + ? this.readonlyTemplate + : editableFieldTemplate({ + type: "url", + value: this.value, + })} + `; + } +} + +customElements.define("login-origin-field", LoginOriginField); diff --git a/browser/components/aboutlogins/content/components/input-field/login-password-field.mjs b/browser/components/aboutlogins/content/components/input-field/login-password-field.mjs new file mode 100644 index 0000000000..6d903beb0a --- /dev/null +++ b/browser/components/aboutlogins/content/components/input-field/login-password-field.mjs @@ -0,0 +1,84 @@ +/* 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 { html, classMap } from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; +import { editableFieldTemplate, stylesTemplate } from "./input-field.mjs"; + +class LoginPasswordField extends MozLitElement { + static CONCEALED_PASSWORD_TEXT = " ".repeat(8); + + static properties = { + _value: { type: String, state: true }, + readonly: { type: Boolean, reflect: true }, + visible: { type: Boolean, reflect: true }, + }; + + static queries = { + input: "input", + button: "button", + }; + + set value(newValue) { + this._value = newValue; + } + + get #type() { + return this.visible ? "text" : "password"; + } + + get #password() { + return this.readonly && !this.visible + ? LoginPasswordField.CONCEALED_PASSWORD_TEXT + : this._value; + } + + render() { + return html` + ${stylesTemplate()} + <label + class="field-label" + data-l10n-id="login-item-password-label" + ></label> + ${editableFieldTemplate({ + type: this.#type, + value: this.#password, + labelId: "login-item-password-label", + disabled: this.readonly, + onFocus: this.handleFocus, + onBlur: this.handleBlur, + })} + <button + class=${classMap({ + revealed: this.visible, + "reveal-password-button": true, + })} + data-l10n-id="login-item-password-reveal-checkbox" + @click=${this.toggleVisibility} + ></button> + `; + } + + handleFocus(ev) { + if (ev.relatedTarget !== this.button) { + this.visible = true; + } + } + + handleBlur(ev) { + if (ev.relatedTarget !== this.button) { + this.visible = false; + } + } + + toggleVisibility() { + this.visible = !this.visible; + if (this.visible) { + this.onPasswordVisible?.(); + } + this.input.focus(); + } +} + +customElements.define("login-password-field", LoginPasswordField); diff --git a/browser/components/aboutlogins/content/components/input-field/login-username-field.mjs b/browser/components/aboutlogins/content/components/input-field/login-username-field.mjs new file mode 100644 index 0000000000..87743f3689 --- /dev/null +++ b/browser/components/aboutlogins/content/components/input-field/login-username-field.mjs @@ -0,0 +1,30 @@ +/* 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 { html } from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; +import { editableFieldTemplate, stylesTemplate } from "./input-field.mjs"; + +class LoginUsernameField extends MozLitElement { + static properties = { + value: { type: String, reflect: true }, + readonly: { type: Boolean, reflect: true }, + }; + + render() { + return html` + ${stylesTemplate()} + <label + class="field-label" + data-l10n-id="login-item-username-label" + ></label> + ${editableFieldTemplate({ + type: "text", + value: this.value, + disabled: this.readonly, + })} + `; + } +} + +customElements.define("login-username-field", LoginUsernameField); diff --git a/browser/components/aboutlogins/content/components/login-alert.css b/browser/components/aboutlogins/content/components/login-alert.css new file mode 100644 index 0000000000..b2d17b433e --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-alert.css @@ -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/. */ + +:host(login-alert) { + display: grid; + column-gap: 16px; + grid-template-areas: "icon title action" "icon content content"; + grid-template-columns: min-content 1fr auto; + padding: 16px 32px; + color: var(--in-content-text-color); + background-color: var(--in-content-box-background); + border-radius: 4px; + border: 1px solid var(--in-content-border-color); + box-shadow: 0 2px 8px 0 rgba(0, 0, 0, .1); + font-size: .9em; +} + +:host([variant="info"]) { + background-color: var(--in-content-box-background); +} + +:host([variant="error"]) { + background-color: #a4000f; + color: white; +} + +:host([variant="warning"]) { + background: #d7b600; + color: black; +} + +:host(login-alert) img { + grid-area: icon; + width: 24px; + -moz-context-properties: fill; + fill: currentColor; +} + +:host(login-alert) h3 { + grid-area: title; + font-size: 1.5em; + font-weight: normal; + margin: 0; + padding: 0; +} + +:host(login-alert) slot[name="action"] { + grid-area: action; +} + +:host(login-alert) slot[name="content"] { + grid-area: content; +} + +:host(login-breach-alert) div[slot="content"], +:host(login-vulnerable-password-alert) div[slot="content"] { + margin-block-start: 8px; +} + +:host(login-vulnerable-password-alert) a { + font-weight: 600; +} + +:host(login-vulnerable-password-alert) div[slot="content"] > a { + color: var(--link-color); +} + +:host(login-vulnerable-password-alert) a[slot="action"] { + color: var(--text-color-deemphasized); +} + +:host(login-breach-alert) h4 { + margin: 0; + padding: 0; +} + +:host(login-breach-alert) a { + font-weight: 600; + color: inherit; +} diff --git a/browser/components/aboutlogins/content/components/login-alert.mjs b/browser/components/aboutlogins/content/components/login-alert.mjs new file mode 100644 index 0000000000..f435b9daef --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-alert.mjs @@ -0,0 +1,150 @@ +/* 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 { + html, + ifDefined, + guard, +} from "chrome://global/content/vendor/lit.all.mjs"; + +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +export class LoginAlert extends MozLitElement { + static get properties() { + return { + variant: { type: String, reflect: true }, + icon: { type: String }, + titleId: { type: String }, + }; + } + + render() { + return html` + <link + rel="stylesheet" + href="chrome://browser/content/aboutlogins/components/login-alert.css" + /> + <img src=${ifDefined(this.icon)} /> + <h3 data-l10n-id=${ifDefined(this.titleId)}></h3> + <div> + <slot name="action"></slot> + </div> + <slot name="content"></slot> + `; + } +} + +export class VulnerablePasswordAlert extends MozLitElement { + static get properties() { + return { + hostname: { type: String, reflect: true }, + }; + } + + constructor() { + super(); + this.hostname = ""; + } + render() { + return html` + <link + rel="stylesheet" + href="chrome://browser/content/aboutlogins/components/login-alert.css" + /> + <login-alert + variant="info" + icon="chrome://browser/content/aboutlogins/icons/vulnerable-password.svg" + titleId="about-logins-vulnerable-alert-title" + > + <div slot="content"> + <span + class="alert-text" + data-l10n-id="about-logins-vulnerable-alert-text2" + ></span> + <a + class="alert-link" + data-l10n-id="about-logins-vulnerable-alert-link" + data-l10n-args=${JSON.stringify({ + hostname: this.hostname, + })} + href=${this.hostname} + rel="noreferrer" + target="_blank" + ></a> + </div> + <a + slot="action" + class="alert-learn-more-link" + data-l10n-id="about-logins-vulnerable-alert-learn-more-link" + href="https://support.mozilla.org/1/firefox/114.0.1/Darwin/en-CA/lockwise-alerts" + rel="noreferrer" + target="_blank" + ></a> + </login-alert> + `; + } +} + +export class LoginBreachAlert extends MozLitElement { + static get properties() { + return { + date: { type: Number, reflect: true }, + hostname: { type: String, reflect: true }, + }; + } + + constructor() { + super(); + this.date = 0; + this.hostname = ""; + } + + get displayHostname() { + try { + return new URL(this.hostname).hostname; + } catch (err) { + return this.hostname; + } + } + + render() { + return html` + <link + rel="stylesheet" + href="chrome://browser/content/aboutlogins/components/login-alert.css" + /> + <login-alert + variant="error" + icon="chrome://browser/content/aboutlogins/icons/breached-website.svg" + titleId="about-logins-breach-alert-title" + > + <div slot="content"> + <h4 + data-l10n-id="about-logins-breach-alert-date" + data-l10n-args=${JSON.stringify({ date: this.date })} + ></h4> + <span data-l10n-id="breach-alert-text"></span> + <a + data-l10n-id="about-logins-breach-alert-link" + data-l10n-args=${guard([this.hostname], () => + JSON.stringify({ + hostname: this.displayHostname, + }) + )} + href=${this.hostname} + rel="noreferrer" + target="_blank" + ></a> + </div> + </login-alert> + `; + } +} + +customElements.define( + "login-vulnerable-password-alert", + VulnerablePasswordAlert +); +customElements.define("login-breach-alert", LoginBreachAlert); +customElements.define("login-alert", LoginAlert); diff --git a/browser/components/aboutlogins/content/components/login-alert.stories.mjs b/browser/components/aboutlogins/content/components/login-alert.stories.mjs new file mode 100644 index 0000000000..7eaa2dadf4 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-alert.stories.mjs @@ -0,0 +1,79 @@ +/* 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/. */ + +// eslint-disable-next-line import/no-unresolved +import { html } from "lit.all.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "./login-alert.mjs"; + +export default { + title: "Domain-specific UI Widgets/Credential Management/Login Alert", + component: "login-alert", +}; + +window.MozXULElement.insertFTLIfNeeded("browser/aboutLogins.ftl"); + +export const BasicLoginAlert = ({ variant, icon }) => { + return html` + <login-alert + .variant=${variant} + .icon=${icon} + titleId="about-logins-login-intro-heading-message" + > + <a slot="action"> Some action </a> + <div slot="content"> + Alert extra content, could be a description for more context. + </div> + </login-alert> + `; +}; + +BasicLoginAlert.argTypes = { + variant: { + options: ["info", "error", "warning"], + control: { type: "radio" }, + defaultValue: "info", + }, + icon: { + options: { + info: "chrome://global/skin/icons/info-filled.svg", + "breached-website": + "chrome://browser/content/aboutlogins/icons/breached-website.svg", + "vulnerable-password": + "chrome://browser/content/aboutlogins/icons/vulnerable-password.svg", + }, + control: { type: "select" }, + defaultValue: "chrome://global/skin/icons/info-filled.svg", + }, +}; + +export const VulnerablePasswordAlert = ({ hostname }) => + html` + <login-vulnerable-password-alert + .hostname=${hostname} + ></login-vulnerable-password-alert> + `; + +VulnerablePasswordAlert.args = { + hostname: "https://www.example.com", +}; + +export const LoginBreachAlert = ({ date, hostname }) => + html` + <login-breach-alert + .date=${date} + .hostname=${hostname} + ></login-breach-alert> + `; + +LoginBreachAlert.argTypes = { + date: { + control: { type: "date" }, + defaultValue: 1684849435571, + }, + hostname: { + control: { type: "text" }, + defaultValue: "https://www.example.com", + }, +}; diff --git a/browser/components/aboutlogins/content/components/login-command-button.css b/browser/components/aboutlogins/content/components/login-command-button.css new file mode 100644 index 0000000000..40b3f9a455 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-command-button.css @@ -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/. */ + +:host button { + margin: 0; + display: inline-flex; + flex-direction: row; + align-items: center; + min-width: auto; +} + +:host img { + padding-inline-end: 8px; + -moz-context-properties: fill; + fill: currentColor; +} + +:host(create-login-button) img, +:host(.copy-button) img { + padding: 0; +} + +:host([data-copied]) button { + color: var(--in-content-success-icon-color) !important; + background-color: transparent; + opacity: 1; + /* override common.css fading out disabled buttons */ +} + +:host([data-copied]) { + -moz-context-properties: fill; + fill: currentColor; +} diff --git a/browser/components/aboutlogins/content/components/login-command-button.mjs b/browser/components/aboutlogins/content/components/login-command-button.mjs new file mode 100644 index 0000000000..d8d195bfcc --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-command-button.mjs @@ -0,0 +1,187 @@ +/* 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/. */ + +/* + * Note: For now, to display the tooltip for a <login-command-button> you need to + * use data-l10n-id attribute instead of the l10nId attribute in the tag. + * Bug 1844869 will make an attempt to fix this. + */ + +import { + html, + ifDefined, + when, +} from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +export const stylesTemplate = () => html`<link + rel="stylesheet" + href="chrome://global/skin/in-content/common.css" + /> + <link + rel="stylesheet" + href="chrome://browser/content/aboutlogins/components/login-command-button.css" + />`; + +export const LoginCommandButton = ({ + onClick, + l10nId, + icon, + variant, + disabled, + buttonText, +}) => html`<button + class=${variant} + data-l10n-id=${ifDefined(l10nId)} + ?disabled=${disabled} + @click=${ifDefined(onClick)} +> + <img src=${ifDefined(icon)} role="presentation" /> + + <span data-l10n-id=${ifDefined(buttonText)}></span> +</button>`; + +export class CreateLoginButton extends MozLitElement { + static get properties() { + return { + disabled: { type: Boolean, reflect: true }, + }; + } + + constructor() { + super(); + this.disabled = false; + } + render() { + return html` + ${stylesTemplate()} + ${LoginCommandButton({ + l10nId: "create-login-button", + variant: "icon-button", + icon: "chrome://global/skin/icons/plus.svg", + disabled: this.disabled, + })} + `; + } +} + +export class EditButton extends MozLitElement { + static get properties() { + return { + disabled: { type: Boolean, reflect: true }, + }; + } + + constructor() { + super(); + this.disabled = false; + } + render() { + return html` + ${stylesTemplate()} + ${LoginCommandButton({ + buttonText: "login-item-edit-button", + variant: "ghost-button", + icon: "chrome://global/skin/icons/edit.svg", + disabled: this.disabled, + })} + `; + } +} + +export class DeleteButton extends MozLitElement { + static get properties() { + return { + disabled: { type: Boolean, reflect: true }, + }; + } + + constructor() { + super(); + this.disabled = false; + } + render() { + return html` ${stylesTemplate()} + ${LoginCommandButton({ + buttonText: "about-logins-login-item-remove-button", + variant: "ghost-button", + icon: "chrome://global/skin/icons/delete.svg", + disabled: this.disabled, + })}`; + } +} + +export class CopyUsernameButton extends MozLitElement { + static get properties() { + return { + copiedText: { type: Boolean, reflect: true }, + disabled: { type: Boolean, reflect: true }, + }; + } + + constructor() { + super(); + this.copiedText = false; + this.disabled = false; + } + render() { + this.className = this.copiedText ? "copied-button" : "copy-button"; + return html` ${stylesTemplate()} + ${when( + this.copiedText, + () => + html`${LoginCommandButton({ + buttonText: "login-item-copied-username-button-text", + icon: "chrome://global/skin/icons/check.svg", + disabled: this.disabled, + })}`, + () => + html`${LoginCommandButton({ + variant: "text-button", + buttonText: "login-item-copy-username-button-text", + disabled: this.disabled, + })}` + )}`; + } +} + +export class CopyPasswordButton extends MozLitElement { + static get properties() { + return { + copiedText: { type: Boolean, reflect: true }, + disabled: { type: Boolean, reflect: true }, + }; + } + + constructor() { + super(); + this.copiedText = false; + this.disabled = false; + } + render() { + this.className = this.copiedText ? "copied-button" : "copy-button"; + return html` ${stylesTemplate()} + ${when( + this.copiedText, + () => + html`${LoginCommandButton({ + buttonText: "login-item-copied-password-button-text", + icon: "chrome://global/skin/icons/check.svg", + disabled: this.disabled, + })}`, + () => + html`${LoginCommandButton({ + variant: "text-button", + buttonText: "login-item-copy-password-button-text", + disabled: this.disabled, + })}` + )}`; + } +} + +customElements.define("copy-password-button", CopyPasswordButton); +customElements.define("copy-username-button", CopyUsernameButton); +customElements.define("delete-button", DeleteButton); +customElements.define("edit-button", EditButton); +customElements.define("create-login-button", CreateLoginButton); 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..e27f0bddd0 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-intro.css @@ -0,0 +1,23 @@ +/* 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; +} + +section { + line-height: 2; +} + +.description { + font-weight: var(--font-weight-bold); + 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..f988910eb6 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-intro.mjs @@ -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/. */ + +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 = "about-logins-login-intro-heading-message"; + 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..07471c35ef --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-item.css @@ -0,0 +1,328 @@ +/* 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; +} + +/* 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; + } +} + +.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]) :is(.copy-button, .copied-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; + margin-top: 5px; +} + +.title { + margin-block: 0; + flex-grow: 1; +} + +origin-warning, password-warning { + display: none; +} + +input[type = "url"]:focus:not(:user-invalid):invalid ~ origin-warning, +input[type = "url"]:focus:user-invalid:not(:placeholder-shown) ~ origin-warning { + display: block; +} + +input[name = "password"]:focus ~ password-warning { + display: block; +} + +.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; +} + +:host([data-editing]) .detail-grid > .detail-row { + display: flex; +} + +.detail-grid > .detail-row:not([hidden]) { + display: contents; +} + +.detail-grid > .detail-row > .detail-cell { + grid-column: 1; +} + +.detail-grid > .detail-row > :is(.copy-button, .copied-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 { + 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; + 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, +.copied-button { + margin-bottom: 0; /* Align button at the bottom of the row */ +} + +.copied-button[data-copied]:focus-visible { + outline-width: 0; + box-shadow: none; +} + +input.password-display, +input[name="password"] { + font-family: monospace !important; /* override `all: unset` in the rule above */ +} + +.reveal-password-checkbox { + appearance: none; + background-image: url("resource://gre-resources/password.svg"); + margin-inline: 10px 0; + cursor: pointer; + -moz-context-properties: fill; + fill: currentColor; + color: inherit; + opacity: var(--reveal-checkbox-opacity); + + &:hover { + opacity: var(--reveal-checkbox-opacity-hover); + + &:active { + opacity: var(--reveal-checkbox-opacity-active); + } + } + + &:checked { + background-image: url("resource://gre-resources/password-hide.svg"); + } +} + +.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; +} + +login-breach-alert, +login-vulnerable-password-alert { + margin-block-end: 40px; +} + +login-command-button { + margin-block-start: 4px; /* Focus did not display entirely on edit and remove with margin:0 */ +} + +.alert-title { + font-size: var(--font-size-xlarge); + font-weight: var(--font-weight-default); + line-height: 1em; + margin-block: 0 12px; +} + +.alert-date { + display: block; + font-weight: var(--font-weight-bold); +} + +.alert-link:visited, +.alert-link { + font-weight: var(--font-weight-bold); + 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: var(--font-size-small); +} + +.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: var(--font-weight-bold); +} + +.action-buttons { + display: flex; + flex-direction: row; +} + +.action-buttons .form-actions-row { + margin-inline: 0 5px; +} 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..fc7dffff8b --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-item.mjs @@ -0,0 +1,1038 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + CONCEALED_PASSWORD_TEXT, + recordTelemetryEvent, + promptForPrimaryPassword, +} from "../aboutLoginsUtils.mjs"; + +export default class LoginItem extends HTMLElement { + /** + * The number of milliseconds to display the "Copied" success message + * before reverting to the normal "Copy" button. + */ + static get COPY_BUTTON_RESET_TIMEOUT() { + return 5000; + } + + constructor() { + super(); + this._login = {}; + this._error = null; + this._copyUsernameTimeoutId = 0; + this._copyPasswordTimeoutId = 0; + } + + connectedCallback() { + if (this.shadowRoot) { + this.render(); + return; + } + + let loginItemTemplate = document.querySelector("#login-item-template"); + let shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + shadowRoot.appendChild(loginItemTemplate.content.cloneNode(true)); + + this._cancelButton = this.shadowRoot.querySelector(".cancel-button"); + this._confirmDeleteDialog = document.querySelector("confirm-delete-dialog"); + this._copyPasswordButton = this.shadowRoot.querySelector( + "copy-password-button" + ); + this._copyUsernameButton = this.shadowRoot.querySelector( + "copy-username-button" + ); + this._deleteButton = this.shadowRoot.querySelector("delete-button"); + this._editButton = this.shadowRoot.querySelector("edit-button"); + this._errorMessage = this.shadowRoot.querySelector(".error-message"); + this._errorMessageLink = this._errorMessage.querySelector( + ".error-message-link" + ); + this._errorMessageText = this._errorMessage.querySelector( + ".error-message-text" + ); + this._form = this.shadowRoot.querySelector("form"); + this._originInput = this.shadowRoot.querySelector("input[name='origin']"); + this._originDisplayInput = + this.shadowRoot.querySelector("a[name='origin']"); + this._usernameInput = this.shadowRoot.querySelector( + "input[name='username']" + ); + // type=password field for display which only ever contains spaces the correct + // length of the password. + this._passwordDisplayInput = this.shadowRoot.querySelector( + "input.password-display" + ); + // type=text field for editing the password with the actual password value. + this._passwordInput = this.shadowRoot.querySelector( + "input[name='password']" + ); + this._revealCheckbox = this.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + this._saveChangesButton = this.shadowRoot.querySelector( + ".save-changes-button" + ); + this._favicon = this.shadowRoot.querySelector(".login-item-favicon"); + this._title = this.shadowRoot.querySelector(".login-item-title"); + this._breachAlert = this.shadowRoot.querySelector("login-breach-alert"); + this._vulnerableAlert = this.shadowRoot.querySelector( + "login-vulnerable-password-alert" + ); + this._passwordWarning = this.shadowRoot.querySelector("password-warning"); + this._originWarning = this.shadowRoot.querySelector("origin-warning"); + + this.render(); + + this._cancelButton.addEventListener("click", e => + this.handleCancelEvent(e) + ); + + window.addEventListener("keydown", e => this.handleKeydown(e)); + + // TODO: Using the addEventListener to listen for clicks and pass the event handler due to a CSP error. + // This will be fixed as login-item itself is converted into a lit component. We will then be able to use the onclick + // prop of login-command-button as seen in the example below (functionality works and passes tests). + // this._editButton.onClick = e => this.handleEditEvent(e); + + this._editButton.addEventListener("click", e => this.handleEditEvent(e)); + + this._copyPasswordButton.addEventListener("click", e => + this.handleCopyPasswordClick(e) + ); + + this._copyUsernameButton.addEventListener("click", e => + this.handleCopyUsernameClick(e) + ); + + this._deleteButton.addEventListener("click", e => + this.handleDeleteEvent(e) + ); + + this._errorMessageLink.addEventListener("click", e => + this.handleDuplicateErrorGuid(e) + ); + + this._form.addEventListener("submit", e => this.handleInputSubmit(e)); + this._originInput.addEventListener("blur", e => this.addHTTPSPrefix(e)); + + this._originInput.addEventListener("click", e => + this.handleOriginInputClick(e) + ); + + this._originInput.addEventListener( + "mousedown", + e => this.handleInputMousedown(e), + true + ); + + this._originInput.addEventListener("auxclick", e => + this.handleInputAuxclick(e) + ); + + this._originDisplayInput.addEventListener("click", e => + this.handleOriginInputClick(e) + ); + + this._revealCheckbox.addEventListener("click", e => + this.handleRevealPasswordClick(e) + ); + + this._passwordInput.addEventListener("focus", e => + this.handlePasswordDisplayFocus(e) + ); + + this._passwordInput.addEventListener("blur", e => + this.dataset.editing + ? this.handleEditPasswordInputBlur(e) + : this.addHTTPSPrefix(e) + ); + + this._passwordDisplayInput.addEventListener("focus", e => + this.handlePasswordDisplayFocus(e) + ); + this._passwordDisplayInput.addEventListener("blur", e => + this.handlePasswordDisplayBlur(e) + ); + + window.addEventListener("AboutLoginsInitialLoginSelected", e => + this.handleAboutLoginsInitial(e) + ); + window.addEventListener("AboutLoginsLoginSelected", e => + this.handleAboutLoginsLoginSelected(e) + ); + window.addEventListener("AboutLoginsShowBlankLogin", e => + this.handleAboutLoginsShowBlankLogin(e) + ); + window.addEventListener("AboutLoginsRemaskPassword", e => + this.handleAboutLoginsRemaskPassword(e) + ); + } + + focus() { + if (!this._editButton.disabled) { + this._editButton.focus(); + } else if (!this._deleteButton.disabled) { + this._deleteButton.focus(); + } else { + this._originInput.focus(); + } + } + + async render( + { onlyUpdateErrorsAndAlerts } = { onlyUpdateErrorsAndAlerts: false } + ) { + if (this._error) { + if (this._error.errorMessage.includes("This login already exists")) { + document.l10n.setAttributes( + this._errorMessageLink, + "about-logins-error-message-duplicate-login-with-link", + { + loginTitle: this._error.login.title, + } + ); + this._errorMessageLink.dataset.errorGuid = + this._error.existingLoginGuid; + this._errorMessageText.hidden = true; + this._errorMessageLink.hidden = false; + } else { + this._errorMessageText.hidden = false; + this._errorMessageLink.hidden = true; + } + } + this._errorMessage.hidden = !this._error; + + this._breachAlert.hidden = + !this._breachesMap || !this._breachesMap.has(this._login.guid); + if (!this._breachAlert.hidden) { + const breachDetails = this._breachesMap.get(this._login.guid); + const breachTimestamp = new Date(breachDetails.BreachDate ?? 0).getTime(); + this.#updateBreachAlert(this._login.origin, breachTimestamp); + } + this._vulnerableAlert.hidden = + !this._vulnerableLoginsMap || + !this._vulnerableLoginsMap.has(this._login.guid) || + !this._breachAlert.hidden; + if (!this._vulnerableAlert.hidden) { + this.#updateVulnerablePasswordAlert(this._login.origin); + } + if (onlyUpdateErrorsAndAlerts) { + return; + } + + this._favicon.src = `page-icon:${this._login.origin}`; + this._title.textContent = this._login.title; + this._title.title = this._login.title; + this._originInput.defaultValue = this._login.origin || ""; + if (this._login.origin) { + // Creates anchor element with origin URL + this._originDisplayInput.href = this._login.origin || ""; + this._originDisplayInput.innerText = this._login.origin || ""; + } + this._usernameInput.defaultValue = this._login.username || ""; + if (this._login.password) { + // We use .value instead of .defaultValue since the latter updates the + // content attribute making the password easily viewable with Inspect + // Element even when Primary Password is enabled. This is only run when + // the password is non-empty since setting the field to an empty value + // would mark the field as 'dirty' for form validation and thus trigger + // the error styling since the password field is 'required'. + // This element is only in the document while unmasked or editing. + this._passwordInput.value = this._login.password; + + // In masked non-edit mode we use a different "display" element to render + // the masked password so that one cannot simply remove/change + // @type=password to reveal the real password. + this._passwordDisplayInput.value = CONCEALED_PASSWORD_TEXT; + } + + if (this.dataset.editing) { + this._usernameInput.removeAttribute("data-l10n-id"); + this._usernameInput.placeholder = ""; + } else { + document.l10n.setAttributes( + this._usernameInput, + "about-logins-login-item-username" + ); + } + this._copyUsernameButton.disabled = !this._login.username; + document.l10n.setAttributes( + this._saveChangesButton, + this.dataset.isNewLogin + ? "login-item-save-new-button" + : "about-logins-login-item-save-changes-button" + ); + this._updatePasswordRevealState(); + this._updateOriginDisplayState(); + this.#updateTimeline(); + this.#updatePasswordMessage(); + } + + #updateTimeline() { + let timeline = this.shadowRoot.querySelector("login-timeline"); + timeline.hidden = !this._login.guid; + const createdTime = { + actionId: "login-item-timeline-action-created", + time: this._login.timeCreated, + }; + const lastUpdatedTime = { + actionId: "login-item-timeline-action-updated", + time: this._login.timePasswordChanged, + }; + const lastUsedTime = { + actionId: "login-item-timeline-action-used", + time: this._login.timeLastUsed, + }; + timeline.history = + this._login.timeCreated == this._login.timePasswordChanged + ? [createdTime, lastUsedTime] + : [createdTime, lastUpdatedTime, lastUsedTime]; + } + + setBreaches(breachesByLoginGUID) { + this._internalSetMonitorData("_breachesMap", breachesByLoginGUID); + } + + updateBreaches(breachesByLoginGUID) { + this._internalUpdateMonitorData("_breachesMap", breachesByLoginGUID); + } + + setVulnerableLogins(vulnerableLoginsByLoginGUID) { + this._internalSetMonitorData( + "_vulnerableLoginsMap", + vulnerableLoginsByLoginGUID + ); + } + + updateVulnerableLogins(vulnerableLoginsByLoginGUID) { + this._internalUpdateMonitorData( + "_vulnerableLoginsMap", + vulnerableLoginsByLoginGUID + ); + } + + _internalSetMonitorData(internalMemberName, mapByLoginGUID) { + this[internalMemberName] = mapByLoginGUID; + this.render({ onlyUpdateErrorsAndAlerts: true }); + } + + _internalUpdateMonitorData(internalMemberName, mapByLoginGUID) { + if (!this[internalMemberName]) { + this[internalMemberName] = new Map(); + } + for (const [guid, data] of [...mapByLoginGUID]) { + if (data) { + this[internalMemberName].set(guid, data); + } else { + this[internalMemberName].delete(guid); + } + } + this._internalSetMonitorData(internalMemberName, this[internalMemberName]); + } + + showLoginItemError(error) { + this._error = error; + this.render(); + } + + async handleKeydown(e) { + // The below handleKeydown will be cleaned up when Bug 1848785 lands. + if (e.key === "Escape" && this.dataset.editing) { + this.handleCancelEvent(); + } else if (e.altKey && e.key === "Enter" && !this.dataset.editing) { + this.handleEditEvent(); + } else if (e.altKey && (e.key === "Backspace" || e.key === "Delete")) { + this.handleDeleteEvent(); + } + } + + async handlePasswordDisplayFocus(e) { + // TODO(Bug 1838494): Remove this if block + // This is a temporary fix until Bug 1750072 lands + const focusFromCheckbox = e && e.relatedTarget === this._revealCheckbox; + const isEditingMode = this.dataset.editing || this.dataset.isNewLogin; + if (focusFromCheckbox && isEditingMode) { + this._passwordInput.type = this._revealCheckbox.checked + ? "text" + : "password"; + return; + } + + this._revealCheckbox.checked = !!this.dataset.editing; + this._updatePasswordRevealState(); + } + + async addHTTPSPrefix(e) { + // TODO(Bug 1838494): Remove this if block + // This is a temporary fix until Bug 1750072 lands + const focusCheckboxNext = e && e.relatedTarget === this._revealCheckbox; + if (focusCheckboxNext) { + return; + } + + // Add https:// prefix if one was not provided. + let originValue = this._originInput.value.trim(); + if (!originValue) { + return; + } + + if (!originValue.match(/:\/\//)) { + this._originInput.value = "https://" + originValue; + } + } + + async handlePasswordDisplayBlur(e) { + // TODO(Bug 1838494): Remove this if block + // This is a temporary fix until Bug 1750072 lands + const focusCheckboxNext = e && e.relatedTarget === this._revealCheckbox; + if (focusCheckboxNext) { + return; + } + + this._revealCheckbox.checked = !!this.dataset.editing; + this._updatePasswordRevealState(); + + this.addHTTPSPrefix(); + } + + async handleEditPasswordInputBlur(e) { + // TODO(Bug 1838494): Remove this if block + // This is a temporary fix until Bug 1750072 lands + const focusCheckboxNext = e && e.relatedTarget === this._revealCheckbox; + if (focusCheckboxNext) { + return; + } + + this._revealCheckbox.checked = false; + this._updatePasswordRevealState(); + + this.addHTTPSPrefix(); + } + + async handleRevealPasswordClick() { + // TODO(Bug 1838494): Remove this if block + // This is a temporary fix until Bug 1750072 lands + if (this.dataset.editing || this.dataset.isNewLogin) { + this._passwordDisplayInput.replaceWith(this._passwordInput); + this._passwordInput.type = "text"; + this._passwordInput.focus(); + return; + } + + // We prompt for the primary password when entering edit mode already. + if (this._revealCheckbox.checked && !this.dataset.editing) { + let primaryPasswordAuth = await promptForPrimaryPassword( + "about-logins-reveal-password-os-auth-dialog-message" + ); + if (!primaryPasswordAuth) { + this._revealCheckbox.checked = false; + return; + } + } + this._updatePasswordRevealState(); + + let method = this._revealCheckbox.checked ? "show" : "hide"; + this._recordTelemetryEvent({ object: "password", method }); + } + + async handleCancelEvent(e) { + let wasExistingLogin = !!this._login.guid; + if (wasExistingLogin) { + if (this.hasPendingChanges()) { + this.showConfirmationDialog("discard-changes", () => { + this.setLogin(this._login); + }); + } else { + this.setLogin(this._login); + } + } else if (!this.hasPendingChanges()) { + window.dispatchEvent(new CustomEvent("AboutLoginsClearSelection")); + this._recordTelemetryEvent({ + object: "new_login", + method: "cancel", + }); + + this.setLogin(this._login, { skipFocusChange: true }); + this._toggleEditing(false); + this.render(); + } else { + this.showConfirmationDialog("discard-changes", () => { + window.dispatchEvent(new CustomEvent("AboutLoginsClearSelection")); + + this.setLogin({}, { skipFocusChange: true }); + this._toggleEditing(false); + this.render(); + }); + } + } + + async handleCopyPasswordClick({ currentTarget }) { + let primaryPasswordAuth = await promptForPrimaryPassword( + "about-logins-copy-password-os-auth-dialog-message" + ); + if (!primaryPasswordAuth) { + return; + } + currentTarget.dataset.copied = true; + currentTarget.copiedText = true; + currentTarget.disabled = true; + let propertyToCopy = this._login.password; + document.dispatchEvent( + new CustomEvent("AboutLoginsCopyLoginDetail", { + bubbles: true, + detail: propertyToCopy, + }) + ); + // If there is no username, this must be triggered by the password button, + // don't enable otherCopyButton (username copy button) in this case. + if (this._login.username) { + this._copyUsernameButton.copiedText = false; + this._copyUsernameButton.disabled = false; + delete this._copyUsernameButton.dataset.copied; + } + clearTimeout(this._copyUsernameTimeoutId); + clearTimeout(this._copyPasswordTimeoutId); + let timeoutId = setTimeout(() => { + currentTarget.disabled = false; + currentTarget.copiedText = false; + delete currentTarget.dataset.copied; + }, LoginItem.COPY_BUTTON_RESET_TIMEOUT); + this._copyPasswordTimeoutId = timeoutId; + this._recordTelemetryEvent({ + object: "password", + method: "copy", + }); + } + + async handleCopyUsernameClick({ currentTarget }) { + currentTarget.dataset.copied = true; + currentTarget.copiedText = true; + currentTarget.disabled = true; + let propertyToCopy = this._login.username; + document.dispatchEvent( + new CustomEvent("AboutLoginsCopyLoginDetail", { + bubbles: true, + detail: propertyToCopy, + }) + ); + // If there is no username, this must be triggered by the password button, + // don't enable otherCopyButton (username copy button) in this case. + if (this._login.username) { + this._copyPasswordButton.copiedText = false; + this._copyPasswordButton.disabled = false; + delete this._copyPasswordButton.dataset.copied; + } + clearTimeout(this._copyUsernameTimeoutId); + clearTimeout(this._copyPasswordTimeoutId); + let timeoutId = setTimeout(() => { + currentTarget.disabled = false; + currentTarget.copiedText = false; + delete currentTarget.dataset.copied; + }, LoginItem.COPY_BUTTON_RESET_TIMEOUT); + this._copyUsernameTimeoutId = timeoutId; + this._recordTelemetryEvent({ + object: "username", + method: "copy", + }); + } + + async handleDeleteEvent() { + this.showConfirmationDialog("delete", () => { + document.dispatchEvent( + new CustomEvent("AboutLoginsDeleteLogin", { + bubbles: true, + detail: this._login, + }) + ); + }); + } + + async handleEditEvent() { + let primaryPasswordAuth = await promptForPrimaryPassword( + "about-logins-edit-login-os-auth-dialog-message2" + ); + if (!primaryPasswordAuth) { + return; + } + + this._toggleEditing(); + this.render(); + + this._recordTelemetryEvent({ + object: "existing_login", + method: "edit", + }); + } + + async handleAlertLearnMoreClick({ currentTarget }) { + if (currentTarget.closest(".vulnerable-alert")) { + this._recordTelemetryEvent({ + object: "existing_login", + method: "learn_more_vuln", + }); + } + } + + async handleOriginInputClick() { + this._handleOriginClick(); + } + + async handleDuplicateErrorGuid({ currentTarget }) { + let existingDuplicateLogin = { + guid: currentTarget.dataset.errorGuid, + }; + window.dispatchEvent( + new CustomEvent("AboutLoginsLoginSelected", { + detail: existingDuplicateLogin, + cancelable: true, + }) + ); + } + + async handleInputSubmit(event) { + // Prevent page navigation form submit behavior. + event.preventDefault(); + if (!this._isFormValid({ reportErrors: true })) { + return; + } + if (!this.hasPendingChanges()) { + this._toggleEditing(false); + this.render(); + return; + } + let loginUpdates = this._loginFromForm(); + if (this._login.guid) { + loginUpdates.guid = this._login.guid; + document.dispatchEvent( + new CustomEvent("AboutLoginsUpdateLogin", { + bubbles: true, + detail: loginUpdates, + }) + ); + + this._recordTelemetryEvent({ + object: "existing_login", + method: "save", + }); + this._toggleEditing(false); + this.render(); + } else { + document.dispatchEvent( + new CustomEvent("AboutLoginsCreateLogin", { + bubbles: true, + detail: loginUpdates, + }) + ); + + this._recordTelemetryEvent({ object: "new_login", method: "save" }); + } + } + + async handleInputAuxclick({ button }) { + if (button == 1) { + this._handleOriginClick(); + } + } + + async handleInputMousedown(event) { + // No AutoScroll when middle clicking on origin input. + if (event.currentTarget == this._originInput && event.button == 1) { + event.preventDefault(); + } + } + + async handleAboutLoginsInitial({ detail }) { + this.setLogin(detail, { skipFocusChange: true }); + } + + async handleAboutLoginsLoginSelected(event) { + this.#confirmPendingChangesOnEvent(event, event.detail); + } + + async handleAboutLoginsShowBlankLogin(event) { + this.#confirmPendingChangesOnEvent(event, {}); + } + + async handleAboutLoginsRemaskPassword() { + if (this._revealCheckbox.checked && !this.dataset.editing) { + this._revealCheckbox.checked = false; + } + this._updatePasswordRevealState(); + let method = this._revealCheckbox.checked ? "show" : "hide"; + this._recordTelemetryEvent({ object: "password", method }); + } + + /** + * Helper to show the "Discard changes" confirmation dialog and delay the + * received event after confirmation. + * @param {object} event The event to be delayed. + * @param {object} login The login to be shown on confirmation. + */ + #confirmPendingChangesOnEvent(event, login) { + if (this.hasPendingChanges()) { + event.preventDefault(); + this.showConfirmationDialog("discard-changes", () => { + // Clear any pending changes + this.setLogin(login); + + window.dispatchEvent( + new CustomEvent(event.type, { + detail: login, + cancelable: false, + }) + ); + }); + } else { + this.setLogin(login, { skipFocusChange: true }); + } + } + + /** + * Shows a confirmation dialog. + * @param {string} type The type of confirmation dialog to display. + * @param {boolean} onConfirm Optional, the function to execute when the confirm button is clicked. + */ + showConfirmationDialog(type, onConfirm = () => {}) { + const dialog = document.querySelector("confirmation-dialog"); + let options; + switch (type) { + case "delete": { + options = { + title: "about-logins-confirm-delete-dialog-title", + message: "about-logins-confirm-delete-dialog-message", + confirmButtonLabel: + "about-logins-confirm-remove-dialog-confirm-button", + }; + break; + } + case "discard-changes": { + options = { + title: "confirm-discard-changes-dialog-title", + message: "confirm-discard-changes-dialog-message", + confirmButtonLabel: "confirm-discard-changes-dialog-confirm-button", + }; + break; + } + } + let wasExistingLogin = !!this._login.guid; + let method = type == "delete" ? "delete" : "cancel"; + let dialogPromise = dialog.show(options); + dialogPromise.then( + () => { + try { + onConfirm(); + } catch (ex) {} + this._recordTelemetryEvent({ + object: wasExistingLogin ? "existing_login" : "new_login", + method, + }); + }, + () => {} + ); + return dialogPromise; + } + + hasPendingChanges() { + let valuesChanged = !window.AboutLoginsUtils.doLoginsMatch( + Object.assign({ username: "", password: "", origin: "" }, this._login), + this._loginFromForm() + ); + + return this.dataset.editing && valuesChanged; + } + + resetForm() { + // If the password input (which uses HTML form validation) wasn't connected, + // append it to the form so it gets included in the reset, specifically for + // .value and the dirty state for validation. + let wasConnected = this._passwordInput.isConnected; + if (!wasConnected) { + this._revealCheckbox.insertAdjacentElement( + "beforebegin", + this._passwordInput + ); + } + + this._form.reset(); + if (!wasConnected) { + this._passwordInput.remove(); + } + } + + /** + * @param {login} login The login that should be displayed. The login object is + * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo. + * @param {boolean} skipFocusChange Optional, if present and set to true, the Edit button of the + * login will not get focus automatically. This is used to prevent + * stealing focus from the search filter upon page load. + */ + setLogin(login, { skipFocusChange } = {}) { + this._login = login; + this._error = null; + + this.resetForm(); + + if (login.guid) { + delete this.dataset.isNewLogin; + } else { + this.dataset.isNewLogin = true; + } + document.documentElement.classList.toggle("login-selected", login.guid); + this._toggleEditing(!login.guid); + + this._revealCheckbox.checked = false; + + clearTimeout(this._copyUsernameTimeoutId); + clearTimeout(this._copyPasswordTimeoutId); + for (let currentTarget of [ + this._copyUsernameButton, + this._copyPasswordButton, + ]) { + currentTarget.disabled = false; + this._copyPasswordButton.copiedText = false; + this._copyUsernameButton.copiedText = false; + delete currentTarget.dataset.copied; + } + + if (!skipFocusChange) { + this._editButton.focus(); + } + this.render(); + } + + /** + * Updates the view if the login argument matches the login currently + * displayed. + * + * @param {login} login The login that was added to storage. The login object is + * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo. + */ + loginAdded(login) { + if ( + this._login.guid || + !window.AboutLoginsUtils.doLoginsMatch(login, this._loginFromForm()) + ) { + return; + } + + this.setLogin(login); + this.dispatchEvent( + new CustomEvent("AboutLoginsLoginSelected", { + bubbles: true, + composed: true, + detail: login, + }) + ); + } + + /** + * Updates the view if the login argument matches the login currently + * displayed. + * + * @param {login} login The login that was modified in storage. The login object is + * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo. + */ + loginModified(login) { + if (this._login.guid != login.guid) { + return; + } + + let valuesChanged = + this.dataset.editing && + !window.AboutLoginsUtils.doLoginsMatch(login, this._loginFromForm()); + if (valuesChanged) { + this.showConfirmationDialog("discard-changes", () => { + this.setLogin(login); + }); + } else { + this.setLogin(login); + } + } + + /** + * Clears the displayed login if the argument matches the currently + * displayed login. + * + * @param {login} login The login that was removed from storage. The login object is + * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo. + */ + loginRemoved(login) { + if (login.guid != this._login.guid) { + return; + } + + this.setLogin({}, { skipFocusChange: true }); + this._toggleEditing(false); + } + + _handleOriginClick() { + this._recordTelemetryEvent({ + object: "existing_login", + method: "open_site", + }); + } + + /** + * Checks that the edit/new-login form has valid values present for their + * respective required fields. + * + * @param {boolean} reportErrors If true, validation errors will be reported + * to the user. + */ + _isFormValid({ reportErrors } = {}) { + let fields = [this._passwordInput]; + if (this.dataset.isNewLogin) { + fields.push(this._originInput); + } + let valid = true; + // Check validity on all required fields so each field will get :invalid styling + // if applicable. + for (let field of fields) { + if (reportErrors) { + valid &= field.reportValidity(); + } else { + valid &= field.checkValidity(); + } + } + return valid; + } + + _loginFromForm() { + return Object.assign({}, this._login, { + username: this._usernameInput.value.trim(), + password: this._passwordInput.value, + origin: + window.AboutLoginsUtils.getLoginOrigin(this._originInput.value) || "", + }); + } + + _recordTelemetryEvent(eventObject) { + // Breach alerts have higher priority than vulnerable logins, the + // following conditionals must reflect this priority. + const extra = eventObject.hasOwnProperty("extra") ? eventObject.extra : {}; + if (this._breachesMap && this._breachesMap.has(this._login.guid)) { + Object.assign(extra, { breached: "true" }); + eventObject.extra = extra; + } else if ( + this._vulnerableLoginsMap && + this._vulnerableLoginsMap.has(this._login.guid) + ) { + Object.assign(extra, { vulnerable: "true" }); + eventObject.extra = extra; + } + recordTelemetryEvent(eventObject); + } + + /** + * Toggles the login-item view from editing to non-editing mode. + * + * @param {boolean} force When true puts the form in 'edit' mode, otherwise + * puts the form in read-only mode. + */ + _toggleEditing(force) { + let shouldEdit = force !== undefined ? force : !this.dataset.editing; + + if (!shouldEdit) { + delete this.dataset.isNewLogin; + } + + // Reset cursor to the start of the input for long text names. + this._usernameInput.scrollLeft = 0; + + if (shouldEdit) { + this._passwordInput.style.removeProperty("width"); + } else { + // Need to set a shorter width than -moz-available so the reveal checkbox + // will still appear next to the password. + this._passwordInput.style.width = + (this._login.password || "").length + "ch"; + } + + this._deleteButton.disabled = this.dataset.isNewLogin; + this._editButton.disabled = shouldEdit; + let inputTabIndex = shouldEdit ? 0 : -1; + this._originInput.readOnly = !this.dataset.isNewLogin; + this._originInput.tabIndex = inputTabIndex; + this._usernameInput.readOnly = !shouldEdit; + this._usernameInput.tabIndex = inputTabIndex; + this._passwordInput.readOnly = !shouldEdit; + this._passwordInput.tabIndex = inputTabIndex; + if (shouldEdit) { + this.dataset.editing = true; + this._usernameInput.focus(); + this._usernameInput.select(); + } else { + delete this.dataset.editing; + // Only reset the reveal checkbox when exiting 'edit' mode + this._revealCheckbox.checked = false; + } + } + + _updatePasswordRevealState() { + if ( + window.AboutLoginsUtils && + window.AboutLoginsUtils.passwordRevealVisible === false + ) { + this._revealCheckbox.hidden = true; + } + + let { checked } = this._revealCheckbox; + let inputType = checked ? "text" : "password"; + this._passwordInput.type = inputType; + + if (this.dataset.editing) { + this._passwordDisplayInput.removeAttribute("tabindex"); + this._revealCheckbox.hidden = true; + } else { + this._passwordDisplayInput.setAttribute("tabindex", -1); + this._revealCheckbox.hidden = false; + } + + // Swap which <input> is in the document depending on whether we need the + // real .value (which means that the primary password was already entered, + // if applicable) + if (checked || this.dataset.isNewLogin) { + this._passwordDisplayInput.replaceWith(this._passwordInput); + + // Focus the input if it hasn't been already. + if (this.dataset.editing && inputType === "text") { + this._passwordInput.focus(); + } + } else { + this._passwordInput.replaceWith(this._passwordDisplayInput); + } + } + + _updateOriginDisplayState() { + // Switches between the origin input and anchor tag depending + // if a new login is being created. + if (this.dataset.isNewLogin) { + this._originDisplayInput.replaceWith(this._originInput); + this._originInput.focus(); + } else { + this._originInput.replaceWith(this._originDisplayInput); + } + } + + // TODO(Bug 1838182): This is glue code to make lit component work + // Once login-item itself is a lit component, this method is going to be deleted + // in favour of updating the props themselves. + // NOTE: Adding this method here instead of login-alert because this file will be + // refactored soon. + #updateBreachAlert(hostname, date) { + this._breachAlert.hostname = hostname; + this._breachAlert.date = date; + } + + #updateVulnerablePasswordAlert(hostname) { + this._vulnerableAlert.hostname = hostname; + } + + #updatePasswordMessage() { + this._passwordWarning.isNewLogin = this.dataset.isNewLogin; + this._passwordWarning.webTitle = this._login.title; + } +} +customElements.define("login-item", LoginItem); 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..32c8dec98f --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-list-item.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/. */ + +import { LoginListItem, NewListItem } from "./login-list-lit-item.mjs"; +/** + * This file will be removed once login-list is made into a lit-component since + * whatever the update function does, lit does internally automatically. + */ +export default class LoginListItemFactory { + static create(login) { + if (!login.guid) { + const newListItem = new NewListItem(); + newListItem.classList.add("list-item"); + return newListItem; + } + const loginListItem = new LoginListItem(); + loginListItem.classList.add("list-item"); + LoginListItemFactory.update(loginListItem, login); + return loginListItem; + } + + static update(listItem, login) { + listItem.title = login.title; + listItem.username = login.username; + listItem.favicon = `page-icon:${login.origin}`; + + // 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; + } + } +} diff --git a/browser/components/aboutlogins/content/components/login-list-item.stories.mjs b/browser/components/aboutlogins/content/components/login-list-item.stories.mjs new file mode 100644 index 0000000000..994f32922c --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-list-item.stories.mjs @@ -0,0 +1,62 @@ +/* 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/. */ + +// eslint-disable-next-line import/no-unresolved +import { html } from "lit.all.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "./login-list-lit-item.mjs"; + +export default { + title: "Domain-specific UI Widgets/Credential Management/Login List Item", + component: "login-list-item", +}; + +window.MozXULElement.insertFTLIfNeeded("browser/aboutLogins.ftl"); + +export const NewLoginListItem = ({ selected }) => { + return html` <new-list-item .selected=${selected}> </new-list-item> `; +}; + +NewLoginListItem.argTypes = { + selected: { + options: [true, false], + control: { type: "radio" }, + defaultValue: false, + }, +}; + +export const LoginListItem = ({ + title, + username, + notificationIcon, + selected, +}) => { + return html` + <login-list-item + .title=${title} + .username=${username} + .notificationIcon=${notificationIcon} + .selected=${selected} + > + </login-list-item> + `; +}; + +LoginListItem.argTypes = { + notificationIcon: { + options: ["default", "breached", "vulnerable"], + control: { type: "radio" }, + defaultValue: "default", + }, + selected: { + options: [true, false], + control: { type: "radio" }, + defaultValue: false, + }, +}; + +LoginListItem.args = { + title: "https://www.example.com", + username: "test-username", +}; diff --git a/browser/components/aboutlogins/content/components/login-list-lit-item.css b/browser/components/aboutlogins/content/components/login-list-lit-item.css new file mode 100644 index 0000000000..69b6d72b0c --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-list-lit-item.css @@ -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/. */ + +.list-item { + display: flex; + align-items: center; + padding-block: 10px; + padding-inline: 12px 18px; + border-inline-start: 4px solid transparent; + user-select: none; +} + +.list-item:not(.selected):hover { + background-color: var(--in-content-button-background-hover); + color: var(--in-content-button-text-color-hover); +} + +.list-item:not(.selected):hover:active { + background-color: var(--in-content-button-background-active); + color: var(--in-content-button-text-color-active); +} + +.list-item.keyboard-selected { + border-inline-start-color: var(--in-content-border-color); + background-color: var(--in-content-button-background-hover); +} + +.list-item.selected { + border-inline-start-color: var(--in-content-accent-color); + background-color: var(--in-content-page-background); +} + +.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, +.subtitle { + display: block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.icon { + height: 16px; + width: 16px; + margin-inline-end: 12px; + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + fill-opacity: 0.8; +} + +.subtitle { + 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; + + :host([notificationIcon="breached"]) & { + fill: var(--red-60); + fill-opacity: 1; + } +} diff --git a/browser/components/aboutlogins/content/components/login-list-lit-item.mjs b/browser/components/aboutlogins/content/components/login-list-lit-item.mjs new file mode 100644 index 0000000000..9bd9632971 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-list-lit-item.mjs @@ -0,0 +1,169 @@ +/* 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 { + html, + classMap, + choose, + when, +} from "chrome://global/content/vendor/lit.all.mjs"; + +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +export class ListItem extends MozLitElement { + static get properties() { + return { + icon: { type: String }, + selected: { type: Boolean }, + }; + } + + constructor() { + super(); + this.icon = ""; + this.selected = false; + } + + render() { + const classes = { selected: this.selected, "list-item": true }; + return html` <link + rel="stylesheet" + href="chrome://browser/content/aboutlogins/components/login-list-lit-item.css" + /> + <li class=${classMap(classes)} role="option"> + <img class="icon" src=${this.icon} /> + <slot name="login-info"></slot> + <slot name="notificationIcon"></slot> + </li>`; + } +} + +export class NewListItem extends MozLitElement { + static properties = { + icon: { type: String }, + selected: { type: Boolean }, + }; + + constructor() { + super(); + this.id = "new-login-list-item"; + this.selected = false; + this.icon = "page-icon:undefined"; + } + + render() { + return html` + <link + rel="stylesheet" + href="chrome://browser/content/aboutlogins/components/login-list-lit-item.css" + /> + <list-item ?selected=${this.selected} icon=${this.icon}> + <div class="labels" slot="login-info"> + <span + class="title" + dir="auto" + data-l10n-id="login-list-item-title-new-login2" + ></span + ><span class="subtitle" dir="auto"></span> + </div> + </list-item> + `; + } +} + +export class LoginListItem extends MozLitElement { + static get properties() { + return { + favicon: { type: String }, + title: { type: String, reflect: true }, + username: { type: String, reflect: true }, + notificationIcon: { type: String, reflect: true }, + selected: { type: Boolean }, + }; + } + + constructor() { + super(); + this.favicon = ""; + this.title = ""; + this.username = ""; + this.notificationIcon = ""; + this.selected = false; + } + render() { + switch (this.notificationIcon) { + case "breached": + this.classList.add("breached"); + break; + case "vulnerable": + this.classList.add("vulnerable"); + break; + default: + this.classList.remove("breached"); + this.classList.remove("vulnerable"); + break; + } + + return html` + <link + rel="stylesheet" + href="chrome://browser/content/aboutlogins/components/login-list-lit-item.css" + /> + <list-item + icon=${this.favicon} + title=${this.title} + username=${this.username} + notificationIcon=${this.notificationIcon} + ?selected=${this.selected} + > + <div class="labels" slot="login-info"> + <span class="title" dir="auto">${this.title}</span> + ${when( + this.username, + () => html` <span class="subtitle" dir="auto"> + ${this.username} + </span>`, + () => html`<span + class="subtitle" + dir="auto" + data-l10n-id="login-list-item-subtitle-missing-username" + ></span>` + )} + </div> + <div slot="notificationIcon"> + ${choose( + this.notificationIcon, + [ + [ + "breached", + () => + html`<img + class="alert-icon" + data-l10n-id="about-logins-list-item-breach-icon" + title="" + src="chrome://browser/content/aboutlogins/icons/breached-website.svg" + />`, + ], + [ + "vulnerable", + () => + html`<img + class="alert-icon" + data-l10n-id="about-logins-list-item-vulnerable-password-icon" + title="" + src="chrome://browser/content/aboutlogins/icons/vulnerable-password.svg" + />`, + ], + ], + () => html`<img class="alert-icon" title="" src="" />` + )} + </div> + </list-item> + `; + } +} + +customElements.define("list-item", ListItem); +customElements.define("new-list-item", NewListItem); +customElements.define("login-list-item", LoginListItem); 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..bc8e72a3cd --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-list.css @@ -0,0 +1,163 @@ +/* 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); +} + +.meta > label > span { + margin-inline-end: 2px; +} + +#login-sort { + --logical-padding: 0px; + margin: 0; + background-color: transparent; + color: var(--in-content-text-color) !important; +} + +#login-sort:hover:not([disabled]) { + background-color: var(--in-content-button-background); +} + +#login-sort > option { + font-weight: var(--font-weight-default); +} + +.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 */ +} + +.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: var(--font-weight-bold); + 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: var(--font-weight-bold); +} + +.labels { + flex-grow: 1; + overflow: hidden; + min-height: 40px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.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); +} 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..80afdbb58d --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-list.mjs @@ -0,0 +1,923 @@ +/* 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); + + // TODO: Using the addEventListener to listen for clicks and pass the event handler due to a CSP error. + // This will be fixed as login-list itself is converted into a lit component. We will then be able to use the onclick + // prop of login-command-button as seen in the example below (functionality works and passes tests). + // this._createLoginButton.onClick = e => this.handleCreateNewLogin(e); + + this._createLoginButton.addEventListener("click", e => + this.handleCreateNewLogin(e) + ); + } + + 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 } = this._logins[guid]; + + if (guid == this._selectedGuid) { + this._setListItemAsSelected(listItem); + } + + if ( + !!this._breachesByLoginGUID && + this._breachesByLoginGUID.has(listItem.dataset.guid) + ) { + listItem.notificationIcon = "breached"; + } else if ( + !!this._vulnerableLoginsByLoginGUID && + this._vulnerableLoginsByLoginGUID.has(listItem.dataset.guid) && + listItem.notificationIcon !== "breached" + ) { + listItem.notificationIcon = "vulnerable"; + } else { + listItem.notificationIcon = ""; + } + 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; + } + + handleCreateNewLogin() { + window.dispatchEvent( + new CustomEvent("AboutLoginsShowBlankLogin", { + cancelable: true, + }) + ); + recordTelemetryEvent({ object: "new_login", method: "new" }); + } + + handleEvent(event) { + switch (event.type) { + case "click": { + let listItem; + if (event.originalTarget.tagName === "LOGIN-LIST-ITEM") { + listItem = event.originalTarget; + } else { + listItem = event.originalTarget + ? event.originalTarget.getRootNode().host + : null; + } + 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.notificationIcon === "breached") { + extra = { breached: "true" }; + } else if (listItem.notificationIcon === "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-count2" : "login-list-filtered-count2", + { 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.tagName !== "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.tagName !== "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.tagName !== "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)); + + const selectedLogin = this._logins[selectedLoginGuid]?.login; + + if (selectedLogin) { + window.dispatchEvent( + new CustomEvent("AboutLoginsInitialLoginSelected", { + detail: selectedLogin, + }) + ); + this.updateSelectedLocationHash(selectedLoginGuid); + } + } + + _setListItemAsSelected(listItem) { + let oldSelectedItem = this._list.querySelector(".selected"); + if (oldSelectedItem) { + oldSelectedItem.classList.remove("selected"); + oldSelectedItem.selected = false; + 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.selected = true; + 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 = guid ? `#${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-message-popup.css b/browser/components/aboutlogins/content/components/login-message-popup.css new file mode 100644 index 0000000000..802c8c0fa4 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-message-popup.css @@ -0,0 +1,47 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +:host .tooltip-container { + position: absolute; + inset-inline-start: 315px; + width: 232px; + box-shadow: 2px 2px 10px 1px rgba(0,0,0,0.18); + top: 0; +} + +:host .tooltip-message { + margin: 0; + font-size: 14px; +} + +:host .arrow-box { + position: relative; + padding: 12px; + background-color: var(--in-content-box-background); + border: 1px solid var(--in-content-border-color); + border-radius: 4px; +} + +:host .arrow-box::before, +:host .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; +} + +:host .arrow-box::after { + border-inline-end-color: var(--in-content-box-background); + border-width: 10px; + margin-top: -10px; +} +:host .arrow-box::before { + border-inline-end-color: var(--in-content-border-color); + border-width: 11px; + margin-top: -11px; +} diff --git a/browser/components/aboutlogins/content/components/login-message-popup.mjs b/browser/components/aboutlogins/content/components/login-message-popup.mjs new file mode 100644 index 0000000000..5949917142 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-message-popup.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 https://mozilla.org/MPL/2.0/. */ + +import { html, ifDefined } from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +const stylesTemplate = () => html` <link + rel="stylesheet" + href="chrome://browser/content/aboutlogins/components/login-message-popup.css" +/>`; + +export const MessagePopup = ({ l10nid, webTitle = "" }) => { + return html` <div class="tooltip-container"> + <div class="arrow-box"> + <p + class="tooltip-message" + data-l10n-id=${ifDefined(l10nid)} + data-l10n-args=${JSON.stringify({ webTitle })} + ></p> + </div> + </div>`; +}; + +export class PasswordWarning extends MozLitElement { + static get properties() { + return { + isNewLogin: { type: Boolean, reflect: true }, + webTitle: { type: String, reflect: true }, + }; + } + + constructor() { + super(); + this.isNewLogin = false; + } + render() { + return this.isNewLogin + ? html`${stylesTemplate()} + ${MessagePopup({ + l10nid: "about-logins-add-password-tooltip", + })}` + : html`${stylesTemplate()} + ${MessagePopup({ + l10nid: "about-logins-edit-password-tooltip", + webTitle: this.webTitle, + })}`; + } +} + +export class OriginWarning extends MozLitElement { + render() { + return html`${stylesTemplate()} + ${MessagePopup({ l10nid: "about-logins-origin-tooltip2" })}`; + } +} + +customElements.define("password-warning", PasswordWarning); +customElements.define("origin-warning", OriginWarning); diff --git a/browser/components/aboutlogins/content/components/login-timeline.css b/browser/components/aboutlogins/content/components/login-timeline.css new file mode 100644 index 0000000000..1e6eaa2b30 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-timeline.css @@ -0,0 +1,62 @@ +/* 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/. */ + +.timeline { + display: grid; + grid-template-rows: 24px auto auto; + font-size: smaller; + color: var(--text-color-deemphasized); + padding-inline-start: 0; + 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; +} 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..7925602fc9 --- /dev/null +++ b/browser/components/aboutlogins/content/components/login-timeline.mjs @@ -0,0 +1,79 @@ +/* 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, +} 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 = []; + } + + 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` + <svg + style=${styles} + class="point" + viewBox="0 0 300 150" + xmlns="http://www.w3.org/2000/svg" + > + <circle cx="150" cy="75" r="75" /> + </svg> + <div + style=${styles} + class="date" + data-l10n-id="login-item-timeline-point-date" + data-l10n-args=${JSON.stringify({ datetime: entry.time })} + ></div> + <div + style=${styles} + class="action" + data-l10n-id=${entry.actionId} + </div> + `; + }); + + return html` + <link + rel="stylesheet" + href="chrome://browser/content/aboutlogins/components/login-timeline.css" + /> + <div + class="timeline ${classMap({ empty: !this.history.length })}" + style=${styleMap({ gridTemplateColumns: columns })} + > + <div class="line"></div> + <div class="line"></div> + <div class="line"></div> + ${points} + </div> + `; + } +} + +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); |