summaryrefslogtreecommitdiffstats
path: root/browser/components/aboutlogins/content/components
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/aboutlogins/content/components')
-rw-r--r--browser/components/aboutlogins/content/components/confirmation-dialog.css71
-rw-r--r--browser/components/aboutlogins/content/components/confirmation-dialog.mjs105
-rw-r--r--browser/components/aboutlogins/content/components/fxaccounts-button.css80
-rw-r--r--browser/components/aboutlogins/content/components/fxaccounts-button.mjs83
-rw-r--r--browser/components/aboutlogins/content/components/generic-dialog.css65
-rw-r--r--browser/components/aboutlogins/content/components/generic-dialog.mjs63
-rw-r--r--browser/components/aboutlogins/content/components/import-details-row.mjs60
-rw-r--r--browser/components/aboutlogins/content/components/import-error-dialog.css28
-rw-r--r--browser/components/aboutlogins/content/components/import-error-dialog.mjs59
-rw-r--r--browser/components/aboutlogins/content/components/import-summary-dialog.css42
-rw-r--r--browser/components/aboutlogins/content/components/import-summary-dialog.mjs72
-rw-r--r--browser/components/aboutlogins/content/components/login-filter.css29
-rw-r--r--browser/components/aboutlogins/content/components/login-filter.mjs99
-rw-r--r--browser/components/aboutlogins/content/components/login-intro.css27
-rw-r--r--browser/components/aboutlogins/content/components/login-intro.mjs67
-rw-r--r--browser/components/aboutlogins/content/components/login-item.css444
-rw-r--r--browser/components/aboutlogins/content/components/login-item.mjs952
-rw-r--r--browser/components/aboutlogins/content/components/login-list-item.mjs81
-rw-r--r--browser/components/aboutlogins/content/components/login-list-section.mjs34
-rw-r--r--browser/components/aboutlogins/content/components/login-list.css202
-rw-r--r--browser/components/aboutlogins/content/components/login-list.mjs912
-rw-r--r--browser/components/aboutlogins/content/components/login-timeline.mjs137
-rw-r--r--browser/components/aboutlogins/content/components/menu-button.css93
-rw-r--r--browser/components/aboutlogins/content/components/menu-button.mjs183
-rw-r--r--browser/components/aboutlogins/content/components/remove-logins-dialog.css102
-rw-r--r--browser/components/aboutlogins/content/components/remove-logins-dialog.mjs117
26 files changed, 4207 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..bdd1e23c58
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/confirmation-dialog.css
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ .overlay {
+ position: fixed;
+ z-index: 1;
+ inset: 0;
+ /* TODO: this color is used in the about:preferences overlay, but
+ why isn't it declared as a variable? */
+ background-color: rgba(0,0,0,0.5);
+ display: flex;
+}
+
+.container {
+ z-index: 2;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-width: 250px;
+ max-width: 500px;
+ min-height: 200px;
+ margin: auto;
+ background: var(--in-content-page-background);
+ color: var(--in-content-page-color);
+ box-shadow: var(--shadow-30);
+ /* show a border in high contrast mode */
+ outline: 1px solid transparent;
+}
+
+.title {
+ font-size: 1.5em;
+ font-weight: normal;
+ user-select: none;
+ margin: 0;
+}
+
+.message {
+ color: var(--text-color-deemphasized);
+ margin-bottom: 0;
+}
+
+.dismiss-button {
+ position: absolute;
+ top: 0;
+ inset-inline-end: 0;
+ min-width: 20px;
+ min-height: 20px;
+ margin: 16px;
+ padding: 0;
+ line-height: 0;
+}
+
+.dismiss-icon {
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.warning-icon {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: 40px;
+ height: 40px;
+ margin: 16px;
+}
+
+.content,
+.buttons {
+ text-align: center;
+ padding: 16px 32px;
+}
diff --git a/browser/components/aboutlogins/content/components/confirmation-dialog.mjs b/browser/components/aboutlogins/content/components/confirmation-dialog.mjs
new file mode 100644
index 0000000000..91a9c3a9d7
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/confirmation-dialog.mjs
@@ -0,0 +1,105 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { setKeyboardAccessForNonDialogElements } from "../aboutLoginsUtils.mjs";
+
+export default class ConfirmationDialog extends HTMLElement {
+ constructor() {
+ super();
+ this._promise = null;
+ }
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+ let template = document.querySelector("#confirmation-dialog-template");
+ let shadowRoot = this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(shadowRoot);
+ shadowRoot.appendChild(template.content.cloneNode(true));
+
+ this._buttons = this.shadowRoot.querySelector(".buttons");
+ this._cancelButton = this.shadowRoot.querySelector(".cancel-button");
+ this._confirmButton = this.shadowRoot.querySelector(".confirm-button");
+ this._dismissButton = this.shadowRoot.querySelector(".dismiss-button");
+ this._message = this.shadowRoot.querySelector(".message");
+ this._overlay = this.shadowRoot.querySelector(".overlay");
+ this._title = this.shadowRoot.querySelector(".title");
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "keydown":
+ if (event.repeat) {
+ // Prevent repeat keypresses from accidentally confirming the
+ // dialog since the confirmation button is focused by default.
+ event.preventDefault();
+ return;
+ }
+ if (event.key === "Escape" && !event.defaultPrevented) {
+ this.onCancel();
+ }
+ break;
+ case "click":
+ if (
+ event.target.classList.contains("cancel-button") ||
+ event.currentTarget.classList.contains("dismiss-button") ||
+ event.target.classList.contains("overlay")
+ ) {
+ this.onCancel();
+ } else if (event.target.classList.contains("confirm-button")) {
+ this.onConfirm();
+ }
+ }
+ }
+
+ hide() {
+ setKeyboardAccessForNonDialogElements(true);
+ this._cancelButton.removeEventListener("click", this);
+ this._confirmButton.removeEventListener("click", this);
+ this._dismissButton.removeEventListener("click", this);
+ this._overlay.removeEventListener("click", this);
+ window.removeEventListener("keydown", this);
+
+ this.hidden = true;
+ }
+
+ show({ title, message, confirmButtonLabel }) {
+ setKeyboardAccessForNonDialogElements(false);
+ this.hidden = false;
+
+ document.l10n.setAttributes(this._title, title);
+ document.l10n.setAttributes(this._message, message);
+ document.l10n.setAttributes(this._confirmButton, confirmButtonLabel);
+
+ this._cancelButton.addEventListener("click", this);
+ this._confirmButton.addEventListener("click", this);
+ this._dismissButton.addEventListener("click", this);
+ this._overlay.addEventListener("click", this);
+ window.addEventListener("keydown", this);
+
+ // For speed-of-use, focus the confirm button when the
+ // dialog loads. Showing the dialog itself provides enough
+ // of a buffer for accidental deletions.
+ this._confirmButton.focus();
+
+ this._promise = new Promise((resolve, reject) => {
+ this._resolve = resolve;
+ this._reject = reject;
+ });
+
+ return this._promise;
+ }
+
+ onCancel() {
+ this._reject();
+ this.hide();
+ }
+
+ onConfirm() {
+ this._resolve();
+ this.hide();
+ }
+}
+customElements.define("confirmation-dialog", ConfirmationDialog);
diff --git a/browser/components/aboutlogins/content/components/fxaccounts-button.css b/browser/components/aboutlogins/content/components/fxaccounts-button.css
new file mode 100644
index 0000000000..2e2ef7f080
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/fxaccounts-button.css
@@ -0,0 +1,80 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.logged-out-view,
+.logged-in-view {
+ display: flex;
+ align-items: center;
+}
+
+.fxaccounts-extra-text {
+ /* Only show at most 3 lines of text to limit the
+ text from overflowing the header. */
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ overflow: hidden;
+
+ color: var(--text-color-deemphasized);
+ text-align: end;
+}
+
+.fxaccounts-extra-text,
+.fxaccount-email,
+.fxaccounts-enable-button {
+ font-size: 13px;
+}
+
+@media (max-width: 830px) {
+ .fxaccounts-extra-text,
+ .fxaccount-email {
+ display: none;
+ }
+}
+
+.fxaccount-avatar,
+.fxaccounts-enable-button {
+ margin-inline-start: 9px;
+}
+
+.fxaccounts-enable-button {
+ min-width: 120px;
+ padding-inline: 16px;
+ /* See bug 1626764: The width of button could go lesser than 120px in small window size which could wrap the texts into two lines in systems with different default fonts */
+ flex-shrink: 0;
+}
+
+.fxaccounts-avatar-button {
+ cursor: pointer;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.fxaccount-email {
+ vertical-align: middle;
+}
+
+.fxaccount-avatar {
+ display: inline-block;
+ vertical-align: middle;
+ background-image: var(--avatar-url,
+ url(chrome://browser/skin/fxa/avatar-color.svg));
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: cover;
+ border-radius: 1000px;
+ width: 32px;
+ height: 32px;
+}
+
+@media not (prefers-contrast) {
+ .fxaccounts-avatar-button:hover {
+ background-color: transparent !important;
+ }
+
+ .fxaccounts-avatar-button:hover > .fxaccount-email {
+ text-decoration: underline;
+ }
+}
diff --git a/browser/components/aboutlogins/content/components/fxaccounts-button.mjs b/browser/components/aboutlogins/content/components/fxaccounts-button.mjs
new file mode 100644
index 0000000000..d39969d726
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/fxaccounts-button.mjs
@@ -0,0 +1,83 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export default class FxAccountsButton extends HTMLElement {
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+ let template = document.querySelector("#fxaccounts-button-template");
+ let shadowRoot = this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(shadowRoot);
+ shadowRoot.appendChild(template.content.cloneNode(true));
+
+ this._avatarButton = shadowRoot.querySelector(".fxaccounts-avatar-button");
+ this._extraText = shadowRoot.querySelector(".fxaccounts-extra-text");
+ this._enableButton = shadowRoot.querySelector(".fxaccounts-enable-button");
+ this._loggedOutView = shadowRoot.querySelector(".logged-out-view");
+ this._loggedInView = shadowRoot.querySelector(".logged-in-view");
+ this._emailText = shadowRoot.querySelector(".fxaccount-email");
+
+ this._avatarButton.addEventListener("click", this);
+ this._enableButton.addEventListener("click", this);
+
+ this.render();
+ }
+
+ handleEvent(event) {
+ if (event.currentTarget == this._avatarButton) {
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsSyncOptions", {
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ if (event.target == this._enableButton) {
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsSyncEnable", {
+ bubbles: true,
+ })
+ );
+ }
+ }
+
+ render() {
+ this._loggedOutView.hidden = !!this._loggedIn;
+ this._loggedInView.hidden = !this._loggedIn;
+ this._emailText.textContent = this._email;
+ if (this._avatarURL) {
+ this._avatarButton.style.setProperty(
+ "--avatar-url",
+ `url(${this._avatarURL})`
+ );
+ } else {
+ let defaultAvatar = "chrome://browser/skin/fxa/avatar-color.svg";
+ this._avatarButton.style.setProperty(
+ "--avatar-url",
+ `url(${defaultAvatar})`
+ );
+ }
+ }
+
+ /**
+ *
+ * @param {object} state
+ * loggedIn: {Boolean} FxAccount authentication
+ * status.
+ * email: {String} Email address used with FxAccount. Must
+ * be empty if `loggedIn` is false.
+ * avatarURL: {String} URL of account avatar. Must
+ * be empty if `loggedIn` is false.
+ */
+ updateState(state) {
+ this.hidden = !state.fxAccountsEnabled;
+ this._loggedIn = state.loggedIn;
+ this._email = state.email;
+ this._avatarURL = state.avatarURL;
+
+ this.render();
+ }
+}
+customElements.define("fxaccounts-button", FxAccountsButton);
diff --git a/browser/components/aboutlogins/content/components/generic-dialog.css b/browser/components/aboutlogins/content/components/generic-dialog.css
new file mode 100644
index 0000000000..d8fbbfe93c
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/generic-dialog.css
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.overlay {
+ position: fixed;
+ z-index: 1;
+ inset: 0;
+ /* TODO: this color is used in the about:preferences overlay, but
+ why isn't it declared as a variable? */
+ background-color: rgba(0,0,0,0.5);
+ display: flex;
+}
+
+.container {
+ z-index: 2;
+ position: relative;
+ display: grid;
+ grid-template-columns: 37px auto;
+ grid-template-rows: 32px auto 50px;
+ grid-gap: 5px;
+ align-items: center;
+ width: 580px;
+ height: 290px;
+ padding: 50px 50px 20px;
+ margin: auto;
+ background-color: var(--in-content-page-background);
+ color: var(--in-content-page-color);
+ box-shadow: var(--shadow-30);
+ /* show a border in high contrast mode */
+ outline: 1px solid transparent;
+}
+
+::slotted([slot="dialog-icon"]) {
+ width: 32px;
+ height: 32px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+::slotted([slot="dialog-title"]) {
+ font-size: 2.2em;
+ font-weight: 300;
+ user-select: none;
+ margin: 0;
+}
+
+::slotted([slot="content"]) {
+ grid-column-start: 2;
+ align-self: baseline;
+ margin-top: 16px;
+ line-height: 1.4em;
+}
+
+::slotted([slot="buttons"]) {
+ grid-column: 1 / 4;
+ grid-row-start: 3;
+ border-top: 1px solid var(--in-content-border-color);
+ padding-top: 12px;
+}
+
+.dialog-body {
+ padding-block: 40px 16px;
+ padding-inline: 45px 32px;
+}
diff --git a/browser/components/aboutlogins/content/components/generic-dialog.mjs b/browser/components/aboutlogins/content/components/generic-dialog.mjs
new file mode 100644
index 0000000000..8d9ddc9d36
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/generic-dialog.mjs
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ setKeyboardAccessForNonDialogElements,
+ initDialog,
+} from "../aboutLoginsUtils.mjs";
+
+export default class GenericDialog extends HTMLElement {
+ constructor() {
+ super();
+ this._promise = null;
+ }
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+ const shadowRoot = initDialog(this, "#generic-dialog-template");
+ this._dismissButton = this.querySelector(".dismiss-button");
+ this._overlay = shadowRoot.querySelector(".overlay");
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "keydown":
+ if (event.key === "Escape" && !event.defaultPrevented) {
+ this.hide();
+ }
+ break;
+ case "click":
+ if (
+ event.currentTarget.classList.contains("dismiss-button") ||
+ event.target.classList.contains("overlay")
+ ) {
+ this.hide();
+ }
+ }
+ }
+
+ show() {
+ setKeyboardAccessForNonDialogElements(false);
+ this.hidden = false;
+ this.parentNode.host.hidden = false;
+
+ this._dismissButton.addEventListener("click", this);
+ this._overlay.addEventListener("click", this);
+ window.addEventListener("keydown", this);
+ }
+
+ hide() {
+ setKeyboardAccessForNonDialogElements(true);
+ this._dismissButton.removeEventListener("click", this);
+ this._overlay.removeEventListener("click", this);
+ window.removeEventListener("keydown", this);
+
+ this.hidden = true;
+ this.parentNode.host.hidden = true;
+ }
+}
+
+customElements.define("generic-dialog", GenericDialog);
diff --git a/browser/components/aboutlogins/content/components/import-details-row.mjs b/browser/components/aboutlogins/content/components/import-details-row.mjs
new file mode 100644
index 0000000000..13fe40da59
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/import-details-row.mjs
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const resultToUiData = {
+ no_change: {
+ message: "about-logins-import-report-row-description-no-change",
+ },
+ modified: {
+ message: "about-logins-import-report-row-description-modified",
+ },
+ added: {
+ message: "about-logins-import-report-row-description-added",
+ },
+ error: {
+ message: "about-logins-import-report-row-description-error",
+ isError: true,
+ },
+ error_multiple_values: {
+ message: "about-logins-import-report-row-description-error-multiple-values",
+ isError: true,
+ },
+ error_missing_field: {
+ message: "about-logins-import-report-row-description-error-missing-field",
+ isError: true,
+ },
+};
+
+export default class ImportDetailsRow extends HTMLElement {
+ constructor(number, reportRow) {
+ super();
+ this._login = reportRow;
+
+ let rowElement = document
+ .querySelector("#import-details-row-template")
+ .content.cloneNode(true);
+
+ const uiData = resultToUiData[reportRow.result];
+ if (uiData.isError) {
+ this.classList.add("error");
+ }
+ const rowCount = rowElement.querySelector(".row-count");
+ const rowDetails = rowElement.querySelector(".row-details");
+ while (rowElement.childNodes.length) {
+ this.appendChild(rowElement.childNodes[0]);
+ }
+ document.l10n.connectRoot(this);
+ document.l10n.setAttributes(
+ rowCount,
+ "about-logins-import-report-row-index",
+ {
+ number,
+ }
+ );
+ document.l10n.setAttributes(rowDetails, uiData.message, {
+ field: reportRow.field_name,
+ });
+ }
+}
+customElements.define("import-details-row", ImportDetailsRow);
diff --git a/browser/components/aboutlogins/content/components/import-error-dialog.css b/browser/components/aboutlogins/content/components/import-error-dialog.css
new file mode 100644
index 0000000000..6fc2e945e4
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/import-error-dialog.css
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.content {
+ display: flex;
+ flex-direction: column;
+ grid-area: 2 / 2 / 3 / 4;
+ align-items: flex-start;
+}
+
+.error-title {
+ font-weight: 600;
+ margin-top: 20px;
+}
+
+.no-logins {
+ margin-top: 25px;
+}
+
+.error-learn-more-link {
+ font-weight: 600;
+}
+
+.warning-icon {
+ -moz-context-properties: fill;
+ fill: #FFBF00;
+}
diff --git a/browser/components/aboutlogins/content/components/import-error-dialog.mjs b/browser/components/aboutlogins/content/components/import-error-dialog.mjs
new file mode 100644
index 0000000000..31ad29512f
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/import-error-dialog.mjs
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { initDialog } from "../aboutLoginsUtils.mjs";
+
+export default class ImportErrorDialog extends HTMLElement {
+ constructor() {
+ super();
+ this._promise = null;
+ this._errorMessages = {};
+ this._errorMessages.CONFLICTING_VALUES_ERROR = {
+ title: "about-logins-import-dialog-error-conflicting-values-title",
+ description:
+ "about-logins-import-dialog-error-conflicting-values-description",
+ };
+ this._errorMessages.FILE_FORMAT_ERROR = {
+ title: "about-logins-import-dialog-error-file-format-title",
+ description: "about-logins-import-dialog-error-file-format-description",
+ };
+ this._errorMessages.FILE_PERMISSIONS_ERROR = {
+ title: "about-logins-import-dialog-error-file-permission-title",
+ description:
+ "about-logins-import-dialog-error-file-permission-description",
+ };
+ this._errorMessages.UNABLE_TO_READ_ERROR = {
+ title: "about-logins-import-dialog-error-unable-to-read-title",
+ description:
+ "about-logins-import-dialog-error-unable-to-read-description",
+ };
+ }
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+ const shadowRoot = initDialog(this, "#import-error-dialog-template");
+ this._titleElement = shadowRoot.querySelector(".error-title");
+ this._descriptionElement = shadowRoot.querySelector(".error-description");
+ this._genericDialog = this.shadowRoot.querySelector("generic-dialog");
+ this._focusedElement = this.shadowRoot.querySelector("a");
+ const tryImportAgain = this.shadowRoot.querySelector(".try-import-again");
+ tryImportAgain.addEventListener("click", () => {
+ this._genericDialog.hide();
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsImportFromFile", { bubbles: true })
+ );
+ });
+ }
+
+ show(errorType) {
+ const { title, description } = this._errorMessages[errorType];
+ document.l10n.setAttributes(this._titleElement, title);
+ document.l10n.setAttributes(this._descriptionElement, description);
+ this._genericDialog.show();
+ window.AboutLoginsUtils.setFocus(this._focusedElement);
+ }
+}
+customElements.define("import-error-dialog", ImportErrorDialog);
diff --git a/browser/components/aboutlogins/content/components/import-summary-dialog.css b/browser/components/aboutlogins/content/components/import-summary-dialog.css
new file mode 100644
index 0000000000..20dd987958
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/import-summary-dialog.css
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.content {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+}
+
+.import-summary {
+ display: grid;
+ grid-template-columns: max-content max-content max-content;
+}
+
+.import-summary > * > span {
+ margin-block: 0 2px;
+ margin-inline: 0 10px;
+}
+
+.import-items-row {
+ grid-column: 1 / 4;
+ display: grid;
+ grid-template-columns: subgrid;
+}
+
+.result-count {
+ text-align: end;
+ font-weight: bold;
+}
+
+.result-meta {
+ font-style: italic;
+}
+.import-items-errors .result-meta {
+ color: var(--dialog-warning-text-color);
+}
+
+.open-detailed-report {
+ margin-block-start: 30px;
+ font-weight: 600;
+}
diff --git a/browser/components/aboutlogins/content/components/import-summary-dialog.mjs b/browser/components/aboutlogins/content/components/import-summary-dialog.mjs
new file mode 100644
index 0000000000..76d19b0190
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/import-summary-dialog.mjs
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { initDialog } from "../aboutLoginsUtils.mjs";
+
+export default class ImportSummaryDialog extends HTMLElement {
+ constructor() {
+ super();
+ this._promise = null;
+ }
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+ initDialog(this, "#import-summary-dialog-template");
+ this._added = this.shadowRoot.querySelector(".import-items-added");
+ this._modified = this.shadowRoot.querySelector(".import-items-modified");
+ this._noChange = this.shadowRoot.querySelector(".import-items-no-change");
+ this._error = this.shadowRoot.querySelector(".import-items-errors");
+ this._genericDialog = this.shadowRoot.querySelector("generic-dialog");
+ }
+
+ show({ logins }) {
+ const report = {
+ added: 0,
+ modified: 0,
+ no_change: 0,
+ error: 0,
+ };
+ for (let loginRow of logins) {
+ if (loginRow.result.includes("error")) {
+ report.error++;
+ } else {
+ report[loginRow.result]++;
+ }
+ }
+ this._updateCount(
+ report.added,
+ this._added,
+ "about-logins-import-dialog-items-added"
+ );
+ this._updateCount(
+ report.modified,
+ this._modified,
+ "about-logins-import-dialog-items-modified"
+ );
+ this._updateCount(
+ report.no_change,
+ this._noChange,
+ "about-logins-import-dialog-items-no-change"
+ );
+ this._updateCount(
+ report.error,
+ this._error,
+ "about-logins-import-dialog-items-error"
+ );
+ this._noChange.querySelector(".result-meta").hidden =
+ report.no_change === 0;
+ this._error.querySelector(".result-meta").hidden = report.error === 0;
+ this._genericDialog.show();
+ window.AboutLoginsUtils.setFocus(this._genericDialog._dismissButton);
+ }
+
+ _updateCount(count, component, message) {
+ if (count != document.l10n.getAttributes(component).args.count) {
+ document.l10n.setAttributes(component, message, { count });
+ }
+ }
+}
+customElements.define("import-summary-dialog", ImportSummaryDialog);
diff --git a/browser/components/aboutlogins/content/components/login-filter.css b/browser/components/aboutlogins/content/components/login-filter.css
new file mode 100644
index 0000000000..f7db0e6770
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-filter.css
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.filter[type="text"] {
+ -moz-context-properties: fill, fill-opacity;
+ fill: currentColor;
+ fill-opacity: 0.4;
+ background-image: url("chrome://global/skin/icons/search-glass.svg");
+ background-position: 8px center;
+ background-repeat: no-repeat;
+ background-size: 16px;
+ text-align: match-parent;
+ width: 100%;
+ margin: 0;
+ box-sizing: border-box;
+ padding-block: 6px;
+}
+
+:host(:dir(ltr)) .filter {
+ /* We use separate RTL rules over logical properties since we want the visual direction
+ to be independent from the user input direction */
+ padding-left: 32px;
+}
+
+:host(:dir(rtl)) .filter {
+ background-position-x: right 8px;
+ padding-right: 32px;
+}
diff --git a/browser/components/aboutlogins/content/components/login-filter.mjs b/browser/components/aboutlogins/content/components/login-filter.mjs
new file mode 100644
index 0000000000..e5b89327d6
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-filter.mjs
@@ -0,0 +1,99 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { recordTelemetryEvent } from "../aboutLoginsUtils.mjs";
+
+export default class LoginFilter extends HTMLElement {
+ get #loginList() {
+ return document.querySelector("login-list");
+ }
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+
+ let loginFilterTemplate = document.querySelector("#login-filter-template");
+ let shadowRoot = this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(shadowRoot);
+ shadowRoot.appendChild(loginFilterTemplate.content.cloneNode(true));
+
+ this._input = this.shadowRoot.querySelector("input");
+
+ this.addEventListener("input", this);
+ this._input.addEventListener("keydown", this);
+ window.addEventListener("AboutLoginsFilterLogins", this);
+ }
+
+ focus() {
+ this._input.focus();
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "AboutLoginsFilterLogins":
+ this.#filterLogins(event.detail);
+ break;
+ case "input":
+ this.#input(event.originalTarget.value);
+ break;
+ case "keydown":
+ this.#keyDown(event);
+ break;
+ }
+ }
+
+ #filterLogins(filterText) {
+ if (this.value != filterText) {
+ this.value = filterText;
+ }
+ }
+
+ #input(value) {
+ this._dispatchFilterEvent(value);
+ }
+
+ #keyDown(e) {
+ switch (e.code) {
+ case "ArrowUp":
+ e.preventDefault();
+ this.#loginList.selectPrevious();
+ break;
+ case "ArrowDown":
+ e.preventDefault();
+ this.#loginList.selectNext();
+ break;
+ case "Escape":
+ e.preventDefault();
+ this.value = "";
+ break;
+ case "Enter":
+ e.preventDefault();
+ this.#loginList.clickSelected();
+ break;
+ }
+ }
+
+ get value() {
+ return this._input.value;
+ }
+
+ set value(val) {
+ this._input.value = val;
+ this._dispatchFilterEvent(val);
+ }
+
+ _dispatchFilterEvent(value) {
+ this.dispatchEvent(
+ new CustomEvent("AboutLoginsFilterLogins", {
+ bubbles: true,
+ composed: true,
+ detail: value,
+ })
+ );
+
+ recordTelemetryEvent({ object: "list", method: "filter" });
+ }
+}
+customElements.define("login-filter", LoginFilter);
diff --git a/browser/components/aboutlogins/content/components/login-intro.css b/browser/components/aboutlogins/content/components/login-intro.css
new file mode 100644
index 0000000000..3c5ecdc577
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-intro.css
@@ -0,0 +1,27 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:host {
+ padding: 60px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.heading {
+ font-size: 1.5em;
+}
+
+section {
+ line-height: 2;
+}
+
+.description {
+ font-weight: 600;
+ margin-bottom: 0;
+}
+
+.illustration.logged-in {
+ opacity: .5;
+}
diff --git a/browser/components/aboutlogins/content/components/login-intro.mjs b/browser/components/aboutlogins/content/components/login-intro.mjs
new file mode 100644
index 0000000000..682ddb32d8
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-intro.mjs
@@ -0,0 +1,67 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export default class LoginIntro extends HTMLElement {
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+
+ let loginIntroTemplate = document.querySelector("#login-intro-template");
+ let shadowRoot = this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(shadowRoot);
+ shadowRoot.appendChild(loginIntroTemplate.content.cloneNode(true));
+ }
+
+ focus() {
+ let helpLink = this.shadowRoot.querySelector(".intro-help-link");
+ helpLink.focus();
+ }
+
+ handleEvent(event) {
+ if (
+ event.currentTarget.classList.contains("intro-import-text") &&
+ event.target.localName == "a"
+ ) {
+ let eventName =
+ event.target.dataset.l10nName == "import-file-link"
+ ? "AboutLoginsImportFromFile"
+ : "AboutLoginsImportFromBrowser";
+ document.dispatchEvent(
+ new CustomEvent(eventName, {
+ bubbles: true,
+ })
+ );
+ }
+ event.preventDefault();
+ }
+
+ updateState(syncState) {
+ let l10nId = syncState.loggedIn
+ ? "about-logins-login-intro-heading-logged-in"
+ : "about-logins-login-intro-heading-logged-out2";
+ document.l10n.setAttributes(
+ this.shadowRoot.querySelector(".heading"),
+ l10nId
+ );
+
+ this.shadowRoot
+ .querySelector(".illustration")
+ .classList.toggle("logged-in", syncState.loggedIn);
+ let supportURL =
+ window.AboutLoginsUtils.supportBaseURL +
+ "password-manager-remember-delete-edit-logins";
+ this.shadowRoot
+ .querySelector(".intro-help-link")
+ .setAttribute("href", supportURL);
+
+ let importClass = window.AboutLoginsUtils.fileImportEnabled
+ ? ".intro-import-text.file-import"
+ : ".intro-import-text.no-file-import";
+ let importText = this.shadowRoot.querySelector(importClass);
+ importText.addEventListener("click", this);
+ importText.hidden = !window.AboutLoginsUtils.importVisible;
+ }
+}
+customElements.define("login-intro", LoginIntro);
diff --git a/browser/components/aboutlogins/content/components/login-item.css b/browser/components/aboutlogins/content/components/login-item.css
new file mode 100644
index 0000000000..e11cb01700
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-item.css
@@ -0,0 +1,444 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ :host {
+ overflow: hidden;
+
+ --reveal-checkbox-opacity: .8;
+ --reveal-checkbox-opacity-hover: .6;
+ --reveal-checkbox-opacity-active: 1;
+ --success-color: #2AC3A2;
+ --edit-delete-button-color: #4a4a4f;
+}
+
+/* Only overwrite the deemphasized text color in non-dark mode. */
+@media not (prefers-color-scheme: dark) {
+ :host {
+ --text-color-deemphasized: #737373;
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ :host {
+ --reveal-checkbox-opacity: .8;
+ --reveal-checkbox-opacity-hover: 1;
+ --reveal-checkbox-opacity-active: .6;
+ --success-color: #54FFBD;
+ --edit-delete-button-color: #cfcfd1;
+ }
+}
+
+.container {
+ overflow: auto;
+ padding: 0 40px;
+ box-sizing: border-box;
+ height: 100%;
+}
+
+@media (max-width: 830px) {
+ .container {
+ padding-inline: 20px;
+ }
+}
+
+.column {
+ min-height: 100%;
+ max-width: 700px;
+ display: flex;
+ flex-direction: column;
+}
+
+button {
+ min-width: 100px;
+}
+
+form {
+ flex-grow: 1;
+}
+
+:host([data-editing]) .edit-button,
+:host([data-editing]) .copy-button,
+:host([data-is-new-login]) .delete-button,
+:host([data-is-new-login]) .origin-saved-value,
+:host([data-is-new-login]) login-timeline,
+:host([data-is-new-login]) .login-item-title,
+:host(:not([data-is-new-login])) .new-login-title,
+:host(:not([data-editing])) .form-actions-row {
+ display: none;
+}
+
+input[type="password"],
+input[type="text"],
+input[type="url"] {
+ text-align: match-parent !important; /* override `all: unset` in the rule below */
+}
+
+:host(:not([data-editing])) input[type="password"]:read-only,
+input[type="text"]:read-only,
+input[type="url"]:read-only {
+ all: unset;
+ font-size: 1.1em;
+ display: inline-block;
+ background-color: transparent !important; /* override common.inc.css */
+ text-overflow: ellipsis;
+ overflow: hidden;
+ width: 100%;
+}
+
+/* We can't use `margin-inline-start` here because we force
+ * the input to have dir="ltr", so we set the margin manually
+ * using the parent element's directionality. */
+.detail-cell:dir(ltr) input:not([type="checkbox"]) {
+ margin-left: 0;
+}
+
+.detail-cell:dir(rtl) input:not([type="checkbox"]) {
+ margin-right: 0;
+}
+
+.save-changes-button {
+ margin-inline-start: 0; /* Align the button on the start side */
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 40px;
+}
+
+.title {
+ margin-block: 0;
+ flex-grow: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.delete-button,
+.edit-button {
+ color: var(--edit-delete-button-color);
+ background-repeat: no-repeat;
+ background-position: 8px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ min-width: auto;
+ /* See bug 1627164: In CJK locales, line break could happen in any letter of the button. The fix here is to explicitly specify flex property so that the button couldn't grow or shrink. */
+ flex: 0 0 auto;
+}
+
+.delete-button:dir(rtl),
+.edit-button:dir(rtl) {
+ background-position-x: right 8px;
+}
+
+.delete-button {
+ background-image: url("chrome://global/skin/icons/delete.svg");
+ padding-inline-start: 30px; /* 8px on each side, and 14px for icon width */
+}
+
+.edit-button {
+ background-image: url("chrome://global/skin/icons/edit.svg");
+ padding-inline-start: 32px; /* 8px on each side, and 16px for icon width */
+}
+
+input[type="url"]:read-only {
+ color: var(--in-content-link-color) !important;
+ cursor: pointer;
+}
+
+input[type="url"]:read-only:hover {
+ color: var(--in-content-link-color-hover) !important;
+ text-decoration: underline;
+}
+
+input[type="url"]:read-only:hover:active {
+ color: var(--in-content-link-color-active) !important;
+}
+
+input[type = "url"]:focus:not(:-moz-ui-invalid):invalid ~ .tooltip-container {
+ display: block;
+}
+
+input[type = "url"]:focus:-moz-ui-invalid:not(:placeholder-shown) ~ .tooltip-container {
+ display: block;
+}
+
+.tooltip-container {
+ display: none;
+ position: absolute;
+ inset-inline-start: 315px;
+ width: 232px;
+ box-shadow: 2px 2px 10px 1px rgba(0,0,0,0.18);
+ top: 0;
+}
+
+.tooltip-message {
+ margin: 0;
+ font-size: 14px;
+}
+
+.arrow-box {
+ position: relative;
+ padding: 12px;
+ background-color: var(--in-content-box-background);
+ border: 1px solid var(--in-content-border-color);
+ border-radius: 4px;
+}
+
+.arrow-box::before,
+.arrow-box::after {
+ inset-inline-end: 100%;
+ top: 40px; /* This allows the arrow to stay in the correct position, even if the text length is changed */
+ border: solid transparent;
+ content: "";
+ height: 0;
+ width: 0;
+ position: absolute;
+ pointer-events: none;
+}
+
+.arrow-box::after {
+ border-inline-end-color: var(--in-content-box-background);
+ border-width: 10px;
+ margin-top: -10px;
+}
+.arrow-box::before {
+ border-inline-end-color: var(--in-content-border-color);
+ border-width: 11px;
+ margin-top: -11px;
+}
+
+.reveal-password-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.detail-grid {
+ display: grid;
+ grid-template-columns: minmax(240px, max-content) auto;
+ grid-template-rows: auto;
+ column-gap: 20px;
+ row-gap: 40px;
+ justify-content: start;
+}
+
+:host([data-editing]) .detail-grid {
+ grid-template-columns: auto;
+}
+
+.detail-grid > .detail-row:not([hidden]) {
+ display: contents;
+}
+
+.detail-grid > .detail-row > .detail-cell {
+ grid-column: 1;
+}
+
+.detail-grid > .detail-row > .copy-button {
+ grid-column: 2;
+ margin-inline-start: 0; /* Reset button's margin so it doesn't affect the overall grid's width */
+ justify-self: start;
+ align-self: end;
+}
+
+.detail-row {
+ display: flex;
+ position: relative; /* Allows for the hint message to be positioned correctly */
+}
+
+.detail-grid,
+.detail-row,
+.form-actions-row {
+ margin-bottom: 40px;
+}
+
+.detail-cell {
+ flex-grow: 1;
+ min-width: 0; /* Allow long passwords to collapse down to flex item width */
+}
+
+.field-label {
+ display: block;
+ font-size: smaller;
+ color: var(--text-color-deemphasized);
+ margin-bottom: 8px;
+}
+
+moz-button-group,
+:host([data-editing]) .detail-cell input:read-write:not([type="checkbox"]),
+:host([data-editing]) input[type="password"]:read-only {
+ width: 298px;
+ box-sizing: border-box;
+}
+
+.copy-button {
+ margin-bottom: 0; /* Align button at the bottom of the row */
+}
+
+.copy-button:not([data-copied]) .copied-button-text,
+.copy-button[data-copied] .copy-button-text {
+ display: none;
+}
+
+.copy-button[data-copied] {
+ color: var(--success-color) !important; /* override common.css */
+ background-color: transparent;
+ opacity: 1; /* override common.css fading out disabled buttons */
+}
+
+.copy-button[data-copied]:-moz-focusring {
+ outline-width: 0;
+ box-shadow: none;
+}
+
+.copied-button-text {
+ background-image: url(chrome://global/skin/icons/check.svg);
+ background-repeat: no-repeat;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ padding-inline-start: 22px;
+}
+
+.copied-button-text:dir(rtl) {
+ background-position-x: right;
+}
+
+input.password-display,
+input[name="password"] {
+ font-family: monospace !important; /* override `all: unset` in the rule above */
+}
+
+.reveal-password-checkbox {
+ /* !important is needed to override common.css styling for checkboxes */
+ background-color: transparent !important;
+ border-width: 0 !important;
+ background-image: url("resource://gre-resources/password.svg") !important;
+ margin-inline: 10px 0 !important;
+ cursor: pointer;
+ -moz-context-properties: fill;
+ fill: currentColor !important;
+ color: inherit !important;
+ opacity: var(--reveal-checkbox-opacity);
+ flex-shrink: 0;
+}
+
+.reveal-password-checkbox:hover {
+ opacity: var(--reveal-checkbox-opacity-hover);
+}
+
+.reveal-password-checkbox:hover:active {
+ opacity: var(--reveal-checkbox-opacity-active);
+}
+
+.reveal-password-checkbox:checked {
+ background-image: url("resource://gre-resources/password-hide.svg") !important;
+}
+
+.login-item-favicon {
+ margin-inline-end: 12px;
+ height: 24px;
+ width: 24px;
+ flex-shrink: 0;
+ -moz-context-properties: fill, fill-opacity;
+ fill: currentColor;
+ fill-opacity: 0.75;
+}
+
+.vulnerable-alert,
+.breach-alert {
+ border-radius: 4px;
+ border: 1px solid var(--in-content-border-color);
+ box-shadow: 0 2px 8px 0 var(--grey-90-a10);
+ font-size: .9em;
+ line-height: 1.4;
+ padding-block: 12px;
+ padding-inline: 64px 32px;
+ margin-block-end: 40px;
+ position: relative;
+}
+
+.breach-alert {
+ background-color: var(--red-70);
+ color: #fff;
+}
+
+.vulnerable-alert {
+ background-color: var(--in-content-box-background);
+ color: var(--in-content-text-color);
+}
+
+.alert-title {
+ font-size: 22px;
+ font-weight: normal;
+ line-height: 1em;
+ margin-block: 0 12px;
+}
+
+.alert-date {
+ display: block;
+ font-weight: 600;
+}
+
+.alert-link:visited,
+.alert-link {
+ font-weight: 600;
+ overflow-wrap: anywhere;
+}
+
+.breach-alert > .alert-link:visited,
+.breach-alert > .alert-link {
+ color: inherit;
+ text-decoration: underline;
+}
+
+.alert-icon {
+ position: absolute;
+ inset-block-start: 16px;
+ inset-inline-start: 32px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: 24px;
+}
+
+.alert-learn-more-link:hover,
+.alert-learn-more-link:visited,
+.alert-learn-more-link {
+ position: absolute;
+ inset-block-start: 16px;
+ inset-inline-end: 32px;
+ color: inherit;
+ font-size: 13px;
+}
+
+.vulnerable-alert > .alert-learn-more-link {
+ color: var(--text-color-deemphasized);
+}
+
+.error-message {
+ color: #fff;
+ background-color: var(--red-60);
+ border: 1px solid transparent;
+ padding-block: 6px;
+ display: inline-block;
+ padding-inline: 32px 16px;
+ background-image: url("chrome://global/skin/icons/warning.svg");
+ background-repeat: no-repeat;
+ background-position: left 10px center;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ margin-bottom: 38px;
+}
+
+.error-message:dir(rtl) {
+ background-position-x: right 10px;
+}
+
+.error-message-link > a,
+.error-message-link > a:hover,
+.error-message-link > a:hover:active {
+ color: currentColor;
+ text-decoration: underline;
+ font-weight: 600;
+}
diff --git a/browser/components/aboutlogins/content/components/login-item.mjs b/browser/components/aboutlogins/content/components/login-item.mjs
new file mode 100644
index 0000000000..35bca46163
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-item.mjs
@@ -0,0 +1,952 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ CONCEALED_PASSWORD_TEXT,
+ recordTelemetryEvent,
+ promptForPrimaryPassword,
+} from "../aboutLoginsUtils.mjs";
+
+export default class LoginItem extends HTMLElement {
+ /**
+ * The number of milliseconds to display the "Copied" success message
+ * before reverting to the normal "Copy" button.
+ */
+ static get COPY_BUTTON_RESET_TIMEOUT() {
+ return 5000;
+ }
+
+ constructor() {
+ super();
+ this._login = {};
+ this._error = null;
+ this._copyUsernameTimeoutId = 0;
+ this._copyPasswordTimeoutId = 0;
+ }
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ this.render();
+ return;
+ }
+
+ let loginItemTemplate = document.querySelector("#login-item-template");
+ let shadowRoot = this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(shadowRoot);
+ shadowRoot.appendChild(loginItemTemplate.content.cloneNode(true));
+
+ this._cancelButton = this.shadowRoot.querySelector(".cancel-button");
+ this._confirmDeleteDialog = document.querySelector("confirm-delete-dialog");
+ this._copyPasswordButton = this.shadowRoot.querySelector(
+ ".copy-password-button"
+ );
+ this._copyUsernameButton = this.shadowRoot.querySelector(
+ ".copy-username-button"
+ );
+ this._deleteButton = this.shadowRoot.querySelector(".delete-button");
+ this._editButton = this.shadowRoot.querySelector(".edit-button");
+ this._errorMessage = this.shadowRoot.querySelector(".error-message");
+ this._errorMessageLink = this._errorMessage.querySelector(
+ ".error-message-link"
+ );
+ this._errorMessageText = this._errorMessage.querySelector(
+ ".error-message-text"
+ );
+ this._form = this.shadowRoot.querySelector("form");
+ this._originInput = this.shadowRoot.querySelector("input[name='origin']");
+ this._originDisplayInput =
+ this.shadowRoot.querySelector("a[name='origin']");
+ this._usernameInput = this.shadowRoot.querySelector(
+ "input[name='username']"
+ );
+ // type=password field for display which only ever contains spaces the correct
+ // length of the password.
+ this._passwordDisplayInput = this.shadowRoot.querySelector(
+ "input.password-display"
+ );
+ // type=text field for editing the password with the actual password value.
+ this._passwordInput = this.shadowRoot.querySelector(
+ "input[name='password']"
+ );
+ this._revealCheckbox = this.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ this._saveChangesButton = this.shadowRoot.querySelector(
+ ".save-changes-button"
+ );
+ this._favicon = this.shadowRoot.querySelector(".login-item-favicon");
+ this._title = this.shadowRoot.querySelector(".login-item-title");
+ this._breachAlert = this.shadowRoot.querySelector(".breach-alert");
+ this._breachAlertLink = this._breachAlert.querySelector(".alert-link");
+ this._breachAlertDate = this._breachAlert.querySelector(".alert-date");
+ this._vulnerableAlert = this.shadowRoot.querySelector(".vulnerable-alert");
+ this._vulnerableAlertLink =
+ this._vulnerableAlert.querySelector(".alert-link");
+ this._vulnerableAlertLearnMoreLink = this._vulnerableAlert.querySelector(
+ ".alert-learn-more-link"
+ );
+
+ this.render();
+
+ this._cancelButton.addEventListener("click", this);
+ this._copyPasswordButton.addEventListener("click", this);
+ this._copyUsernameButton.addEventListener("click", this);
+ this._deleteButton.addEventListener("click", this);
+ this._editButton.addEventListener("click", this);
+ this._errorMessageLink.addEventListener("click", this);
+ this._form.addEventListener("submit", this);
+ this._originInput.addEventListener("blur", this);
+ this._originInput.addEventListener("click", this);
+ this._originInput.addEventListener("mousedown", this, true);
+ this._originInput.addEventListener("auxclick", this);
+ this._originDisplayInput.addEventListener("click", this);
+ this._revealCheckbox.addEventListener("click", this);
+ this._vulnerableAlertLearnMoreLink.addEventListener("click", this);
+
+ this._passwordInput.addEventListener("focus", this);
+ this._passwordInput.addEventListener("blur", this);
+ this._passwordDisplayInput.addEventListener("focus", this);
+ this._passwordDisplayInput.addEventListener("blur", this);
+
+ window.addEventListener("AboutLoginsInitialLoginSelected", this);
+ window.addEventListener("AboutLoginsLoginSelected", this);
+ window.addEventListener("AboutLoginsShowBlankLogin", this);
+ window.addEventListener("AboutLoginsRemaskPassword", this);
+ }
+
+ focus() {
+ if (!this._editButton.disabled) {
+ this._editButton.focus();
+ } else if (!this._deleteButton.disabled) {
+ this._deleteButton.focus();
+ } else {
+ this._originInput.focus();
+ }
+ }
+
+ async render(
+ { onlyUpdateErrorsAndAlerts } = { onlyUpdateErrorsAndAlerts: false }
+ ) {
+ if (this._error) {
+ if (this._error.errorMessage.includes("This login already exists")) {
+ document.l10n.setAttributes(
+ this._errorMessageLink,
+ "about-logins-error-message-duplicate-login-with-link",
+ {
+ loginTitle: this._error.login.title,
+ }
+ );
+ this._errorMessageLink.dataset.errorGuid =
+ this._error.existingLoginGuid;
+ this._errorMessageText.hidden = true;
+ this._errorMessageLink.hidden = false;
+ } else {
+ this._errorMessageText.hidden = false;
+ this._errorMessageLink.hidden = true;
+ }
+ }
+ this._errorMessage.hidden = !this._error;
+
+ this._breachAlert.hidden =
+ !this._breachesMap || !this._breachesMap.has(this._login.guid);
+ if (!this._breachAlert.hidden) {
+ const breachDetails = this._breachesMap.get(this._login.guid);
+ this._breachAlertLink.href = this._login.origin;
+ document.l10n.setAttributes(
+ this._breachAlertLink,
+ "about-logins-breach-alert-link",
+ { hostname: this._login.displayOrigin }
+ );
+ if (breachDetails.BreachDate) {
+ let breachDate = new Date(breachDetails.BreachDate);
+ this._breachAlertDate.hidden = isNaN(breachDate);
+ if (!isNaN(breachDate)) {
+ document.l10n.setAttributes(
+ this._breachAlertDate,
+ "about-logins-breach-alert-date",
+ {
+ date: breachDate.getTime(),
+ }
+ );
+ }
+ }
+ }
+ this._vulnerableAlert.hidden =
+ !this._vulnerableLoginsMap ||
+ !this._vulnerableLoginsMap.has(this._login.guid) ||
+ !this._breachAlert.hidden;
+ if (!this._vulnerableAlert.hidden) {
+ this._vulnerableAlertLink.href = this._login.origin;
+ document.l10n.setAttributes(
+ this._vulnerableAlertLink,
+ "about-logins-vulnerable-alert-link",
+ {
+ hostname: this._login.displayOrigin,
+ }
+ );
+ this._vulnerableAlertLearnMoreLink.setAttribute(
+ "href",
+ window.AboutLoginsUtils.supportBaseURL + "lockwise-alerts"
+ );
+ }
+ if (onlyUpdateErrorsAndAlerts) {
+ return;
+ }
+
+ this._favicon.src = `page-icon:${this._login.origin}`;
+ this._title.textContent = this._login.title;
+ this._title.title = this._login.title;
+ this._originInput.defaultValue = this._login.origin || "";
+ if (this._login.origin) {
+ // Creates anchor element with origin URL
+ this._originDisplayInput.href = this._login.origin || "";
+ this._originDisplayInput.innerText = this._login.origin || "";
+ }
+ this._usernameInput.defaultValue = this._login.username || "";
+ if (this._login.password) {
+ // We use .value instead of .defaultValue since the latter updates the
+ // content attribute making the password easily viewable with Inspect
+ // Element even when Primary Password is enabled. This is only run when
+ // the password is non-empty since setting the field to an empty value
+ // would mark the field as 'dirty' for form validation and thus trigger
+ // the error styling since the password field is 'required'.
+ // This element is only in the document while unmasked or editing.
+ this._passwordInput.value = this._login.password;
+
+ // In masked non-edit mode we use a different "display" element to render
+ // the masked password so that one cannot simply remove/change
+ // @type=password to reveal the real password.
+ this._passwordDisplayInput.value = CONCEALED_PASSWORD_TEXT;
+ }
+
+ if (this.dataset.editing) {
+ this._usernameInput.removeAttribute("data-l10n-id");
+ this._usernameInput.placeholder = "";
+ } else {
+ document.l10n.setAttributes(
+ this._usernameInput,
+ "about-logins-login-item-username"
+ );
+ }
+ this._copyUsernameButton.disabled = !this._login.username;
+ document.l10n.setAttributes(
+ this._saveChangesButton,
+ this.dataset.isNewLogin
+ ? "login-item-save-new-button"
+ : "login-item-save-changes-button"
+ );
+ this._updatePasswordRevealState();
+ this._updateOriginDisplayState();
+ this.#updateTimeline();
+ }
+
+ #updateTimeline() {
+ let timeline = this.shadowRoot.querySelector("login-timeline");
+ timeline.hidden = !this._login.guid;
+ timeline.history = [
+ {
+ actionId: "login-item-timeline-action-created",
+ time: this._login.timeCreated,
+ },
+ {
+ actionId: "login-item-timeline-action-updated",
+ time: this._login.timePasswordChanged,
+ },
+ {
+ actionId: "login-item-timeline-action-used",
+ time: this._login.timeLastUsed,
+ },
+ ];
+ }
+
+ setBreaches(breachesByLoginGUID) {
+ this._internalSetMonitorData("_breachesMap", breachesByLoginGUID);
+ }
+
+ updateBreaches(breachesByLoginGUID) {
+ this._internalUpdateMonitorData("_breachesMap", breachesByLoginGUID);
+ }
+
+ setVulnerableLogins(vulnerableLoginsByLoginGUID) {
+ this._internalSetMonitorData(
+ "_vulnerableLoginsMap",
+ vulnerableLoginsByLoginGUID
+ );
+ }
+
+ updateVulnerableLogins(vulnerableLoginsByLoginGUID) {
+ this._internalUpdateMonitorData(
+ "_vulnerableLoginsMap",
+ vulnerableLoginsByLoginGUID
+ );
+ }
+
+ _internalSetMonitorData(internalMemberName, mapByLoginGUID) {
+ this[internalMemberName] = mapByLoginGUID;
+ this.render({ onlyUpdateErrorsAndAlerts: true });
+ }
+
+ _internalUpdateMonitorData(internalMemberName, mapByLoginGUID) {
+ if (!this[internalMemberName]) {
+ this[internalMemberName] = new Map();
+ }
+ for (const [guid, data] of [...mapByLoginGUID]) {
+ if (data) {
+ this[internalMemberName].set(guid, data);
+ } else {
+ this[internalMemberName].delete(guid);
+ }
+ }
+ this._internalSetMonitorData(internalMemberName, this[internalMemberName]);
+ }
+
+ showLoginItemError(error) {
+ this._error = error;
+ this.render();
+ }
+
+ async handleEvent(event) {
+ switch (event.type) {
+ case "AboutLoginsInitialLoginSelected": {
+ this.setLogin(event.detail, { skipFocusChange: true });
+ break;
+ }
+ case "AboutLoginsLoginSelected": {
+ this.confirmPendingChangesOnEvent(event, event.detail);
+ break;
+ }
+ case "AboutLoginsShowBlankLogin": {
+ this.confirmPendingChangesOnEvent(event, {});
+ break;
+ }
+ case "auxclick": {
+ if (event.button == 1) {
+ this._handleOriginClick();
+ }
+ break;
+ }
+ case "blur": {
+ // TODO(Bug 1838494): Remove this if block
+ // This is a temporary fix until Bug 1750072 lands
+ const focusCheckboxNext = event.relatedTarget === this._revealCheckbox;
+ if (focusCheckboxNext) {
+ return;
+ }
+
+ if (this.dataset.editing && event.target === this._passwordInput) {
+ this._revealCheckbox.checked = false;
+ this._updatePasswordRevealState();
+ }
+
+ if (event.target === this._passwordDisplayInput) {
+ this._revealCheckbox.checked = !!this.dataset.editing;
+ this._updatePasswordRevealState();
+ }
+
+ // Add https:// prefix if one was not provided.
+ let originValue = this._originInput.value.trim();
+ if (!originValue) {
+ return;
+ }
+
+ if (!originValue.match(/:\/\//)) {
+ this._originInput.value = "https://" + originValue;
+ }
+
+ break;
+ }
+ case "focus": {
+ // TODO(Bug 1838494): Remove this if block
+ // This is a temporary fix until Bug 1750072 lands
+ const focusFromCheckbox = event.relatedTarget === this._revealCheckbox;
+ const isEditingMode = this.dataset.editing || this.dataset.isNewLogin;
+ if (focusFromCheckbox && isEditingMode) {
+ this._passwordInput.type = this._revealCheckbox.checked
+ ? "text"
+ : "password";
+ return;
+ }
+
+ if (event.target === this._passwordDisplayInput) {
+ this._revealCheckbox.checked = !!this.dataset.editing;
+ this._updatePasswordRevealState();
+ }
+
+ break;
+ }
+ case "click": {
+ let classList = event.currentTarget.classList;
+ if (classList.contains("reveal-password-checkbox")) {
+ // TODO(Bug 1838494): Remove this if block
+ // This is a temporary fix until Bug 1750072 lands
+ if (this.dataset.editing || this.dataset.isNewLogin) {
+ this._passwordDisplayInput.replaceWith(this._passwordInput);
+ this._passwordInput.type = "text";
+ this._passwordInput.focus();
+ return;
+ }
+ // We prompt for the primary password when entering edit mode already.
+ if (this._revealCheckbox.checked && !this.dataset.editing) {
+ let primaryPasswordAuth = await promptForPrimaryPassword(
+ "about-logins-reveal-password-os-auth-dialog-message"
+ );
+ if (!primaryPasswordAuth) {
+ this._revealCheckbox.checked = false;
+ return;
+ }
+ }
+ this._updatePasswordRevealState();
+
+ let method = this._revealCheckbox.checked ? "show" : "hide";
+ this._recordTelemetryEvent({ object: "password", method });
+ return;
+ }
+
+ if (classList.contains("cancel-button")) {
+ let wasExistingLogin = !!this._login.guid;
+ if (wasExistingLogin) {
+ if (this.hasPendingChanges()) {
+ this.showConfirmationDialog("discard-changes", () => {
+ this.setLogin(this._login);
+ });
+ } else {
+ this.setLogin(this._login);
+ }
+ } else if (!this.hasPendingChanges()) {
+ window.dispatchEvent(new CustomEvent("AboutLoginsClearSelection"));
+ this._recordTelemetryEvent({
+ object: "new_login",
+ method: "cancel",
+ });
+
+ this.setLogin(this._login, { skipFocusChange: true });
+ this._toggleEditing(false);
+ this.render();
+ } else {
+ this.showConfirmationDialog("discard-changes", () => {
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsClearSelection")
+ );
+
+ this.setLogin({}, { skipFocusChange: true });
+ this._toggleEditing(false);
+ this.render();
+ });
+ }
+
+ return;
+ }
+ if (
+ classList.contains("copy-password-button") ||
+ classList.contains("copy-username-button")
+ ) {
+ let copyButton = event.currentTarget;
+ let otherCopyButton =
+ copyButton == this._copyPasswordButton
+ ? this._copyUsernameButton
+ : this._copyPasswordButton;
+ if (copyButton.dataset.copyLoginProperty == "password") {
+ let primaryPasswordAuth = await promptForPrimaryPassword(
+ "about-logins-copy-password-os-auth-dialog-message"
+ );
+ if (!primaryPasswordAuth) {
+ return;
+ }
+ }
+
+ copyButton.disabled = true;
+ copyButton.dataset.copied = true;
+ let propertyToCopy =
+ this._login[copyButton.dataset.copyLoginProperty];
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsCopyLoginDetail", {
+ bubbles: true,
+ detail: propertyToCopy,
+ })
+ );
+ // If there is no username, this must be triggered by the password button,
+ // don't enable otherCopyButton (username copy button) in this case.
+ if (this._login.username) {
+ otherCopyButton.disabled = false;
+ delete otherCopyButton.dataset.copied;
+ }
+ clearTimeout(this._copyUsernameTimeoutId);
+ clearTimeout(this._copyPasswordTimeoutId);
+ let timeoutId = setTimeout(() => {
+ copyButton.disabled = false;
+ delete copyButton.dataset.copied;
+ }, LoginItem.COPY_BUTTON_RESET_TIMEOUT);
+ if (copyButton.dataset.copyLoginProperty == "password") {
+ this._copyPasswordTimeoutId = timeoutId;
+ } else {
+ this._copyUsernameTimeoutId = timeoutId;
+ }
+
+ this._recordTelemetryEvent({
+ object: copyButton.dataset.telemetryObject,
+ method: "copy",
+ });
+ return;
+ }
+ if (classList.contains("delete-button")) {
+ this.showConfirmationDialog("delete", () => {
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsDeleteLogin", {
+ bubbles: true,
+ detail: this._login,
+ })
+ );
+ });
+ return;
+ }
+ if (classList.contains("edit-button")) {
+ let primaryPasswordAuth = await promptForPrimaryPassword(
+ "about-logins-edit-login-os-auth-dialog-message"
+ );
+ if (!primaryPasswordAuth) {
+ return;
+ }
+
+ this._toggleEditing();
+ this.render();
+
+ this._recordTelemetryEvent({
+ object: "existing_login",
+ method: "edit",
+ });
+ return;
+ }
+ if (
+ event.target.dataset.l10nName == "duplicate-link" &&
+ event.currentTarget.dataset.errorGuid
+ ) {
+ let existingDuplicateLogin = {
+ guid: event.currentTarget.dataset.errorGuid,
+ };
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsLoginSelected", {
+ detail: existingDuplicateLogin,
+ cancelable: true,
+ })
+ );
+ return;
+ }
+ if (classList.contains("origin-input")) {
+ this._handleOriginClick();
+ }
+ if (classList.contains("alert-learn-more-link")) {
+ if (event.currentTarget.closest(".vulnerable-alert")) {
+ this._recordTelemetryEvent({
+ object: "existing_login",
+ method: "learn_more_vuln",
+ });
+ }
+ }
+ break;
+ }
+ case "submit": {
+ // Prevent page navigation form submit behavior.
+ event.preventDefault();
+ if (!this._isFormValid({ reportErrors: true })) {
+ return;
+ }
+ if (!this.hasPendingChanges()) {
+ this._toggleEditing(false);
+ this.render();
+ return;
+ }
+ let loginUpdates = this._loginFromForm();
+ if (this._login.guid) {
+ loginUpdates.guid = this._login.guid;
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsUpdateLogin", {
+ bubbles: true,
+ detail: loginUpdates,
+ })
+ );
+
+ this._recordTelemetryEvent({
+ object: "existing_login",
+ method: "save",
+ });
+ } else {
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsCreateLogin", {
+ bubbles: true,
+ detail: loginUpdates,
+ })
+ );
+
+ this._recordTelemetryEvent({ object: "new_login", method: "save" });
+ }
+ break;
+ }
+ case "mousedown": {
+ // No AutoScroll when middle clicking on origin input.
+ if (event.currentTarget == this._originInput && event.button == 1) {
+ event.preventDefault();
+ }
+ break;
+ }
+ case "AboutLoginsRemaskPassword": {
+ if (this._revealCheckbox.checked && !this.dataset.editing) {
+ this._revealCheckbox.checked = false;
+ }
+ this._updatePasswordRevealState();
+ let method = this._revealCheckbox.checked ? "show" : "hide";
+ this._recordTelemetryEvent({ object: "password", method });
+ break;
+ }
+ }
+ }
+
+ /**
+ * Helper to show the "Discard changes" confirmation dialog and delay the
+ * received event after confirmation.
+ * @param {object} event The event to be delayed.
+ * @param {object} login The login to be shown on confirmation.
+ */
+ confirmPendingChangesOnEvent(event, login) {
+ if (this.hasPendingChanges()) {
+ event.preventDefault();
+ this.showConfirmationDialog("discard-changes", () => {
+ // Clear any pending changes
+ this.setLogin(login);
+
+ window.dispatchEvent(
+ new CustomEvent(event.type, {
+ detail: login,
+ cancelable: false,
+ })
+ );
+ });
+ } else {
+ this.setLogin(login, { skipFocusChange: true });
+ }
+ }
+
+ /**
+ * Shows a confirmation dialog.
+ * @param {string} type The type of confirmation dialog to display.
+ * @param {boolean} onConfirm Optional, the function to execute when the confirm button is clicked.
+ */
+ showConfirmationDialog(type, onConfirm = () => {}) {
+ const dialog = document.querySelector("confirmation-dialog");
+ let options;
+ switch (type) {
+ case "delete": {
+ options = {
+ title: "about-logins-confirm-remove-dialog-title",
+ message: "confirm-delete-dialog-message",
+ confirmButtonLabel:
+ "about-logins-confirm-remove-dialog-confirm-button",
+ };
+ break;
+ }
+ case "discard-changes": {
+ options = {
+ title: "confirm-discard-changes-dialog-title",
+ message: "confirm-discard-changes-dialog-message",
+ confirmButtonLabel: "confirm-discard-changes-dialog-confirm-button",
+ };
+ break;
+ }
+ }
+ let wasExistingLogin = !!this._login.guid;
+ let method = type == "delete" ? "delete" : "cancel";
+ let dialogPromise = dialog.show(options);
+ dialogPromise.then(
+ () => {
+ try {
+ onConfirm();
+ } catch (ex) {}
+ this._recordTelemetryEvent({
+ object: wasExistingLogin ? "existing_login" : "new_login",
+ method,
+ });
+ },
+ () => {}
+ );
+ return dialogPromise;
+ }
+
+ hasPendingChanges() {
+ let valuesChanged = !window.AboutLoginsUtils.doLoginsMatch(
+ Object.assign({ username: "", password: "", origin: "" }, this._login),
+ this._loginFromForm()
+ );
+
+ return this.dataset.editing && valuesChanged;
+ }
+
+ resetForm() {
+ // If the password input (which uses HTML form validation) wasn't connected,
+ // append it to the form so it gets included in the reset, specifically for
+ // .value and the dirty state for validation.
+ let wasConnected = this._passwordInput.isConnected;
+ if (!wasConnected) {
+ this._revealCheckbox.insertAdjacentElement(
+ "beforebegin",
+ this._passwordInput
+ );
+ }
+
+ this._form.reset();
+ if (!wasConnected) {
+ this._passwordInput.remove();
+ }
+ }
+
+ /**
+ * @param {login} login The login that should be displayed. The login object is
+ * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo.
+ * @param {boolean} skipFocusChange Optional, if present and set to true, the Edit button of the
+ * login will not get focus automatically. This is used to prevent
+ * stealing focus from the search filter upon page load.
+ */
+ setLogin(login, { skipFocusChange } = {}) {
+ this._login = login;
+ this._error = null;
+
+ this.resetForm();
+
+ if (login.guid) {
+ delete this.dataset.isNewLogin;
+ } else {
+ this.dataset.isNewLogin = true;
+ }
+ document.documentElement.classList.toggle("login-selected", login.guid);
+ this._toggleEditing(!login.guid);
+
+ this._revealCheckbox.checked = false;
+
+ clearTimeout(this._copyUsernameTimeoutId);
+ clearTimeout(this._copyPasswordTimeoutId);
+ for (let copyButton of [
+ this._copyUsernameButton,
+ this._copyPasswordButton,
+ ]) {
+ copyButton.disabled = false;
+ delete copyButton.dataset.copied;
+ }
+
+ if (!skipFocusChange) {
+ this._editButton.focus();
+ }
+ this.render();
+ }
+
+ /**
+ * Updates the view if the login argument matches the login currently
+ * displayed.
+ *
+ * @param {login} login The login that was added to storage. The login object is
+ * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo.
+ */
+ loginAdded(login) {
+ if (
+ this._login.guid ||
+ !window.AboutLoginsUtils.doLoginsMatch(login, this._loginFromForm())
+ ) {
+ return;
+ }
+
+ this.setLogin(login);
+ this.dispatchEvent(
+ new CustomEvent("AboutLoginsLoginSelected", {
+ bubbles: true,
+ composed: true,
+ detail: login,
+ })
+ );
+ }
+
+ /**
+ * Updates the view if the login argument matches the login currently
+ * displayed.
+ *
+ * @param {login} login The login that was modified in storage. The login object is
+ * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo.
+ */
+ loginModified(login) {
+ if (this._login.guid != login.guid) {
+ return;
+ }
+
+ let valuesChanged =
+ this.dataset.editing &&
+ !window.AboutLoginsUtils.doLoginsMatch(login, this._loginFromForm());
+ if (valuesChanged) {
+ this.showConfirmationDialog("discard-changes", () => {
+ this.setLogin(login);
+ });
+ } else {
+ this.setLogin(login);
+ }
+ }
+
+ /**
+ * Clears the displayed login if the argument matches the currently
+ * displayed login.
+ *
+ * @param {login} login The login that was removed from storage. The login object is
+ * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo.
+ */
+ loginRemoved(login) {
+ if (login.guid != this._login.guid) {
+ return;
+ }
+
+ this.setLogin({}, { skipFocusChange: true });
+ this._toggleEditing(false);
+ }
+
+ _handleOriginClick() {
+ this._recordTelemetryEvent({
+ object: "existing_login",
+ method: "open_site",
+ });
+ }
+
+ /**
+ * Checks that the edit/new-login form has valid values present for their
+ * respective required fields.
+ *
+ * @param {boolean} reportErrors If true, validation errors will be reported
+ * to the user.
+ */
+ _isFormValid({ reportErrors } = {}) {
+ let fields = [this._passwordInput];
+ if (this.dataset.isNewLogin) {
+ fields.push(this._originInput);
+ }
+ let valid = true;
+ // Check validity on all required fields so each field will get :invalid styling
+ // if applicable.
+ for (let field of fields) {
+ if (reportErrors) {
+ valid &= field.reportValidity();
+ } else {
+ valid &= field.checkValidity();
+ }
+ }
+ return valid;
+ }
+
+ _loginFromForm() {
+ return Object.assign({}, this._login, {
+ username: this._usernameInput.value.trim(),
+ password: this._passwordInput.value,
+ origin:
+ window.AboutLoginsUtils.getLoginOrigin(this._originInput.value) || "",
+ });
+ }
+
+ _recordTelemetryEvent(eventObject) {
+ // Breach alerts have higher priority than vulnerable logins, the
+ // following conditionals must reflect this priority.
+ const extra = eventObject.hasOwnProperty("extra") ? eventObject.extra : {};
+ if (this._breachesMap && this._breachesMap.has(this._login.guid)) {
+ Object.assign(extra, { breached: "true" });
+ eventObject.extra = extra;
+ } else if (
+ this._vulnerableLoginsMap &&
+ this._vulnerableLoginsMap.has(this._login.guid)
+ ) {
+ Object.assign(extra, { vulnerable: "true" });
+ eventObject.extra = extra;
+ }
+ recordTelemetryEvent(eventObject);
+ }
+
+ /**
+ * Toggles the login-item view from editing to non-editing mode.
+ *
+ * @param {boolean} force When true puts the form in 'edit' mode, otherwise
+ * puts the form in read-only mode.
+ */
+ _toggleEditing(force) {
+ let shouldEdit = force !== undefined ? force : !this.dataset.editing;
+
+ if (!shouldEdit) {
+ delete this.dataset.isNewLogin;
+ }
+
+ // Reset cursor to the start of the input for long text names.
+ this._usernameInput.scrollLeft = 0;
+
+ if (shouldEdit) {
+ this._passwordInput.style.removeProperty("width");
+ } else {
+ // Need to set a shorter width than -moz-available so the reveal checkbox
+ // will still appear next to the password.
+ this._passwordInput.style.width =
+ (this._login.password || "").length + "ch";
+ }
+
+ this._deleteButton.disabled = this.dataset.isNewLogin;
+ this._editButton.disabled = shouldEdit;
+ let inputTabIndex = shouldEdit ? 0 : -1;
+ this._originInput.readOnly = !this.dataset.isNewLogin;
+ this._originInput.tabIndex = inputTabIndex;
+ this._usernameInput.readOnly = !shouldEdit;
+ this._usernameInput.tabIndex = inputTabIndex;
+ this._passwordInput.readOnly = !shouldEdit;
+ this._passwordInput.tabIndex = inputTabIndex;
+ if (shouldEdit) {
+ this.dataset.editing = true;
+ this._usernameInput.focus();
+ this._usernameInput.select();
+ } else {
+ delete this.dataset.editing;
+ // Only reset the reveal checkbox when exiting 'edit' mode
+ this._revealCheckbox.checked = false;
+ }
+ }
+
+ _updatePasswordRevealState() {
+ if (
+ window.AboutLoginsUtils &&
+ window.AboutLoginsUtils.passwordRevealVisible === false
+ ) {
+ this._revealCheckbox.hidden = true;
+ }
+
+ let { checked } = this._revealCheckbox;
+ let inputType = checked ? "text" : "password";
+ this._passwordInput.type = inputType;
+
+ if (this.dataset.editing) {
+ this._passwordDisplayInput.removeAttribute("tabindex");
+ } else {
+ this._passwordDisplayInput.setAttribute("tabindex", -1);
+ }
+
+ // Swap which <input> is in the document depending on whether we need the
+ // real .value (which means that the primary password was already entered,
+ // if applicable)
+ if (checked || this.dataset.isNewLogin) {
+ this._passwordDisplayInput.replaceWith(this._passwordInput);
+
+ // Focus the input if it hasn't been already.
+ if (this.dataset.editing && inputType === "text") {
+ this._passwordInput.focus();
+ }
+ } else {
+ this._passwordInput.replaceWith(this._passwordDisplayInput);
+ }
+ }
+
+ _updateOriginDisplayState() {
+ // Switches between the origin input and anchor tag depending
+ // if a new login is being created.
+ if (this.dataset.isNewLogin) {
+ this._originDisplayInput.replaceWith(this._originInput);
+ this._originInput.focus();
+ } else {
+ this._originInput.replaceWith(this._originDisplayInput);
+ }
+ }
+}
+customElements.define("login-item", LoginItem);
diff --git a/browser/components/aboutlogins/content/components/login-list-item.mjs b/browser/components/aboutlogins/content/components/login-list-item.mjs
new file mode 100644
index 0000000000..2fe37c6b12
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-list-item.mjs
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * LoginListItemFactory is used instead of the "login-list-item" custom element
+ * since there may be 100s of login-list-items on about:logins and each
+ * custom element carries with it significant overhead when used in large
+ * numbers.
+ */
+export default class LoginListItemFactory {
+ static create(login) {
+ let template = document.querySelector("#login-list-item-template");
+ let fragment = template.content.cloneNode(true);
+ let listItem = fragment.firstElementChild;
+
+ LoginListItemFactory.update(listItem, login);
+
+ return listItem;
+ }
+
+ static update(listItem, login) {
+ let title = listItem.querySelector(".title");
+ let username = listItem.querySelector(".username");
+ let alertIcon = listItem.querySelector(".alert-icon");
+
+ const favicon = listItem.querySelector(".favicon");
+ favicon.src = `page-icon:${login.origin}`;
+
+ if (!login.guid) {
+ listItem.id = "new-login-list-item";
+ document.l10n.setAttributes(title, "login-list-item-title-new-login");
+ document.l10n.setAttributes(
+ username,
+ "login-list-item-subtitle-new-login"
+ );
+ return;
+ }
+
+ // Prepend the ID with a string since IDs must not begin with a number.
+ if (!listItem.id) {
+ listItem.id = "lli-" + login.guid;
+ listItem.dataset.guid = login.guid;
+ }
+ if (title.textContent != login.title) {
+ title.textContent = login.title;
+ }
+
+ let trimmedUsernameValue = login.username.trim();
+ if (trimmedUsernameValue) {
+ if (username.textContent != trimmedUsernameValue) {
+ username.removeAttribute("data-l10n-id");
+ username.textContent = trimmedUsernameValue;
+ }
+ } else {
+ document.l10n.setAttributes(
+ username,
+ "login-list-item-subtitle-missing-username"
+ );
+ }
+
+ if (listItem.classList.contains("breached")) {
+ alertIcon.src =
+ "chrome://browser/content/aboutlogins/icons/breached-website.svg";
+ document.l10n.setAttributes(
+ alertIcon,
+ "about-logins-list-item-breach-icon"
+ );
+ } else if (listItem.classList.contains("vulnerable")) {
+ alertIcon.src =
+ "chrome://browser/content/aboutlogins/icons/vulnerable-password.svg";
+
+ document.l10n.setAttributes(
+ alertIcon,
+ "about-logins-list-item-vulnerable-password-icon"
+ );
+ } else {
+ alertIcon.src = "";
+ }
+ }
+}
diff --git a/browser/components/aboutlogins/content/components/login-list-section.mjs b/browser/components/aboutlogins/content/components/login-list-section.mjs
new file mode 100644
index 0000000000..5495f55e28
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-list-section.mjs
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export default class LoginListHeaderFactory {
+ static ID_PREFIX = "id-";
+
+ static create(header) {
+ let template = document.querySelector("#login-list-section-template");
+ let fragment = template.content.cloneNode(true);
+ let sectionItem = fragment.firstElementChild;
+
+ this.update(sectionItem, header);
+
+ return sectionItem;
+ }
+
+ static update(headerItem, header) {
+ let headerElement = headerItem.querySelector(".login-list-header");
+ if (header) {
+ if (header.startsWith(this.ID_PREFIX)) {
+ document.l10n.setAttributes(
+ headerElement,
+ header.substring(this.ID_PREFIX.length)
+ );
+ } else {
+ headerElement.textContent = header;
+ }
+ headerElement.hidden = false;
+ } else {
+ headerElement.hidden = true;
+ }
+ }
+}
diff --git a/browser/components/aboutlogins/content/components/login-list.css b/browser/components/aboutlogins/content/components/login-list.css
new file mode 100644
index 0000000000..b58af780a1
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-list.css
@@ -0,0 +1,202 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:host {
+ border-inline-end: 1px solid var(--in-content-border-color);
+ background-color: var(--in-content-box-background);
+ display: flex;
+ flex-direction: column;
+ overflow: auto;
+}
+
+.meta {
+ display: flex;
+ align-items: center;
+ padding: 0 16px 16px;
+ border-bottom: 1px solid var(--in-content-border-color);
+ background-color: var(--in-content-box-background);
+ color: var(--text-color-deemphasized);
+ font-size: 0.8em;
+}
+
+.meta > label > span {
+ margin-inline-end: 2px;
+}
+
+#login-sort {
+ background-color: transparent;
+ margin: 0;
+ padding-inline: 0 16px;
+ min-height: initial;
+ font: inherit;
+ font-weight: 600;
+ color: var(--in-content-text-color) !important;
+}
+
+#login-sort:hover:not([disabled]) {
+ background-color: var(--in-content-button-background);
+}
+
+#login-sort > option {
+ font-weight: normal;
+}
+
+.count {
+ flex-grow: 1;
+ text-align: end;
+ margin-inline-start: 18px;
+}
+
+.container {
+ display: contents;
+}
+
+.listHeader {
+ display: flex;
+ justify-content: center;
+ align-content: center;
+ gap: 16px;
+ padding: 16px;
+}
+
+:host(.no-logins) .empty-search-message,
+:host(:not(.empty-search)) .empty-search-message,
+:host(.empty-search:not(.create-login-selected)) ol,
+:host(.no-logins:not(.create-login-selected)) ol,
+:host(:not(.no-logins)) .intro,
+:host(.create-login-selected) .intro,
+:host(.create-login-selected) .empty-search-message {
+ display: none;
+}
+
+:host(:not(.initialized)) .count,
+:host(:not(.initialized)) .empty-search-message {
+ visibility: hidden;
+}
+
+.empty-search-message,
+.intro {
+ text-align: center;
+ padding: 1em;
+ max-width: 50ch; /* This should be kept in sync with login-list-item username and title max-width */
+ flex-grow: 1;
+ border-bottom: 1px solid var(--in-content-border-color);
+}
+
+.empty-search-message span,
+.intro span {
+ font-size: 0.85em;
+}
+
+ol {
+ outline-offset: var(--in-content-focus-outline-inset);
+ margin-block: 0;
+ padding-inline-start: 0;
+ overflow: hidden auto;
+ flex-grow: 1;
+ scroll-padding-top: 24px; /* there is the section header that is sticky to the top */
+}
+
+.create-login-button {
+ margin: 0;
+ min-width: auto;
+ background-repeat: no-repeat;
+ background-image: url("chrome://global/skin/icons/plus.svg");
+ background-position: center;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.login-list-item {
+ display: flex;
+ align-items: center;
+ padding-block: 10px;
+ padding-inline: 12px 18px;
+ border-inline-start: 4px solid transparent;
+ user-select: none;
+}
+
+.login-list-header {
+ display: block;
+ position: sticky;
+ top: 0;
+ font-size: .85em;
+ font-weight: 600;
+ padding: 4px 16px;
+ border-bottom: 1px solid var(--in-content-border-color);
+ background-color: var(--in-content-box-background);
+ margin-block-start: 2px;
+ margin-inline: 2px;
+}
+
+.login-list-item:not(.selected):hover {
+ background-color: var(--in-content-button-background-hover);
+ color: var(--in-content-button-text-color-hover);
+}
+
+.login-list-item:not(.selected):hover:active {
+ background-color: var(--in-content-button-background-active);
+ color: var(--in-content-button-text-color-active);
+}
+
+.login-list-item.keyboard-selected {
+ border-inline-start-color: var(--in-content-border-color);
+ background-color: var(--in-content-button-background-hover);
+}
+
+.login-list-item.selected {
+ border-inline-start-color: var(--in-content-accent-color);
+ background-color: var(--in-content-page-background);
+}
+
+.login-list-item.selected .title {
+ font-weight: 600;
+}
+
+.labels {
+ flex-grow: 1;
+ overflow: hidden;
+ min-height: 40px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+.title,
+.username {
+ display: block;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.favicon {
+ height: 16px;
+ width: 16px;
+ margin-inline-end: 12px;
+ -moz-context-properties: fill, fill-opacity;
+ fill: currentColor;
+ fill-opacity: 0.8;
+}
+
+.username {
+ font-size: 0.85em;
+ color: var(--text-color-deemphasized);
+}
+
+.alert-icon {
+ min-width: 16px;
+ width: 16px;
+ margin-inline-start: 12px;
+ -moz-context-properties: fill, fill-opacity;
+ fill: currentColor;
+ fill-opacity: 0.75;
+}
+
+@media not (prefers-contrast) {
+ .breached > .alert-icon {
+ fill: var(--red-60);
+ fill-opacity: 1;
+ }
+}
diff --git a/browser/components/aboutlogins/content/components/login-list.mjs b/browser/components/aboutlogins/content/components/login-list.mjs
new file mode 100644
index 0000000000..2af1a12a7a
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-list.mjs
@@ -0,0 +1,912 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import LoginListItemFactory from "./login-list-item.mjs";
+import LoginListSectionFactory from "./login-list-section.mjs";
+import { recordTelemetryEvent } from "../aboutLoginsUtils.mjs";
+
+const collator = new Intl.Collator();
+const monthFormatter = new Intl.DateTimeFormat(undefined, { month: "long" });
+const yearMonthFormatter = new Intl.DateTimeFormat(undefined, {
+ year: "numeric",
+ month: "long",
+});
+const dayDuration = 24 * 60 * 60_000;
+const sortFnOptions = {
+ name: (a, b) => collator.compare(a.title, b.title),
+ "name-reverse": (a, b) => collator.compare(b.title, a.title),
+ username: (a, b) => collator.compare(a.username, b.username),
+ "username-reverse": (a, b) => collator.compare(b.username, a.username),
+ "last-used": (a, b) => a.timeLastUsed < b.timeLastUsed,
+ "last-changed": (a, b) => a.timePasswordChanged < b.timePasswordChanged,
+ alerts: (a, b, breachesByLoginGUID, vulnerableLoginsByLoginGUID) => {
+ const aIsBreached = breachesByLoginGUID && breachesByLoginGUID.has(a.guid);
+ const bIsBreached = breachesByLoginGUID && breachesByLoginGUID.has(b.guid);
+ const aIsVulnerable =
+ vulnerableLoginsByLoginGUID && vulnerableLoginsByLoginGUID.has(a.guid);
+ const bIsVulnerable =
+ vulnerableLoginsByLoginGUID && vulnerableLoginsByLoginGUID.has(b.guid);
+
+ if ((aIsBreached && !bIsBreached) || (aIsVulnerable && !bIsVulnerable)) {
+ return -1;
+ }
+
+ if ((!aIsBreached && bIsBreached) || (!aIsVulnerable && bIsVulnerable)) {
+ return 1;
+ }
+ return sortFnOptions.name(a, b);
+ },
+};
+
+const headersFnOptions = {
+ // TODO: name should use the ICU API, see Bug 1592834
+ // name: l =>
+ // l.title.length && letterRegExp.test(l.title[0])
+ // ? l.title[0].toUpperCase()
+ // : "#",
+ // "name-reverse": l => headersFnOptions.name(l),
+ name: () => "",
+ "name-reverse": () => "",
+ username: () => "",
+ "username-reverse": () => "",
+ "last-used": l => headerFromDate(l.timeLastUsed),
+ "last-changed": l => headerFromDate(l.timePasswordChanged),
+ alerts: (l, breachesByLoginGUID, vulnerableLoginsByLoginGUID) => {
+ const isBreached = breachesByLoginGUID && breachesByLoginGUID.has(l.guid);
+ const isVulnerable =
+ vulnerableLoginsByLoginGUID && vulnerableLoginsByLoginGUID.has(l.guid);
+ if (isBreached) {
+ return (
+ LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-breach"
+ );
+ } else if (isVulnerable) {
+ return (
+ LoginListSectionFactory.ID_PREFIX +
+ "about-logins-list-section-vulnerable"
+ );
+ }
+ return (
+ LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-nothing"
+ );
+ },
+};
+
+function headerFromDate(timestamp) {
+ let now = new Date();
+ now.setHours(0, 0, 0, 0); // reset to start of day
+ let date = new Date(timestamp);
+
+ if (now < date) {
+ return (
+ LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-today"
+ );
+ } else if (now - dayDuration < date) {
+ return (
+ LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-yesterday"
+ );
+ } else if (now - 7 * dayDuration < date) {
+ return LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-week";
+ } else if (now.getFullYear() == date.getFullYear()) {
+ return monthFormatter.format(date);
+ } else if (now.getFullYear() - 1 == date.getFullYear()) {
+ return yearMonthFormatter.format(date);
+ }
+ return String(date.getFullYear());
+}
+
+export default class LoginList extends HTMLElement {
+ // An array of login GUIDs, stored in sorted order.
+ _loginGuidsSortedOrder = [];
+ // A map of login GUID -> {login, listItem}.
+ _logins = {};
+ // A map of section header -> sectionItem
+ _sections = {};
+ _filter = "";
+ _selectedGuid = null;
+ _blankLoginListItem = LoginListItemFactory.create({});
+
+ constructor() {
+ super();
+ this._blankLoginListItem.hidden = true;
+ }
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+ let loginListTemplate = document.querySelector("#login-list-template");
+ let shadowRoot = this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(shadowRoot);
+ shadowRoot.appendChild(loginListTemplate.content.cloneNode(true));
+
+ this._count = shadowRoot.querySelector(".count");
+ this._createLoginButton = shadowRoot.querySelector(".create-login-button");
+ this._list = shadowRoot.querySelector("ol");
+ this._list.appendChild(this._blankLoginListItem);
+ this._sortSelect = shadowRoot.querySelector("#login-sort");
+
+ this.render();
+
+ this.shadowRoot
+ .getElementById("login-sort")
+ .addEventListener("change", this);
+ window.addEventListener("AboutLoginsClearSelection", this);
+ window.addEventListener("AboutLoginsFilterLogins", this);
+ window.addEventListener("AboutLoginsInitialLoginSelected", this);
+ window.addEventListener("AboutLoginsLoginSelected", this);
+ window.addEventListener("AboutLoginsShowBlankLogin", this);
+ this._list.addEventListener("click", this);
+ this.addEventListener("keydown", this);
+ this.addEventListener("keyup", this);
+ this._createLoginButton.addEventListener("click", this);
+ }
+
+ get #activeDescendant() {
+ const activeDescendantId = this._list.getAttribute("aria-activedescendant");
+ let activeDescendant =
+ activeDescendantId && this.shadowRoot.getElementById(activeDescendantId);
+
+ return activeDescendant;
+ }
+
+ selectLoginByDomainOrGuid(searchParam) {
+ this._preselectLogin = searchParam;
+ }
+
+ render() {
+ let visibleLoginGuids = this._applyFilter();
+ this.#updateVisibleLoginCount(
+ visibleLoginGuids.size,
+ this._loginGuidsSortedOrder.length
+ );
+ this.classList.toggle("empty-search", !visibleLoginGuids.size);
+ document.documentElement.classList.toggle(
+ "empty-search",
+ this._filter && !visibleLoginGuids.size
+ );
+ this._sortSelect.disabled = !visibleLoginGuids.size;
+
+ // Add all of the logins that are not in the DOM yet.
+ let fragment = document.createDocumentFragment();
+ for (let guid of this._loginGuidsSortedOrder) {
+ if (this._logins[guid].listItem) {
+ continue;
+ }
+ let login = this._logins[guid].login;
+ let listItem = LoginListItemFactory.create(login);
+ this._logins[login.guid] = Object.assign(this._logins[login.guid], {
+ listItem,
+ });
+ fragment.appendChild(listItem);
+ }
+ this._list.appendChild(fragment);
+
+ // Show, hide, and update state of the list items per the applied search filter.
+ for (let guid of this._loginGuidsSortedOrder) {
+ let { listItem, login } = this._logins[guid];
+
+ if (guid == this._selectedGuid) {
+ this._setListItemAsSelected(listItem);
+ }
+ listItem.classList.toggle(
+ "breached",
+ !!this._breachesByLoginGUID &&
+ this._breachesByLoginGUID.has(listItem.dataset.guid)
+ );
+ listItem.classList.toggle(
+ "vulnerable",
+ !!this._vulnerableLoginsByLoginGUID &&
+ this._vulnerableLoginsByLoginGUID.has(listItem.dataset.guid) &&
+ !listItem.classList.contains("breached")
+ );
+ if (
+ listItem.classList.contains("breached") ||
+ listItem.classList.contains("vulnerable")
+ ) {
+ LoginListItemFactory.update(listItem, login);
+ }
+ listItem.hidden = !visibleLoginGuids.has(listItem.dataset.guid);
+ }
+
+ let sectionsKey = Object.keys(this._sections);
+ for (let sectionKey of sectionsKey) {
+ this._sections[sectionKey]._inUse = false;
+ }
+
+ if (this._loginGuidsSortedOrder.length) {
+ let section = null;
+ let currentHeader = null;
+ // Re-arrange the login-list-items according to their sort and
+ // create / re-arrange sections
+ for (let i = this._loginGuidsSortedOrder.length - 1; i >= 0; i--) {
+ let guid = this._loginGuidsSortedOrder[i];
+ let { listItem, _header } = this._logins[guid];
+
+ if (!listItem.hidden) {
+ if (currentHeader != _header) {
+ section = this.renderSectionHeader((currentHeader = _header));
+ }
+
+ section.insertBefore(
+ listItem,
+ section.firstElementChild.nextElementSibling
+ );
+ }
+ }
+ }
+
+ for (let sectionKey of sectionsKey) {
+ let section = this._sections[sectionKey];
+ if (section._inUse) {
+ continue;
+ }
+
+ section.hidden = true;
+ }
+
+ let activeDescendant = this.#activeDescendant;
+ if (!activeDescendant || activeDescendant.hidden) {
+ let visibleListItem = this._list.querySelector(
+ ".login-list-item:not([hidden])"
+ );
+ if (visibleListItem) {
+ this._list.setAttribute("aria-activedescendant", visibleListItem.id);
+ }
+ }
+
+ if (
+ this._sortSelect.namedItem("alerts").hidden &&
+ ((this._breachesByLoginGUID &&
+ this._loginGuidsSortedOrder.some(loginGuid =>
+ this._breachesByLoginGUID.has(loginGuid)
+ )) ||
+ (this._vulnerableLoginsByLoginGUID &&
+ this._loginGuidsSortedOrder.some(loginGuid =>
+ this._vulnerableLoginsByLoginGUID.has(loginGuid)
+ )))
+ ) {
+ // Make available the "alerts" option but don't change the
+ // selected sort so the user's current task isn't interrupted.
+ this._sortSelect.namedItem("alerts").hidden = false;
+ }
+ }
+
+ renderSectionHeader(header) {
+ let section = this._sections[header];
+ if (!section) {
+ section = this._sections[header] = LoginListSectionFactory.create(header);
+ }
+
+ this._list.insertBefore(
+ section,
+ this._blankLoginListItem.nextElementSibling
+ );
+
+ section._inUse = true;
+ section.hidden = false;
+ return section;
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "click": {
+ if (event.originalTarget == this._createLoginButton) {
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsShowBlankLogin", {
+ cancelable: true,
+ })
+ );
+ recordTelemetryEvent({ object: "new_login", method: "new" });
+ return;
+ }
+
+ let listItem = event.originalTarget.closest(".login-list-item");
+ if (!listItem || !listItem.dataset.guid) {
+ return;
+ }
+
+ let { login } = this._logins[listItem.dataset.guid];
+ this.dispatchEvent(
+ new CustomEvent("AboutLoginsLoginSelected", {
+ bubbles: true,
+ composed: true,
+ cancelable: true, // allow calling preventDefault() on event
+ detail: login,
+ })
+ );
+
+ let extra = {};
+ if (listItem.classList.contains("breached")) {
+ extra = { breached: "true" };
+ } else if (listItem.classList.contains("vulnerable")) {
+ extra = { vulnerable: "true" };
+ }
+ recordTelemetryEvent({
+ object: "existing_login",
+ method: "select",
+ extra,
+ });
+ break;
+ }
+ case "change": {
+ this._applyHeaders();
+ this._applySortAndScrollToTop();
+ const extra = { sort_key: this._sortSelect.value };
+ recordTelemetryEvent({ object: "list", method: "sort", extra });
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsSortChanged", {
+ bubbles: true,
+ detail: this._sortSelect.value,
+ })
+ );
+ break;
+ }
+ case "AboutLoginsClearSelection": {
+ if (!this._loginGuidsSortedOrder.length) {
+ this._createLoginButton.disabled = false;
+ this.classList.remove("create-login-selected");
+ return;
+ }
+
+ let firstVisibleListItem = this._list.querySelector(
+ ".login-list-item[data-guid]:not([hidden])"
+ );
+ let newlySelectedLogin;
+ if (firstVisibleListItem) {
+ newlySelectedLogin =
+ this._logins[firstVisibleListItem.dataset.guid].login;
+ } else {
+ // Clear the filter if all items have been filtered out.
+ this.classList.remove("create-login-selected");
+ this._createLoginButton.disabled = false;
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsFilterLogins", {
+ detail: "",
+ })
+ );
+ newlySelectedLogin =
+ this._logins[this._loginGuidsSortedOrder[0]].login;
+ }
+
+ // Select the first visible login after any possible filter is applied.
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsLoginSelected", {
+ detail: newlySelectedLogin,
+ cancelable: true,
+ })
+ );
+ break;
+ }
+ case "AboutLoginsFilterLogins": {
+ this._filter = event.detail.toLocaleLowerCase();
+ this.render();
+ break;
+ }
+ case "AboutLoginsInitialLoginSelected":
+ case "AboutLoginsLoginSelected": {
+ if (event.defaultPrevented || this._selectedGuid == event.detail.guid) {
+ return;
+ }
+
+ // XXX If an AboutLoginsLoginSelected event is received that doesn't contain
+ // the full login object, re-dispatch the event with the full login object since
+ // only the login-list knows the full details of each login object.
+ if (
+ Object.keys(event.detail).length == 1 &&
+ event.detail.hasOwnProperty("guid")
+ ) {
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsLoginSelected", {
+ detail: this._logins[event.detail.guid].login,
+ cancelable: true,
+ })
+ );
+ return;
+ }
+
+ let listItem = this._list.querySelector(
+ `.login-list-item[data-guid="${event.detail.guid}"]`
+ );
+ if (listItem) {
+ this._setListItemAsSelected(listItem);
+ } else {
+ this.render();
+ }
+ break;
+ }
+ case "AboutLoginsShowBlankLogin": {
+ if (!event.defaultPrevented) {
+ this._selectedGuid = null;
+ this._setListItemAsSelected(this._blankLoginListItem);
+ }
+ break;
+ }
+ case "keyup":
+ case "keydown": {
+ if (event.type == "keydown") {
+ if (
+ this.shadowRoot.activeElement &&
+ this.shadowRoot.activeElement.closest("ol") &&
+ (event.key == " " ||
+ event.key == "ArrowUp" ||
+ event.key == "ArrowDown")
+ ) {
+ // Since Space, ArrowUp and ArrowDown will perform actions, prevent
+ // them from also scrolling the list.
+ event.preventDefault();
+ }
+ }
+
+ this._handleKeyboardNavWithinList(event);
+ break;
+ }
+ }
+ }
+
+ /**
+ * @param {login[]} logins An array of logins used for displaying in the list.
+ */
+ setLogins(logins) {
+ this._loginGuidsSortedOrder = [];
+ this._logins = logins.reduce((map, login) => {
+ this._loginGuidsSortedOrder.push(login.guid);
+ map[login.guid] = { login };
+ return map;
+ }, {});
+ this._sections = {};
+ this._applyHeaders();
+ this._applySort();
+ this._list.textContent = "";
+ this._list.appendChild(this._blankLoginListItem);
+ this.render();
+
+ if (!this._selectedGuid || !this._logins[this._selectedGuid]) {
+ this._selectFirstVisibleLogin();
+ }
+ }
+
+ /**
+ * @param {Map} breachesByLoginGUID A Map of breaches by login GUIDs used
+ * for displaying breached login indicators.
+ */
+ setBreaches(breachesByLoginGUID) {
+ this._internalSetMonitorData("_breachesByLoginGUID", breachesByLoginGUID);
+ }
+
+ /**
+ * @param {Map} breachesByLoginGUID A Map of breaches by login GUIDs that
+ * should be added to the local cache of
+ * breaches.
+ */
+ updateBreaches(breachesByLoginGUID) {
+ this._internalUpdateMonitorData(
+ "_breachesByLoginGUID",
+ breachesByLoginGUID
+ );
+ }
+
+ setVulnerableLogins(vulnerableLoginsByLoginGUID) {
+ this._internalSetMonitorData(
+ "_vulnerableLoginsByLoginGUID",
+ vulnerableLoginsByLoginGUID
+ );
+ }
+
+ updateVulnerableLogins(vulnerableLoginsByLoginGUID) {
+ this._internalUpdateMonitorData(
+ "_vulnerableLoginsByLoginGUID",
+ vulnerableLoginsByLoginGUID
+ );
+ }
+
+ _internalSetMonitorData(
+ internalMemberName,
+ mapByLoginGUID,
+ updateSortAndSelectedLogin = true
+ ) {
+ this[internalMemberName] = mapByLoginGUID;
+ if (this[internalMemberName].size) {
+ for (let [loginGuid] of mapByLoginGUID) {
+ if (this._logins[loginGuid]) {
+ let { login, listItem } = this._logins[loginGuid];
+ LoginListItemFactory.update(listItem, login);
+ }
+ }
+ if (updateSortAndSelectedLogin) {
+ const alertsSortOptionElement = this._sortSelect.namedItem("alerts");
+ alertsSortOptionElement.hidden = false;
+ this._sortSelect.selectedIndex = alertsSortOptionElement.index;
+ this._applyHeaders();
+ this._applySortAndScrollToTop();
+ this._selectFirstVisibleLogin();
+ }
+ }
+ this.render();
+ }
+
+ _internalUpdateMonitorData(internalMemberName, mapByLoginGUID) {
+ if (!this[internalMemberName]) {
+ this[internalMemberName] = new Map();
+ }
+ for (const [guid, data] of [...mapByLoginGUID]) {
+ if (data) {
+ this[internalMemberName].set(guid, data);
+ } else {
+ this[internalMemberName].delete(guid);
+ }
+ }
+ this._internalSetMonitorData(
+ internalMemberName,
+ this[internalMemberName],
+ false
+ );
+ }
+
+ setSortDirection(sortDirection) {
+ // The 'alerts' sort becomes visible when there are known alerts.
+ // Don't restore to the 'alerts' sort if there are no alerts to show.
+ if (
+ sortDirection == "alerts" &&
+ this._sortSelect.namedItem("alerts").hidden
+ ) {
+ return;
+ }
+ this._sortSelect.value = sortDirection;
+ this._applyHeaders();
+ this._applySortAndScrollToTop();
+ this._selectFirstVisibleLogin();
+ }
+
+ /**
+ * @param {login} login A login that was added to storage.
+ */
+ loginAdded(login) {
+ this._logins[login.guid] = { login };
+ this._loginGuidsSortedOrder.push(login.guid);
+ this._applyHeaders(false);
+ this._applySort();
+
+ // Add the list item and update any other related state that may pertain
+ // to the list item such as breach alerts.
+ this.render();
+
+ if (
+ this.classList.contains("no-logins") &&
+ !this.classList.contains("create-login-selected")
+ ) {
+ this._selectFirstVisibleLogin();
+ }
+ }
+
+ /**
+ * @param {login} login A login that was modified in storage. The related
+ * login-list-item will get updated.
+ */
+ loginModified(login) {
+ this._logins[login.guid] = Object.assign(this._logins[login.guid], {
+ login,
+ _header: null, // reset header
+ });
+ this._applyHeaders(false);
+ this._applySort();
+ let loginObject = this._logins[login.guid];
+ LoginListItemFactory.update(loginObject.listItem, login);
+
+ // Update any other related state that may pertain to the list item
+ // such as breach alerts that may or may not now apply.
+ this.render();
+ }
+
+ /**
+ * @param {login} login A login that was removed from storage. The related
+ * login-list-item will get removed. The login object
+ * is a plain JS object representation of
+ * nsILoginInfo/nsILoginMetaInfo.
+ */
+ loginRemoved(login) {
+ // Update the selected list item to the previous item in the list
+ // if one exists, otherwise the next item. If no logins remain
+ // the login-intro or empty-search text will be shown instead of the login-list.
+ if (this._selectedGuid == login.guid) {
+ let visibleListItems = this._list.querySelectorAll(
+ ".login-list-item[data-guid]:not([hidden])"
+ );
+ if (visibleListItems.length > 1) {
+ let index = [...visibleListItems].findIndex(listItem => {
+ return listItem.dataset.guid == login.guid;
+ });
+ let newlySelectedIndex = index > 0 ? index - 1 : index + 1;
+ let newlySelectedLogin =
+ this._logins[visibleListItems[newlySelectedIndex].dataset.guid].login;
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsLoginSelected", {
+ detail: newlySelectedLogin,
+ cancelable: true,
+ })
+ );
+ }
+ }
+
+ this._logins[login.guid].listItem.remove();
+ delete this._logins[login.guid];
+ this._loginGuidsSortedOrder = this._loginGuidsSortedOrder.filter(guid => {
+ return guid != login.guid;
+ });
+
+ // Render the login-list to update the search result count and show the
+ // empty-search message if needed.
+ this.render();
+ }
+
+ /**
+ * @returns {Set} Set of login guids that match the filter.
+ */
+ _applyFilter() {
+ let matchingLoginGuids;
+ if (this._filter) {
+ matchingLoginGuids = new Set(
+ this._loginGuidsSortedOrder.filter(guid => {
+ let { login } = this._logins[guid];
+ return (
+ login.origin.toLocaleLowerCase().includes(this._filter) ||
+ (!!login.httpRealm &&
+ login.httpRealm.toLocaleLowerCase().includes(this._filter)) ||
+ login.username.toLocaleLowerCase().includes(this._filter) ||
+ login.password.toLocaleLowerCase().includes(this._filter)
+ );
+ })
+ );
+ } else {
+ matchingLoginGuids = new Set([...this._loginGuidsSortedOrder]);
+ }
+
+ return matchingLoginGuids;
+ }
+
+ _applySort() {
+ const sort = this._sortSelect.value;
+ this._loginGuidsSortedOrder = this._loginGuidsSortedOrder.sort((a, b) => {
+ let loginA = this._logins[a].login;
+ let loginB = this._logins[b].login;
+ return sortFnOptions[sort](
+ loginA,
+ loginB,
+ this._breachesByLoginGUID,
+ this._vulnerableLoginsByLoginGUID
+ );
+ });
+ }
+
+ _applyHeaders(updateAll = true) {
+ let headerFn = headersFnOptions[this._sortSelect.value];
+ for (let guid of this._loginGuidsSortedOrder) {
+ let login = this._logins[guid];
+ if (updateAll || !login._header) {
+ login._header = headerFn(
+ login.login,
+ this._breachesByLoginGUID,
+ this._vulnerableLoginsByLoginGUID
+ );
+ }
+ }
+ }
+
+ _applySortAndScrollToTop() {
+ this._applySort();
+ this.render();
+ this._list.scrollTop = 0;
+ }
+
+ #updateVisibleLoginCount(count, total) {
+ const args = document.l10n.getAttributes(this._count).args;
+ if (count != args.count || total != args.total) {
+ document.l10n.setAttributes(
+ this._count,
+ count == total ? "login-list-count" : "login-list-filtered-count",
+ { count, total }
+ );
+ }
+ }
+
+ #findPreviousItem(item) {
+ let previousItem = item;
+ do {
+ previousItem =
+ (previousItem.tagName == "SECTION"
+ ? previousItem.lastElementChild
+ : previousItem.previousElementSibling) ||
+ (previousItem.parentElement.tagName == "SECTION" &&
+ previousItem.parentElement.previousElementSibling);
+ } while (
+ previousItem &&
+ (previousItem.hidden ||
+ !previousItem.classList.contains("login-list-item"))
+ );
+
+ return previousItem;
+ }
+
+ #findNextItem(item) {
+ let nextItem = item;
+ do {
+ nextItem =
+ (nextItem.tagName == "SECTION"
+ ? nextItem.firstElementChild.nextElementSibling
+ : nextItem.nextElementSibling) ||
+ (nextItem.parentElement.tagName == "SECTION" &&
+ nextItem.parentElement.nextElementSibling);
+ } while (
+ nextItem &&
+ (nextItem.hidden || !nextItem.classList.contains("login-list-item"))
+ );
+
+ return nextItem;
+ }
+
+ #pickByDirection(ltr, rtl) {
+ return document.dir == "ltr" ? ltr : rtl;
+ }
+
+ //TODO May be we can use this fn in render(), but logic is different a bit
+ get #activeDescendantForSelection() {
+ let activeDescendant = this.#activeDescendant;
+ if (
+ !activeDescendant ||
+ activeDescendant.hidden ||
+ !activeDescendant.classList.contains("login-list-item")
+ ) {
+ activeDescendant =
+ this._list.querySelector(".login-list-item[data-guid]:not([hidden])") ||
+ this._list.firstElementChild;
+ }
+
+ return activeDescendant;
+ }
+
+ _handleKeyboardNavWithinList(event) {
+ if (this._list != this.shadowRoot.activeElement) {
+ return;
+ }
+
+ let command = null;
+
+ switch (event.type) {
+ case "keyup":
+ switch (event.key) {
+ case " ":
+ case "Enter":
+ command = "click";
+ break;
+ }
+ break;
+ case "keydown":
+ switch (event.key) {
+ case "ArrowDown":
+ command = "next";
+ break;
+ case "ArrowLeft":
+ command = this.#pickByDirection("previous", "next");
+ break;
+ case "ArrowRight":
+ command = this.#pickByDirection("next", "previous");
+ break;
+ case "ArrowUp":
+ command = "previous";
+ break;
+ }
+ break;
+ }
+
+ if (command) {
+ event.preventDefault();
+
+ switch (command) {
+ case "click":
+ this.clickSelected();
+ break;
+ case "next":
+ this.selectNext();
+ break;
+ case "previous":
+ this.selectPrevious();
+ break;
+ }
+ }
+ }
+
+ clickSelected() {
+ this.#activeDescendantForSelection?.click();
+ }
+
+ selectNext() {
+ const activeDescendant = this.#activeDescendantForSelection;
+ if (activeDescendant) {
+ this.#moveSelection(
+ activeDescendant,
+ this.#findNextItem(activeDescendant)
+ );
+ }
+ }
+
+ selectPrevious() {
+ const activeDescendant = this.#activeDescendantForSelection;
+ if (activeDescendant) {
+ this.#moveSelection(
+ activeDescendant,
+ this.#findPreviousItem(activeDescendant)
+ );
+ }
+ }
+
+ #moveSelection(from, to) {
+ if (to) {
+ this._list.setAttribute("aria-activedescendant", to.id);
+ from?.classList.remove("keyboard-selected");
+ to.classList.add("keyboard-selected");
+ to.scrollIntoView({ block: "nearest" });
+ this.clickSelected();
+ }
+ }
+
+ /**
+ * Selects the first visible login as part of the initial load of the page,
+ * which will bypass any focus changes that occur during manual login
+ * selection.
+ */
+ _selectFirstVisibleLogin() {
+ const visibleLoginsGuids = this._applyFilter();
+ let selectedLoginGuid =
+ this._loginGuidsSortedOrder.find(guid => guid === this._preselectLogin) ??
+ this.findLoginGuidFromDomain(this._preselectLogin) ??
+ this._loginGuidsSortedOrder[0];
+
+ selectedLoginGuid = [
+ selectedLoginGuid,
+ ...this._loginGuidsSortedOrder,
+ ].find(guid => visibleLoginsGuids.has(guid));
+
+ if (selectedLoginGuid && this._logins[selectedLoginGuid]) {
+ let { login } = this._logins[selectedLoginGuid];
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsInitialLoginSelected", {
+ detail: login,
+ })
+ );
+ this.updateSelectedLocationHash(selectedLoginGuid);
+ }
+ }
+
+ _setListItemAsSelected(listItem) {
+ let oldSelectedItem = this._list.querySelector(".selected");
+ if (oldSelectedItem) {
+ oldSelectedItem.classList.remove("selected");
+ oldSelectedItem.removeAttribute("aria-selected");
+ }
+ this.classList.toggle("create-login-selected", !listItem.dataset.guid);
+ this._blankLoginListItem.hidden = !!listItem.dataset.guid;
+ this._createLoginButton.disabled = !listItem.dataset.guid;
+ listItem.classList.add("selected");
+ listItem.setAttribute("aria-selected", "true");
+ this._list.setAttribute("aria-activedescendant", listItem.id);
+ this._selectedGuid = listItem.dataset.guid;
+ this.updateSelectedLocationHash(this._selectedGuid);
+ // Scroll item into view if it isn't visible
+ listItem.scrollIntoView({ block: "nearest" });
+ }
+
+ updateSelectedLocationHash(guid) {
+ window.location.hash = `#${encodeURIComponent(guid)}`;
+ }
+
+ findLoginGuidFromDomain(domain) {
+ for (let guid of this._loginGuidsSortedOrder) {
+ let login = this._logins[guid].login;
+ if (login.hostname === domain) {
+ return guid;
+ }
+ }
+ return null;
+ }
+}
+customElements.define("login-list", LoginList);
diff --git a/browser/components/aboutlogins/content/components/login-timeline.mjs b/browser/components/aboutlogins/content/components/login-timeline.mjs
new file mode 100644
index 0000000000..52d053e999
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-timeline.mjs
@@ -0,0 +1,137 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ styleMap,
+ classMap,
+ html,
+ css,
+} from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+export default class Timeline extends MozLitElement {
+ static get properties() {
+ return {
+ history: { type: Array },
+ };
+ }
+
+ constructor() {
+ super();
+ this.history = [];
+ }
+
+ static styles = css`
+ .timeline {
+ display: grid;
+ grid-template-rows: 24px auto auto;
+ font-size: smaller;
+ color: var(--text-color-deemphasized);
+ padding-inline-start: 0px;
+ text-align: center;
+ }
+
+ .timeline.empty {
+ display: none;
+ }
+
+ .timeline > svg {
+ grid-row: 1 / 1;
+ fill: var(--in-content-box-background);
+ }
+
+ .timeline > .line {
+ height: 2px;
+ justify-self: stretch;
+ align-self: center;
+ background-color: var(--in-content-border-color);
+ grid-row: 1;
+ }
+
+ .timeline > .line:nth-child(1) {
+ grid-column: 1;
+ width: 50%;
+ justify-self: flex-end;
+ }
+
+ .timeline > .line:nth-child(2) {
+ grid-column: 2/-2;
+ }
+
+ .timeline > .line:nth-child(3) {
+ grid-column: -2;
+ width: 50%;
+ justify-self: flex-start;
+ }
+
+ .timeline > .point {
+ width: 24px;
+ height: 24px;
+ stroke: var(--in-content-border-color);
+ stroke-width: 30px;
+ justify-self: center;
+ }
+
+ .timeline > .date {
+ grid-row: 2;
+ padding: 4px 8px;
+ }
+
+ .timeline > .action {
+ grid-row: 3;
+ }
+ `;
+
+ render() {
+ this.history = this.history.filter(historyPoint => historyPoint.time);
+ this.history.sort((a, b) => a.time - b.time);
+ let columns = "auto";
+
+ // Add each history event to the timeline
+ let points = this.history.map((entry, index) => {
+ if (index > 0) {
+ // add a gap between previous point and current one
+ columns += ` ${entry.time - this.history[index - 1].time}fr auto`;
+ }
+
+ let columnNumber = 2 * index + 1;
+ let styles = styleMap({ gridColumn: columnNumber });
+ return html`
+ <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`
+ <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);