diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /browser/components/aboutlogins/content | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/aboutlogins/content')
37 files changed, 5452 insertions, 0 deletions
diff --git a/browser/components/aboutlogins/content/aboutLogins.css b/browser/components/aboutlogins/content/aboutLogins.css new file mode 100644 index 0000000000..6b4a16451c --- /dev/null +++ b/browser/components/aboutlogins/content/aboutLogins.css @@ -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/. */ + +html { + position: fixed; +} +html, +body { + height: 100%; + width: 100%; +} + +body { + --sidebar-width: 320px; + display: grid; + grid-template-columns: var(--sidebar-width) 1fr; + grid-template-rows: auto 1fr; +} + +@media (max-width: 830px) { + body { + --sidebar-width: 270px; + } +} + +header { + display: flex; + align-items: center; + justify-content: flex-end; + background-color: var(--in-content-page-background); + padding-block: 9px; + padding-inline-start: 16px; + padding-inline-end: 23px; +} + +login-filter { + min-width: 200px; + max-width: 400px; + margin-inline: 40px auto; + flex-grow: 0.5; + align-self: center; +} + +fxaccounts-button, +menu-button { + margin-inline-start: 18px; +} + +login-list { + grid-row: 1/4; +} + +:root:not(.initialized) login-intro, +:root:not(.initialized) login-item, +:root.empty-search login-intro, +:root:not(.no-logins, .empty-search, .login-selected) login-intro, +login-item[data-editing="true"] + login-intro, +.login-selected login-intro, +:root:not(.login-selected) login-item:not([data-editing="true"]), +.no-logins login-item:not([data-editing="true"]) { + display: none; +} + +.heading-wrapper { + display: flex; + justify-content: center; + width: var(--sidebar-width); + font-weight: 600; +} + +:root:not(.primary-password-auth-required) #primary-password-required-overlay { + display: none; +} + +.primary-password-auth-required > body > header, +.primary-password-auth-required > body > login-list, +.primary-password-auth-required > body > section { + filter: blur(2px) +} + +#primary-password-required-overlay { + z-index: 1; + position: fixed; + width: 100vw; + height: 100vh; + background-color: rgba(0,0,0,0.2); +} + +body > section { + display: grid; + grid-template-rows: auto 1fr; + overflow-y: hidden; + overflow-x: auto; +} + +login-intro { + overflow-y: scroll; +} diff --git a/browser/components/aboutlogins/content/aboutLogins.html b/browser/components/aboutlogins/content/aboutLogins.html new file mode 100644 index 0000000000..c7138a909a --- /dev/null +++ b/browser/components/aboutlogins/content/aboutLogins.html @@ -0,0 +1,392 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <meta http-equiv="Content-Security-Policy" content="default-src 'none'; object-src 'none'; script-src resource: chrome:; img-src data: blob: https://firefoxusercontent.com https://profile.accounts.firefox.com;"> + <meta name="color-scheme" content="light dark"> + <title data-l10n-id="about-logins-page-title"></title> + <link rel="localization" href="branding/brand.ftl"> + <link rel="localization" href="browser/aboutLogins.ftl"> + <link rel="localization" href="toolkit/branding/accounts.ftl"> + <link rel="localization" href="toolkit/branding/brandings.ftl"> + <script type="module" src="chrome://browser/content/aboutlogins/components/confirmation-dialog.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/remove-logins-dialog.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/import-summary-dialog.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/import-error-dialog.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/generic-dialog.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/fxaccounts-button.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/login-filter.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/login-intro.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/login-item.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/login-list.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/login-list-item.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/login-list-section.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/menu-button.mjs"></script> + <script type="module" src="chrome://global/content/elements/moz-button-group.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/aboutLogins.mjs"></script> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/aboutLogins.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/common.css"> + <link rel="icon" href="chrome://branding/content/icon32.png"> + </head> + <body> + <header> + <fxaccounts-button hidden></fxaccounts-button> + <menu-button></menu-button> + </header> + <login-list></login-list> + <login-item></login-item> + <login-intro></login-intro> + <confirmation-dialog hidden></confirmation-dialog> + <remove-logins-dialog hidden></remove-logins-dialog> + <import-summary-dialog hidden></import-summary-dialog> + <import-error-dialog hidden></import-error-dialog> + <div id="primary-password-required-overlay"></div> + + <template id="confirmation-dialog-template"> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/components/confirmation-dialog.css"> + <div class="overlay"> + <div class="container" role="dialog" aria-labelledby="title" aria-describedby="message"> + <button class="dismiss-button ghost-button" data-l10n-id="confirmation-dialog-dismiss-button"> + <img class="dismiss-icon" src="chrome://global/skin/icons/close.svg" draggable="false"/> + </button> + <div class="content"> + <img class="warning-icon" src="chrome://global/skin/icons/warning.svg" draggable="false"/> + <h1 class="title" id="title"></h1> + <p class="message" id="message"></p> + </div> + <moz-button-group class="buttons"> + <button class="confirm-button primary danger-button"></button> + <button class="cancel-button" data-l10n-id="confirmation-dialog-cancel-button"></button> + </moz-button-group> + </div> + </div> + </template> + + <template id="generic-dialog-template"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/components/generic-dialog.css"> + <div class="overlay"> + <div class="container" role="dialog" aria-labelledby="title"> + <slot name="dialog-icon" part="dialog-icon"></slot> + <slot name="dialog-title"></slot> + <slot name="content"></slot> + <slot name="buttons"></slot> + </div> + </div> + </template> + + <template id="import-summary-dialog-template"> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/components/generic-dialog.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/components/import-summary-dialog.css"> + <generic-dialog> + <span slot="dialog-title" data-l10n-id="about-logins-import-dialog-title"></span> + <img slot="dialog-icon" part="dialog-icon" src="chrome://browser/skin/import.svg"/> + <div slot="content" class="content"> + <div class="import-summary"> + <div class="import-items-added import-items-row" data-l10n-id="about-logins-import-dialog-items-added" data-l10n-args='{"count": 0}'> + <span data-l10n-name="count" class="result-count"></span> + </div> + <div class="import-items-modified import-items-row" data-l10n-id="about-logins-import-dialog-items-modified" data-l10n-args='{"count": 0}'> + <span data-l10n-name="count" class="result-count"></span> + </div> + <div class="import-items-no-change import-items-row" data-l10n-id="about-logins-import-dialog-items-no-change" data-l10n-name="no-change" data-l10n-args='{"count": 0}'> + <span data-l10n-name="count" class="result-count"></span> + <span hidden data-l10n-name="meta" class="result-meta"></span> + </div> + <div class="import-items-errors import-items-row" data-l10n-id="about-logins-import-dialog-items-error" data-l10n-args='{"count": 0}'> + <span data-l10n-name="count" class="result-count"></span> + <span hidden data-l10n-name="meta" class="result-meta"></span> + </div> + </div> + <a class="open-detailed-report" href="about:loginsimportreport" target="_blank" data-l10n-id="about-logins-alert-import-message"></a> + </div> + <moz-button-group slot="buttons" class="buttons"> + <button class="dismiss-button primary" data-l10n-id="about-logins-import-dialog-done"></button> + </moz-button-group> + </generic-dialog> + </template> + + <template id="import-error-dialog-template"> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/components/generic-dialog.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/components/import-error-dialog.css"> + <generic-dialog> + <span slot="dialog-title" data-l10n-id="about-logins-import-dialog-error-title"></span> + <img slot="dialog-icon" part="dialog-icon" class="warning-icon" src="chrome://global/skin/icons/warning.svg" draggable="false"/> + <div slot="content" class="content"> + <span class="error-title" data-l10n-id="about-logins-import-dialog-error-unable-to-read-title"></span> + <span class="error-description" data-l10n-id="about-logins-import-dialog-error-unable-to-read-description"></span> + <span class="no-logins" data-l10n-id="about-logins-import-dialog-error-no-logins-imported"></span> + <a class="error-learn-more-link" href="https://support.mozilla.org/kb/import-login-data-file" + data-l10n-id="about-logins-import-dialog-error-learn-more" target="_blank" rel="noreferrer"></a> + </div> + <moz-button-group slot="buttons" class="buttons"> + <button class="dismiss-button" data-l10n-id="about-logins-import-dialog-error-cancel"></button> + <button class="try-import-again primary" data-l10n-id="about-logins-import-dialog-error-try-import-again"></button> + </moz-button-group> + </generic-dialog> + </template> + + <template id="remove-logins-dialog-template"> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/components/remove-logins-dialog.css"> + <div class="overlay"> + <div class="container" role="dialog" aria-labelledby="title" aria-describedby="message"> + <button class="dismiss-button ghost-button" data-l10n-id="confirmation-dialog-dismiss-button"> + <img class="dismiss-icon" src="chrome://global/skin/icons/close.svg" draggable="false"/> + </button> + <div class="content"> + <img class="warning-icon" src="chrome://global/skin/icons/delete.svg" draggable="false"/> + <h1 class="title" id="title"></h1> + <p class="message" id="message"></p> + <label class="checkbox-wrapper toggle-container-with-text"> + <input id="confirmation-checkbox" type="checkbox" class="checkbox"></input> + <span class="checkbox-text"></span> + </label> + </div> + <moz-button-group class="buttons"> + <button class="confirm-button primary danger-button"></button> + <button class="cancel-button" data-l10n-id="confirmation-dialog-cancel-button"></button> + </moz-button-group> + </div> + </template> + + <template id="fxaccounts-button-template"> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/components/fxaccounts-button.css"> + <div class="logged-out-view"> + <p class="fxaccounts-extra-text" data-l10n-id="fxaccounts-sign-in-text"></p> + <button class="fxaccounts-enable-button" data-l10n-id="fxaccounts-sign-in-sync-button"></button> + </div> + <div class="logged-in-view"> + <button class="fxaccounts-avatar-button ghost-button" data-l10n-id="fxaccounts-avatar-button"> + <span class="fxaccount-email"></span> + <span class="fxaccount-avatar"></span> + </button> + </div> + </template> + + <template id="login-list-template"> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/components/login-list.css"> + <div class="listHeader"> + <login-filter></login-filter> + <div class="create-login-button-container"> + <button class="create-login-button" data-l10n-id="create-new-login-button"></button> + </div> + </div> + <div class="meta"> + <label for="login-sort"> + <span data-l10n-id="login-list-sort-label-text"></span> + <select id="login-sort"> + <option name="name" data-l10n-id="login-list-name-option" value="name"> + <option name="name-reverse" data-l10n-id="login-list-name-reverse-option" value="name-reverse"> + <option name="username" data-l10n-id="login-list-username-option" value="username"> + <option name="username-reverse" data-l10n-id="login-list-username-reverse-option" value="username-reverse"> + <option name="last-used" data-l10n-id="login-list-last-used-option" value="last-used"> + <option name="last-changed" data-l10n-id="login-list-last-changed-option" value="last-changed"> + <option name="alerts" data-l10n-id="about-logins-login-list-alerts-option" value="alerts" hidden> + </select> + </label> + <span class="count" data-l10n-id="login-list-count" data-l10n-args='{"count": 0}'></span> + </div> + <!-- This container is to work around bug 1569292 --> + <div class="container"> + <ol role="listbox" tabindex="0" data-l10n-id="login-list"></ol> + <div class="intro"> + <p data-l10n-id="login-list-intro-title"></p> + <span data-l10n-id="login-list-intro-description"></span> + </div> + <div class="empty-search-message"> + <p data-l10n-id="about-logins-login-list-empty-search-title"></p> + <span data-l10n-id="about-logins-login-list-empty-search-description"></span> + </div> + </div> + </template> + + <template id="login-list-item-template"> + <li class="login-list-item" role="option"> + <img class="favicon" /> + <div class="labels"> + <span class="title" dir="auto"></span> + <span class="username" dir="ltr"></span> + </div> + <img class="alert-icon" title="" src=""/> + </li> + </template> + + <template id="login-list-section-template"> + <section class="login-list-section"> + <span class="login-list-header" dir="auto"></span> + </section> + </template> + + <template id="login-intro-template"> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/components/login-intro.css"> + + <img class="illustration" src="chrome://browser/content/aboutlogins/icons/intro-illustration.svg"/> + <h1 class="heading" data-l10n-id="about-logins-login-intro-heading-logged-out2"></h1> + <section> + <p class="description" data-l10n-id="login-intro-description"></p> + <ul> + <li data-l10n-id="login-intro-instructions-fxa"></li> + <li data-l10n-id="login-intro-instructions-fxa-settings"></li> + <li data-l10n-id="login-intro-instructions-fxa-passwords-help"> + <a data-l10n-name="passwords-help-link" class="intro-help-link" target="_blank" rel="noreferrer"></a> + </li> + </ul> + <p class="description intro-import-text no-file-import" hidden data-l10n-id="about-logins-intro-browser-only-import"> + <a data-l10n-name="import-link" href="#"></a> + </p> + <p class="description intro-import-text file-import" hidden data-l10n-id="about-logins-intro-import2"> + <a data-l10n-name="import-browser-link" href="#"></a> + <a data-l10n-name="import-file-link" href="#"></a> + </p> + </section> + </template> + + <template id="login-item-template"> + <script type="module" src="chrome://browser/content/aboutlogins/components/login-timeline.mjs"></script> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/components/login-item.css"> + <div class="container"> + <div class="column"> + <div class="error-message"> + <span class="error-message-text" data-l10n-id="about-logins-error-message-default"></span> + <span class="error-message-link"> + <a data-l10n-name="duplicate-link" tabindex="0" href=""></a> + </span> + </div> + <div class="header"> + <img class="login-item-favicon" /> + <h2 class="title"> + <span class="login-item-title"></span> + <span class="new-login-title" data-l10n-id="login-item-new-login-title"></span> + </h2> + <button class="edit-button ghost-button" data-l10n-id="login-item-edit-button"></button> + <button class="delete-button ghost-button" data-l10n-id="about-logins-login-item-remove-button"></button> + </div> + <div class="breach-alert"> + <h3 class="alert-title" data-l10n-id="about-logins-breach-alert-title"></h3> + <img class="alert-icon" src="chrome://browser/content/aboutlogins/icons/breached-website.svg"/> + <span class="alert-date" data-l10n-id="about-logins-breach-alert-date" data-l10n-args='{"date": 0}'></span> + <span class="alert-text" data-l10n-id="breach-alert-text"></span> + <a class="alert-link" data-l10n-id="about-logins-breach-alert-link" data-l10n-args='{"hostname": ""}' href="#" rel="noreferrer" target="_blank"></a> + </div> + <div class="vulnerable-alert"> + <h3 class="alert-title" data-l10n-id="about-logins-vulnerable-alert-title"></h3> + <img class="alert-icon" src="chrome://browser/content/aboutlogins/icons/vulnerable-password.svg"/> + <span class="alert-text" data-l10n-id="about-logins-vulnerable-alert-text2"></span> + <a class="alert-link" data-l10n-id="about-logins-vulnerable-alert-link" data-l10n-args='{"hostname": ""}' href="#" rel="noreferrer" target="_blank"></a> + <a class="alert-learn-more-link" data-l10n-id="about-logins-vulnerable-alert-learn-more-link" href="#" rel="noreferrer" target="_blank"></a> + </div> + <form> + <div class="detail-row"> + <label class="detail-cell"> + <span class="origin-label field-label" data-l10n-id="login-item-origin-label"></span> + <!-- Default text inputs to readonly to reduce jumping of the field + size on page load since it always starts readonly. --> + + <input type="url" + name="origin" + required + data-l10n-id="login-item-origin" + dir="auto" + readonly/> + <a class="origin-input" dir="auto" target="_blank" rel="noreferrer" name="origin" href=""></a> + <div class="tooltip-container"> + <div class="arrow-box"> + <p class="tooltip-message" data-l10n-id="login-item-tooltip-message"></p> + </div> + </div> + </label> + </div> + <div class="detail-grid"> + <div class="detail-row"> + <label class="detail-cell"> + <span class="username-label field-label" data-l10n-id="login-item-username-label"></span> + <input type="text" + name="username" + data-l10n-id="login-item-username" + dir="ltr" + readonly/> + </label> + <button class="copy-button copy-username-button" data-copy-login-property="username" data-telemetry-object="username" type="button"> + <span class="copied-button-text" data-l10n-id="login-item-copied-username-button-text"></span> + <span class="copy-button-text" data-l10n-id="login-item-copy-username-button-text"></span> + </button> + </div> + <div class="detail-row"> + <label class="detail-cell"> + <span class="password-label field-label" data-l10n-id="login-item-password-label"></span> + <div class="reveal-password-wrapper"> + <input type="password" + name="password" + autocomplete="off" + dir="ltr" + required + readonly/> + <input class="password-display" + type="password" + autocomplete="off" + dir="ltr" + readonly/> + <input type="checkbox" + class="reveal-password-checkbox" + data-l10n-id="login-item-password-reveal-checkbox"/> + </div> + </label> + <button class="copy-button copy-password-button" data-copy-login-property="password" data-telemetry-object="password" type="button"> + <span class="copied-button-text" data-l10n-id="login-item-copied-password-button-text"></span> + <span class="copy-button-text" data-l10n-id="login-item-copy-password-button-text"></span> + </button> + </div> + </div> + <moz-button-group class="form-actions-row"> + <button class="save-changes-button" type="submit"></button> + <button class="cancel-button" data-l10n-id="login-item-cancel-button" type="button"></button> + </moz-button-group> + <login-timeline hidden ></login-timeline> + </form> + </div> + </div> + </template> + + <template id="login-filter-template"> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/components/login-filter.css"> + <input data-l10n-id="about-logins-login-filter" class="filter" type="text" dir="auto"/> + </template> + + <template id="menu-button-template"> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/common.css"> + <link rel="stylesheet" href="chrome://browser/content/aboutlogins/components/menu-button.css"> + <button class="menu-button ghost-button" data-l10n-id="menu"></button> + <ul class="menu" role="menu" hidden> + <button role="menuitem" class="menuitem-button menuitem-import-browser ghost-button" hidden data-supported-platforms="Win32,MacIntel" data-event-name="AboutLoginsImportFromBrowser" data-l10n-id="about-logins-menu-menuitem-import-from-another-browser"></button> + <button role="menuitem" class="menuitem-button menuitem-import-file ghost-button" data-event-name="AboutLoginsImportFromFile" data-l10n-id="about-logins-menu-menuitem-import-from-a-file"></button> + <button role="menuitem" class="menuitem-button menuitem-export ghost-button" data-event-name="AboutLoginsExportPasswordsDialog" data-l10n-id="about-logins-menu-menuitem-export-logins"></button> + <button role="menuitem" class="menuitem-button menuitem-remove-all-logins ghost-button" data-event-name="AboutLoginsRemoveAllLoginsDialog" data-l10n-id="about-logins-menu-menuitem-remove-all-logins"></button> + <hr role="separator" class="menuitem-separator"></hr> + <button role="menuitem" class="menuitem-button menuitem-preferences ghost-button" data-event-name="AboutLoginsOpenPreferences" data-l10n-id="menu-menuitem-preferences"></button> + <button role="menuitem" class="menuitem-button menuitem-help ghost-button" data-event-name="AboutLoginsGetHelp" data-l10n-id="about-logins-menu-menuitem-help"></button> + </ul> + </template> + + </body> +</html> diff --git a/browser/components/aboutlogins/content/aboutLogins.mjs b/browser/components/aboutlogins/content/aboutLogins.mjs new file mode 100644 index 0000000000..f0402fedc1 --- /dev/null +++ b/browser/components/aboutlogins/content/aboutLogins.mjs @@ -0,0 +1,288 @@ +/* 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, + setKeyboardAccessForNonDialogElements, +} from "./aboutLoginsUtils.mjs"; + +// The init code isn't wrapped in a DOMContentLoaded/load event listener so the +// page works properly when restored from session restore. +const gElements = { + fxAccountsButton: document.querySelector("fxaccounts-button"), + loginList: document.querySelector("login-list"), + loginIntro: document.querySelector("login-intro"), + loginItem: document.querySelector("login-item"), + loginFilter: document + .querySelector("login-list") + .shadowRoot.querySelector("login-filter"), + menuButton: document.querySelector("menu-button"), + // removeAllLogins button is nested inside of menuButton + get removeAllButton() { + return this.menuButton.shadowRoot.querySelector( + ".menuitem-remove-all-logins" + ); + }, +}; + +let numberOfLogins = 0; + +function updateNoLogins() { + document.documentElement.classList.toggle("no-logins", numberOfLogins == 0); + gElements.loginList.classList.toggle("no-logins", numberOfLogins == 0); + gElements.loginItem.classList.toggle("no-logins", numberOfLogins == 0); + gElements.removeAllButton.disabled = numberOfLogins == 0; +} + +function handleAllLogins(logins) { + gElements.loginList.setLogins(logins); + numberOfLogins = logins.length; + updateNoLogins(); +} + +let fxaLoggedIn = null; +let passwordSyncEnabled = null; + +function handleSyncState(syncState) { + gElements.fxAccountsButton.updateState(syncState); + gElements.loginIntro.updateState(syncState); + fxaLoggedIn = syncState.loggedIn; + passwordSyncEnabled = syncState.passwordSyncEnabled; +} + +window.addEventListener("AboutLoginsChromeToContent", event => { + switch (event.detail.messageType) { + case "AllLogins": { + document.documentElement.classList.remove( + "primary-password-auth-required" + ); + setKeyboardAccessForNonDialogElements(true); + handleAllLogins(event.detail.value); + break; + } + case "ImportPasswordsDialog": { + let dialog = document.querySelector("import-summary-dialog"); + let options = { + logins: event.detail.value, + }; + dialog.show(options); + break; + } + case "ImportPasswordsErrorDialog": { + let dialog = document.querySelector("import-error-dialog"); + dialog.show(event.detail.value); + break; + } + case "LoginAdded": { + gElements.loginList.loginAdded(event.detail.value); + gElements.loginItem.loginAdded(event.detail.value); + numberOfLogins++; + updateNoLogins(); + break; + } + case "LoginModified": { + gElements.loginList.loginModified(event.detail.value); + gElements.loginItem.loginModified(event.detail.value); + break; + } + case "LoginRemoved": { + // The loginRemoved function of loginItem needs to be called before + // the one in loginList since it will remove the editing. So that the + // discard dialog won't show up if we delete a login after edit it. + gElements.loginItem.loginRemoved(event.detail.value); + gElements.loginList.loginRemoved(event.detail.value); + numberOfLogins--; + updateNoLogins(); + break; + } + case "PrimaryPasswordAuthRequired": { + document.documentElement.classList.add("primary-password-auth-required"); + setKeyboardAccessForNonDialogElements(false); + break; + } + case "RemaskPassword": { + window.dispatchEvent(new CustomEvent("AboutLoginsRemaskPassword")); + break; + } + case "RemoveAllLogins": { + handleAllLogins(event.detail.value); + document.documentElement.classList.remove("login-selected"); + break; + } + case "SetBreaches": { + gElements.loginList.setBreaches(event.detail.value); + gElements.loginItem.setBreaches(event.detail.value); + break; + } + case "SetVulnerableLogins": { + gElements.loginList.setVulnerableLogins(event.detail.value); + gElements.loginItem.setVulnerableLogins(event.detail.value); + break; + } + case "Setup": { + handleAllLogins(event.detail.value.logins); + handleSyncState(event.detail.value.syncState); + gElements.loginList.setSortDirection(event.detail.value.selectedSort); + document.documentElement.classList.add("initialized"); + gElements.loginList.classList.add("initialized"); + break; + } + case "ShowLoginItemError": { + gElements.loginItem.showLoginItemError(event.detail.value); + break; + } + case "SyncState": { + handleSyncState(event.detail.value); + break; + } + case "UpdateBreaches": { + gElements.loginList.updateBreaches(event.detail.value); + gElements.loginItem.updateBreaches(event.detail.value); + break; + } + case "UpdateVulnerableLogins": { + gElements.loginList.updateVulnerableLogins(event.detail.value); + gElements.loginItem.updateVulnerableLogins(event.detail.value); + break; + } + } +}); + +window.addEventListener("AboutLoginsRemoveAllLoginsDialog", () => { + let loginItem = document.querySelector("login-item"); + let options = {}; + if (fxaLoggedIn && passwordSyncEnabled) { + options.title = "about-logins-confirm-remove-all-sync-dialog-title"; + options.message = "about-logins-confirm-remove-all-sync-dialog-message"; + } else { + options.title = "about-logins-confirm-remove-all-dialog-title"; + options.message = "about-logins-confirm-remove-all-dialog-message"; + } + options.confirmCheckboxLabel = + "about-logins-confirm-remove-all-dialog-checkbox-label"; + options.confirmButtonLabel = + "about-logins-confirm-remove-all-dialog-confirm-button-label"; + options.count = numberOfLogins; + + let dialog = document.querySelector("remove-logins-dialog"); + let dialogPromise = dialog.show(options); + try { + dialogPromise.then( + () => { + if (loginItem.dataset.isNewLogin) { + // Bug 1681042 - Resetting the form prevents a double confirmation dialog since there + // may be pending changes in the new login. + loginItem.resetForm(); + window.dispatchEvent(new CustomEvent("AboutLoginsClearSelection")); + } else if (loginItem.dataset.editing) { + loginItem._toggleEditing(); + } + window.document.documentElement.classList.remove("login-selected"); + let removeAllEvt = new CustomEvent("AboutLoginsRemoveAllLogins", { + bubbles: true, + }); + window.dispatchEvent(removeAllEvt); + }, + () => {} + ); + } catch (e) { + if (e != undefined) { + throw e; + } + } +}); + +window.addEventListener("AboutLoginsExportPasswordsDialog", async () => { + recordTelemetryEvent({ + object: "export", + method: "mgmt_menu_item_used", + }); + let dialog = document.querySelector("confirmation-dialog"); + let options = { + title: "about-logins-confirm-export-dialog-title", + message: "about-logins-confirm-export-dialog-message", + confirmButtonLabel: "about-logins-confirm-export-dialog-confirm-button", + }; + try { + await dialog.show(options); + document.dispatchEvent( + new CustomEvent("AboutLoginsExportPasswords", { bubbles: true }) + ); + } catch (ex) { + // The user cancelled the dialog. + } +}); + +async function interceptFocusKey() { + // Intercept Ctrl+F on the page to focus login filter box + const [findKey] = await document.l10n.formatMessages([ + { id: "about-logins-login-filter" }, + ]); + const focusKey = findKey.attributes + .find(a => a.name == "key") + .value.toLowerCase(); + document.addEventListener("keydown", event => { + if (event.key == focusKey && event.getModifierState("Accel")) { + event.preventDefault(); + document + .querySelector("login-list") + .shadowRoot.querySelector("login-filter") + .shadowRoot.querySelector("input") + .focus(); + } + }); +} + +await interceptFocusKey(); + +// Begin code that executes on page load. + +let searchParamsChanged = false; +let { protocol, pathname, searchParams } = new URL(document.location); + +recordTelemetryEvent({ + method: "open_management", + object: searchParams.get("entryPoint") || "direct", +}); + +if (searchParams.has("entryPoint")) { + // Remove this parameter from the URL (after recording above) to make it + // cleaner for bookmarking and switch-to-tab and so that bookmarked values + // don't skew telemetry. + searchParams.delete("entryPoint"); + searchParamsChanged = true; +} + +if (searchParams.has("filter")) { + let filter = searchParams.get("filter"); + if (!filter) { + // Remove empty `filter` params to give a cleaner URL for bookmarking and + // switch-to-tab + searchParams.delete("filter"); + searchParamsChanged = true; + } +} + +if (searchParamsChanged) { + const paramsPart = searchParams.toString() ? `?${searchParams}` : ""; + const newURL = protocol + pathname + paramsPart + document.location.hash; + // This redirect doesn't stop this script from running so ensure you guard + // later code if it shouldn't run before and after the redirect. + window.location.replace(newURL); +} else if (searchParams.has("filter")) { + // This must be after the `location.replace` so it doesn't cause telemetry to + // record a filter event before the navigation to clean the URL. + gElements.loginFilter.value = searchParams.get("filter"); +} + +if (!searchParamsChanged) { + if (document.location.hash) { + const loginDomainOrGuid = decodeURIComponent( + document.location.hash.slice(1) + ); + gElements.loginList.selectLoginByDomainOrGuid(loginDomainOrGuid); + } + gElements.loginFilter.focus(); + document.dispatchEvent(new CustomEvent("AboutLoginsInit", { bubbles: true })); +} diff --git a/browser/components/aboutlogins/content/aboutLoginsImportReport.css b/browser/components/aboutlogins/content/aboutLoginsImportReport.css new file mode 100644 index 0000000000..8e126ecb62 --- /dev/null +++ b/browser/components/aboutlogins/content/aboutLoginsImportReport.css @@ -0,0 +1,125 @@ +/* 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/. */ + +.importreport { + display: block; +} + +#report-body { + display: grid; + grid-template-columns: repeat(6, auto); + grid-template-rows: 110px 145px auto; + grid-column: logins/login; + height: 100%; +} + +.import-report-heading { + font-weight: 600; + margin-block: auto; + margin-inline-start: 48px; +} + +.summary { + grid-column: 2 / 5; + grid-row-start: 1; + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.summary h2 { + font-size: 24px; + margin-block: 32px 8px; +} + + +.summary > a { + margin-top: 12px; +} + +.new-logins, +.exiting-logins, +.duplicate-logins, +.errors-logins { + display: flex; + flex-direction: column; + width: 120px; + height: 100px; + align-items: center; + margin: auto; +} + +.count-details { + margin-top: 8px; + text-align: center; +} + +.result-count { + font-size: 40px; + font-weight: bold; +} + +.new-logins { + grid-column: 2; + grid-row-start: 2; +} + +.exiting-logins { + grid-column: 3; + grid-row-start: 2; +} + +.duplicate-logins { + grid-column: 4; + grid-row-start: 2; +} + +.errors-logins { + grid-column: 5; + grid-row-start: 2; +} + +.logins-list { + grid-column: 2 / 6; + grid-row-start: 3; + display: grid; + grid-template-columns: auto 1fr; + border-top: 1px solid var(--in-content-border-color); + grid-auto-rows: 28px; + overflow-y: auto; +} + +.not-imported { + font-style: italic; + font-weight: bold; +} + +.error { + color: var(--dialog-warning-text-color); +} + +.not-imported-hidden { + visibility: hidden; +} + +import-details-row:nth-child(odd) { + background-color: var(--in-content-box-background-odd); +} + +import-details-row { + height: 20px; + margin-block: 1px; + display: grid; + grid-column: 1 / 3; + grid-template-columns: subgrid; + grid-gap: 16px; +} + +import-details-row .row-count { + padding-inline: 8px 12px; +} + +import-details-row .row-details { + padding-inline-start: 5px; +} diff --git a/browser/components/aboutlogins/content/aboutLoginsImportReport.html b/browser/components/aboutlogins/content/aboutLoginsImportReport.html new file mode 100644 index 0000000000..5f52fdf29e --- /dev/null +++ b/browser/components/aboutlogins/content/aboutLoginsImportReport.html @@ -0,0 +1,103 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <meta + http-equiv="Content-Security-Policy" + content="default-src 'none'; object-src 'none'; script-src resource: chrome:; img-src data: blob: https://firefoxusercontent.com;" + /> + <meta name="color-scheme" content="light dark" /> + <title data-l10n-id="about-logins-import-report-page-title"></title> + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="browser/aboutLogins.ftl" /> + <link rel="localization" href="toolkit/branding/accounts.ftl" /> + <link rel="localization" href="toolkit/branding/brandings.ftl" /> + <script + type="module" + src="chrome://browser/content/aboutlogins/aboutLoginsImportReport.mjs" + ></script> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link + rel="stylesheet" + href="chrome://browser/content/aboutlogins/aboutLogins.css" + /> + <link + rel="stylesheet" + href="chrome://browser/content/aboutlogins/aboutLoginsImportReport.css" + /> + <link + rel="stylesheet" + href="chrome://browser/content/aboutlogins/common.css" + /> + <link rel="icon" href="chrome://branding/content/icon32.png" /> + </head> + <body class="importreport"> + <section id="report-body"> + <div class="summary"> + <h2 data-l10n-id="about-logins-import-report-title"></h2> + <div data-l10n-id="about-logins-import-report-description"></div> + <a + href="https://support.mozilla.org/kb/import-login-data-file" + class="about-logins-import-report-learn-more" + data-l10n-id="about-logins-import-dialog-error-learn-more" + target="_blank" + rel="noreferrer" + ></a> + </div> + <div + class="new-logins" + data-l10n-id="about-logins-import-report-added" + data-l10n-args='{"count": 0}' + > + <div data-l10n-name="count" class="result-count"></div> + <div data-l10n-name="details" class="count-details"></div> + </div> + <div + class="exiting-logins" + data-l10n-id="about-logins-import-report-modified" + data-l10n-args='{"count": 0}' + > + <div data-l10n-name="count" class="result-count"></div> + <div data-l10n-name="details" class="count-details"></div> + </div> + <div + class="duplicate-logins" + data-l10n-id="about-logins-import-report-no-change" + data-l10n-args='{"count": 0}' + > + <div data-l10n-name="count" class="result-count"></div> + <div data-l10n-name="details" class="count-details"></div> + <div + data-l10n-name="not-imported" + class="count-details not-imported not-imported-hidden" + ></div> + </div> + <div + class="errors-logins" + data-l10n-id="about-logins-import-report-error" + data-l10n-args='{"count": 0}' + > + <div data-l10n-name="count" class="result-count"></div> + <div data-l10n-name="details" class="count-details"></div> + <div + data-l10n-name="not-imported" + class="count-details not-imported error not-imported-hidden" + ></div> + </div> + <div class="logins-list"></div> + </section> + + <template id="import-details-row-template"> + <span + class="row-count" + data-l10n-id="about-logins-import-report-row-index" + data-l10n-args='{"number": 0}' + ></span> + <span class="row-details"></span> + </template> + </body> +</html> diff --git a/browser/components/aboutlogins/content/aboutLoginsImportReport.mjs b/browser/components/aboutlogins/content/aboutLoginsImportReport.mjs new file mode 100644 index 0000000000..3800256382 --- /dev/null +++ b/browser/components/aboutlogins/content/aboutLoginsImportReport.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/. */ + +import ImportDetailsRow from "./components/import-details-row.mjs"; + +const detailsLoginsList = document.querySelector(".logins-list"); +const detailedNewCount = document.querySelector(".new-logins"); +const detailedExitingCount = document.querySelector(".exiting-logins"); +const detailedDuplicateCount = document.querySelector(".duplicate-logins"); +const detailedErrorsCount = document.querySelector(".errors-logins"); + +document.dispatchEvent( + new CustomEvent("AboutLoginsImportReportInit", { bubbles: true }) +); + +function importReportDataHandler(event) { + switch (event.detail.messageType) { + case "ImportReportData": + const logins = event.detail.value; + 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]++; + } + } + document.l10n.setAttributes( + detailedNewCount, + "about-logins-import-report-added", + { count: report.added } + ); + document.l10n.setAttributes( + detailedExitingCount, + "about-logins-import-report-modified", + { count: report.modified } + ); + document.l10n.setAttributes( + detailedDuplicateCount, + "about-logins-import-report-no-change", + { count: report.no_change } + ); + document.l10n.setAttributes( + detailedErrorsCount, + "about-logins-import-report-error", + { count: report.error } + ); + if (report.no_change > 0) { + detailedDuplicateCount + .querySelector(".not-imported") + .classList.toggle("not-imported-hidden"); + } + if (report.error > 0) { + detailedErrorsCount + .querySelector(".not-imported") + .classList.toggle("not-imported-hidden"); + } + + detailsLoginsList.innerHTML = ""; + let fragment = document.createDocumentFragment(); + for (let index = 0; index < logins.length; index++) { + const row = new ImportDetailsRow(index + 1, logins[index]); + fragment.appendChild(row); + } + detailsLoginsList.appendChild(fragment); + window.removeEventListener( + "AboutLoginsChromeToContent", + importReportDataHandler + ); + document.dispatchEvent( + new CustomEvent("AboutLoginsImportReportReady", { bubbles: true }) + ); + break; + } +} + +window.addEventListener("AboutLoginsChromeToContent", importReportDataHandler); diff --git a/browser/components/aboutlogins/content/aboutLoginsUtils.mjs b/browser/components/aboutlogins/content/aboutLoginsUtils.mjs new file mode 100644 index 0000000000..4e55487cec --- /dev/null +++ b/browser/components/aboutlogins/content/aboutLoginsUtils.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/. */ + +export const CONCEALED_PASSWORD_TEXT = " ".repeat(8); + +/** + * Dispatches a custom event to the AboutLoginsChild.sys.mjs script which + * will record the event. + * @param {object} event.method The telemety event method + * @param {object} event.object The telemety event object + * @param {object} event.value [optional] The telemety event value + */ +export function recordTelemetryEvent(event) { + document.dispatchEvent( + new CustomEvent("AboutLoginsRecordTelemetryEvent", { + bubbles: true, + detail: event, + }) + ); +} + +export function setKeyboardAccessForNonDialogElements(enableKeyboardAccess) { + const pageElements = document.querySelectorAll( + "login-item, login-list, menu-button, login-filter, fxaccounts-button, [tabindex]" + ); + + let { activeElement: docActiveElement } = document; + if ( + !enableKeyboardAccess && + docActiveElement && + !docActiveElement.closest("confirmation-dialog") + ) { + let elementToBlur = + docActiveElement?.shadowRoot?.activeElement ?? docActiveElement; + elementToBlur.blur(); + } + + pageElements.forEach(el => { + if (!enableKeyboardAccess) { + if (el.tabIndex > -1) { + el.dataset.oldTabIndex = el.tabIndex; + } + el.tabIndex = "-1"; + } else if (el.dataset.oldTabIndex) { + el.tabIndex = el.dataset.oldTabIndex; + delete el.dataset.oldTabIndex; + } else { + el.removeAttribute("tabindex"); + } + }); +} + +export function promptForPrimaryPassword(messageId) { + return new Promise(resolve => { + window.AboutLoginsUtils.promptForPrimaryPassword(resolve, messageId); + }); +} + +/** + * Initializes a dialog based on a template using shadow dom. + * @param {HTMLElement} element The element to attach the shadow dom to. + * @param {string} templateSelector The selector of the template to be used. + * @returns {object} The shadow dom that is attached. + */ +export function initDialog(element, templateSelector) { + let template = document.querySelector(templateSelector); + let shadowRoot = element.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + shadowRoot.appendChild(template.content.cloneNode(true)); + return shadowRoot; +} diff --git a/browser/components/aboutlogins/content/common.css b/browser/components/aboutlogins/content/common.css new file mode 100644 index 0000000000..2771a6b03e --- /dev/null +++ b/browser/components/aboutlogins/content/common.css @@ -0,0 +1,9 @@ +/* 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/. */ + +/* [hidden] isn't applying to elements in Shadow DOM. */ +:host([hidden]), +[hidden] { + display: none !important; +} 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); diff --git a/browser/components/aboutlogins/content/icons/breached-website.svg b/browser/components/aboutlogins/content/icons/breached-website.svg new file mode 100644 index 0000000000..7ab9d5a173 --- /dev/null +++ b/browser/components/aboutlogins/content/icons/breached-website.svg @@ -0,0 +1,6 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="context-fill" fill-opacity="context-fill-opacity"> + <path d="M10 1.755A1.755 1.755 0 0112.279.081l6.55 2.046A1.67 1.67 0 0120 3.72v12.546c0 .73-.475 1.375-1.171 1.592l-6.551 2.047A1.754 1.754 0 0110 18.232zM7 2a1 1 0 010 2H2.491A.491.491 0 002 4.491V15.51c0 .271.22.491.491.491h4.51a1 1 0 010 2H2.49A2.494 2.494 0 010 15.51V4.49a2.494 2.494 0 012.491-2.49zm8 11.993c-.552 0-1 .45-1 1.004S14.448 16 15 16s1-.449 1-1.003c0-.555-.448-1.004-1-1.004zM15 4a1 1 0 00-1 1v6a1 1 0 002 0V5a1 1 0 00-1-1z"/> +</svg> diff --git a/browser/components/aboutlogins/content/icons/intro-illustration.svg b/browser/components/aboutlogins/content/icons/intro-illustration.svg new file mode 100644 index 0000000000..accbd3b979 --- /dev/null +++ b/browser/components/aboutlogins/content/icons/intro-illustration.svg @@ -0,0 +1,62 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="300" height="209" viewBox="0 0 300 209"> + <defs> + <linearGradient id="lockwise-a" x1="70.127%" x2="70.127%" y1="96.836%" y2="-15.705%"> + <stop offset="0%" stop-color="#CDCDD4" stop-opacity="0"/> + <stop offset="58%" stop-color="#CDCDD4" stop-opacity=".02"/> + <stop offset="77%" stop-color="#CDCDD4" stop-opacity=".08"/> + <stop offset="96%" stop-color="#CDCDD4" stop-opacity=".18"/> + <stop offset="100%" stop-color="#CDCDD4" stop-opacity=".2"/> + </linearGradient> + <radialGradient id="lockwise-b" cx="126.893%" cy="255.606%" r="325.015%" fx="126.893%" fy="255.606%" gradientTransform="matrix(.5786 0 0 1 .535 0)"> + <stop offset="26%" stop-color="#CDCDD4" stop-opacity="0"/> + <stop offset="40%" stop-color="#CDCDD4" stop-opacity=".02"/> + <stop offset="55%" stop-color="#CDCDD4" stop-opacity=".08"/> + <stop offset="69%" stop-color="#CDCDD4" stop-opacity=".18"/> + <stop offset="72%" stop-color="#CDCDD4" stop-opacity=".2"/> + </radialGradient> + <radialGradient id="lockwise-c" cx="-55.742%" cy="260.74%" r="317.936%" fx="-55.742%" fy="260.74%" gradientTransform="matrix(.57221 0 0 1 -.238 0)"> + <stop offset="27%" stop-color="#CDCDD4" stop-opacity="0"/> + <stop offset="46%" stop-color="#CDCDD4" stop-opacity=".02"/> + <stop offset="66%" stop-color="#CDCDD4" stop-opacity=".08"/> + <stop offset="86%" stop-color="#CDCDD4" stop-opacity=".18"/> + <stop offset="90%" stop-color="#CDCDD4" stop-opacity=".2"/> + </radialGradient> + <linearGradient id="lockwise-d" x1="7.536%" x2="68.583%" y1="65.726%" y2="43.12%"> + <stop offset="0%" stop-color="#B833E1"/> + <stop offset="91%" stop-color="#FF4F5E"/> + </linearGradient> + <linearGradient id="lockwise-e" x1="-68.282%" x2="131.387%" y1="139.888%" y2="-11.036%"> + <stop offset="28%" stop-color="#7542E5"/> + <stop offset="42%" stop-color="#824DEB"/> + <stop offset="79%" stop-color="#A067FA"/> + <stop offset="100%" stop-color="#AB71FF"/> + </linearGradient> + <linearGradient id="lockwise-f" x1="-43.795%" x2="124.252%" y1="84.765%" y2="22.502%"> + <stop offset="40%" stop-color="#0090ED"/> + <stop offset="56%" stop-color="#2A88F1"/> + <stop offset="92%" stop-color="#9275FC"/> + <stop offset="100%" stop-color="#AB71FF"/> + </linearGradient> + <linearGradient id="lockwise-g" x1="-17.372%" x2="109.306%" y1="151.599%" y2="-39.422%"> + <stop offset="43%" stop-color="#00B3F4"/> + <stop offset="61%" stop-color="#00BBF6"/> + <stop offset="89%" stop-color="#00D2FC"/> + <stop offset="100%" stop-color="#0DF"/> + </linearGradient> + </defs> + <circle cx="154.306" cy="109.889" r="107.444" fill="url(#lockwise-a)"/> + <path fill="url(#lockwise-b)" d="M85.5416667,80.8472222 C85.5416667,89.0277438 78.9304323,95.6701066 70.75,95.7083333 L11.2916667,95.7083333 C5.17541993,95.4943857 0.327726784,90.4741542 0.327726784,84.3541667 C0.327726784,78.2341791 5.17541993,73.2139476 11.2916667,73 C12.2285282,72.99817 13.1618599,73.1148364 14.0694444,73.3472222 C13.8641906,72.4823819 13.7570332,71.5971688 13.75,70.7083333 C13.7942051,66.3953789 16.2033401,62.4551064 20.0221021,60.4499661 C23.840864,58.4448259 28.4522835,58.6987739 32.0277778,61.1111111 C34.528326,51.5379538 43.7475818,45.3095965 53.5622385,46.562831 C63.3768953,47.8160655 70.7352578,56.1612205 70.75,66.0555556 C78.9065601,66.0860192 85.511203,72.6906621 85.5416667,80.8472222 L85.5416667,80.8472222 Z"/> + <path fill="url(#lockwise-c)" d="M281.944444,40.1527778 C280.56777,40.1478647 279.195927,40.3158455 277.861111,40.6527778 C279.459938,33.5659175 276.55372,26.223224 270.537855,22.1502383 C264.521991,18.0772526 256.625128,18.1058193 250.638889,22.2222222 C246.813608,7.86060669 232.951542,-1.45416361 218.209226,0.430746832 C203.46691,2.31565727 192.394381,14.8184936 192.305556,29.6805556 C184.346472,29.6805554 176.991976,33.9266759 173.012434,40.8194443 C169.032892,47.7122128 169.032892,56.2044538 173.012434,63.0972223 C176.991976,69.9899908 184.346472,74.2361112 192.305556,74.2361111 L281.944444,74.2361111 C288.136326,74.4130762 293.935121,71.2108318 297.083278,65.8760561 C300.231434,60.5412805 300.231434,53.9170528 297.083278,48.5822772 C293.935121,43.2475016 288.136326,40.0452571 281.944444,40.2222222 L281.944444,40.1527778 Z"/> + <path fill="url(#lockwise-d)" d="M104.458333,73.4861111 L45.9444444,73.4861111 C40.4279303,73.5013713 35.9597046,77.9695969 35.9444444,83.4861111 L35.9444444,92.5694444 C35.9597046,98.0859586 40.4279303,102.554184 45.9444444,102.569444 L104.458333,102.569444 C109.974848,102.554184 114.443073,98.0859586 114.458333,92.5694444 L114.458333,83.4861111 C114.443073,77.9695969 109.974848,73.5013713 104.458333,73.4861111 L104.458333,73.4861111 Z M62.9166667,89.3333333 C63.0662911,89.4003249 63.1817989,89.5258768 63.2361111,89.6805556 C63.3039323,89.8258065 63.3039323,89.9936379 63.2361111,90.1388889 L62,92.2361111 C61.9188249,92.3739635 61.7821642,92.4701321 61.625,92.5 C61.4654217,92.5408987 61.2958989,92.5049393 61.1666667,92.4027778 L58.5138889,90.3472222 L58.9305556,93.75 C58.955548,93.9074535 58.9039566,94.0673867 58.7916667,94.1805556 C58.6993523,94.3464174 58.5231234,94.4478826 58.3333333,94.4445291 L55.9027778,94.4445291 C55.737875,94.4450288 55.5808164,94.3740991 55.4722222,94.25 C55.3686393,94.1321904 55.3228314,93.9744078 55.3472222,93.8194444 L55.7638889,90.3888889 L52.9861111,92.4722222 C52.8568789,92.5743837 52.6873561,92.6103431 52.5277778,92.5694444 C52.3695719,92.5330645 52.2336813,92.4324047 52.1527778,92.2916667 L50.9861111,90.2083333 C50.9163117,90.0635278 50.9163117,89.8948055 50.9861111,89.75 C51.0357662,89.5970999 51.1463206,89.4714698 51.2916667,89.4027778 L54.4583333,88.0833333 L51.2916667,86.6944444 C51.1420422,86.6274529 51.0265345,86.501901 50.9722222,86.3472222 C50.9096885,86.2008434 50.9096885,86.0352677 50.9722222,85.8888889 L52.1805556,83.8333333 C52.2650018,83.6957958 52.399379,83.5962572 52.5555556,83.5555556 C52.7171068,83.5281479 52.8828231,83.5683215 53.0138889,83.6666667 L55.7916667,85.7361111 L55.375,82.3472222 C55.3537717,82.1881879 55.3989845,82.0274311 55.5,81.9027778 C55.6139657,81.7879807 55.7687969,81.7230515 55.9305556,81.7221814 L58.3333333,81.7221814 C58.4916013,81.7203046 58.6431729,81.7859857 58.75,81.9027778 C58.8608604,82.022453 58.9118807,82.1857182 58.8888889,82.3472222 L58.4722222,85.7361111 L61.25,83.6944444 C61.3809719,83.5960482 61.5486937,83.5604708 61.7083333,83.5972222 C61.870593,83.6244458 62.0100075,83.7277158 62.0833333,83.875 L63.2638889,85.9583333 C63.3336883,86.1031388 63.3336883,86.2718612 63.2638889,86.4166667 C63.2095767,86.5713454 63.0940689,86.6968974 62.9444444,86.7638889 L59.7222222,88.0277778 L62.9166667,89.3333333 Z M80.9722222,89.3333333 C81.1218467,89.4003249 81.2373544,89.5258768 81.2916667,89.6805556 C81.3542004,89.8269344 81.3542004,89.9925101 81.2916667,90.1388889 L80.0555556,92.2361111 C79.9713468,92.3772725 79.8289514,92.473898 79.6666667,92.5 C79.5114611,92.5385087 79.3471742,92.5025709 79.2222222,92.4027778 L76.5694444,90.3472222 L76.9861111,93.75 C77.0262601,93.9261394 76.9804471,94.1108808 76.8626515,94.2478525 C76.7448558,94.3848241 76.5690536,94.4577748 76.3888889,94.4444444 L74.0138889,94.4444444 C73.8489861,94.4450288 73.6919275,94.3740991 73.5833333,94.25 C73.4764505,94.133611 73.4257216,93.9763514 73.4444444,93.8194444 L73.875,90.2777778 L71.0972222,92.3611111 C70.96799,92.4632726 70.7984672,92.499232 70.6388889,92.4583333 C70.480683,92.4219533 70.3447924,92.3212936 70.2638889,92.1805556 L69.0833333,90.0972222 C69.0279356,89.9494663 69.0279356,89.7866448 69.0833333,89.6388889 C69.1376456,89.4842101 69.2531533,89.3586582 69.4027778,89.2916667 L72.5694444,87.9722222 L69.4027778,86.5833333 C69.2531533,86.5163418 69.1376456,86.3907899 69.0833333,86.2361111 C69.0207996,86.0897323 69.0207996,85.9241566 69.0833333,85.7777778 L70.2916667,83.7222222 C70.3761129,83.5846847 70.5104901,83.4851461 70.6666667,83.4444444 C70.8239003,83.4158311 70.9858407,83.4563162 71.1111111,83.5555556 L73.8888889,85.625 L73.4583333,82.2361111 C73.4409303,82.0751066 73.4912407,81.9141134 73.5972222,81.7916667 C73.7111879,81.6768696 73.8660191,81.6119404 74.0277778,81.6111111 L76.3888889,81.6111111 C76.5464642,81.6125465 76.6967556,81.6776728 76.8055556,81.7916667 C76.9164159,81.9113419 76.9674363,82.074607 76.9444444,82.2361111 L76.5277778,85.625 L79.3055556,83.5833333 C79.437033,83.4869248 79.6035278,83.4514952 79.7628672,83.4860188 C79.9222066,83.5205423 80.059106,83.6217073 80.1388889,83.7638889 L81.3194444,85.8472222 C81.3748422,85.9949781 81.3748422,86.1577996 81.3194444,86.3055556 C81.2651322,86.4602343 81.1496244,86.5857862 81,86.6527778 L77.8333333,87.9583333 L80.9722222,89.3333333 Z M99.0277778,89.3333333 C99.1738255,89.405199 99.2877552,89.5290357 99.3472222,89.6805556 C99.4097559,89.8269344 99.4097559,89.9925101 99.3472222,90.1388889 L98.0972222,92.2361111 C98.0223028,92.3795857 97.8825665,92.4779186 97.7222222,92.5 C97.5670167,92.5385087 97.4027298,92.5025709 97.2777778,92.4027778 L94.6111111,90.3472222 L95.0416667,93.75 C95.0603895,93.906907 95.0096606,94.0641666 94.9027778,94.1805556 C94.7973939,94.3010555 94.6461569,94.3716327 94.4861111,94.375 L92.1111111,94.375 C91.9510653,94.3716327 91.7998284,94.3010555 91.6944444,94.1805556 C91.5875616,94.0641666 91.5368327,93.906907 91.5555556,93.75 L91.9861111,90.2777778 L89.2083333,92.3611111 C89.0781988,92.4613349 88.9096163,92.4970948 88.75,92.4583333 C88.5917942,92.4219533 88.4559035,92.3212936 88.375,92.1805556 L87.1944444,90.0972222 C87.1319107,89.9508434 87.1319107,89.7852677 87.1944444,89.6388889 C87.2487567,89.4842101 87.3642644,89.3586582 87.5138889,89.2916667 L90.6666667,87.9722222 L87.5,86.5833333 C87.3574937,86.5109248 87.2481469,86.386667 87.1944444,86.2361111 C87.1266233,86.0908601 87.1266233,85.9230288 87.1944444,85.7777778 L88.4027778,83.7222222 C88.4849309,83.5826024 88.6202775,83.4823456 88.7777778,83.4444444 C88.9349481,83.417627 89.0961625,83.4579306 89.2222222,83.5555556 L92,85.625 L91.5694444,82.2361111 C91.5520414,82.0751066 91.6023518,81.9141134 91.7083333,81.7916667 C91.8180306,81.6789665 91.9677551,81.6140859 92.125,81.6111111 L94.5555556,81.6111111 C94.7128004,81.6140859 94.862525,81.6789665 94.9722222,81.7916667 C95.0782038,81.9141134 95.1285142,82.0751066 95.1111111,82.2361111 L94.6805556,85.625 L97.4583333,83.5833333 C97.5898108,83.4869248 97.7563056,83.4514952 97.915645,83.4860188 C98.0749844,83.5205423 98.2118837,83.6217073 98.2916667,83.7638889 L99.4583333,85.8472222 C99.5281327,85.9920277 99.5281327,86.1607501 99.4583333,86.3055556 C99.4086783,86.4584557 99.2981238,86.5840857 99.1527778,86.6527778 L95.9861111,87.9583333 L99.0277778,89.3333333 Z"/> + <path fill="#AB71FF" d="M214.25,43.875 L93.8888889,43.875 C88.9029849,43.875 84.8611111,47.9168738 84.8611111,52.9027778 L84.8611111,139.472222 C84.8611111,144.458126 88.9029849,148.5 93.8888889,148.5 L217.263889,148.5 C220.585268,148.5 223.277778,145.80749 223.277778,142.486111 L223.277778,52.9027778 C223.277778,47.9168738 219.235904,43.875 214.25,43.875 Z"/> + <path fill="url(#lockwise-e)" d="M213.777778,43.875 L93.4166667,43.875 C88.4307627,43.875 84.3888889,47.9168738 84.3888889,52.9027778 L84.3888889,139.472222 C84.3888889,141.866538 85.340027,144.162788 87.0330638,145.855825 C88.7261005,147.548862 91.0223511,148.5 93.4166667,148.5 L216.791667,148.5 C220.113046,148.5 222.805556,145.80749 222.805556,142.486111 L222.805556,52.9027778 C222.805556,50.5084622 221.854417,48.2122116 220.161381,46.5191749 C218.468344,44.8261381 216.172093,43.875 213.777778,43.875 Z M216.791667,55.9166667 L90.4027778,55.9166667 L90.4027778,52.9027778 C90.4104235,51.2436794 91.7575507,49.9027778 93.4166667,49.9027778 L213.777778,49.9027778 C215.436894,49.9027778 216.784021,51.2436794 216.791667,52.9027778 L216.791667,55.9166667 Z"/> + <rect width="69.153" height="23.403" x="64.889" y="143.014" fill="#F9F9FA" rx="5.4"/> + <path fill="url(#lockwise-f)" d="M129.166667,140.541667 L70.7083333,140.541667 C65.1918192,140.556927 60.7235935,145.025152 60.7083333,150.541667 L60.7083333,159.611111 C60.7235935,165.127625 65.1918192,169.595851 70.7083333,169.611111 L129.166667,169.611111 C134.683181,169.595851 139.151407,165.127625 139.166667,159.611111 L139.166667,150.541667 C139.151407,145.025152 134.683181,140.556927 129.166667,140.541667 L129.166667,140.541667 Z M87.6805556,156.388889 C87.8322323,156.452998 87.948731,156.579627 88,156.736111 C88.0625337,156.88249 88.0625337,157.048066 88,157.194444 L86.75,159.291667 C86.6750806,159.435141 86.5353443,159.533474 86.375,159.555556 C86.2178297,159.582373 86.0566153,159.542069 85.9305556,159.444444 L83.2638889,157.402778 L83.6944444,160.791667 C83.7136028,160.952838 83.6630753,161.114526 83.5555556,161.236111 C83.4501716,161.356611 83.2989347,161.427188 83.1388889,161.430556 L80.6527778,161.430556 C80.492732,161.427188 80.341495,161.356611 80.2361111,161.236111 C80.1285914,161.114526 80.0780639,160.952838 80.0972222,160.791667 L80.5277778,157.375 L77.75,159.444444 C77.6213033,159.547708 77.453364,159.58842 77.2916667,159.555556 C77.1354901,159.514854 77.0011129,159.415315 76.9166667,159.277778 L75.7361111,157.180556 C75.6735774,157.034177 75.6735774,156.868601 75.7361111,156.722222 C75.787538,156.569022 75.9046818,156.446785 76.0555556,156.388889 L79.2083333,155.069444 L76.0416667,153.680556 C75.8971578,153.617797 75.7860909,153.496633 75.7361111,153.347222 C75.6677688,153.197233 75.6677688,153.024989 75.7361111,152.875 L76.9444444,150.819444 C77.022707,150.678939 77.1607659,150.581786 77.3194444,150.555556 C77.4746659,150.524804 77.6356838,150.560026 77.7638889,150.652778 L80.5555556,152.777778 L80.125,149.402778 C80.1058416,149.241606 80.1563692,149.079918 80.2638889,148.958333 C80.3692728,148.837833 80.5205097,148.767256 80.6805556,148.763889 L83.1111111,148.763889 C83.2711569,148.767256 83.4223939,148.837833 83.5277778,148.958333 C83.6352975,149.079918 83.685825,149.241606 83.6666667,149.402778 L83.2083333,152.777778 L85.9861111,150.736111 C86.1164398,150.636214 86.2828376,150.595875 86.4444444,150.625 C86.600621,150.665702 86.7349982,150.76524 86.8194444,150.902778 L87.9861111,153 C88.0559105,153.144805 88.0559105,153.313528 87.9861111,153.458333 C87.939427,153.609659 87.8272587,153.732025 87.6805556,153.791667 L84.5138889,155.180556 L87.6805556,156.388889 Z M105.736111,156.388889 C105.893939,156.443955 106.01381,156.574249 106.055556,156.736111 C106.125355,156.880917 106.125355,157.049639 106.055556,157.194444 L104.805556,159.291667 C104.72438,159.429519 104.58772,159.525688 104.430556,159.555556 C104.273385,159.582373 104.112171,159.542069 103.986111,159.444444 L101.388889,157.402778 L101.819444,160.791667 C101.830706,160.952012 101.781113,161.11071 101.680556,161.236111 C101.571961,161.36021 101.414903,161.43114 101.25,161.430559 L98.7638889,161.430559 C98.60245,161.427825 98.4497595,161.35669 98.3438116,161.23485 C98.2378637,161.11301 98.1886198,160.951921 98.2083333,160.791667 L98.625,157.375 L95.8472222,159.444444 C95.7191348,159.546637 95.552467,159.587072 95.3917905,159.554937 C95.2311139,159.522801 95.0928187,159.421373 95.0138889,159.277778 L93.8333333,157.180556 C93.763534,157.03575 93.763534,156.867028 93.8333333,156.722222 C93.8847603,156.569022 94.001904,156.446785 94.1527778,156.388889 L97.3055556,155.069444 L94.1388889,153.680556 C93.9921857,153.620914 93.8800175,153.498548 93.8333333,153.347222 C93.764991,153.197233 93.764991,153.024989 93.8333333,152.875 L95.0416667,150.819444 C95.1258754,150.678283 95.2682709,150.581658 95.4305556,150.555556 C95.585777,150.524804 95.7467949,150.560026 95.875,150.652778 L98.6111111,152.777778 L98.1944444,149.402778 C98.1747309,149.242524 98.2239748,149.081435 98.3299227,148.959595 C98.4358706,148.837755 98.5885611,148.766619 98.75,148.763885 L101.166667,148.763885 C101.331569,148.763305 101.488628,148.834234 101.597222,148.958333 C101.69778,149.083735 101.747373,149.242433 101.736111,149.402778 L101.388889,152.777778 L104.166667,150.736111 C104.292726,150.638486 104.453941,150.598183 104.611111,150.625 C104.770826,150.658057 104.907946,150.759628 104.986111,150.902778 L106.166667,153 C106.236466,153.144805 106.236466,153.313528 106.166667,153.458333 C106.11524,153.611533 105.998096,153.73377 105.847222,153.791667 L102.694444,155.180556 L105.736111,156.388889 Z M123.791667,156.388889 C123.942901,156.450423 124.05941,156.57537 124.110239,156.73053 C124.161067,156.885691 124.141068,157.055355 124.055556,157.194444 L122.819444,159.291667 C122.738269,159.429519 122.601609,159.525688 122.444444,159.555556 C122.282893,159.582963 122.117177,159.54279 121.986111,159.444444 L119.444444,157.402778 L119.861111,160.791667 C119.885571,160.952818 119.838027,161.116581 119.731072,161.239579 C119.624117,161.362578 119.468542,161.432401 119.305556,161.430591 L116.875,161.430591 C116.713561,161.427825 116.560871,161.35669 116.454923,161.23485 C116.348975,161.11301 116.299731,160.951921 116.319444,160.791667 L116.736111,157.375 L113.958333,159.444444 C113.83482,159.547201 113.671227,159.588099 113.513889,159.555556 C113.354174,159.522498 113.217054,159.420928 113.138889,159.277778 L111.958333,157.180556 C111.888534,157.03575 111.888534,156.867028 111.958333,156.722222 C112.008313,156.572812 112.11938,156.451648 112.263889,156.388889 L115.430556,155.069444 L112.263889,153.680556 C112.113412,153.626463 111.999162,153.501826 111.958333,153.347222 C111.889991,153.197233 111.889991,153.024989 111.958333,152.875 L113.166667,150.819444 C113.247842,150.681592 113.384502,150.585423 113.541667,150.555556 C113.701318,150.522488 113.867522,150.557743 114,150.652778 L116.777778,152.722222 L116.361111,149.347222 C116.336651,149.186071 116.384195,149.022308 116.49115,148.89931 C116.598106,148.776311 116.75368,148.706488 116.916667,148.708298 L119.333333,148.708298 C119.49632,148.706488 119.651894,148.776311 119.75885,148.89931 C119.865805,149.022308 119.913349,149.186071 119.888889,149.347222 L119.444444,152.777778 L122.222222,150.736111 C122.353979,150.63924 122.519065,150.599219 122.680556,150.625 C122.838056,150.662901 122.973402,150.763158 123.055556,150.902778 L124.236111,153 C124.30591,153.144805 124.30591,153.313528 124.236111,153.458333 C124.184684,153.611533 124.06754,153.73377 123.916667,153.791667 L120.763889,155.180556 L123.791667,156.388889 Z"/> + <path fill="#FFF" d="M51.64,20.7666667 C46.0864858,14.4998219 40.1601781,8.57351416 33.8933333,3.02 C30.082543,-0.123985623 24.577457,-0.123985623 20.7666667,3.02 C14.4996228,8.57566521 8.573311,14.5042033 3.02,20.7733333 C-0.123985623,24.5841237 -0.123985623,30.0892096 3.02,33.9 C8.57566521,40.1670439 14.5042033,46.0933557 20.7733333,51.6466667 C22.6030951,53.1992337 24.9339084,54.0353892 27.3333333,54 C29.7438562,54.0326548 32.0843096,53.1893335 33.92,51.6266667 C37.1866667,48.68 40.1666667,45.8333333 43.04,42.9133333 C44.0581483,41.7108072 43.9586152,39.9221388 42.8133333,38.84 L34,30.62 C36.274261,28.5981416 37.5179816,25.6602189 37.3866667,22.62 C37.1392566,17.3965683 33.0052958,13.1937081 27.7866667,12.86 C24.9872333,12.6967419 22.2453042,13.7008286 20.2133333,15.6333333 C18.1443724,17.5899155 16.9888257,20.3231815 17.026824,23.1705258 C17.0648224,26.01787 18.2928965,28.7193262 20.4133333,30.62 L17.2266667,33.5066667 C16.2045489,34.4642307 16.1359367,36.0633963 17.0722806,37.1049882 C18.0086245,38.1465801 19.6060411,38.2480612 20.6666667,37.3333333 L24.1933333,34.1333333 L24.2866667,34.0466667 C25.2417489,33.0901939 25.7520065,31.7770665 25.6933333,30.4266667 C25.6438002,29.0668006 25.03025,27.7889736 24,26.9 C22.3771375,25.5322057 21.7818989,23.2961006 22.5099768,21.3024992 C23.2380547,19.3088978 25.1342756,17.982692 27.2566667,17.982692 C29.3790578,17.982692 31.2752786,19.3088978 32.0033565,21.3024992 C32.7314345,23.2961006 32.1361959,25.5322057 30.5133333,26.9 C29.4615006,27.7865292 28.8282135,29.0724403 28.7666667,30.4466667 C28.7144664,31.7900781 29.2241686,33.0945293 30.1733333,34.0466667 L30.2333333,34.1066667 L37.6333333,41.04 C35.3666667,43.2866667 33.0066667,45.5133333 30.4933333,47.7933333 C28.6241984,49.1933351 26.0558016,49.1933351 24.1866667,47.7933333 C18.0681361,42.3666642 12.2800024,36.5785306 6.85333333,30.46 C5.45774974,28.5893962 5.45774974,26.0239371 6.85333333,24.1533333 C12.2766966,18.03169 18.0650233,12.2433633 24.1866667,6.82 C26.0544402,5.42332443 28.6188932,5.42332443 30.4866667,6.82 C36.60831,12.2433633 42.3966367,18.03169 47.82,24.1533333 C49.2166756,26.0211068 49.2166756,28.5855598 47.82,30.4533333 C46.9333333,31.4533333 46.0466667,32.4533333 45.1533333,33.3666667 C44.1923579,34.4104848 44.2595152,36.0356912 45.3033333,36.9966667 C46.3471515,37.9576421 47.9723579,37.8904848 48.9333333,36.8466667 C49.8133333,35.8933333 50.72,34.8933333 51.6,33.8666667 C54.7409838,30.0702181 54.7577411,24.5822259 51.64,20.7666667 Z" transform="translate(126.347 68.5)"/> + <rect width="63.083" height="95.125" x="186.611" y="79.222" fill="url(#lockwise-g)" rx="5.4"/> + <rect width="11.75" height="5.875" x="212.181" y="158.847" fill="#0DF"/> + <path fill="#FFF" d="M33.3508333,13.4118056 C29.7641888,9.3644683 25.9367817,5.53706123 21.8894444,1.95041667 C19.428309,-0.0800740485 15.872941,-0.0800740485 13.4118056,1.95041667 C9.36433971,5.53845045 5.53693002,9.36729793 1.95041667,13.4161111 C-0.0800740485,15.8772466 -0.0800740485,19.4326145 1.95041667,21.89375 C5.53845045,25.9412158 9.36729793,29.7686255 13.4161111,33.3551389 C14.5978323,34.3578384 16.1031492,34.8978556 17.6527778,34.875 C19.2095738,34.8960895 20.7211166,34.3514445 21.9066667,33.3422222 C24.0163889,31.4391667 25.9409722,29.6006944 27.7966667,27.7148611 C28.4542208,26.9382297 28.389939,25.783048 27.6502778,25.0841667 L21.9583333,19.7754167 C23.4271269,18.4696331 24.2303631,16.5722247 24.1455556,14.60875 C23.9857699,11.2352837 21.3159202,8.52093647 17.9455556,8.30541667 C16.1375882,8.19997912 14.366759,8.84845178 13.0544444,10.0965278 C11.7182405,11.3601537 10.9719499,13.1253881 10.9964905,14.9642979 C11.0210311,16.8032077 11.8141623,18.5478982 13.1836111,19.7754167 L11.1255556,21.6397222 C10.4654378,22.258149 10.4211258,23.2909435 11.0258479,23.9636382 C11.63057,24.636333 12.6622349,24.7018729 13.3472222,24.1111111 L15.6248611,22.0444444 L15.6851389,21.9884722 C16.3019629,21.3707502 16.6315042,20.5226888 16.5936111,19.6505556 C16.561621,18.7723087 16.1653698,17.9470455 15.5,17.3729167 C14.4519013,16.4895495 14.0674763,15.0453983 14.5376933,13.7578641 C15.0079103,12.4703298 16.232553,11.6138219 17.6032639,11.6138219 C18.9739748,11.6138219 20.1986174,12.4703298 20.6688344,13.7578641 C21.1390514,15.0453983 20.7546265,16.4895495 19.7065278,17.3729167 C19.0272191,17.9454668 18.6182212,18.775951 18.5784722,19.6634722 C18.5447595,20.5310921 18.8739422,21.3735502 19.4869444,21.9884722 L19.5256944,22.0272222 L24.3048611,26.505 C22.8409722,27.9559722 21.3168056,29.3940278 19.6936111,30.8665278 C18.4864615,31.7706956 16.8277052,31.7706956 15.6205556,30.8665278 C11.6690046,27.361804 7.93083491,23.6236343 4.42611111,19.6720833 C3.52479671,18.463985 3.52479671,16.8071261 4.42611111,15.5990278 C7.9286999,11.6454665 11.6669942,7.90717212 15.6205556,4.40458333 C16.8268259,3.5025637 18.4830352,3.5025637 19.6893056,4.40458333 C23.6428669,7.90717212 27.3811612,11.6454665 30.88375,15.5990278 C31.7857696,16.8052982 31.7857696,18.4615074 30.88375,19.6677778 C30.3111111,20.3136111 29.7384722,20.9594444 29.1615278,21.5493056 C28.5408978,22.2234381 28.5842702,23.2730506 29.2584028,23.8936806 C29.9325354,24.5143105 30.9821478,24.4709381 31.6027778,23.7968056 C32.1711111,23.1811111 32.7566667,22.5352778 33.325,21.8722222 C35.3535521,19.4203492 35.3643744,15.8760209 33.3508333,13.4118056 Z" transform="translate(199.472 103.222)"/> +</svg> diff --git a/browser/components/aboutlogins/content/icons/vulnerable-password.svg b/browser/components/aboutlogins/content/icons/vulnerable-password.svg new file mode 100644 index 0000000000..9ffac637c9 --- /dev/null +++ b/browser/components/aboutlogins/content/icons/vulnerable-password.svg @@ -0,0 +1,6 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity"> + <path d="M12 7a4 4 0 0 0-3.86 3H1a1 1 0 0 0 0 2v1a1 1 0 0 0 2 0v-1h1v1a1 1 0 0 0 2 0v-1h2.14A4 4 0 1 0 12 7zm0 6a2 2 0 1 1 2-2 2 2 0 0 1-2 2zM8.4 4.92a1 1 0 0 0 .92.61A1 1 0 0 0 9.7 5.46a1 1 0 0 0 .55-1.31L9.1 1.38a1 1 0 0 0-1.85.76zM5.84 7.53a1 1 0 0 0 .61.21 1 1 0 0 0 .79-.39A1 1 0 0 0 7.06 6L4.68 4.12a1 1 0 1 0-1.22 1.59zM12.78 5.05h.14a1 1 0 0 0 1-.87l.4-3a1 1 0 1 0-2-.26l-.39 3a1 1 0 0 0 .85 1.13z"/> +</svg> |