diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /browser/components/aboutlogins | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/aboutlogins')
85 files changed, 15318 insertions, 0 deletions
diff --git a/browser/components/aboutlogins/AboutLoginsChild.sys.mjs b/browser/components/aboutlogins/AboutLoginsChild.sys.mjs new file mode 100644 index 0000000000..40d553936a --- /dev/null +++ b/browser/components/aboutlogins/AboutLoginsChild.sys.mjs @@ -0,0 +1,315 @@ +/* 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 { LoginHelper } from "resource://gre/modules/LoginHelper.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "ClipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper" +); + +const TELEMETRY_EVENT_CATEGORY = "pwmgr"; +const TELEMETRY_MIN_MS_BETWEEN_OPEN_MANAGEMENT = 5000; + +let gLastOpenManagementBrowserId = null; +let gLastOpenManagementEventTime = Number.NEGATIVE_INFINITY; +let gPrimaryPasswordPromise; + +function recordTelemetryEvent(event) { + try { + let { method, object, extra = {}, value = null } = event; + Services.telemetry.recordEvent( + TELEMETRY_EVENT_CATEGORY, + method, + object, + value, + extra + ); + } catch (ex) { + console.error( + "AboutLoginsChild: error recording telemetry event: " + ex.message + ); + } +} + +export class AboutLoginsChild extends JSWindowActorChild { + handleEvent(event) { + switch (event.type) { + case "AboutLoginsInit": { + this.#aboutLoginsInit(); + break; + } + case "AboutLoginsImportReportInit": { + this.#aboutLoginsImportReportInit(); + break; + } + case "AboutLoginsCopyLoginDetail": { + this.#aboutLoginsCopyLoginDetail(event.detail); + break; + } + case "AboutLoginsCreateLogin": { + this.#aboutLoginsCreateLogin(event.detail); + break; + } + case "AboutLoginsDeleteLogin": { + this.#aboutLoginsDeleteLogin(event.detail); + break; + } + case "AboutLoginsExportPasswords": { + this.#aboutLoginsExportPasswords(); + break; + } + case "AboutLoginsGetHelp": { + this.#aboutLoginsGetHelp(); + break; + } + case "AboutLoginsImportFromBrowser": { + this.#aboutLoginsImportFromBrowser(); + break; + } + case "AboutLoginsImportFromFile": { + this.#aboutLoginsImportFromFile(); + break; + } + case "AboutLoginsOpenPreferences": { + this.#aboutLoginsOpenPreferences(); + break; + } + case "AboutLoginsRecordTelemetryEvent": { + this.#aboutLoginsRecordTelemetryEvent(event); + break; + } + case "AboutLoginsRemoveAllLogins": { + this.#aboutLoginsRemoveAllLogins(); + break; + } + case "AboutLoginsSortChanged": { + this.#aboutLoginsSortChanged(event.detail); + break; + } + case "AboutLoginsSyncEnable": { + this.#aboutLoginsSyncEnable(); + break; + } + case "AboutLoginsSyncOptions": { + this.#aboutLoginsSyncOptions(); + break; + } + case "AboutLoginsUpdateLogin": { + this.#aboutLoginsUpdateLogin(event.detail); + break; + } + } + } + + #aboutLoginsInit() { + this.sendAsyncMessage("AboutLogins:Subscribe"); + + let win = this.browsingContext.window; + let waivedContent = Cu.waiveXrays(win); + let that = this; + let AboutLoginsUtils = { + doLoginsMatch(loginA, loginB) { + return LoginHelper.doLoginsMatch(loginA, loginB, {}); + }, + getLoginOrigin(uriString) { + return LoginHelper.getLoginOrigin(uriString); + }, + setFocus(element) { + Services.focus.setFocus(element, Services.focus.FLAG_BYKEY); + }, + /** + * Shows the Primary Password prompt if enabled, or the + * OS auth dialog otherwise. + * @param resolve Callback that is called with result of authentication. + * @param messageId The string ID that corresponds to a string stored in aboutLogins.ftl. + * This string will be displayed only when the OS auth dialog is used. + */ + async promptForPrimaryPassword(resolve, messageId) { + gPrimaryPasswordPromise = { + resolve, + }; + + that.sendAsyncMessage("AboutLogins:PrimaryPasswordRequest", messageId); + + return gPrimaryPasswordPromise; + }, + fileImportEnabled: Services.prefs.getBoolPref( + "signon.management.page.fileImport.enabled" + ), + // Default to enabled just in case a search is attempted before we get a response. + primaryPasswordEnabled: true, + passwordRevealVisible: true, + }; + waivedContent.AboutLoginsUtils = Cu.cloneInto( + AboutLoginsUtils, + waivedContent, + { + cloneFunctions: true, + } + ); + } + + #aboutLoginsImportReportInit() { + this.sendAsyncMessage("AboutLogins:ImportReportInit"); + } + + #aboutLoginsCopyLoginDetail(detail) { + lazy.ClipboardHelper.copyString(detail, lazy.ClipboardHelper.Sensitive); + } + + #aboutLoginsCreateLogin(login) { + this.sendAsyncMessage("AboutLogins:CreateLogin", { + login, + }); + } + + #aboutLoginsDeleteLogin(login) { + this.sendAsyncMessage("AboutLogins:DeleteLogin", { + login, + }); + } + + #aboutLoginsExportPasswords() { + this.sendAsyncMessage("AboutLogins:ExportPasswords"); + } + + #aboutLoginsGetHelp() { + this.sendAsyncMessage("AboutLogins:GetHelp"); + } + + #aboutLoginsImportFromBrowser() { + this.sendAsyncMessage("AboutLogins:ImportFromBrowser"); + recordTelemetryEvent({ + object: "import_from_browser", + method: "mgmt_menu_item_used", + }); + } + + #aboutLoginsImportFromFile() { + this.sendAsyncMessage("AboutLogins:ImportFromFile"); + recordTelemetryEvent({ + object: "import_from_csv", + method: "mgmt_menu_item_used", + }); + } + + #aboutLoginsOpenPreferences() { + this.sendAsyncMessage("AboutLogins:OpenPreferences"); + recordTelemetryEvent({ + object: "preferences", + method: "mgmt_menu_item_used", + }); + } + + #aboutLoginsRecordTelemetryEvent(event) { + let { method } = event.detail; + + if (method == "open_management") { + let { docShell } = this.browsingContext; + // Compare to the last time open_management was recorded for the same + // outerWindowID to not double-count them due to a redirect to remove + // the entryPoint query param (since replaceState isn't allowed for + // about:). Don't use performance.now for the tab since you can't + // compare that number between different tabs and this JSM is shared. + let now = docShell.now(); + if ( + this.browsingContext.browserId == gLastOpenManagementBrowserId && + now - gLastOpenManagementEventTime < + TELEMETRY_MIN_MS_BETWEEN_OPEN_MANAGEMENT + ) { + return; + } + gLastOpenManagementEventTime = now; + gLastOpenManagementBrowserId = this.browsingContext.browserId; + } + recordTelemetryEvent(event.detail); + } + + #aboutLoginsRemoveAllLogins() { + this.sendAsyncMessage("AboutLogins:RemoveAllLogins"); + } + + #aboutLoginsSortChanged(detail) { + this.sendAsyncMessage("AboutLogins:SortChanged", detail); + } + + #aboutLoginsSyncEnable() { + this.sendAsyncMessage("AboutLogins:SyncEnable"); + } + + #aboutLoginsSyncOptions() { + this.sendAsyncMessage("AboutLogins:SyncOptions"); + } + + #aboutLoginsUpdateLogin(login) { + this.sendAsyncMessage("AboutLogins:UpdateLogin", { + login, + }); + } + + receiveMessage(message) { + switch (message.name) { + case "AboutLogins:ImportReportData": + this.#importReportData(message.data); + break; + case "AboutLogins:PrimaryPasswordResponse": + this.#primaryPasswordResponse(message.data); + break; + case "AboutLogins:RemaskPassword": + this.#remaskPassword(message.data); + break; + case "AboutLogins:Setup": + this.#setup(message.data); + break; + default: + this.#passMessageDataToContent(message); + } + } + + #importReportData(data) { + this.sendToContent("ImportReportData", data); + } + + #primaryPasswordResponse(data) { + if (gPrimaryPasswordPromise) { + gPrimaryPasswordPromise.resolve(data.result); + recordTelemetryEvent(data.telemetryEvent); + } + } + + #remaskPassword(data) { + this.sendToContent("RemaskPassword", data); + } + + #setup(data) { + let utils = Cu.waiveXrays(this.browsingContext.window).AboutLoginsUtils; + utils.primaryPasswordEnabled = data.primaryPasswordEnabled; + utils.passwordRevealVisible = data.passwordRevealVisible; + utils.importVisible = data.importVisible; + utils.supportBaseURL = Services.urlFormatter.formatURLPref( + "app.support.baseURL" + ); + this.sendToContent("Setup", data); + } + + #passMessageDataToContent(message) { + this.sendToContent(message.name.replace("AboutLogins:", ""), message.data); + } + + sendToContent(messageType, detail) { + let win = this.document.defaultView; + let message = Object.assign({ messageType }, { value: detail }); + let event = new win.CustomEvent("AboutLoginsChromeToContent", { + detail: Cu.cloneInto(message, win), + }); + win.dispatchEvent(event); + } +} diff --git a/browser/components/aboutlogins/AboutLoginsParent.sys.mjs b/browser/components/aboutlogins/AboutLoginsParent.sys.mjs new file mode 100644 index 0000000000..a342586c72 --- /dev/null +++ b/browser/components/aboutlogins/AboutLoginsParent.sys.mjs @@ -0,0 +1,866 @@ +/* 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/. */ + +// _AboutLogins is only exported for testing +import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { E10SUtils } from "resource://gre/modules/E10SUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginBreaches: "resource:///modules/LoginBreaches.sys.mjs", + LoginCSVImport: "resource://gre/modules/LoginCSVImport.sys.mjs", + LoginExport: "resource://gre/modules/LoginExport.sys.mjs", + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", + OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", + UIState: "resource://services-sync/UIState.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + return lazy.LoginHelper.createLogger("AboutLoginsParent"); +}); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "BREACH_ALERTS_ENABLED", + "signon.management.page.breach-alerts.enabled", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "FXA_ENABLED", + "identity.fxaccounts.enabled", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "OS_AUTH_ENABLED", + "signon.management.page.os-auth.enabled", + true +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "VULNERABLE_PASSWORDS_ENABLED", + "signon.management.page.vulnerable-passwords.enabled", + false +); +XPCOMUtils.defineLazyGetter(lazy, "AboutLoginsL10n", () => { + return new Localization(["branding/brand.ftl", "browser/aboutLogins.ftl"]); +}); + +const ABOUT_LOGINS_ORIGIN = "about:logins"; +const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes +const PRIMARY_PASSWORD_NOTIFICATION_ID = "primary-password-login-required"; + +// about:logins will always use the privileged content process, +// even if it is disabled for other consumers such as about:newtab. +const EXPECTED_ABOUTLOGINS_REMOTE_TYPE = E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE; +let _gPasswordRemaskTimeout = null; +const convertSubjectToLogin = subject => { + subject.QueryInterface(Ci.nsILoginMetaInfo).QueryInterface(Ci.nsILoginInfo); + const login = lazy.LoginHelper.loginToVanillaObject(subject); + if (!lazy.LoginHelper.isUserFacingLogin(login)) { + return null; + } + return augmentVanillaLoginObject(login); +}; + +const SUBDOMAIN_REGEX = new RegExp(/^www\d*\./); +const augmentVanillaLoginObject = login => { + // Note that `displayOrigin` can also include a httpRealm. + let title = login.displayOrigin.replace(SUBDOMAIN_REGEX, ""); + return Object.assign({}, login, { + title, + }); +}; + +export class AboutLoginsParent extends JSWindowActorParent { + async receiveMessage(message) { + if (!this.browsingContext.embedderElement) { + return; + } + + // Only respond to messages sent from a privlegedabout process. Ideally + // we would also check the contentPrincipal.originNoSuffix but this + // check has been removed due to bug 1576722. + if ( + this.browsingContext.embedderElement.remoteType != + EXPECTED_ABOUTLOGINS_REMOTE_TYPE + ) { + throw new Error( + `AboutLoginsParent: Received ${message.name} message the remote type didn't match expectations: ${this.browsingContext.embedderElement.remoteType} == ${EXPECTED_ABOUTLOGINS_REMOTE_TYPE}` + ); + } + + AboutLogins.subscribers.add(this.browsingContext); + + switch (message.name) { + case "AboutLogins:CreateLogin": { + await this.#createLogin(message.data.login); + break; + } + case "AboutLogins:DeleteLogin": { + this.#deleteLogin(message.data.login); + break; + } + case "AboutLogins:SortChanged": { + this.#sortChanged(message.data); + break; + } + case "AboutLogins:SyncEnable": { + this.#syncEnable(); + break; + } + case "AboutLogins:SyncOptions": { + this.#syncOptions(); + break; + } + case "AboutLogins:ImportFromBrowser": { + this.#importFromBrowser(); + break; + } + case "AboutLogins:ImportReportInit": { + this.#importReportInit(); + break; + } + case "AboutLogins:GetHelp": { + this.#getHelp(); + break; + } + case "AboutLogins:OpenPreferences": { + this.#openPreferences(); + break; + } + case "AboutLogins:PrimaryPasswordRequest": { + await this.#primaryPasswordRequest(message.data); + break; + } + case "AboutLogins:Subscribe": { + await this.#subscribe(); + break; + } + case "AboutLogins:UpdateLogin": { + this.#updateLogin(message.data.login); + break; + } + case "AboutLogins:ExportPasswords": { + await this.#exportPasswords(); + break; + } + case "AboutLogins:ImportFromFile": { + await this.#importFromFile(); + break; + } + case "AboutLogins:RemoveAllLogins": { + this.#removeAllLogins(); + break; + } + } + } + + get #ownerGlobal() { + return this.browsingContext.embedderElement.ownerGlobal; + } + + async #createLogin(newLogin) { + if (!Services.policies.isAllowed("removeMasterPassword")) { + if (!lazy.LoginHelper.isPrimaryPasswordSet()) { + this.#ownerGlobal.openDialog( + "chrome://mozapps/content/preferences/changemp.xhtml", + "", + "centerscreen,chrome,modal,titlebar" + ); + if (!lazy.LoginHelper.isPrimaryPasswordSet()) { + return; + } + } + } + // Remove the path from the origin, if it was provided. + let origin = lazy.LoginHelper.getLoginOrigin(newLogin.origin); + if (!origin) { + console.error( + "AboutLogins:CreateLogin: Unable to get an origin from the login details." + ); + return; + } + newLogin.origin = origin; + Object.assign(newLogin, { + formActionOrigin: "", + usernameField: "", + passwordField: "", + }); + newLogin = lazy.LoginHelper.vanillaObjectToLogin(newLogin); + try { + await Services.logins.addLoginAsync(newLogin); + } catch (error) { + this.#handleLoginStorageErrors(newLogin, error); + } + } + + #deleteLogin(loginObject) { + let login = lazy.LoginHelper.vanillaObjectToLogin(loginObject); + Services.logins.removeLogin(login); + } + + #sortChanged(sort) { + Services.prefs.setCharPref("signon.management.page.sort", sort); + } + + #syncEnable() { + this.#ownerGlobal.gSync.openFxAEmailFirstPage("password-manager"); + } + + #syncOptions() { + this.#ownerGlobal.gSync.openFxAManagePage("password-manager"); + } + + #importFromBrowser() { + try { + lazy.MigrationUtils.showMigrationWizard(this.#ownerGlobal, { + entrypoint: lazy.MigrationUtils.MIGRATION_ENTRYPOINTS.PASSWORDS, + }); + } catch (ex) { + console.error(ex); + } + } + + #importReportInit() { + let reportData = lazy.LoginCSVImport.lastImportReport; + this.sendAsyncMessage("AboutLogins:ImportReportData", reportData); + } + + #getHelp() { + const SUPPORT_URL = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "password-manager-remember-delete-edit-logins"; + this.#ownerGlobal.openWebLinkIn(SUPPORT_URL, "tab", { + relatedToCurrent: true, + }); + } + + #openPreferences() { + this.#ownerGlobal.openPreferences("privacy-logins"); + } + + async #primaryPasswordRequest(messageId) { + if (!messageId) { + throw new Error("AboutLogins:PrimaryPasswordRequest: no messageId."); + } + let messageText = { value: "NOT SUPPORTED" }; + let captionText = { value: "" }; + + // This feature is only supported on Windows and macOS + // but we still call in to OSKeyStore on Linux to get + // the proper auth_details for Telemetry. + // See bug 1614874 for Linux support. + if (lazy.OS_AUTH_ENABLED && lazy.OSKeyStore.canReauth()) { + messageId += "-" + AppConstants.platform; + [messageText, captionText] = await lazy.AboutLoginsL10n.formatMessages([ + { + id: messageId, + }, + { + id: "about-logins-os-auth-dialog-caption", + }, + ]); + } + + let { isAuthorized, telemetryEvent } = await lazy.LoginHelper.requestReauth( + this.browsingContext.embedderElement, + lazy.OS_AUTH_ENABLED, + AboutLogins._authExpirationTime, + messageText.value, + captionText.value + ); + this.sendAsyncMessage("AboutLogins:PrimaryPasswordResponse", { + result: isAuthorized, + telemetryEvent, + }); + if (isAuthorized) { + AboutLogins._authExpirationTime = Date.now() + AUTH_TIMEOUT_MS; + const remaskPasswords = () => { + this.sendAsyncMessage("AboutLogins:RemaskPassword"); + }; + clearTimeout(_gPasswordRemaskTimeout); + _gPasswordRemaskTimeout = setTimeout(remaskPasswords, AUTH_TIMEOUT_MS); + } + } + + async #subscribe() { + AboutLogins._authExpirationTime = Number.NEGATIVE_INFINITY; + AboutLogins.addObservers(); + + const logins = await AboutLogins.getAllLogins(); + try { + let syncState = AboutLogins.getSyncState(); + + let selectedSort = Services.prefs.getCharPref( + "signon.management.page.sort", + "name" + ); + if (selectedSort == "breached") { + // The "breached" value was used since Firefox 70 and + // replaced with "alerts" in Firefox 76. + selectedSort = "alerts"; + } + this.sendAsyncMessage("AboutLogins:Setup", { + logins, + selectedSort, + syncState, + primaryPasswordEnabled: lazy.LoginHelper.isPrimaryPasswordSet(), + passwordRevealVisible: Services.policies.isAllowed("passwordReveal"), + importVisible: + Services.policies.isAllowed("profileImport") && + AppConstants.platform != "linux", + }); + + await AboutLogins.sendAllLoginRelatedObjects( + logins, + this.browsingContext + ); + } catch (ex) { + if (ex.result != Cr.NS_ERROR_NOT_INITIALIZED) { + throw ex; + } + + // The message manager may be destroyed before the replies can be sent. + lazy.log.debug( + "AboutLogins:Subscribe: exception when replying with logins", + ex + ); + } + } + + #updateLogin(loginUpdates) { + let logins = lazy.LoginHelper.searchLoginsWithObject({ + guid: loginUpdates.guid, + }); + if (logins.length != 1) { + lazy.log.warn( + `AboutLogins:UpdateLogin: expected to find a login for guid: ${loginUpdates.guid} but found ${logins.length}` + ); + return; + } + + let modifiedLogin = logins[0].clone(); + if (loginUpdates.hasOwnProperty("username")) { + modifiedLogin.username = loginUpdates.username; + } + if (loginUpdates.hasOwnProperty("password")) { + modifiedLogin.password = loginUpdates.password; + } + try { + Services.logins.modifyLogin(logins[0], modifiedLogin); + } catch (error) { + this.#handleLoginStorageErrors(modifiedLogin, error); + } + } + + async #exportPasswords() { + let messageText = { value: "NOT SUPPORTED" }; + let captionText = { value: "" }; + + // This feature is only supported on Windows and macOS + // but we still call in to OSKeyStore on Linux to get + // the proper auth_details for Telemetry. + // See bug 1614874 for Linux support. + if (lazy.OSKeyStore.canReauth()) { + let messageId = + "about-logins-export-password-os-auth-dialog-message-" + + AppConstants.platform; + [messageText, captionText] = await lazy.AboutLoginsL10n.formatMessages([ + { + id: messageId, + }, + { + id: "about-logins-os-auth-dialog-caption", + }, + ]); + } + + let { isAuthorized, telemetryEvent } = await lazy.LoginHelper.requestReauth( + this.browsingContext.embedderElement, + true, + null, // Prompt regardless of a recent prompt + messageText.value, + captionText.value + ); + + let { method, object, extra = {}, value = null } = telemetryEvent; + Services.telemetry.recordEvent("pwmgr", method, object, value, extra); + + if (!isAuthorized) { + return; + } + + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + function fpCallback(aResult) { + if (aResult != Ci.nsIFilePicker.returnCancel) { + lazy.LoginExport.exportAsCSV(fp.file.path); + Services.telemetry.recordEvent( + "pwmgr", + "mgmt_menu_item_used", + "export_complete" + ); + } + } + let [title, defaultFilename, okButtonLabel, csvFilterTitle] = + await lazy.AboutLoginsL10n.formatValues([ + { + id: "about-logins-export-file-picker-title", + }, + { + id: "about-logins-export-file-picker-default-filename", + }, + { + id: "about-logins-export-file-picker-export-button", + }, + { + id: "about-logins-export-file-picker-csv-filter-title", + }, + ]); + + fp.init(this.#ownerGlobal, title, Ci.nsIFilePicker.modeSave); + fp.appendFilter(csvFilterTitle, "*.csv"); + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.defaultString = defaultFilename; + fp.defaultExtension = "csv"; + fp.okButtonLabel = okButtonLabel; + fp.open(fpCallback); + } + + async #importFromFile() { + let [title, okButtonLabel, csvFilterTitle, tsvFilterTitle] = + await lazy.AboutLoginsL10n.formatValues([ + { + id: "about-logins-import-file-picker-title", + }, + { + id: "about-logins-import-file-picker-import-button", + }, + { + id: "about-logins-import-file-picker-csv-filter-title", + }, + { + id: "about-logins-import-file-picker-tsv-filter-title", + }, + ]); + let { result, path } = await this.openFilePickerDialog( + title, + okButtonLabel, + [ + { + title: csvFilterTitle, + extensionPattern: "*.csv", + }, + { + title: tsvFilterTitle, + extensionPattern: "*.tsv", + }, + ], + this.#ownerGlobal + ); + + if (result != Ci.nsIFilePicker.returnCancel) { + let summary; + try { + summary = await lazy.LoginCSVImport.importFromCSV(path); + } catch (e) { + console.error(e); + this.sendAsyncMessage( + "AboutLogins:ImportPasswordsErrorDialog", + e.errorType + ); + } + if (summary) { + this.sendAsyncMessage("AboutLogins:ImportPasswordsDialog", summary); + Services.telemetry.recordEvent( + "pwmgr", + "mgmt_menu_item_used", + "import_csv_complete" + ); + } + } + } + + #removeAllLogins() { + Services.logins.removeAllUserFacingLogins(); + } + + #handleLoginStorageErrors(login, error) { + let messageObject = { + login: augmentVanillaLoginObject( + lazy.LoginHelper.loginToVanillaObject(login) + ), + errorMessage: error.message, + }; + + if (error.message.includes("This login already exists")) { + // See comment in LoginHelper.createLoginAlreadyExistsError as to + // why we need to call .toString() on the nsISupportsString. + messageObject.existingLoginGuid = error.data.toString(); + } + + this.sendAsyncMessage("AboutLogins:ShowLoginItemError", messageObject); + } + + async openFilePickerDialog(title, okButtonLabel, appendFilters, ownerGlobal) { + return new Promise(resolve => { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init(ownerGlobal, title, Ci.nsIFilePicker.modeOpen); + for (const appendFilter of appendFilters) { + fp.appendFilter(appendFilter.title, appendFilter.extensionPattern); + } + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.okButtonLabel = okButtonLabel; + fp.open(async result => { + resolve({ result, path: fp.file.path }); + }); + }); + } +} + +class AboutLoginsInternal { + subscribers = new WeakSet(); + #observersAdded = false; + authExpirationTime = Number.NEGATIVE_INFINITY; + + async observe(subject, topic, type) { + if (!ChromeUtils.nondeterministicGetWeakSetKeys(this.subscribers).length) { + this.#removeObservers(); + return; + } + + switch (topic) { + case "passwordmgr-reload-all": { + await this.#reloadAllLogins(); + break; + } + case "passwordmgr-crypto-login": { + this.#removeNotifications(PRIMARY_PASSWORD_NOTIFICATION_ID); + await this.#reloadAllLogins(); + break; + } + case "passwordmgr-crypto-loginCanceled": { + this.#showPrimaryPasswordLoginNotifications(); + break; + } + case lazy.UIState.ON_UPDATE: { + this.#messageSubscribers("AboutLogins:SyncState", this.getSyncState()); + break; + } + case "passwordmgr-storage-changed": { + switch (type) { + case "addLogin": { + await this.#addLogin(subject); + break; + } + case "modifyLogin": { + this.#modifyLogin(subject); + break; + } + case "removeLogin": { + this.#removeLogin(subject); + break; + } + case "removeAllLogins": { + this.#removeAllLogins(); + break; + } + } + } + } + } + + async #addLogin(subject) { + const login = convertSubjectToLogin(subject); + if (!login) { + return; + } + + if (lazy.BREACH_ALERTS_ENABLED) { + this.#messageSubscribers( + "AboutLogins:UpdateBreaches", + await lazy.LoginBreaches.getPotentialBreachesByLoginGUID([login]) + ); + if (lazy.VULNERABLE_PASSWORDS_ENABLED) { + this.#messageSubscribers( + "AboutLogins:UpdateVulnerableLogins", + await lazy.LoginBreaches.getPotentiallyVulnerablePasswordsByLoginGUID( + [login] + ) + ); + } + } + + this.#messageSubscribers("AboutLogins:LoginAdded", login); + } + + async #modifyLogin(subject) { + subject.QueryInterface(Ci.nsIArrayExtensions); + const login = convertSubjectToLogin(subject.GetElementAt(1)); + if (!login) { + return; + } + + if (lazy.BREACH_ALERTS_ENABLED) { + let breachesForThisLogin = + await lazy.LoginBreaches.getPotentialBreachesByLoginGUID([login]); + let breachData = breachesForThisLogin.size + ? breachesForThisLogin.get(login.guid) + : false; + this.#messageSubscribers( + "AboutLogins:UpdateBreaches", + new Map([[login.guid, breachData]]) + ); + if (lazy.VULNERABLE_PASSWORDS_ENABLED) { + let vulnerablePasswordsForThisLogin = + await lazy.LoginBreaches.getPotentiallyVulnerablePasswordsByLoginGUID( + [login] + ); + let isLoginVulnerable = !!vulnerablePasswordsForThisLogin.size; + this.#messageSubscribers( + "AboutLogins:UpdateVulnerableLogins", + new Map([[login.guid, isLoginVulnerable]]) + ); + } + } + + this.#messageSubscribers("AboutLogins:LoginModified", login); + } + + #removeLogin(subject) { + const login = convertSubjectToLogin(subject); + if (!login) { + return; + } + this.#messageSubscribers("AboutLogins:LoginRemoved", login); + } + + #removeAllLogins() { + this.#messageSubscribers("AboutLogins:RemoveAllLogins", []); + } + + async #reloadAllLogins() { + let logins = await this.getAllLogins(); + this.#messageSubscribers("AboutLogins:AllLogins", logins); + await this.sendAllLoginRelatedObjects(logins); + } + + #showPrimaryPasswordLoginNotifications() { + this.#showNotifications({ + id: PRIMARY_PASSWORD_NOTIFICATION_ID, + priority: "PRIORITY_WARNING_MEDIUM", + iconURL: "chrome://browser/skin/login.svg", + messageId: "about-logins-primary-password-notification-message", + buttonIds: ["master-password-reload-button"], + onClicks: [ + function onReloadClick(browser) { + browser.reload(); + }, + ], + }); + this.#messageSubscribers("AboutLogins:PrimaryPasswordAuthRequired"); + } + + #showNotifications({ + id, + priority, + iconURL, + messageId, + buttonIds, + onClicks, + extraFtl = [], + } = {}) { + for (let subscriber of this.#subscriberIterator()) { + let browser = subscriber.embedderElement; + let MozXULElement = browser.ownerGlobal.MozXULElement; + MozXULElement.insertFTLIfNeeded("browser/aboutLogins.ftl"); + for (let ftl of extraFtl) { + MozXULElement.insertFTLIfNeeded(ftl); + } + + // If there's already an existing notification bar, don't do anything. + let { gBrowser } = browser.ownerGlobal; + let notificationBox = gBrowser.getNotificationBox(browser); + let notification = notificationBox.getNotificationWithValue(id); + if (notification) { + continue; + } + + let buttons = []; + for (let i = 0; i < buttonIds.length; i++) { + buttons[i] = { + "l10n-id": buttonIds[i], + popup: null, + callback: () => { + onClicks[i](browser); + }, + }; + } + + notification = notificationBox.appendNotification( + id, + { + label: { "l10n-id": messageId }, + image: iconURL, + priority: notificationBox[priority], + }, + buttons + ); + } + } + + #removeNotifications(notificationId) { + for (let subscriber of this.#subscriberIterator()) { + let browser = subscriber.embedderElement; + let { gBrowser } = browser.ownerGlobal; + let notificationBox = gBrowser.getNotificationBox(browser); + let notification = + notificationBox.getNotificationWithValue(notificationId); + if (!notification) { + continue; + } + notificationBox.removeNotification(notification); + } + } + + *#subscriberIterator() { + let subscribers = ChromeUtils.nondeterministicGetWeakSetKeys( + this.subscribers + ); + for (let subscriber of subscribers) { + let browser = subscriber.embedderElement; + if ( + browser?.remoteType != EXPECTED_ABOUTLOGINS_REMOTE_TYPE || + browser?.contentPrincipal?.originNoSuffix != ABOUT_LOGINS_ORIGIN + ) { + this.subscribers.delete(subscriber); + continue; + } + yield subscriber; + } + } + + #messageSubscribers(name, details) { + for (let subscriber of this.#subscriberIterator()) { + try { + if (subscriber.currentWindowGlobal) { + let actor = subscriber.currentWindowGlobal.getActor("AboutLogins"); + actor.sendAsyncMessage(name, details); + } + } catch (ex) { + if (ex.result == Cr.NS_ERROR_NOT_INITIALIZED) { + // The actor may be destroyed before the message is sent. + lazy.log.debug( + "messageSubscribers: exception when calling sendAsyncMessage", + ex + ); + } else { + throw ex; + } + } + } + } + + async getAllLogins() { + try { + let logins = await lazy.LoginHelper.getAllUserFacingLogins(); + return logins + .map(lazy.LoginHelper.loginToVanillaObject) + .map(augmentVanillaLoginObject); + } catch (e) { + if (e.result == Cr.NS_ERROR_ABORT) { + // If the user cancels the MP prompt then return no logins. + return []; + } + throw e; + } + } + + async sendAllLoginRelatedObjects(logins, browsingContext) { + let sendMessageFn = (name, details) => { + if (browsingContext?.currentWindowGlobal) { + let actor = browsingContext.currentWindowGlobal.getActor("AboutLogins"); + actor.sendAsyncMessage(name, details); + } else { + this.#messageSubscribers(name, details); + } + }; + + if (lazy.BREACH_ALERTS_ENABLED) { + sendMessageFn( + "AboutLogins:SetBreaches", + await lazy.LoginBreaches.getPotentialBreachesByLoginGUID(logins) + ); + if (lazy.VULNERABLE_PASSWORDS_ENABLED) { + sendMessageFn( + "AboutLogins:SetVulnerableLogins", + await lazy.LoginBreaches.getPotentiallyVulnerablePasswordsByLoginGUID( + logins + ) + ); + } + } + } + + getSyncState() { + const state = lazy.UIState.get(); + // As long as Sync is configured, about:logins will treat it as + // authenticated. More diagnostics and error states can be handled + // by other more Sync-specific pages. + const loggedIn = state.status != lazy.UIState.STATUS_NOT_CONFIGURED; + const passwordSyncEnabled = state.syncEnabled && lazy.PASSWORD_SYNC_ENABLED; + + return { + loggedIn, + email: state.email, + avatarURL: state.avatarURL, + fxAccountsEnabled: lazy.FXA_ENABLED, + passwordSyncEnabled, + }; + } + + onPasswordSyncEnabledPreferenceChange(data, previous, latest) { + this.#messageSubscribers("AboutLogins:SyncState", this.getSyncState()); + } + + #observedTopics = [ + "passwordmgr-crypto-login", + "passwordmgr-crypto-loginCanceled", + "passwordmgr-storage-changed", + "passwordmgr-reload-all", + lazy.UIState.ON_UPDATE, + ]; + + addObservers() { + if (!this.#observersAdded) { + for (const topic of this.#observedTopics) { + Services.obs.addObserver(this, topic); + } + this.#observersAdded = true; + } + } + + #removeObservers() { + for (const topic of this.#observedTopics) { + Services.obs.removeObserver(this, topic); + } + this.#observersAdded = false; + } +} + +let AboutLogins = new AboutLoginsInternal(); +export var _AboutLogins = AboutLogins; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "PASSWORD_SYNC_ENABLED", + "services.sync.engine.passwords", + false, + AboutLogins.onPasswordSyncEnabledPreferenceChange +); diff --git a/browser/components/aboutlogins/LoginBreaches.sys.mjs b/browser/components/aboutlogins/LoginBreaches.sys.mjs new file mode 100644 index 0000000000..bd7a8cdf66 --- /dev/null +++ b/browser/components/aboutlogins/LoginBreaches.sys.mjs @@ -0,0 +1,176 @@ +/* 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/. */ + +/** + * Manages breach alerts for saved logins using data from Firefox Monitor via + * RemoteSettings. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + RemoteSettingsClient: + "resource://services-settings/RemoteSettingsClient.sys.mjs", +}); + +export const LoginBreaches = { + REMOTE_SETTINGS_COLLECTION: "fxmonitor-breaches", + + async update(breaches = null) { + const logins = await lazy.LoginHelper.getAllUserFacingLogins(); + await this.getPotentialBreachesByLoginGUID(logins, breaches); + }, + + /** + * Return a Map of login GUIDs to a potential breach affecting that login + * by considering only breaches affecting passwords. + * + * This only uses the breach `Domain` and `timePasswordChanged` to determine + * if a login may be breached which means it may contain false-positives if + * login timestamps are incorrect, the user didn't save their password change + * in Firefox, or the breach didn't contain all accounts, etc. As a result, + * consumers should avoid making stronger claims than the data supports. + * + * @param {nsILoginInfo[]} logins Saved logins to check for potential breaches. + * @param {object[]} [breaches = null] Only ones involving passwords will be used. + * @returns {Map} with a key for each login GUID potentially in a breach. + */ + async getPotentialBreachesByLoginGUID(logins, breaches = null) { + const breachesByLoginGUID = new Map(); + if (!breaches) { + try { + breaches = await lazy + .RemoteSettings(this.REMOTE_SETTINGS_COLLECTION) + .get(); + } catch (ex) { + if (ex instanceof lazy.RemoteSettingsClient.UnknownCollectionError) { + lazy.log.warn( + "Could not get Remote Settings collection.", + this.REMOTE_SETTINGS_COLLECTION, + ex + ); + return breachesByLoginGUID; + } + throw ex; + } + } + const BREACH_ALERT_URL = Services.prefs.getStringPref( + "signon.management.page.breachAlertUrl" + ); + const baseBreachAlertURL = new URL(BREACH_ALERT_URL); + + await Services.logins.initializationPromise; + const storageJSON = + Services.logins.wrappedJSObject._storage.wrappedJSObject; + const dismissedBreachAlertsByLoginGUID = + storageJSON.getBreachAlertDismissalsByLoginGUID(); + + // Determine potentially breached logins by checking their origin and the last time + // they were changed. It's important to note here that we are NOT considering the + // username and password of that login. + for (const login of logins) { + let loginHost; + try { + // nsIURI.host can throw if the URI scheme doesn't have a host. + loginHost = Services.io.newURI(login.origin).host; + } catch { + continue; + } + for (const breach of breaches) { + if ( + !breach.Domain || + !Services.eTLD.hasRootDomain(loginHost, breach.Domain) || + !this._breachInvolvedPasswords(breach) || + !this._breachWasAfterPasswordLastChanged(breach, login) + ) { + continue; + } + + if (!storageJSON.isPotentiallyVulnerablePassword(login)) { + storageJSON.addPotentiallyVulnerablePassword(login); + } + + if ( + this._breachAlertIsDismissed( + login, + breach, + dismissedBreachAlertsByLoginGUID + ) + ) { + continue; + } + + let breachAlertURL = new URL(breach.Name, baseBreachAlertURL); + breachAlertURL.searchParams.set("utm_source", "firefox-desktop"); + breachAlertURL.searchParams.set("utm_medium", "referral"); + breachAlertURL.searchParams.set("utm_campaign", "about-logins"); + breachAlertURL.searchParams.set("utm_content", "about-logins"); + breach.breachAlertURL = breachAlertURL.href; + breachesByLoginGUID.set(login.guid, breach); + } + } + Services.telemetry.scalarSet( + "pwmgr.potentially_breached_passwords", + breachesByLoginGUID.size + ); + return breachesByLoginGUID; + }, + + /** + * Return information about logins using passwords that were potentially in a + * breach. + * @see the caveats in the documentation for `getPotentialBreachesByLoginGUID`. + * + * @param {nsILoginInfo[]} logins to check the passwords of. + * @returns {Map} from login GUID to `true` for logins that have a password + * that may be vulnerable. + */ + getPotentiallyVulnerablePasswordsByLoginGUID(logins) { + const vulnerablePasswordsByLoginGUID = new Map(); + const storageJSON = + Services.logins.wrappedJSObject._storage.wrappedJSObject; + for (const login of logins) { + if (storageJSON.isPotentiallyVulnerablePassword(login)) { + vulnerablePasswordsByLoginGUID.set(login.guid, true); + } + } + return vulnerablePasswordsByLoginGUID; + }, + + async clearAllPotentiallyVulnerablePasswords() { + await Services.logins.initializationPromise; + const storageJSON = + Services.logins.wrappedJSObject._storage.wrappedJSObject; + storageJSON.clearAllPotentiallyVulnerablePasswords(); + }, + + _breachAlertIsDismissed(login, breach, dismissedBreachAlerts) { + const breachAddedDate = new Date(breach.AddedDate).getTime(); + const breachAlertIsDismissed = + dismissedBreachAlerts[login.guid] && + dismissedBreachAlerts[login.guid].timeBreachAlertDismissed > + breachAddedDate; + return breachAlertIsDismissed; + }, + + _breachInvolvedPasswords(breach) { + return ( + breach.hasOwnProperty("DataClasses") && + breach.DataClasses.includes("Passwords") + ); + }, + + _breachWasAfterPasswordLastChanged(breach, login) { + const breachDate = new Date(breach.BreachDate).getTime(); + return login.timePasswordChanged < breachDate; + }, +}; + +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + return lazy.LoginHelper.createLogger("LoginBreaches"); +}); 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> diff --git a/browser/components/aboutlogins/jar.mn b/browser/components/aboutlogins/jar.mn new file mode 100644 index 0000000000..5821de278a --- /dev/null +++ b/browser/components/aboutlogins/jar.mn @@ -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/. + +browser.jar: + content/browser/aboutlogins/components/confirmation-dialog.css (content/components/confirmation-dialog.css) + content/browser/aboutlogins/components/confirmation-dialog.mjs (content/components/confirmation-dialog.mjs) + content/browser/aboutlogins/components/remove-logins-dialog.css (content/components/remove-logins-dialog.css) + content/browser/aboutlogins/components/remove-logins-dialog.mjs (content/components/remove-logins-dialog.mjs) + content/browser/aboutlogins/components/import-summary-dialog.css (content/components/import-summary-dialog.css) + content/browser/aboutlogins/components/import-summary-dialog.mjs (content/components/import-summary-dialog.mjs) + content/browser/aboutlogins/components/import-error-dialog.css (content/components/import-error-dialog.css) + content/browser/aboutlogins/components/import-error-dialog.mjs (content/components/import-error-dialog.mjs) + content/browser/aboutlogins/components/import-details-row.mjs (content/components/import-details-row.mjs) + content/browser/aboutlogins/components/generic-dialog.css (content/components/generic-dialog.css) + content/browser/aboutlogins/components/generic-dialog.mjs (content/components/generic-dialog.mjs) + content/browser/aboutlogins/components/fxaccounts-button.css (content/components/fxaccounts-button.css) + content/browser/aboutlogins/components/fxaccounts-button.mjs (content/components/fxaccounts-button.mjs) + content/browser/aboutlogins/components/login-filter.css (content/components/login-filter.css) + content/browser/aboutlogins/components/login-filter.mjs (content/components/login-filter.mjs) + content/browser/aboutlogins/components/login-intro.css (content/components/login-intro.css) + content/browser/aboutlogins/components/login-intro.mjs (content/components/login-intro.mjs) + content/browser/aboutlogins/components/login-item.css (content/components/login-item.css) + content/browser/aboutlogins/components/login-item.mjs (content/components/login-item.mjs) + content/browser/aboutlogins/components/login-list.css (content/components/login-list.css) + content/browser/aboutlogins/components/login-list.mjs (content/components/login-list.mjs) + content/browser/aboutlogins/components/login-list-item.mjs (content/components/login-list-item.mjs) + content/browser/aboutlogins/components/login-list-section.mjs (content/components/login-list-section.mjs) + content/browser/aboutlogins/components/menu-button.css (content/components/menu-button.css) + content/browser/aboutlogins/components/menu-button.mjs (content/components/menu-button.mjs) + content/browser/aboutlogins/components/login-timeline.mjs (content/components/login-timeline.mjs) + content/browser/aboutlogins/icons/breached-website.svg (content/icons/breached-website.svg) + content/browser/aboutlogins/icons/vulnerable-password.svg (content/icons/vulnerable-password.svg) + content/browser/aboutlogins/icons/intro-illustration.svg (content/icons/intro-illustration.svg) + content/browser/aboutlogins/aboutLogins.css (content/aboutLogins.css) + content/browser/aboutlogins/aboutLogins.mjs (content/aboutLogins.mjs) + content/browser/aboutlogins/aboutLogins.html (content/aboutLogins.html) + content/browser/aboutlogins/aboutLoginsImportReport.css (content/aboutLoginsImportReport.css) + content/browser/aboutlogins/aboutLoginsImportReport.mjs (content/aboutLoginsImportReport.mjs) + content/browser/aboutlogins/aboutLoginsImportReport.html (content/aboutLoginsImportReport.html) + content/browser/aboutlogins/aboutLoginsUtils.mjs (content/aboutLoginsUtils.mjs) + content/browser/aboutlogins/common.css (content/common.css) diff --git a/browser/components/aboutlogins/moz.build b/browser/components/aboutlogins/moz.build new file mode 100644 index 0000000000..200b05fed6 --- /dev/null +++ b/browser/components/aboutlogins/moz.build @@ -0,0 +1,23 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +JAR_MANIFESTS += ["jar.mn"] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "about:logins") + +EXTRA_JS_MODULES += [ + "LoginBreaches.sys.mjs", +] + +FINAL_TARGET_FILES.actors += [ + "AboutLoginsChild.sys.mjs", + "AboutLoginsParent.sys.mjs", +] + +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"] +MOCHITEST_CHROME_MANIFESTS += ["tests/chrome/chrome.ini"] +XPCSHELL_TESTS_MANIFESTS += ["tests/unit/xpcshell.ini"] diff --git a/browser/components/aboutlogins/tests/browser/AboutLoginsTestUtils.sys.mjs b/browser/components/aboutlogins/tests/browser/AboutLoginsTestUtils.sys.mjs new file mode 100644 index 0000000000..44a51b80ad --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/AboutLoginsTestUtils.sys.mjs @@ -0,0 +1,107 @@ +/* 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/. */ + +/** + * An utility class to help out with the about:logins and about:loginsimportreport DOM interaction for the tests. + * + */ +export class AboutLoginsTestUtils { + /** + * An utility method to fetch the data from the CSV import success dialog. + * + * @param {content} content + * The content object. + * @param {ContentTaskUtils} ContentTaskUtils + * The ContentTaskUtils object. + * @returns {Promise<Object>} A promise that contains added, modified, noChange and errors count. + */ + static async getCsvImportSuccessDialogData(content, ContentTaskUtils) { + let dialog = Cu.waiveXrays( + content.document.querySelector("import-summary-dialog") + ); + await ContentTaskUtils.waitForCondition( + () => !dialog.hidden, + "Waiting for the dialog to be visible" + ); + + let added = dialog.shadowRoot.querySelector( + ".import-items-added .result-count" + ).textContent; + let modified = dialog.shadowRoot.querySelector( + ".import-items-modified .result-count" + ).textContent; + let noChange = dialog.shadowRoot.querySelector( + ".import-items-no-change .result-count" + ).textContent; + let errors = dialog.shadowRoot.querySelector( + ".import-items-errors .result-count" + ).textContent; + return { + added, + modified, + noChange, + errors, + l10nFocused: dialog.shadowRoot.activeElement.getAttribute("data-l10n-id"), + }; + } + + /** + * An utility method to fetch the data from the CSV import error dialog. + * + * @param {content} content + * The content object. + * @returns {Promise<Object>} A promise that contains the hidden state and l10n id for title, description and focused element. + */ + static async getCsvImportErrorDialogData(content) { + const dialog = Cu.waiveXrays( + content.document.querySelector("import-error-dialog") + ); + const l10nTitle = dialog._genericDialog + .querySelector(".error-title") + .getAttribute("data-l10n-id"); + const l10nDescription = dialog._genericDialog + .querySelector(".error-description") + .getAttribute("data-l10n-id"); + return { + hidden: dialog.hidden, + l10nFocused: dialog.shadowRoot.activeElement.getAttribute("data-l10n-id"), + l10nTitle, + l10nDescription, + }; + } + + /** + * An utility method to fetch data from the about:loginsimportreport page. + * It also cleans up the tab so you don't have to. + * + * @param {content} content + * The content object. + * @returns {Promise<Object>} A promise that contains the detailed report data like added, modified, noChange, errors and rows. + */ + static async getCsvImportReportData(content) { + const rows = []; + for (let element of content.document.querySelectorAll(".row-details")) { + rows.push(element.getAttribute("data-l10n-id")); + } + const added = content.document.querySelector( + ".new-logins .result-count" + ).textContent; + const modified = content.document.querySelector( + ".exiting-logins .result-count" + ).textContent; + const noChange = content.document.querySelector( + ".duplicate-logins .result-count" + ).textContent; + const errors = content.document.querySelector( + ".errors-logins .result-count" + ).textContent; + return { + rows, + added, + modified, + noChange, + errors, + }; + } +} diff --git a/browser/components/aboutlogins/tests/browser/browser.ini b/browser/components/aboutlogins/tests/browser/browser.ini new file mode 100644 index 0000000000..06eb8ab92a --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser.ini @@ -0,0 +1,58 @@ +[DEFAULT] +support-files = + head.js +prefs = + signon.management.page.vulnerable-passwords.enabled=true + signon.management.page.os-auth.enabled=true + # lower the interval for event telemetry in the content process to update the parent process + toolkit.telemetry.ipcBatchTimeout=10 + +# Run first so content events from previous tests won't trickle in. +# Skip ASAN and debug since waiting for content events is already slow. +[browser_aaa_eventTelemetry_run_first.js] +skip-if = + asan || tsan || ccov || debug || (os == "win" && !debug) # bug 1605494 is more prevalent on linux, Bug 1627419 + os == 'linux' && bits == 64 && !debug # Bug 1648862 +[browser_alertDismissedAfterChangingPassword.js] +skip-if = + os == "mac" && os_version == "10.15" && !debug # Bug 1684513 +[browser_breachAlertShowingForAddedLogin.js] +[browser_confirmDeleteDialog.js] +[browser_contextmenuFillLogins.js] +skip-if = win10_2004 && debug # Bug 1723573 +[browser_copyToClipboardButton.js] +[browser_createLogin.js] +[browser_deleteLogin.js] +[browser_fxAccounts.js] +[browser_loginFilter.js] +[browser_loginItemErrors.js] +skip-if = debug # Bug 1577710 +[browser_loginListChanges.js] +[browser_loginSortOrderRestored.js] +skip-if = os == 'linux' && bits == 64 && os_version == '18.04' # Bug 1587625; Bug 1587626 for linux1804 +[browser_noLoginsView.js] +[browser_openExport.js] +[browser_openFiltered.js] +[browser_openImport.js] +skip-if = + os != "win" && os != "mac" # import is only available on Windows and macOS + os == "mac" && !debug # bug 1775753 +[browser_openImportCSV.js] +[browser_openPreferences.js] +[browser_openPreferencesExternal.js] +[browser_openSite.js] +skip-if = + os == "linux" && bits == 64 # Bug 1581889 +[browser_osAuthDialog.js] +skip-if = (os == 'linux') # bug 1527745 +[browser_primaryPassword.js] +skip-if = + (os == 'linux') # bug 1569789 +[browser_removeAllDialog.js] +[browser_sessionRestore.js] +skip-if = + tsan + debug # Bug 1576876 +[browser_tabKeyNav.js] +[browser_updateLogin.js] +[browser_vulnerableLoginAddedInSecondaryWindow.js] diff --git a/browser/components/aboutlogins/tests/browser/browser_aaa_eventTelemetry_run_first.js b/browser/components/aboutlogins/tests/browser/browser_aaa_eventTelemetry_run_first.js new file mode 100644 index 0000000000..0ff77a2240 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_aaa_eventTelemetry_run_first.js @@ -0,0 +1,271 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +requestLongerTimeout(2); + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +EXPECTED_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.example.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", +}; + +let VULNERABLE_TEST_LOGIN2 = new nsLoginInfo( + "https://2.example.com", + "https://2.example.com", + null, + "user2", + "pass3", + "username", + "password" +); + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + VULNERABLE_TEST_LOGIN2 = await addLogin(VULNERABLE_TEST_LOGIN2); + TEST_LOGIN3 = await addLogin(TEST_LOGIN3); + + await TestUtils.waitForCondition(() => { + Services.telemetry.clearEvents(); + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + return !events || !events.length; + }, "Waiting for telemetry events to get cleared"); + + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_telemetry_events() { + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginList = content.document.querySelector("login-list"); + let loginListItem = loginList.shadowRoot.querySelector( + ".login-list-item.breached" + ); + loginListItem.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(2); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector( + ".copy-username-button" + ); + copyButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(3); + + if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector( + ".copy-password-button" + ); + copyButton.click(); + }); + await reauthObserved; + // When reauth is observed an extra telemetry event will be recorded + // for the reauth, hence the event count increasing by 2 here, and later + // in the test as well. + await LoginTestUtils.telemetry.waitForEventCount(5); + } + let nextTelemetryEventCount = OSKeyStoreTestUtils.canTestOSKeyStoreLogin() + ? 6 + : 4; + + let promiseNewTab = BrowserTestUtils.waitForNewTab( + gBrowser, + TEST_LOGIN3.origin + "/" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let originInput = loginItem.shadowRoot.querySelector(".origin-input"); + originInput.click(); + }); + let newTab = await promiseNewTab; + Assert.ok(true, "New tab opened to " + TEST_LOGIN3.origin); + BrowserTestUtils.removeTab(newTab); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + // Show the password + if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + let reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + nextTelemetryEventCount++; // An extra event is observed for the reauth event. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await reauthObserved; + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + // Hide the password + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + // Don't force the auth timeout here to check that `auth_skipped: true` is set as + // in `extra`. + nextTelemetryEventCount++; // An extra event is observed for the reauth event. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let usernameField = loginItem.shadowRoot.querySelector( + 'input[name="username"]' + ); + usernameField.value = "user1-modified"; + + let saveButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + saveButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + } + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let deleteButton = loginItem.shadowRoot.querySelector(".delete-button"); + deleteButton.click(); + let confirmDeleteDialog = content.document.querySelector( + "confirmation-dialog" + ); + let confirmDeleteButton = + confirmDeleteDialog.shadowRoot.querySelector(".confirm-button"); + confirmDeleteButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let newLoginButton = content.document + .querySelector("login-list") + .shadowRoot.querySelector(".create-login-button"); + newLoginButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button"); + cancelButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginList = content.document.querySelector("login-list"); + let loginListItem = loginList.shadowRoot.querySelector( + ".login-list-item.vulnerable" + ); + loginListItem.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector( + ".copy-username-button" + ); + copyButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let deleteButton = loginItem.shadowRoot.querySelector(".delete-button"); + deleteButton.click(); + let confirmDeleteDialog = content.document.querySelector( + "confirmation-dialog" + ); + let confirmDeleteButton = + confirmDeleteDialog.shadowRoot.querySelector(".confirm-button"); + confirmDeleteButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginSort = content.document + .querySelector("login-list") + .shadowRoot.querySelector("#login-sort"); + loginSort.value = "last-used"; + loginSort.dispatchEvent(new content.Event("change", { bubbles: true })); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.management.page.sort"); + }); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + const loginList = content.document.querySelector("login-list"); + const loginFilter = loginList.shadowRoot.querySelector("login-filter"); + const input = loginFilter.shadowRoot.querySelector("input"); + input.setUserInput("test"); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + const testOSAuth = OSKeyStoreTestUtils.canTestOSKeyStoreLogin(); + let expectedEvents = [ + [true, "pwmgr", "open_management", "direct"], + [true, "pwmgr", "select", "existing_login", null, { breached: "true" }], + [true, "pwmgr", "copy", "username", null, { breached: "true" }], + [testOSAuth, "pwmgr", "reauthenticate", "os_auth", "success"], + [testOSAuth, "pwmgr", "copy", "password", null, { breached: "true" }], + [true, "pwmgr", "open_site", "existing_login", null, { breached: "true" }], + [testOSAuth, "pwmgr", "reauthenticate", "os_auth", "success"], + [testOSAuth, "pwmgr", "show", "password", null, { breached: "true" }], + [testOSAuth, "pwmgr", "hide", "password", null, { breached: "true" }], + [testOSAuth, "pwmgr", "reauthenticate", "os_auth", "success_no_prompt"], + [testOSAuth, "pwmgr", "edit", "existing_login", null, { breached: "true" }], + [testOSAuth, "pwmgr", "save", "existing_login", null, { breached: "true" }], + [true, "pwmgr", "delete", "existing_login", null, { breached: "true" }], + [true, "pwmgr", "new", "new_login"], + [true, "pwmgr", "cancel", "new_login"], + [true, "pwmgr", "select", "existing_login", null, { vulnerable: "true" }], + [true, "pwmgr", "copy", "username", null, { vulnerable: "true" }], + [true, "pwmgr", "delete", "existing_login", null, { vulnerable: "true" }], + [true, "pwmgr", "sort", "list"], + [true, "pwmgr", "filter", "list"], + ]; + expectedEvents = expectedEvents + .filter(event => event[0]) + .map(event => event.slice(1)); + + TelemetryTestUtils.assertEvents( + expectedEvents, + { category: "pwmgr" }, + { clear: true, process: "content" } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_alertDismissedAfterChangingPassword.js b/browser/components/aboutlogins/tests/browser/browser_alertDismissedAfterChangingPassword.js new file mode 100644 index 0000000000..623df38fcb --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_alertDismissedAfterChangingPassword.js @@ -0,0 +1,227 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +EXPECTED_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.example.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", +}; + +let VULNERABLE_TEST_LOGIN2 = new nsLoginInfo( + "https://2.example.com", + "https://2.example.com", + null, + "user2", + "pass3", + "username", + "password" +); + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + VULNERABLE_TEST_LOGIN2 = await addLogin(VULNERABLE_TEST_LOGIN2); + TEST_LOGIN3 = await addLogin(TEST_LOGIN3); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_added_login_shows_breach_warning() { + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn( + browser, + [[TEST_LOGIN1.guid, VULNERABLE_TEST_LOGIN2.guid, TEST_LOGIN3.guid]], + async ([regularLoginGuid, vulnerableLoginGuid, breachedLoginGuid]) => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition( + () => loginList.shadowRoot.querySelectorAll(".login-list-item").length, + "Waiting for login-list to get populated" + ); + let { listItem: regularListItem } = loginList._logins[regularLoginGuid]; + let { listItem: vulnerableListItem } = + loginList._logins[vulnerableLoginGuid]; + let { listItem: breachedListItem } = loginList._logins[breachedLoginGuid]; + await ContentTaskUtils.waitForCondition(() => { + return ( + !regularListItem.matches(".breached.vulnerable") && + vulnerableListItem.matches(".vulnerable") && + breachedListItem.matches(".breached") + ); + }, `waiting for the list items to get their classes updated: ${regularListItem.className} / ${vulnerableListItem.className} / ${breachedListItem.className}`); + Assert.ok( + !regularListItem.classList.contains("breached") && + !regularListItem.classList.contains("vulnerable"), + "regular login should not be marked breached or vulnerable: " + + regularLoginGuid.className + ); + Assert.ok( + !vulnerableListItem.classList.contains("breached") && + vulnerableListItem.classList.contains("vulnerable"), + "vulnerable login should be marked vulnerable: " + + vulnerableListItem.className + ); + Assert.ok( + breachedListItem.classList.contains("breached") && + !breachedListItem.classList.contains("vulnerable"), + "breached login should be marked breached: " + + breachedListItem.className + ); + + breachedListItem.click(); + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition(() => { + return loginItem._login && loginItem._login.guid == breachedLoginGuid; + }, "waiting for breached login to get selected"); + Assert.ok( + !ContentTaskUtils.is_hidden( + loginItem.shadowRoot.querySelector(".breach-alert") + ), + "the breach alert should be visible" + ); + } + ); + + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + info( + "leaving test early since the remaining part of the test requires 'edit' mode which requires 'oskeystore' login" + ); + return; + } + + let reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + // Change the password on the breached login and check that the + // login is no longer marked as breached. The vulnerable login + // should still be marked as vulnerable afterwards. + await SpecialPowers.spawn(browser, [], () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + loginItem.shadowRoot.querySelector(".edit-button").click(); + }); + await reauthObserved; + await SpecialPowers.spawn( + browser, + [[TEST_LOGIN1.guid, VULNERABLE_TEST_LOGIN2.guid, TEST_LOGIN3.guid]], + async ([regularLoginGuid, vulnerableLoginGuid, breachedLoginGuid]) => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing == "true", + "waiting for login-item to enter edit mode" + ); + + // The password display field is in the DOM when password input is unfocused. + // To get the password input field, ensure it receives focus. + let passwordInput = loginItem.shadowRoot.querySelector( + "input[type='password']" + ); + passwordInput.focus(); + passwordInput = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + + const CHANGED_PASSWORD_VALUE = "changedPassword"; + passwordInput.value = CHANGED_PASSWORD_VALUE; + let saveChangesButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + saveChangesButton.click(); + + await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._logins[breachedLoginGuid].login.password == + CHANGED_PASSWORD_VALUE + ); + }, "waiting for stored login to get updated"); + + Assert.ok( + ContentTaskUtils.is_hidden( + loginItem.shadowRoot.querySelector(".breach-alert") + ), + "the breach alert should be hidden now" + ); + + let { listItem: breachedListItem } = loginList._logins[breachedLoginGuid]; + let { listItem: vulnerableListItem } = + loginList._logins[vulnerableLoginGuid]; + Assert.ok( + !breachedListItem.classList.contains("breached") && + !breachedListItem.classList.contains("vulnerable"), + "the originally breached login should no longer be marked as breached" + ); + Assert.ok( + !vulnerableListItem.classList.contains("breached") && + vulnerableListItem.classList.contains("vulnerable"), + "vulnerable login should still be marked vulnerable: " + + vulnerableListItem.className + ); + + // Change the password on the vulnerable login and check that the + // login is no longer marked as vulnerable. + vulnerableListItem.click(); + await ContentTaskUtils.waitForCondition(() => { + return loginItem._login && loginItem._login.guid == vulnerableLoginGuid; + }, "waiting for vulnerable login to get selected"); + Assert.ok( + !ContentTaskUtils.is_hidden( + loginItem.shadowRoot.querySelector(".vulnerable-alert") + ), + "the vulnerable alert should be visible" + ); + loginItem.shadowRoot.querySelector(".edit-button").click(); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing == "true", + "waiting for login-item to enter edit mode" + ); + + passwordInput.value = CHANGED_PASSWORD_VALUE; + saveChangesButton.click(); + + await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._logins[vulnerableLoginGuid].login.password == + CHANGED_PASSWORD_VALUE + ); + }, "waiting for stored login to get updated"); + + Assert.ok( + ContentTaskUtils.is_hidden( + loginItem.shadowRoot.querySelector(".vulnerable-alert") + ), + "the vulnerable alert should be hidden now" + ); + Assert.equal( + vulnerableListItem.querySelector(".alert-icon").src, + "", + ".alert-icon for the vulnerable list item should have its source removed" + ); + vulnerableListItem = loginList._logins[vulnerableLoginGuid].listItem; + Assert.ok( + !vulnerableListItem.classList.contains("breached") && + !vulnerableListItem.classList.contains("vulnerable"), + "vulnerable login should no longer be marked vulnerable: " + + vulnerableListItem.className + ); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_breachAlertShowingForAddedLogin.js b/browser/components/aboutlogins/tests/browser/browser_breachAlertShowingForAddedLogin.js new file mode 100644 index 0000000000..a5aef703fa --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_breachAlertShowingForAddedLogin.js @@ -0,0 +1,123 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +EXPECTED_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.example.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", +}; + +add_setup(async function () { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_added_login_shows_breach_warning() { + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 0, + "the login list should be empty" + ); + }); + + TEST_LOGIN3 = await addLogin(TEST_LOGIN3); + await SpecialPowers.spawn( + browser, + [TEST_LOGIN3.guid], + async aTestLogin3Guid => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition( + () => loginList._loginGuidsSortedOrder.length == 1, + "waiting for login list count to equal one. count=" + + loginList._loginGuidsSortedOrder.length + ); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 1, + "one login should be in the list" + ); + let breachedLoginListItems; + await ContentTaskUtils.waitForCondition(() => { + breachedLoginListItems = loginList._list.querySelectorAll( + ".login-list-item[data-guid].breached" + ); + return breachedLoginListItems.length == 1; + }, "waiting for the login to get marked as breached"); + Assert.equal( + breachedLoginListItems[0].dataset.guid, + aTestLogin3Guid, + "the breached login should be login3" + ); + } + ); + + info("adding a login that uses the same password as the breached login"); + let vulnerableLogin = new nsLoginInfo( + "https://2.example.com", + "https://2.example.com", + null, + "user2", + "pass3", + "username", + "password" + ); + vulnerableLogin = await addLogin(vulnerableLogin); + await SpecialPowers.spawn( + browser, + [[TEST_LOGIN3.guid, vulnerableLogin.guid]], + async ([aTestLogin3Guid, aVulnerableLoginGuid]) => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition( + () => loginList._loginGuidsSortedOrder.length == 2, + "waiting for login list count to equal two. count=" + + loginList._loginGuidsSortedOrder.length + ); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 2, + "two logins should be in the list" + ); + let breachedAndVulnerableLoginListItems; + await ContentTaskUtils.waitForCondition(() => { + breachedAndVulnerableLoginListItems = [ + ...loginList._list.querySelectorAll(".breached, .vulnerable"), + ]; + return breachedAndVulnerableLoginListItems.length == 2; + }, "waiting for the logins to get marked as breached and vulnerable"); + Assert.ok( + !!breachedAndVulnerableLoginListItems.find( + listItem => listItem.dataset.guid == aTestLogin3Guid + ), + "the list should include the breached login: " + + breachedAndVulnerableLoginListItems.map(li => li.dataset.guid) + ); + Assert.ok( + !!breachedAndVulnerableLoginListItems.find( + listItem => listItem.dataset.guid == aVulnerableLoginGuid + ), + "the list should include the vulnerable login: " + + breachedAndVulnerableLoginListItems.map(li => li.dataset.guid) + ); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_confirmDeleteDialog.js b/browser/components/aboutlogins/tests/browser/browser_confirmDeleteDialog.js new file mode 100644 index 0000000000..52c288c780 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_confirmDeleteDialog.js @@ -0,0 +1,128 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test() { + let browser = gBrowser.selectedBrowser; + + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + + let showPromise = loginItem.showConfirmationDialog("delete"); + + let dialog = Cu.waiveXrays( + content.document.querySelector("confirmation-dialog") + ); + let cancelButton = dialog.shadowRoot.querySelector(".cancel-button"); + let confirmDeleteButton = + dialog.shadowRoot.querySelector(".confirm-button"); + let dismissButton = dialog.shadowRoot.querySelector(".dismiss-button"); + let message = dialog.shadowRoot.querySelector(".message"); + let title = dialog.shadowRoot.querySelector(".title"); + + await content.document.l10n.translateElements([ + title, + message, + cancelButton, + confirmDeleteButton, + ]); + + Assert.equal( + title.textContent, + "Remove this login?", + "Title contents should match l10n attribute set on outer element" + ); + Assert.equal( + message.textContent, + "This action cannot be undone.", + "Message contents should match l10n attribute set on outer element" + ); + Assert.equal( + cancelButton.textContent, + "Cancel", + "Cancel button contents should match l10n attribute set on outer element" + ); + Assert.equal( + confirmDeleteButton.textContent, + "Remove", + "Remove button contents should match l10n attribute set on outer element" + ); + + cancelButton.click(); + try { + await showPromise; + Assert.ok( + false, + "Promise returned by show() should not resolve after clicking cancel button" + ); + } catch (ex) { + Assert.ok( + true, + "Promise returned by show() should reject after clicking cancel button" + ); + } + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden" + ); + Assert.ok( + dialog.hidden, + "Dialog should be hidden after clicking cancel button" + ); + + showPromise = loginItem.showConfirmationDialog("delete"); + dismissButton.click(); + try { + await showPromise; + Assert.ok( + false, + "Promise returned by show() should not resolve after clicking dismiss button" + ); + } catch (ex) { + Assert.ok( + true, + "Promise returned by show() should reject after clicking dismiss button" + ); + } + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden" + ); + Assert.ok( + dialog.hidden, + "Dialog should be hidden after clicking dismiss button" + ); + + showPromise = loginItem.showConfirmationDialog("delete"); + confirmDeleteButton.click(); + try { + await showPromise; + Assert.ok( + true, + "Promise returned by show() should resolve after clicking confirm button" + ); + } catch (ex) { + Assert.ok( + false, + "Promise returned by show() should not reject after clicking confirm button" + ); + } + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden" + ); + Assert.ok( + dialog.hidden, + "Dialog should be hidden after clicking confirm button" + ); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_contextmenuFillLogins.js b/browser/components/aboutlogins/tests/browser/browser_contextmenuFillLogins.js new file mode 100644 index 0000000000..a10d92baac --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_contextmenuFillLogins.js @@ -0,0 +1,185 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +const gTests = [ + { + name: "test contextmenu on password field in create login view", + async setup(browser) { + // load up the create login view + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let createButton = loginList._createLoginButton; + createButton.click(); + }); + }, + }, +]; + +if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + gTests[gTests.length] = { + name: "test contextmenu on password field in edit login view", + async setup(browser) { + let osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + + // load up the edit login view + await SpecialPowers.spawn( + browser, + [LoginHelper.loginToVanillaObject(TEST_LOGIN1)], + async login => { + let loginList = content.document.querySelector("login-list"); + let loginListItem = loginList.shadowRoot.querySelector( + ".login-list-item[data-guid]:not([hidden])" + ); + info("Clicking on the first login"); + + loginListItem.click(); + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition(() => { + return ( + loginItem._login.guid == loginListItem.dataset.guid && + loginItem._login.guid == login.guid + ); + }, "Waiting for login item to get populated"); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + } + ); + await osAuthDialogShown; + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "Waiting for login-item to be in editing state" + ); + }); + }, + }; +} + +/** + * Synthesize mouse clicks to open the password manager context menu popup + * for a target input element. + * + */ +async function openContextMenuForPasswordInput(browser) { + const doc = browser.ownerDocument; + const CONTEXT_MENU = doc.getElementById("contentAreaContextMenu"); + + let contextMenuShownPromise = BrowserTestUtils.waitForEvent( + CONTEXT_MENU, + "popupshown" + ); + + let passwordInputCoords = await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + + // The password display field is in the DOM when password input is unfocused. + // To get the password input field, ensure it receives focus. + let passwordInput = loginItem.shadowRoot.querySelector( + "input[type='password']" + ); + passwordInput.focus(); + passwordInput = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + + passwordInput.focus(); + let passwordRect = passwordInput.getBoundingClientRect(); + + // listen for the contextmenu event so we can assert on receiving it + // and examine the target + content.contextmenuPromise = new Promise(resolve => { + content.document.body.addEventListener( + "contextmenu", + event => { + info( + `Received event on target: ${event.target.nodeName}, type: ${event.target.type}` + ); + content.console.log("got contextmenu event: ", event); + resolve(event); + }, + { once: true } + ); + }); + + let coords = { + x: passwordRect.x + passwordRect.width / 2, + y: passwordRect.y + passwordRect.height / 2, + }; + return coords; + }); + + // add the offsets of the <browser> in the chrome window + let browserOffsets = browser.getBoundingClientRect(); + let offsetX = browserOffsets.x + passwordInputCoords.x; + let offsetY = browserOffsets.y + passwordInputCoords.y; + + // Synthesize a right mouse click over the password input element, we have to trigger + // both events because formfill code relies on this event happening before the contextmenu + // (which it does for real user input) in order to not show the password autocomplete. + let eventDetails = { type: "mousedown", button: 2 }; + await EventUtils.synthesizeMouseAtPoint(offsetX, offsetY, eventDetails); + + // Synthesize a contextmenu event to actually open the context menu. + eventDetails = { type: "contextmenu", button: 2 }; + await EventUtils.synthesizeMouseAtPoint(offsetX, offsetY, eventDetails); + + await SpecialPowers.spawn(browser, [], async () => { + let event = await content.contextmenuPromise; + // XXX the event target here is the login-item element, + // not the input[type='password'] in its shadowRoot + info("contextmenu event target: " + event.target.nodeName); + }); + + info("waiting for contextMenuShownPromise"); + await contextMenuShownPromise; + return CONTEXT_MENU; +} + +async function testContextMenuOnInputField(testData) { + let browser = gBrowser.selectedBrowser; + + await SimpleTest.promiseFocus(browser.ownerGlobal); + await testData.setup(browser); + + info("test setup completed"); + let contextMenu = await openContextMenuForPasswordInput(browser); + let fillItem = contextMenu.querySelector("#fill-login"); + Assert.ok(fillItem, "fill menu item exists"); + Assert.ok( + fillItem && EventUtils.isHidden(fillItem), + "fill menu item is hidden" + ); + + let promiseHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + info("Calling hidePopup on contextMenu"); + contextMenu.hidePopup(); + info("waiting for promiseHidden"); + await promiseHidden; +} + +for (let testData of gTests) { + let tmp = { + async [testData.name]() { + await testContextMenuOnInputField(testData); + }, + }; + add_task(tmp[testData.name]); +} diff --git a/browser/components/aboutlogins/tests/browser/browser_copyToClipboardButton.js b/browser/components/aboutlogins/tests/browser/browser_copyToClipboardButton.js new file mode 100644 index 0000000000..f8ca37cc47 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_copyToClipboardButton.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.events.testing.asyncClipboard", true]], + }); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:logins" }, + async function (browser) { + let TEST_LOGIN = { + guid: "70a", + username: "jared", + password: "deraj", + origin: "https://www.example.com", + }; + + await SpecialPowers.spawn(browser, [TEST_LOGIN], async function (login) { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + + // The login object needs to be cloned into the content global. + loginItem.setLogin(Cu.cloneInto(login, content)); + + // Lower the timeout for the test. + Object.defineProperty( + loginItem.constructor, + "COPY_BUTTON_RESET_TIMEOUT", + { + configurable: true, + writable: true, + value: 1000, + } + ); + }); + + let testCases = [[TEST_LOGIN.username, ".copy-username-button"]]; + if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + testCases[1] = [TEST_LOGIN.password, ".copy-password-button"]; + } + + for (let testCase of testCases) { + let testObj = { + expectedValue: testCase[0], + copyButtonSelector: testCase[1], + }; + info( + "waiting for " + testObj.expectedValue + " to be placed on clipboard" + ); + let reauthObserved = true; + if (testObj.copyButtonSelector.includes("password")) { + reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } + + await SimpleTest.promiseClipboardChange( + testObj.expectedValue, + async () => { + await SpecialPowers.spawn( + browser, + [testObj], + async function (aTestObj) { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector( + aTestObj.copyButtonSelector + ); + info("Clicking 'copy' button"); + copyButton.click(); + } + ); + } + ); + await reauthObserved; + Assert.ok(true, testObj.expectedValue + " is on clipboard now"); + + await SpecialPowers.spawn( + browser, + [testObj], + async function (aTestObj) { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let copyButton = loginItem.shadowRoot.querySelector( + aTestObj.copyButtonSelector + ); + let otherCopyButton = + copyButton == loginItem._copyUsernameButton + ? loginItem._copyPasswordButton + : loginItem._copyUsernameButton; + Assert.ok( + !otherCopyButton.dataset.copied, + "The other copy button should have the 'copied' state removed" + ); + Assert.ok( + copyButton.dataset.copied, + "Success message should be shown" + ); + } + ); + } + + // Wait for the 'copied' attribute to get removed from the copyPassword + // button, which is the last button that is clicked in the above testcase. + // Since another Copy button isn't clicked, the state won't get cleared + // instantly. This test covers the built-in timeout of the visual display. + await SpecialPowers.spawn(browser, [], async () => { + let copyButton = Cu.waiveXrays( + content.document.querySelector("login-item") + )._copyPasswordButton; + await ContentTaskUtils.waitForCondition( + () => !copyButton.dataset.copied, + "'copied' attribute should be removed after a timeout" + ); + }); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_createLogin.js b/browser/components/aboutlogins/tests/browser/browser_createLogin.js new file mode 100644 index 0000000000..a876320ecf --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_createLogin.js @@ -0,0 +1,535 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + let aboutLoginsTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(aboutLoginsTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_create_login() { + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + Assert.ok( + !loginList._selectedGuid, + "should not be a selected guid by default" + ); + Assert.ok( + content.document.documentElement.classList.contains("no-logins"), + "Should initially be in no logins view" + ); + Assert.ok( + loginList.classList.contains("no-logins"), + "login-list should initially be in no logins view" + ); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 0, + "login list should be empty" + ); + }); + + let testCases = [ + ["ftp://ftp.example.com/", "ftp://ftp.example.com"], + ["https://example.com/foo", "https://example.com"], + ["http://example.com/", "http://example.com"], + [ + "https://testuser1:testpass1@bugzilla.mozilla.org/show_bug.cgi?id=1556934", + "https://bugzilla.mozilla.org", + ], + ["https://www.example.com/bar", "https://www.example.com"], + ]; + + for (let i = 0; i < testCases.length; i++) { + let originTuple = testCases[i]; + info("Testcase " + i); + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + + await SpecialPowers.spawn( + browser, + [[originTuple, i]], + async ([aOriginTuple, index]) => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let createButton = loginList._createLoginButton; + Assert.ok( + ContentTaskUtils.is_hidden(loginList._blankLoginListItem), + "the blank login list item should be hidden initially" + ); + Assert.ok( + !createButton.disabled, + "Create button should not be disabled initially" + ); + + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + usernameInput.placeholder = "dummy placeholder"; + + createButton.click(); + + Assert.ok( + ContentTaskUtils.is_visible(loginList._blankLoginListItem), + "the blank login list item should be visible after clicking on the create button" + ); + Assert.ok( + createButton.disabled, + "Create button should be disabled after being clicked" + ); + + let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button"); + Assert.ok( + ContentTaskUtils.is_visible(cancelButton), + "cancel button should be visible in create mode with no logins saved" + ); + + let originInput = loginItem.shadowRoot.querySelector( + "input[name='origin']" + ); + let passwordInput = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + + Assert.equal( + content.document.l10n.getAttributes(usernameInput).id, + null, + "there should be no placeholder id on the username input in edit mode" + ); + Assert.equal( + usernameInput.placeholder, + "", + "there should be no placeholder on the username input in edit mode" + ); + originInput.value = aOriginTuple[0]; + usernameInput.value = "testuser1"; + passwordInput.value = "testpass1"; + + let saveChangesButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + saveChangesButton.click(); + } + ); + + info("waiting for login to get added to storage"); + await storageChangedPromised; + info("login added to storage"); + + let canTestOSKeyStoreLogin = OSKeyStoreTestUtils.canTestOSKeyStoreLogin(); + if (canTestOSKeyStoreLogin) { + storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + } + await SpecialPowers.spawn(browser, [originTuple], async aOriginTuple => { + await ContentTaskUtils.waitForCondition(() => { + return !content.document.documentElement.classList.contains( + "no-logins" + ); + }, "waiting for no-logins view to exit"); + Assert.ok( + !content.document.documentElement.classList.contains("no-logins"), + "Should no longer be in no logins view" + ); + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + Assert.ok( + !loginList.classList.contains("no-logins"), + "login-list should no longer be in no logins view" + ); + Assert.ok( + ContentTaskUtils.is_hidden(loginList._blankLoginListItem), + "the blank login list item should be hidden after adding new login" + ); + Assert.ok( + !loginList._createLoginButton.disabled, + "Create button shouldn't be disabled after exiting create login view" + ); + + let loginGuid = await ContentTaskUtils.waitForCondition(() => { + return loginList._loginGuidsSortedOrder.find( + guid => loginList._logins[guid].login.origin == aOriginTuple[1] + ); + }, "Waiting for login to be displayed"); + Assert.ok(loginGuid, "Expected login found in login-list"); + + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + Assert.equal(loginItem._login.guid, loginGuid, "login-item should match"); + + let { login, listItem } = loginList._logins[loginGuid]; + Assert.ok( + listItem.classList.contains("selected"), + "list item should be selected" + ); + Assert.equal( + login.origin, + aOriginTuple[1], + "Stored login should only include the origin of the URL provided during creation" + ); + Assert.equal( + login.username, + "testuser1", + "Stored login should have username provided during creation" + ); + Assert.equal( + login.password, + "testpass1", + "Stored login should have password provided during creation" + ); + + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + await ContentTaskUtils.waitForCondition( + () => usernameInput.placeholder, + "waiting for placeholder to get set" + ); + Assert.ok( + usernameInput.placeholder, + "there should be a placeholder on the username input when not in edit mode" + ); + }); + + if (!canTestOSKeyStoreLogin) { + continue; + } + + let reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + await SpecialPowers.spawn(browser, [originTuple], async aOriginTuple => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + info("clicking on edit button"); + editButton.click(); + }); + info("waiting for oskeystore auth"); + await reauthObserved; + + await SpecialPowers.spawn(browser, [originTuple], async aOriginTuple => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "waiting for 'edit' mode" + ); + info("in edit mode"); + + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + let passwordInput = loginItem.shadowRoot.querySelector( + "input[type='password']" + ); + passwordInput.focus(); + passwordInput = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + usernameInput.value = "testuser2"; + passwordInput.value = "testpass2"; + + let saveChangesButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + info("clicking save changes button"); + saveChangesButton.click(); + }); + + info("waiting for login to get modified in storage"); + await storageChangedPromised; + info("login modified in storage"); + + await SpecialPowers.spawn(browser, [originTuple], async aOriginTuple => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let login; + await ContentTaskUtils.waitForCondition(() => { + login = Object.values(loginList._logins).find( + obj => obj.login.origin == aOriginTuple[1] + ).login; + info(`${login.origin} / ${login.username} / ${login.password}`); + return ( + login.origin == aOriginTuple[1] && + login.username == "testuser2" && + login.password == "testpass2" + ); + }, "waiting for the login to get updated"); + Assert.equal( + login.origin, + aOriginTuple[1], + "Stored login should only include the origin of the URL provided during creation" + ); + Assert.equal( + login.username, + "testuser2", + "Stored login should have modified username" + ); + Assert.equal( + login.password, + "testpass2", + "Stored login should have modified password" + ); + }); + } + + await SpecialPowers.spawn( + browser, + [testCases.length], + async testCasesLength => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 5, + "login list should have a login per testcase" + ); + } + ); +}); + +add_task(async function test_cancel_create_login() { + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + Assert.ok( + loginList._selectedGuid, + "there should be a selected guid before create mode" + ); + Assert.ok( + ContentTaskUtils.is_hidden(loginList._blankLoginListItem), + "the blank login list item should be hidden before create mode" + ); + + let createButton = content.document + .querySelector("login-list") + .shadowRoot.querySelector(".create-login-button"); + createButton.click(); + + Assert.ok( + !loginList._selectedGuid, + "there should be no selected guid when in create mode" + ); + Assert.ok( + ContentTaskUtils.is_visible(loginList._blankLoginListItem), + "the blank login list item should be visible in create mode" + ); + + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button"); + cancelButton.click(); + + Assert.ok( + loginList._selectedGuid, + "there should be a selected guid after canceling create mode" + ); + Assert.ok( + ContentTaskUtils.is_hidden(loginList._blankLoginListItem), + "the blank login list item should be hidden after canceling create mode" + ); + }); +}); + +add_task( + async function test_cancel_create_login_with_filter_showing_one_login() { + const browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + const loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + + const loginFilter = Cu.waiveXrays( + loginList.shadowRoot.querySelector("login-filter") + ); + loginFilter.value = "bugzilla.mozilla.org"; + Assert.equal( + loginList._list.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ).length, + 1, + "filter should have one login showing" + ); + let visibleLoginGuid = loginList.shadowRoot.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + )[0].dataset.guid; + + let createButton = loginList._createLoginButton; + createButton.click(); + + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button"); + Assert.ok( + ContentTaskUtils.is_visible(cancelButton), + "cancel button should be visible in create mode with one login showing" + ); + cancelButton.click(); + + Assert.equal( + loginFilter.value, + "bugzilla.mozilla.org", + "login-filter should not be cleared if there was a login in the list" + ); + Assert.equal( + loginList.shadowRoot.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + )[0].dataset.guid, + visibleLoginGuid, + "the same login should still be visible" + ); + }); + } +); + +add_task(async function test_cancel_create_login_with_logins_filtered_out() { + const browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + const loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + const loginFilter = Cu.waiveXrays( + loginList.shadowRoot.querySelector("login-filter") + ); + loginFilter.value = "XXX-no-logins-should-match-this-XXX"; + await Promise.resolve(); + Assert.equal( + loginList._list.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ).length, + 0, + "filter should have no logins showing" + ); + + let createButton = loginList._createLoginButton; + createButton.click(); + + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button"); + Assert.ok( + ContentTaskUtils.is_visible(cancelButton), + "cancel button should be visible in create mode with no logins showing" + ); + cancelButton.click(); + await Promise.resolve(); + + Assert.equal( + loginFilter.value, + "", + "login-filter should be cleared if there were no logins in the list" + ); + let visibleLoginItems = loginList.shadowRoot.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ); + Assert.equal( + visibleLoginItems.length, + 5, + "all logins should be visible with blank filter" + ); + Assert.equal( + loginList._selectedGuid, + visibleLoginItems[0].dataset.guid, + "the first item in the list should be selected" + ); + }); +}); + +add_task(async function test_create_duplicate_login() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + return; + } + + let browser = gBrowser.selectedBrowser; + EXPECTED_ERROR_MESSAGE = "This login already exists."; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let createButton = loginList._createLoginButton; + createButton.click(); + + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let originInput = loginItem.shadowRoot.querySelector( + "input[name='origin']" + ); + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + let passwordInput = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + const EXISTING_USERNAME = "testuser2"; + const EXISTING_ORIGIN = "https://example.com"; + originInput.value = EXISTING_ORIGIN; + usernameInput.value = EXISTING_USERNAME; + passwordInput.value = "different password value"; + + let saveChangesButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + saveChangesButton.click(); + + await ContentTaskUtils.waitForCondition( + () => !loginItem._errorMessage.hidden, + "waiting until the error message is visible" + ); + let duplicatedGuid = Object.values(loginList._logins).find( + v => + v.login.origin == EXISTING_ORIGIN && + v.login.username == EXISTING_USERNAME + ).login.guid; + Assert.equal( + loginItem._errorMessageLink.dataset.errorGuid, + duplicatedGuid, + "Error message has GUID of existing duplicated login set on it" + ); + + let confirmationDialog = Cu.waiveXrays( + content.document.querySelector("confirmation-dialog") + ); + Assert.ok( + confirmationDialog.hidden, + "the discard-changes dialog should be hidden before clicking the error-message-text" + ); + loginItem._errorMessageLink.querySelector("a").click(); + Assert.ok( + !confirmationDialog.hidden, + "the discard-changes dialog should be visible" + ); + let discardChangesButton = + confirmationDialog.shadowRoot.querySelector(".confirm-button"); + discardChangesButton.click(); + + await ContentTaskUtils.waitForCondition( + () => + Object.keys(loginItem._login).length > 1 && + loginItem._login.guid == duplicatedGuid, + "waiting until the existing duplicated login is selected" + ); + Assert.equal( + loginList._selectedGuid, + duplicatedGuid, + "the duplicated login should be selected in the list" + ); + }); + EXPECTED_ERROR_MESSAGE = null; +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_deleteLogin.js b/browser/components/aboutlogins/tests/browser/browser_deleteLogin.js new file mode 100644 index 0000000000..8aa4201378 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_deleteLogin.js @@ -0,0 +1,182 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test_show_logins() { + let browser = gBrowser.selectedBrowser; + + await SpecialPowers.spawn( + browser, + [[TEST_LOGIN1.guid, TEST_LOGIN2.guid]], + async loginGuids => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._loginGuidsSortedOrder.length == 2 && + loginList._loginGuidsSortedOrder.includes(loginGuids[0]) && + loginList._loginGuidsSortedOrder.includes(loginGuids[1]) + ); + }, "Waiting for logins to be displayed"); + Assert.ok( + !content.document.documentElement.classList.contains("no-logins"), + "Should no longer be in no logins view" + ); + Assert.ok( + !loginList.classList.contains("no-logins"), + "login-list should no longer be in no logins view" + ); + Assert.ok(loginFound, "Newly added logins should be added to the page"); + } + ); +}); + +add_task(async function test_login_item() { + let browser = gBrowser.selectedBrowser; + + function waitForDelete() { + let numLogins = Services.logins.countLogins("", "", ""); + return TestUtils.waitForCondition( + () => Services.logins.countLogins("", "", "") < numLogins, + "Error waiting for login deletion" + ); + } + + async function deleteFirstLoginAfterEdit() { + await SpecialPowers.spawn(browser, [], async () => { + let loginList = content.document.querySelector("login-list"); + let loginListItem = loginList.shadowRoot.querySelector( + ".login-list-item[data-guid]:not([hidden])" + ); + info("Clicking on the first login"); + loginListItem.click(); + + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let loginItemPopulated = await ContentTaskUtils.waitForCondition(() => { + return loginItem._login.guid == loginListItem.dataset.guid; + }, "Waiting for login item to get populated"); + Assert.ok(loginItemPopulated, "The login item should get populated"); + }); + let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + }); + await reauthObserved; + return SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + let passwordInput = loginItem._passwordInput; + usernameInput.value += "-undone"; + passwordInput.value += "-undone"; + + let deleteButton = loginItem.shadowRoot.querySelector(".delete-button"); + deleteButton.click(); + + let confirmDeleteDialog = Cu.waiveXrays( + content.document.querySelector("confirmation-dialog") + ); + let confirmButton = + confirmDeleteDialog.shadowRoot.querySelector(".confirm-button"); + confirmButton.click(); + }); + } + + function deleteFirstLogin() { + return SpecialPowers.spawn(browser, [], async () => { + let loginList = content.document.querySelector("login-list"); + let loginListItem = loginList.shadowRoot.querySelector( + ".login-list-item[data-guid]:not([hidden])" + ); + info("Clicking on the first login"); + loginListItem.click(); + + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let loginItemPopulated = await ContentTaskUtils.waitForCondition(() => { + return loginItem._login.guid == loginListItem.dataset.guid; + }, "Waiting for login item to get populated"); + Assert.ok(loginItemPopulated, "The login item should get populated"); + + let deleteButton = loginItem.shadowRoot.querySelector(".delete-button"); + deleteButton.click(); + + let confirmDeleteDialog = Cu.waiveXrays( + content.document.querySelector("confirmation-dialog") + ); + let confirmButton = + confirmDeleteDialog.shadowRoot.querySelector(".confirm-button"); + confirmButton.click(); + }); + } + + let onDeletePromise; + if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + // Can only test Edit mode in official builds + onDeletePromise = waitForDelete(); + await deleteFirstLoginAfterEdit(); + await onDeletePromise; + + await SpecialPowers.spawn(browser, [], async () => { + let loginList = content.document.querySelector("login-list"); + Assert.ok( + !content.document.documentElement.classList.contains("no-logins"), + "Should not be in no logins view as there is still one login" + ); + Assert.ok( + !loginList.classList.contains("no-logins"), + "Should not be in no logins view as there is still one login" + ); + + let confirmDiscardDialog = Cu.waiveXrays( + content.document.querySelector("confirmation-dialog") + ); + Assert.ok( + confirmDiscardDialog.hidden, + "Discard confirm dialog should not show up after delete an edited login" + ); + }); + } else { + onDeletePromise = waitForDelete(); + await deleteFirstLogin(); + await onDeletePromise; + } + + onDeletePromise = waitForDelete(); + await deleteFirstLogin(); + await onDeletePromise; + + await SpecialPowers.spawn(browser, [], async () => { + let loginList = content.document.querySelector("login-list"); + Assert.ok( + content.document.documentElement.classList.contains("no-logins"), + "Should be in no logins view as all logins got deleted" + ); + Assert.ok( + loginList.classList.contains("no-logins"), + "login-list should be in no logins view as all logins got deleted" + ); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_fxAccounts.js b/browser/components/aboutlogins/tests/browser/browser_fxAccounts.js new file mode 100644 index 0000000000..b66f204c92 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_fxAccounts.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { UIState } = ChromeUtils.importESModule( + "resource://services-sync/UIState.sys.mjs" +); + +function mockState(state) { + UIState.get = () => ({ + status: state.status, + lastSync: new Date(), + email: state.email, + avatarURL: state.avatarURL, + }); +} + +add_setup(async function () { + let aboutLoginsTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + let getState = UIState.get; + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(aboutLoginsTab); + UIState.get = getState; + }); +}); + +add_task(async function test_logged_out() { + mockState({ status: UIState.STATUS_NOT_CONFIGURED }); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + let fxAccountsButton = content.document.querySelector("fxaccounts-button"); + Assert.ok(fxAccountsButton, "fxAccountsButton should exist"); + fxAccountsButton = Cu.waiveXrays(fxAccountsButton); + await ContentTaskUtils.waitForCondition( + () => fxAccountsButton._loggedIn === false, + "waiting for _loggedIn to strictly equal false" + ); + Assert.equal( + fxAccountsButton._loggedIn, + false, + "state should reflect not logged in" + ); + }); +}); + +add_task(async function test_login_syncing_enabled() { + const TEST_EMAIL = "test@example.com"; + const TEST_AVATAR_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; + mockState({ + status: UIState.STATUS_SIGNED_IN, + email: TEST_EMAIL, + avatarURL: TEST_AVATAR_URL, + }); + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.engine.passwords", true]], + }); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn( + browser, + [[TEST_EMAIL, TEST_AVATAR_URL]], + async ([expectedEmail, expectedAvatarURL]) => { + let fxAccountsButton = + content.document.querySelector("fxaccounts-button"); + Assert.ok(fxAccountsButton, "fxAccountsButton should exist"); + fxAccountsButton = Cu.waiveXrays(fxAccountsButton); + await ContentTaskUtils.waitForCondition( + () => fxAccountsButton._email === expectedEmail, + "waiting for _email to strictly equal expectedEmail" + ); + Assert.equal( + fxAccountsButton._loggedIn, + true, + "state should reflect logged in" + ); + Assert.equal( + fxAccountsButton._email, + expectedEmail, + "state should have email set" + ); + Assert.equal( + fxAccountsButton._avatarURL, + expectedAvatarURL, + "state should have avatarURL set" + ); + } + ); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_loginFilter.js b/browser/components/aboutlogins/tests/browser/browser_loginFilter.js new file mode 100644 index 0000000000..765e68713f --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_loginFilter.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + const aboutLoginsTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(aboutLoginsTab); + }); +}); + +add_task(async function focus_filter_by_ctrl_f() { + const browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + function getActiveElement() { + let element = content.document.activeElement; + + while (element?.shadowRoot) { + element = element.shadowRoot.activeElement; + } + + return element; + } + + //// State after load + + const loginFilter = content.document + .querySelector("login-list") + .shadowRoot.querySelector("login-filter") + .shadowRoot.querySelector("input"); + Assert.equal( + getActiveElement(), + loginFilter, + "login filter must be focused after opening about:logins" + ); + + //// Focus something else (Create Login button) + + content.document + .querySelector("login-list") + .shadowRoot.querySelector(".create-login-button") + .focus(); + Assert.notEqual( + getActiveElement(), + loginFilter, + "login filter is not focused" + ); + + //// Ctrl+F key + + EventUtils.synthesizeKey("f", { accelKey: true }, content); + Assert.equal( + getActiveElement(), + loginFilter, + "Ctrl+F/Cmd+F focused login filter" + ); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_loginItemErrors.js b/browser/components/aboutlogins/tests/browser/browser_loginItemErrors.js new file mode 100644 index 0000000000..952149f0db --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_loginItemErrors.js @@ -0,0 +1,153 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_showLoginItemErrors() { + const browser = gBrowser.selectedBrowser; + let LOGIN_TO_UPDATE = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "user2", + "pass2" + ); + LOGIN_TO_UPDATE = await Services.logins.addLoginAsync(LOGIN_TO_UPDATE); + EXPECTED_ERROR_MESSAGE = "This login already exists."; + const LOGIN_UPDATES = { + origin: "https://example.com", + password: "my1GoodPassword", + username: "user1", + }; + + await SpecialPowers.spawn( + browser, + [[LoginHelper.loginToVanillaObject(LOGIN_TO_UPDATE), LOGIN_UPDATES]], + async ([loginToUpdate, loginUpdates]) => { + const loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + const loginItemErrorMessage = Cu.waiveXrays( + loginItem.shadowRoot.querySelector(".error-message") + ); + const loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + + const createButton = loginList._createLoginButton; + createButton.click(); + + const event = Cu.cloneInto( + { + bubbles: true, + detail: loginUpdates, + }, + content + ); + + content.dispatchEvent( + // adds first login + new content.CustomEvent("AboutLoginsCreateLogin", event) + ); + + await ContentTaskUtils.waitForCondition(() => { + return ( + loginList.shadowRoot.querySelectorAll(".login-list-item").length === 3 + ); + }, "Waiting for login item to be created."); + + Assert.ok( + loginItemErrorMessage.hidden, + "An error message should not be displayed after adding a new login." + ); + + content.dispatchEvent( + // adds a duplicate of the first login + new content.CustomEvent("AboutLoginsCreateLogin", event) + ); + + const loginItemErrorMessageVisible = + await ContentTaskUtils.waitForCondition(() => { + return !loginItemErrorMessage.hidden; + }, "Waiting for error message to be shown after attempting to create a duplicate login."); + Assert.ok( + loginItemErrorMessageVisible, + "An error message should be shown after user attempts to add a login that already exists." + ); + + const loginItemErrorMessageText = + loginItemErrorMessage.querySelector("span:not([hidden])"); + Assert.equal( + loginItemErrorMessageText.dataset.l10nId, + "about-logins-error-message-duplicate-login-with-link", + "The correct error message is displayed." + ); + + let loginListItem = Cu.waiveXrays( + loginList.shadowRoot.querySelector( + `.login-list-item[data-guid='${loginToUpdate.guid}']` + ) + ); + loginListItem.click(); + + Assert.ok( + loginItemErrorMessage.hidden, + "The error message should no longer be visible." + ); + } + ); + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + // The rest of the test uses Edit mode which causes an OS prompt in official builds. + return; + } + let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn( + browser, + [[LoginHelper.loginToVanillaObject(LOGIN_TO_UPDATE), LOGIN_UPDATES]], + async ([loginToUpdate, loginUpdates]) => { + const loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + const editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + + const updateEvent = Cu.cloneInto( + { + bubbles: true, + detail: Object.assign({ guid: loginToUpdate.guid }, loginUpdates), + }, + content + ); + + content.dispatchEvent( + // attempt to update LOGIN_TO_UPDATE to a username/origin combination that already exists. + new content.CustomEvent("AboutLoginsUpdateLogin", updateEvent) + ); + + const loginItemErrorMessage = Cu.waiveXrays( + loginItem.shadowRoot.querySelector(".error-message") + ); + const loginAlreadyExistsErrorShownAfterUpdate = + await ContentTaskUtils.waitForCondition(() => { + return !loginItemErrorMessage.hidden; + }, "Waiting for error message to show after updating login to existing login."); + Assert.ok( + loginAlreadyExistsErrorShownAfterUpdate, + "An error message should be shown after updating a login to a username/origin combination that already exists." + ); + } + ); + info("making sure os auth dialog is shown"); + await reauthObserved; + info("saw os auth dialog"); + EXPECTED_ERROR_MESSAGE = null; +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_loginListChanges.js b/browser/components/aboutlogins/tests/browser/browser_loginListChanges.js new file mode 100644 index 0000000000..13df6c1ef6 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_loginListChanges.js @@ -0,0 +1,144 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test_login_added() { + let login = { + guid: "70", + username: "jared", + password: "deraj", + origin: "https://www.example.com", + }; + let browser = gBrowser.selectedBrowser; + browser.browsingContext.currentWindowGlobal + .getActor("AboutLogins") + .sendAsyncMessage("AboutLogins:LoginAdded", login); + + await SpecialPowers.spawn(browser, [login], async addedLogin => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._loginGuidsSortedOrder.length == 1 && + loginList._loginGuidsSortedOrder[0] == addedLogin.guid + ); + }, "Waiting for login to be added"); + Assert.ok(loginFound, "Newly added logins should be added to the page"); + }); +}); + +add_task(async function test_login_modified() { + let login = { + guid: "70", + username: "jared@example.com", + password: "deraj", + origin: "https://www.example.com", + }; + let browser = gBrowser.selectedBrowser; + browser.browsingContext.currentWindowGlobal + .getActor("AboutLogins") + .sendAsyncMessage("AboutLogins:LoginModified", login); + + await SpecialPowers.spawn(browser, [login], async modifiedLogin => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._loginGuidsSortedOrder.length == 1 && + loginList._loginGuidsSortedOrder[0] == modifiedLogin.guid && + loginList._logins[loginList._loginGuidsSortedOrder[0]].login.username == + modifiedLogin.username + ); + }, "Waiting for username to get updated"); + Assert.ok(loginFound, "The login should get updated on the page"); + }); +}); + +add_task(async function test_login_removed() { + let login = { + guid: "70", + username: "jared@example.com", + password: "deraj", + origin: "https://www.example.com", + }; + let browser = gBrowser.selectedBrowser; + browser.browsingContext.currentWindowGlobal + .getActor("AboutLogins") + .sendAsyncMessage("AboutLogins:LoginRemoved", login); + + await SpecialPowers.spawn(browser, [login], async removedLogin => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginRemoved = await ContentTaskUtils.waitForCondition(() => { + return !loginList._loginGuidsSortedOrder.length; + }, "Waiting for login to get removed"); + Assert.ok(loginRemoved, "The login should be removed from the page"); + }); +}); + +add_task(async function test_all_logins_removed() { + // Setup the test with 2 logins. + let logins = [ + { + guid: "70", + username: "jared", + password: "deraj", + origin: "https://www.example.com", + }, + { + guid: "71", + username: "ntim", + password: "verysecurepassword", + origin: "https://www.example.com", + }, + ]; + + let browser = gBrowser.selectedBrowser; + browser.browsingContext.currentWindowGlobal + .getActor("AboutLogins") + .sendAsyncMessage("AboutLogins:AllLogins", logins); + + await SpecialPowers.spawn(browser, [logins], async addedLogins => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._loginGuidsSortedOrder.length == 2 && + loginList._loginGuidsSortedOrder[0] == addedLogins[0].guid && + loginList._loginGuidsSortedOrder[1] == addedLogins[1].guid + ); + }, "Waiting for login to be added"); + Assert.ok(loginFound, "Newly added logins should be added to the page"); + Assert.ok( + !content.document.documentElement.classList.contains("no-logins"), + "Should not be in no logins view after adding logins" + ); + Assert.ok( + !loginList.classList.contains("no-logins"), + "login-list should not be in no logins view after adding logins" + ); + }); + + Services.logins.removeAllUserFacingLogins(); + + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return !loginList._loginGuidsSortedOrder.length; + }, "Waiting for logins to be cleared"); + Assert.ok(loginFound, "Logins should be cleared"); + Assert.ok( + content.document.documentElement.classList.contains("no-logins"), + "Should be in no logins view after clearing" + ); + Assert.ok( + loginList.classList.contains("no-logins"), + "login-list should be in no logins view after clearing" + ); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_loginSortOrderRestored.js b/browser/components/aboutlogins/tests/browser/browser_loginSortOrderRestored.js new file mode 100644 index 0000000000..fb39dda30c --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_loginSortOrderRestored.js @@ -0,0 +1,172 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +EXPECTED_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.example.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", +}; + +const SORT_PREF_NAME = "signon.management.page.sort"; + +add_setup(async function () { + TEST_LOGIN3.QueryInterface(Ci.nsILoginMetaInfo).timePasswordChanged = 1; + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + info(`TEST_LOGIN1 added with guid=${TEST_LOGIN1.guid}`); + TEST_LOGIN3 = await addLogin(TEST_LOGIN3); + info(`TEST_LOGIN3 added with guid=${TEST_LOGIN3.guid}`); + registerCleanupFunction(() => { + Services.logins.removeAllUserFacingLogins(); + Services.prefs.clearUserPref(SORT_PREF_NAME); + }); +}); + +add_task(async function test_sort_order_persisted() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:logins", + }, + async function (browser) { + await ContentTask.spawn( + browser, + [TEST_LOGIN1.guid, TEST_LOGIN3.guid], + async function ([testLogin1Guid, testLogin3Guid]) { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition( + () => loginList._sortSelect.value == "alerts", + "Waiting for login-list sort to get changed to 'alerts'. Current value is: " + + loginList._sortSelect.value + ); + Assert.equal( + loginList._sortSelect.value, + "alerts", + "selected sort should be 'alerts' since there is a breached login" + ); + Assert.equal( + loginList._list.querySelector( + ".login-list-item[data-guid]:not([hidden])" + ).dataset.guid, + testLogin3Guid, + "the first login should be TEST_LOGIN3 since they are sorted by alerts" + ); + + loginList._sortSelect.value = "last-changed"; + loginList._sortSelect.dispatchEvent( + new content.Event("change", { bubbles: true }) + ); + Assert.equal( + loginList._list.querySelector( + ".login-list-item[data-guid]:not([hidden])" + ).dataset.guid, + testLogin1Guid, + "the first login should be TEST_LOGIN1 since it has the most recent timePasswordChanged value" + ); + } + ); + } + ); + + Assert.equal( + Services.prefs.getCharPref(SORT_PREF_NAME), + "last-changed", + "'last-changed' should be stored in the pref" + ); + + // Set the pref to the value used in Fx70-76 to confirm our + // backwards-compat support that "breached" is changed to "alerts" + Services.prefs.setCharPref(SORT_PREF_NAME, "breached"); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:logins", + }, + async function (browser) { + await ContentTask.spawn( + browser, + TEST_LOGIN3.guid, + async function (testLogin3Guid) { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition( + () => loginList._sortSelect.value == "alerts", + "Waiting for login-list sort to get changed to 'alerts'. Current value is: " + + loginList._sortSelect.value + ); + Assert.equal( + loginList._sortSelect.value, + "alerts", + "selected sort should be restored to 'alerts' since 'breached' was in prefs" + ); + Assert.equal( + loginList._list.querySelector( + ".login-list-item[data-guid]:not([hidden])" + ).dataset.guid, + testLogin3Guid, + "the first login should be TEST_LOGIN3 since they are sorted by alerts" + ); + } + ); + } + ); + + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "removeLogin" + ); + Services.logins.removeLogin(TEST_LOGIN3); + await storageChangedPromised; + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + + Assert.equal( + Services.prefs.getCharPref(SORT_PREF_NAME), + "breached", + "confirm that the stored sort is still 'breached' and as such shouldn't apply when the page loads" + ); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:logins", + }, + async function (browser) { + await ContentTask.spawn( + browser, + TEST_LOGIN2.guid, + async function (testLogin2Guid) { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition( + () => + loginList._list.querySelector( + ".login-list-item[data-guid]:not([hidden])" + ), + "wait for a visible loging to get populated" + ); + Assert.equal( + loginList._sortSelect.value, + "name", + "selected sort should be name since 'alerts' no longer applies with no breached or vulnerable logins" + ); + Assert.equal( + loginList._list.querySelector( + ".login-list-item[data-guid]:not([hidden])" + ).dataset.guid, + testLogin2Guid, + "the first login should be TEST_LOGIN2 since it is sorted first by 'name'" + ); + } + ); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_noLoginsView.js b/browser/components/aboutlogins/tests/browser/browser_noLoginsView.js new file mode 100644 index 0000000000..b86304aac1 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_noLoginsView.js @@ -0,0 +1,199 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test_no_logins_class() { + let { platform } = AppConstants; + let wizardPromise; + + // The import link is hidden on Linux, so we don't wait for the migration + // wizard to open on that platform. + if (AppConstants.platform != "linux") { + wizardPromise = BrowserTestUtils.waitForMigrationWizard(window); + } + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [platform], + async aPlatform => { + let loginList = content.document.querySelector("login-list"); + + Assert.ok( + content.document.documentElement.classList.contains("no-logins"), + "root should be in no logins view" + ); + Assert.ok( + loginList.classList.contains("no-logins"), + "login-list should be in no logins view" + ); + + let loginIntro = Cu.waiveXrays( + content.document.querySelector("login-intro") + ); + let loginItem = content.document.querySelector("login-item"); + let loginListIntro = loginList.shadowRoot.querySelector(".intro"); + let loginListList = loginList.shadowRoot.querySelector("ol"); + + Assert.ok( + !ContentTaskUtils.is_hidden(loginIntro), + "login-intro should be shown in no logins view" + ); + Assert.ok( + !ContentTaskUtils.is_hidden(loginListIntro), + "login-list intro should be shown in no logins view" + ); + + Assert.ok( + ContentTaskUtils.is_hidden(loginItem), + "login-item should be hidden in no logins view" + ); + Assert.ok( + ContentTaskUtils.is_hidden(loginListList), + "login-list logins list should be hidden in no logins view" + ); + Assert.equal( + content.document.l10n.getAttributes( + loginIntro.shadowRoot.querySelector(".heading") + ).id, + "about-logins-login-intro-heading-logged-out2", + "The default message should be the non-logged-in message" + ); + Assert.ok( + loginIntro.shadowRoot + .querySelector("a.intro-help-link") + .href.includes("password-manager-remember-delete-edit-logins"), + "Check support href populated" + ); + + loginIntro.updateState(Cu.cloneInto({ loggedIn: true }, content)); + + Assert.equal( + content.document.l10n.getAttributes( + loginIntro.shadowRoot.querySelector(".heading") + ).id, + "about-logins-login-intro-heading-logged-in", + "When logged in the message should update" + ); + + let importClass = Services.prefs.getBoolPref( + "signon.management.page.fileImport.enabled" + ) + ? ".intro-import-text.file-import" + : ".intro-import-text.no-file-import"; + Assert.equal( + ContentTaskUtils.is_hidden( + loginIntro.shadowRoot.querySelector(importClass) + ), + aPlatform == "linux", + "the import link should be hidden on Linux builds" + ); + if (aPlatform == "linux") { + // End the test now for Linux since the link is hidden. + return; + } + loginIntro.shadowRoot.querySelector(importClass + " > a").click(); + info("waiting for MigrationWizard to open"); + } + ); + if (AppConstants.platform == "linux") { + // End the test now for Linux since the link is hidden. + return; + } + let wizard = await wizardPromise; + Assert.ok(wizard, "Migrator window opened"); + await BrowserTestUtils.closeMigrationWizard(wizard); +}); + +add_task( + async function login_selected_when_login_added_and_in_no_logins_view() { + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + let loginList = content.document.querySelector("login-list"); + let loginItem = content.document.querySelector("login-item"); + let loginIntro = content.document.querySelector("login-intro"); + Assert.ok( + loginList.classList.contains("empty-search"), + "login-list should be showing no logins view from a search with no results" + ); + Assert.ok( + loginList.classList.contains("no-logins"), + "login-list should be showing no logins view since there are no saved logins" + ); + Assert.ok( + !loginList.classList.contains("create-login-selected"), + "login-list should not be in create-login-selected mode" + ); + Assert.ok( + loginItem.classList.contains("no-logins"), + "login-item should be marked as having no-logins" + ); + Assert.ok( + ContentTaskUtils.is_hidden(loginItem), + "login-item should be hidden" + ); + Assert.ok( + !ContentTaskUtils.is_hidden(loginIntro), + "login-intro should be visible" + ); + }); + + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [TEST_LOGIN1.guid], + async testLogin1Guid => { + let loginList = content.document.querySelector("login-list"); + let loginItem = content.document.querySelector("login-item"); + let loginIntro = content.document.querySelector("login-intro"); + await ContentTaskUtils.waitForCondition(() => { + return !loginList.classList.contains("no-logins"); + }, "waiting for login-list to leave the no-logins view"); + Assert.ok( + !loginList.classList.contains("empty-search"), + "login-list should not be showing no logins view since one login exists" + ); + Assert.ok( + !loginList.classList.contains("no-logins"), + "login-list should not be showing no logins view since one login exists" + ); + Assert.ok( + !loginList.classList.contains("create-login-selected"), + "login-list should not be in create-login-selected mode" + ); + Assert.equal( + loginList.shadowRoot.querySelector( + ".login-list-item.selected[data-guid]" + ).dataset.guid, + testLogin1Guid, + "the login that was just added should be selected" + ); + Assert.ok( + !loginItem.classList.contains("no-logins"), + "login-item should not be marked as having no-logins" + ); + Assert.equal( + Cu.waiveXrays(loginItem)._login.guid, + testLogin1Guid, + "the login-item should have the newly added login selected" + ); + Assert.ok( + !ContentTaskUtils.is_hidden(loginItem), + "login-item should be visible" + ); + Assert.ok( + ContentTaskUtils.is_hidden(loginIntro), + "login-intro should be hidden" + ); + } + ); + } +); diff --git a/browser/components/aboutlogins/tests/browser/browser_openExport.js b/browser/components/aboutlogins/tests/browser/browser_openExport.js new file mode 100644 index 0000000000..c5df84c447 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openExport.js @@ -0,0 +1,149 @@ +/* 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/. */ + +"use strict"; + +/** + * Test the export logins file picker appears. + */ + +let { OSKeyStore } = ChromeUtils.importESModule( + "resource://gre/modules/OSKeyStore.sys.mjs" +); +let { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +let { MockFilePicker } = SpecialPowers; + +add_setup(async function () { + await TestUtils.waitForCondition(() => { + Services.telemetry.clearEvents(); + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + return !events || !events.length; + }, "Waiting for content telemetry events to get cleared"); + + MockFilePicker.init(window); + MockFilePicker.useAnyFile(); + MockFilePicker.returnValue = MockFilePicker.returnOK; + + registerCleanupFunction(() => { + MockFilePicker.cleanup(); + }); +}); + +function waitForFilePicker() { + return new Promise(resolve => { + MockFilePicker.showCallback = () => { + MockFilePicker.showCallback = null; + Assert.ok(true, "Saw the file picker"); + resolve(); + }; + }); +} + +add_task(async function test_open_export() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:logins" }, + async function (browser) { + await BrowserTestUtils.synthesizeMouseAtCenter( + "menu-button", + {}, + browser + ); + + await SpecialPowers.spawn(browser, [], async () => { + let menuButton = content.document.querySelector("menu-button"); + return ContentTaskUtils.waitForCondition(function waitForMenu() { + return !menuButton.shadowRoot.querySelector(".menu").hidden; + }, "waiting for menu to open"); + }); + + function getExportMenuItem() { + let menuButton = window.document.querySelector("menu-button"); + let exportButton = + menuButton.shadowRoot.querySelector(".menuitem-export"); + return exportButton; + } + + await BrowserTestUtils.synthesizeMouseAtCenter( + getExportMenuItem, + {}, + browser + ); + + // First event is for opening about:logins + await LoginTestUtils.telemetry.waitForEventCount(2); + TelemetryTestUtils.assertEvents( + [["pwmgr", "mgmt_menu_item_used", "export"]], + { category: "pwmgr", method: "mgmt_menu_item_used" }, + { process: "content" } + ); + + info("Clicking confirm button"); + let osReAuthPromise = null; + + if ( + OSKeyStore.canReauth() && + !OSKeyStoreTestUtils.canTestOSKeyStoreLogin() + ) { + todo( + OSKeyStoreTestUtils.canTestOSKeyStoreLogin(), + "Cannot test OS key store login in this build." + ); + return; + } + + if (OSKeyStore.canReauth()) { + osReAuthPromise = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } + let filePicker = waitForFilePicker(); + await BrowserTestUtils.synthesizeMouseAtCenter( + () => { + let confirmExportDialog = window.document.querySelector( + "confirmation-dialog" + ); + return confirmExportDialog.shadowRoot.querySelector( + ".confirm-button" + ); + }, + {}, + browser + ); + + if (osReAuthPromise) { + Assert.ok(osReAuthPromise, "Waiting for OS re-auth promise"); + await osReAuthPromise; + } + + info("waiting for Export file picker to get opened"); + await filePicker; + Assert.ok(true, "Export file picker opened"); + + info("Waiting for the export to complete"); + let expectedEvents = [ + [ + "pwmgr", + "reauthenticate", + "os_auth", + osReAuthPromise ? "success" : "success_unsupported_platform", + ], + ["pwmgr", "mgmt_menu_item_used", "export_complete"], + ]; + await LoginTestUtils.telemetry.waitForEventCount( + expectedEvents.length, + "parent" + ); + + TelemetryTestUtils.assertEvents( + expectedEvents, + { category: "pwmgr", method: /(reauthenticate|mgmt_menu_item_used)/ }, + { process: "parent" } + ); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_openFiltered.js b/browser/components/aboutlogins/tests/browser/browser_openFiltered.js new file mode 100644 index 0000000000..fcd9692065 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openFiltered.js @@ -0,0 +1,295 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + TEST_LOGIN1 = await Services.logins.addLoginAsync(TEST_LOGIN1); + await storageChangedPromised; + storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + TEST_LOGIN2 = await Services.logins.addLoginAsync(TEST_LOGIN2); + await storageChangedPromised; + let tabOpenedPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + url => + url.includes( + `about:logins?filter=${encodeURIComponent(TEST_LOGIN1.origin)}` + ), + true + ); + LoginHelper.openPasswordManager(window, { + filterString: TEST_LOGIN1.origin, + entryPoint: "preferences", + }); + await tabOpenedPromise; + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_query_parameter_filter() { + let browser = gBrowser.selectedBrowser; + let vanillaLogins = [ + LoginHelper.loginToVanillaObject(TEST_LOGIN1), + LoginHelper.loginToVanillaObject(TEST_LOGIN2), + ]; + await SpecialPowers.spawn(browser, [vanillaLogins], async logins => { + const loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition(() => { + return loginList._loginGuidsSortedOrder.length == 2; + }, "Waiting for logins to be cached"); + + await ContentTaskUtils.waitForCondition(() => { + const selectedLoginItem = Cu.waiveXrays( + loginList.shadowRoot.querySelector("li[aria-selected='true']") + ); + return selectedLoginItem.dataset.guid === logins[0].guid; + }, "Waiting for TEST_LOGIN1 to be selected for the login-item view"); + + const loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + + Assert.ok( + ContentTaskUtils.is_visible(loginItem), + "login-item should be visible when a login is selected" + ); + const loginIntro = content.document.querySelector("login-intro"); + Assert.ok( + ContentTaskUtils.is_hidden(loginIntro), + "login-intro should be hidden when a login is selected" + ); + + const loginFilter = loginList.shadowRoot.querySelector("login-filter"); + + const xRayLoginFilter = Cu.waiveXrays(loginFilter); + Assert.equal( + xRayLoginFilter.value, + logins[0].origin, + "The filter should be prepopulated" + ); + Assert.equal( + loginList.shadowRoot.activeElement, + loginFilter, + "login-filter should be focused" + ); + Assert.equal( + loginFilter.shadowRoot.activeElement, + loginFilter.shadowRoot.querySelector(".filter"), + "the actual input inside of login-filter should be focused" + ); + + let hiddenLoginListItems = loginList.shadowRoot.querySelectorAll( + ".login-list-item[hidden]" + ); + let visibleLoginListItems = loginList.shadowRoot.querySelectorAll( + ".login-list-item:not([hidden])" + ); + Assert.equal( + visibleLoginListItems.length, + 1, + "The one login should be visible" + ); + Assert.equal( + visibleLoginListItems[0].dataset.guid, + logins[0].guid, + "TEST_LOGIN1 should be visible" + ); + Assert.equal( + hiddenLoginListItems.length, + 2, + "One saved login and one blank login should be hidden" + ); + Assert.equal( + hiddenLoginListItems[0].id, + "new-login-list-item", + "#new-login-list-item should be hidden" + ); + Assert.equal( + hiddenLoginListItems[1].dataset.guid, + logins[1].guid, + "TEST_LOGIN2 should be hidden" + ); + }); +}); + +add_task(async function test_query_parameter_filter_no_logins_for_site() { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + const HOSTNAME_WITH_NO_LOGINS = "xxx-no-logins-for-site-xxx"; + let tabOpenedPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + url => + url.includes( + `about:logins?filter=${encodeURIComponent(HOSTNAME_WITH_NO_LOGINS)}` + ), + true + ); + LoginHelper.openPasswordManager(window, { + filterString: HOSTNAME_WITH_NO_LOGINS, + entryPoint: "preferences", + }); + await tabOpenedPromise; + + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + await ContentTaskUtils.waitForCondition(() => { + return loginList._loginGuidsSortedOrder.length == 2; + }, "Waiting for logins to be cached"); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 2, + "login list should have two logins stored" + ); + + Assert.ok( + ContentTaskUtils.is_hidden(loginList._list), + "the login list should be hidden when there is a search with no results" + ); + let intro = loginList.shadowRoot.querySelector(".intro"); + Assert.ok( + ContentTaskUtils.is_hidden(intro), + "the intro should be hidden when there is a search with no results" + ); + let emptySearchMessage = loginList.shadowRoot.querySelector( + ".empty-search-message" + ); + Assert.ok( + ContentTaskUtils.is_visible(emptySearchMessage), + "the empty search message should be visible when there is a search with no results" + ); + + let visibleLoginListItems = loginList.shadowRoot.querySelectorAll( + ".login-list-item:not([hidden])" + ); + Assert.equal(visibleLoginListItems.length, 0, "No login should be visible"); + + Assert.ok( + !loginList._createLoginButton.disabled, + "create button should be enabled" + ); + + let loginItem = content.document.querySelector("login-item"); + Assert.ok(!loginItem.dataset.isNewLogin, "should not be in create mode"); + Assert.ok(!loginItem.dataset.editing, "should not be in edit mode"); + Assert.ok( + ContentTaskUtils.is_hidden(loginItem), + "login-item should be hidden when a login is not selected and we're not in create mode" + ); + let loginIntro = content.document.querySelector("login-intro"); + Assert.ok( + ContentTaskUtils.is_hidden(loginIntro), + "login-intro should be hidden when a login is not selected and we're not in create mode" + ); + + loginList._createLoginButton.click(); + + Assert.ok(loginItem.dataset.isNewLogin, "should be in create mode"); + Assert.ok(loginItem.dataset.editing, "should be in edit mode"); + Assert.ok( + ContentTaskUtils.is_visible(loginItem), + "login-item should be visible in create mode" + ); + Assert.ok( + ContentTaskUtils.is_hidden(loginIntro), + "login-intro should be hidden in create mode" + ); + }); +}); + +add_task(async function test_query_parameter_filter_no_login_until_backspace() { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + let tabOpenedPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:logins?filter=" + encodeURIComponent(TEST_LOGIN1.origin) + "x", + true + ); + LoginHelper.openPasswordManager(window, { + filterString: TEST_LOGIN1.origin + "x", + entryPoint: "preferences", + }); + await tabOpenedPromise; + + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + await ContentTaskUtils.waitForCondition(() => { + return loginList._loginGuidsSortedOrder.length == 2; + }, "Waiting for logins to be cached"); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 2, + "login list should have two logins stored" + ); + + Assert.ok( + ContentTaskUtils.is_hidden(loginList._list), + "the login list should be hidden when there is a search with no results" + ); + + // Backspace the trailing 'x' to get matching logins + const EventUtils = ContentTaskUtils.getEventUtils(content); + EventUtils.sendChar("KEY_Backspace", content); + + let intro = loginList.shadowRoot.querySelector(".intro"); + Assert.ok( + ContentTaskUtils.is_hidden(intro), + "the intro should be hidden when there is no selection" + ); + let emptySearchMessage = loginList.shadowRoot.querySelector( + ".empty-search-message" + ); + Assert.ok( + ContentTaskUtils.is_hidden(emptySearchMessage), + "the empty search message should be hidden when there is matching logins" + ); + + let visibleLoginListItems = loginList.shadowRoot.querySelectorAll( + ".login-list-item:not([hidden])" + ); + Assert.equal( + visibleLoginListItems.length, + 1, + "One login should be visible after backspacing" + ); + + Assert.ok( + !loginList._createLoginButton.disabled, + "create button should be enabled" + ); + + let loginItem = content.document.querySelector("login-item"); + Assert.ok(!loginItem.dataset.isNewLogin, "should not be in create mode"); + Assert.ok(!loginItem.dataset.editing, "should not be in edit mode"); + Assert.ok( + ContentTaskUtils.is_hidden(loginItem), + "login-item should be hidden when a login is not selected and we're not in create mode" + ); + let loginIntro = content.document.querySelector("login-intro"); + Assert.ok( + ContentTaskUtils.is_hidden(loginIntro), + "login-intro should be hidden when a login is not selected and we're not in create mode" + ); + + loginList._createLoginButton.click(); + + Assert.ok(loginItem.dataset.isNewLogin, "should be in create mode"); + Assert.ok(loginItem.dataset.editing, "should be in edit mode"); + Assert.ok( + ContentTaskUtils.is_visible(loginItem), + "login-item should be visible in create mode" + ); + Assert.ok( + ContentTaskUtils.is_hidden(loginIntro), + "login-intro should be hidden in create mode" + ); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_openImport.js b/browser/components/aboutlogins/tests/browser/browser_openImport.js new file mode 100644 index 0000000000..627e0d6e3b --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openImport.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +let { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +add_setup(async function () { + await TestUtils.waitForCondition(() => { + Services.telemetry.clearEvents(); + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + return !events || !events.length; + }, "Waiting for content telemetry events to get cleared"); + + let aboutLoginsTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(aboutLoginsTab); + }); +}); + +add_task(async function test_open_import() { + let promiseImportWindow = BrowserTestUtils.waitForMigrationWizard(window); + + let browser = gBrowser.selectedBrowser; + await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser); + await SpecialPowers.spawn(browser, [], async () => { + return ContentTaskUtils.waitForCondition(() => { + let menuButton = Cu.waiveXrays( + content.document.querySelector("menu-button") + ); + return !menuButton.shadowRoot.querySelector(".menu").hidden; + }, "waiting for menu to open"); + }); + + function getImportItem() { + let menuButton = window.document.querySelector("menu-button"); + return menuButton.shadowRoot.querySelector(".menuitem-import-browser"); + } + await BrowserTestUtils.synthesizeMouseAtCenter(getImportItem, {}, browser); + + info("waiting for Import to get opened"); + let importWindow = await promiseImportWindow; + Assert.ok(true, "Import opened"); + + // First event is for opening about:logins + await LoginTestUtils.telemetry.waitForEventCount(2); + TelemetryTestUtils.assertEvents( + [["pwmgr", "mgmt_menu_item_used", "import_from_browser"]], + { category: "pwmgr", method: "mgmt_menu_item_used" }, + { process: "content" } + ); + + await BrowserTestUtils.closeMigrationWizard(importWindow); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_openImportCSV.js b/browser/components/aboutlogins/tests/browser/browser_openImportCSV.js new file mode 100644 index 0000000000..c4994215d8 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openImportCSV.js @@ -0,0 +1,411 @@ +/* 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 { FileTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/FileTestUtils.sys.mjs" +); + +let { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +let { MockFilePicker } = SpecialPowers; + +/** + * A helper class to deal with Login CSV import UI. + */ +class CsvImportHelper { + /** + * Waits until the mock file picker is opened and sets the destFilePath as it's selected file. + * + * @param {nsIFile} destFile + * The file being passed to the picker. + * @returns {string} A promise that is resolved when the picker selects the file. + */ + static waitForOpenFilePicker(destFile) { + return new Promise(resolve => { + MockFilePicker.showCallback = fp => { + info("showCallback"); + info("fileName: " + destFile.path); + MockFilePicker.setFiles([destFile]); + MockFilePicker.filterIndex = 1; + info("done showCallback"); + resolve(); + }; + }); + } + + /** + * Clicks the 3 dot menu and then "Import from a file..." and then it serves a CSV file. + * It also does the needed assertions and telemetry validations. + * If you await for it to return, it will have processed the CSV file already. + * + * @param {browser} browser + * The browser object. + * @param {string[]} linesInFile + * An array of strings to be used to generate the CSV file. Each string is a line. + * @returns {Promise} A promise that is resolved when the picker selects the file. + */ + static async clickImportFromCsvMenu(browser, linesInFile) { + MockFilePicker.init(window); + MockFilePicker.returnValue = MockFilePicker.returnOK; + let csvFile = await LoginTestUtils.file.setupCsvFileWithLines(linesInFile); + + await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser); + + await SpecialPowers.spawn(browser, [], async () => { + let menuButton = content.document.querySelector("menu-button"); + return ContentTaskUtils.waitForCondition(function waitForMenu() { + return !menuButton.shadowRoot.querySelector(".menu").hidden; + }, "waiting for menu to open"); + }); + + Services.telemetry.clearEvents(); + + function getImportMenuItem() { + let menuButton = window.document.querySelector("menu-button"); + let importButton = menuButton.shadowRoot.querySelector( + ".menuitem-import-file" + ); + // Force the menu item to be visible for the test. + importButton.hidden = false; + return importButton; + } + + BrowserTestUtils.synthesizeMouseAtCenter(getImportMenuItem, {}, browser); + + async function waitForFilePicker() { + let filePickerPromise = CsvImportHelper.waitForOpenFilePicker(csvFile); + // First event is for opening about:logins + await LoginTestUtils.telemetry.waitForEventCount( + 1, + "content", + "pwmgr", + "mgmt_menu_item_used" + ); + TelemetryTestUtils.assertEvents( + [["pwmgr", "mgmt_menu_item_used", "import_from_csv"]], + { category: "pwmgr", method: "mgmt_menu_item_used" }, + { process: "content", clear: false } + ); + + info("waiting for Import file picker to get opened"); + await filePickerPromise; + Assert.ok(true, "Import file picker opened"); + } + + await waitForFilePicker(); + } + + /** + * An utility method to fetch the data from the CSV import success dialog. + * + * @param {browser} browser + * The browser object. + * @returns {Promise<Object>} A promise that contains added, modified, noChange and errors count. + */ + static async getCsvImportSuccessDialogData(browser) { + return SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("import-summary-dialog") + ); + await ContentTaskUtils.waitForCondition( + () => !dialog.hidden, + "Waiting for the dialog to be visible" + ); + + let added = dialog.shadowRoot.querySelector( + ".import-items-added .result-count" + ).textContent; + let modified = dialog.shadowRoot.querySelector( + ".import-items-modified .result-count" + ).textContent; + let noChange = dialog.shadowRoot.querySelector( + ".import-items-no-change .result-count" + ).textContent; + let errors = dialog.shadowRoot.querySelector( + ".import-items-errors .result-count" + ).textContent; + const dialogData = { + added, + modified, + noChange, + errors, + }; + if (dialog.shadowRoot.activeElement) { + dialogData.l10nFocused = + dialog.shadowRoot.activeElement.getAttribute("data-l10n-id"); + } + return dialogData; + }); + } + + /** + * An utility method to fetch the data from the CSV import error dialog. + * + * @param {browser} browser + * The browser object. + * @returns {Promise<Object>} A promise that contains the hidden state and l10n id for title, description and focused element. + */ + static async getCsvImportErrorDialogData(browser) { + return SpecialPowers.spawn(browser, [], async () => { + const dialog = Cu.waiveXrays( + content.document.querySelector("import-error-dialog") + ); + const l10nTitle = dialog._genericDialog + .querySelector(".error-title") + .getAttribute("data-l10n-id"); + const l10nDescription = dialog._genericDialog + .querySelector(".error-description") + .getAttribute("data-l10n-id"); + return { + hidden: dialog.hidden, + l10nFocused: + dialog.shadowRoot.activeElement.getAttribute("data-l10n-id"), + l10nTitle, + l10nDescription, + }; + }); + } + + /** + * An utility method to wait until CSV import is complete. + * + * @returns {Promise} A promise that gets resolved when the import is complete. + */ + static async waitForImportToComplete() { + info("Waiting for the import to complete"); + await LoginTestUtils.telemetry.waitForEventCount(1, "parent"); + TelemetryTestUtils.assertEvents( + [["pwmgr", "mgmt_menu_item_used", "import_csv_complete"]], + { category: "pwmgr", method: "mgmt_menu_item_used" }, + { process: "parent" } + ); + } + + /** + * An utility method open the about:loginsimportreport page. + * + * @param {browser} browser + * The browser object. + * @returns {Promise<Object>} A promise that contains the about:loginsimportreport tab. + */ + static async clickDetailedReport(browser) { + let loadedReportTab = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:loginsimportreport", + true + ); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("import-summary-dialog") + ); + await ContentTaskUtils.waitForCondition( + () => !dialog.hidden, + "Waiting for the dialog to be visible" + ); + let detailedReportLink = dialog.shadowRoot.querySelector( + ".open-detailed-report" + ); + + detailedReportLink.click(); + }); + return loadedReportTab; + } + + /** + * An utility method to fetch data from the about:loginsimportreport page. + * + * @param {browser} browser + * The browser object. + * @returns {Promise<Object>} A promise that contains the detailed report data like added, modified, noChange, errors and rows. + */ + static async getDetailedReportData(browser) { + const data = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async () => { + function getCount(selector) { + const attribute = content.document + .querySelector(selector) + .getAttribute("data-l10n-args"); + return JSON.parse(attribute).count; + } + const rows = []; + for (let element of content.document.querySelectorAll(".row-details")) { + rows.push(element.getAttribute("data-l10n-id")); + } + const added = getCount(".new-logins"); + const modified = getCount(".exiting-logins"); + const noChange = getCount(".duplicate-logins"); + const errors = getCount(".errors-logins"); + return { + rows, + added, + modified, + noChange, + errors, + }; + } + ); + return data; + } +} + +const random = Math.round(Math.random() * 100000001); + +add_setup(async function () { + registerCleanupFunction(() => { + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_open_import_one_item_from_csv() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:logins" }, + async browser => { + await CsvImportHelper.clickImportFromCsvMenu(browser, [ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + `https://example.com,joe${random}@example.com,qwerty,My realm,,{${random}-e194-4279-ae1b-d7d281bb46f0},1589617814635,1589710449871,1589617846802`, + ]); + await CsvImportHelper.waitForImportToComplete(); + + let summary = await CsvImportHelper.getCsvImportSuccessDialogData( + browser + ); + Assert.equal(summary.added, "1", "It should have one item as added"); + Assert.equal( + summary.l10nFocused, + "about-logins-import-dialog-done", + "dismiss button should be focused" + ); + } + ); +}); + +add_task(async function test_open_import_all_four_categories() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:logins" }, + async browser => { + const initialCsvData = [ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + `https://example1.com,existing${random},existing,,,{${random}-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924000`, + `https://example1.com,duplicate,duplicate,,,{dddd0080-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363`, + ]; + const updatedCsvData = [ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + `https://example1.com,added${random},added,,,,,,`, + `https://example1.com,existing${random},modified,,,{${random}-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363`, + `https://example1.com,duplicate,duplicate,,,{dddd0080-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363`, + `https://example1.com,error,,,,,,,`, + ]; + + await CsvImportHelper.clickImportFromCsvMenu(browser, initialCsvData); + await CsvImportHelper.waitForImportToComplete(); + await BrowserTestUtils.synthesizeMouseAtCenter( + "dismiss-button", + {}, + browser + ); + await CsvImportHelper.clickImportFromCsvMenu(browser, updatedCsvData); + await CsvImportHelper.waitForImportToComplete(); + + let summary = await CsvImportHelper.getCsvImportSuccessDialogData( + browser + ); + Assert.equal(summary.added, "1", "It should have one item as added"); + Assert.equal( + summary.modified, + "1", + "It should have one item as modified" + ); + Assert.equal( + summary.noChange, + "1", + "It should have one item as unchanged" + ); + Assert.equal(summary.errors, "1", "It should have one item as error"); + } + ); +}); + +add_task(async function test_open_import_all_four_detailed_report() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:logins" }, + async browser => { + const initialCsvData = [ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + `https://example2.com,existing${random},existing,,,{${random}-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924000`, + "https://example2.com,duplicate,duplicate,,,{dddd0080-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363", + ]; + const updatedCsvData = [ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + `https://example2.com,added${random},added,,,,,,`, + `https://example2.com,existing${random},modified,,,{${random}-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363`, + "https://example2.com,duplicate,duplicate,,,{dddd0080-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363", + "https://example2.com,error,,,,,,,", + ]; + + await CsvImportHelper.clickImportFromCsvMenu(browser, initialCsvData); + await CsvImportHelper.waitForImportToComplete(); + await BrowserTestUtils.synthesizeMouseAtCenter( + "dismiss-button", + {}, + browser + ); + await CsvImportHelper.clickImportFromCsvMenu(browser, updatedCsvData); + await CsvImportHelper.waitForImportToComplete(); + const reportTab = await CsvImportHelper.clickDetailedReport(browser); + const report = await CsvImportHelper.getDetailedReportData(browser); + BrowserTestUtils.removeTab(reportTab); + const { added, modified, noChange, errors, rows } = report; + Assert.equal(added, 1, "It should have one item as added"); + Assert.equal(modified, 1, "It should have one item as modified"); + Assert.equal(noChange, 1, "It should have one item as unchanged"); + Assert.equal(errors, 1, "It should have one item as error"); + Assert.deepEqual( + [ + "about-logins-import-report-row-description-added", + "about-logins-import-report-row-description-modified", + "about-logins-import-report-row-description-no-change", + "about-logins-import-report-row-description-error-missing-field", + ], + rows, + "It should have expected rows in order" + ); + } + ); +}); + +add_task(async function test_open_import_from_csv_with_invalid_file() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:logins" }, + async browser => { + await CsvImportHelper.clickImportFromCsvMenu(browser, [ + "invalid csv file", + ]); + + info("Waiting for the import error dialog"); + const errorDialog = await CsvImportHelper.getCsvImportErrorDialogData( + browser + ); + Assert.equal(errorDialog.hidden, false, "Dialog should not be hidden"); + Assert.equal( + errorDialog.l10nTitle, + "about-logins-import-dialog-error-file-format-title", + "Dialog error title should be correct" + ); + Assert.equal( + errorDialog.l10nDescription, + "about-logins-import-dialog-error-file-format-description", + "Dialog error description should be correct" + ); + Assert.equal( + errorDialog.l10nFocused, + "about-logins-import-dialog-error-learn-more", + "Learn more link should be focused." + ); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_openPreferences.js b/browser/components/aboutlogins/tests/browser/browser_openPreferences.js new file mode 100644 index 0000000000..57ca74ba87 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openPreferences.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +let { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +add_setup(async function () { + await TestUtils.waitForCondition(() => { + Services.telemetry.clearEvents(); + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + return !events || !events.length; + }, "Waiting for content telemetry events to get cleared"); + + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test_open_preferences() { + // We want to make sure we visit about:preferences#privacy-logins , as that is + // what causes us to scroll to and highlight the "logins" section. However, + // about:preferences will redirect the URL, so the eventual load event will happen + // on about:preferences#privacy . The `wantLoad` parameter we pass to + // `waitForNewTab` needs to take this into account: + let seenFirstURL = false; + let promiseNewTab = BrowserTestUtils.waitForNewTab( + gBrowser, + url => { + if (url == "about:preferences#privacy-logins") { + seenFirstURL = true; + return true; + } else if (url == "about:preferences#privacy") { + Assert.ok( + seenFirstURL, + "Must have seen an onLocationChange notification for the privacy-logins hash" + ); + return true; + } + return false; + }, + true + ); + + let browser = gBrowser.selectedBrowser; + await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser); + await SpecialPowers.spawn(browser, [], async () => { + return ContentTaskUtils.waitForCondition(() => { + let menuButton = Cu.waiveXrays( + content.document.querySelector("menu-button") + ); + return !menuButton.shadowRoot.querySelector(".menu").hidden; + }, "waiting for menu to open"); + }); + + function getPrefsItem() { + let menuButton = window.document.querySelector("menu-button"); + return menuButton.shadowRoot.querySelector(".menuitem-preferences"); + } + await BrowserTestUtils.synthesizeMouseAtCenter(getPrefsItem, {}, browser); + + info("waiting for new tab to get opened"); + let newTab = await promiseNewTab; + Assert.ok(true, "New tab opened to about:preferences"); + + BrowserTestUtils.removeTab(newTab); + + // First event is for opening about:logins + await LoginTestUtils.telemetry.waitForEventCount(2); + TelemetryTestUtils.assertEvents( + [["pwmgr", "mgmt_menu_item_used", "preferences"]], + { category: "pwmgr", method: "mgmt_menu_item_used" }, + { process: "content" } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_openPreferencesExternal.js b/browser/components/aboutlogins/tests/browser/browser_openPreferencesExternal.js new file mode 100644 index 0000000000..e4290371fb --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openPreferencesExternal.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test_open_feedback() { + const menuArray = [ + { + urlFinal: + "https://example.com/password-manager-remember-delete-edit-logins", + urlBase: "https://example.com/", + pref: "app.support.baseURL", + selector: ".menuitem-help", + }, + ]; + + for (const { urlFinal, urlBase, pref, selector } of menuArray) { + info("Test on " + urlFinal); + + await SpecialPowers.pushPrefEnv({ + set: [[pref, urlBase]], + }); + + let promiseNewTab = BrowserTestUtils.waitForNewTab(gBrowser, urlFinal); + + let browser = gBrowser.selectedBrowser; + await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser); + await SpecialPowers.spawn(browser, [], async () => { + return ContentTaskUtils.waitForCondition(() => { + let menuButton = content.document.querySelector("menu-button"); + return !menuButton.shadowRoot.querySelector(".menu").hidden; + }, "waiting for menu to open"); + }); + + // Not using synthesizeMouseAtCenter here because the element we want clicked on + // is in the shadow DOM and therefore requires using a function 1st argument + // to BrowserTestUtils.synthesizeMouseAtCenter but we need to pass an + // arbitrary selector. See bug 1557489 for more info. As a workaround, this + // manually calculates the position to click. + let { x, y } = await SpecialPowers.spawn( + browser, + [selector], + async menuItemSelector => { + let menuButton = content.document.querySelector("menu-button"); + let prefsItem = menuButton.shadowRoot.querySelector(menuItemSelector); + return prefsItem.getBoundingClientRect(); + } + ); + await BrowserTestUtils.synthesizeMouseAtPoint(x + 5, y + 5, {}, browser); + + info("waiting for new tab to get opened"); + let newTab = await promiseNewTab; + Assert.ok(true, "New tab opened to" + urlFinal); + + BrowserTestUtils.removeTab(newTab); + } +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_openSite.js b/browser/components/aboutlogins/tests/browser/browser_openSite.js new file mode 100644 index 0000000000..f33d57a8e4 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openSite.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_launch_login_item() { + let promiseNewTab = BrowserTestUtils.waitForNewTab( + gBrowser, + TEST_LOGIN1.origin + "/" + ); + + let browser = gBrowser.selectedBrowser; + + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let originInput = loginItem.shadowRoot.querySelector("a[name='origin']"); + let EventUtils = ContentTaskUtils.getEventUtils(content); + // Use synthesizeMouseAtCenter to generate an event that more closely resembles the + // properties of the event object that will be seen when the user clicks the element + // (.click() sets originalTarget while synthesizeMouse has originalTarget as a Restricted object). + await EventUtils.synthesizeMouseAtCenter(originInput, {}, content); + }); + + info("waiting for new tab to get opened"); + let newTab = await promiseNewTab; + Assert.ok(true, "New tab opened to " + TEST_LOGIN1.origin); + BrowserTestUtils.removeTab(newTab); + + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + return; + } + + promiseNewTab = BrowserTestUtils.waitForNewTab( + gBrowser, + TEST_LOGIN1.origin + "/" + ); + let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + loginItem._editButton.click(); + }); + await reauthObserved; + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + loginItem._usernameInput.value += "-changed"; + + Assert.ok( + content.document.querySelector("confirmation-dialog").hidden, + "discard-changes confirmation-dialog should be hidden before opening the site" + ); + + let originInput = loginItem.shadowRoot.querySelector("a[name='origin']"); + let EventUtils = ContentTaskUtils.getEventUtils(content); + // Use synthesizeMouseAtCenter to generate an event that more closely resembles the + // properties of the event object that will be seen when the user clicks the element + // (.click() sets originalTarget while synthesizeMouse has originalTarget as a Restricted object). + await EventUtils.synthesizeMouseAtCenter(originInput, {}, content); + }); + + info("waiting for new tab to get opened"); + newTab = await promiseNewTab; + Assert.ok(true, "New tab opened to " + TEST_LOGIN1.origin); + + let modifiedLogin = TEST_LOGIN1.clone(); + modifiedLogin.timeLastUsed = 9000; + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + Services.logins.modifyLogin(TEST_LOGIN1, modifiedLogin); + await storageChangedPromised; + + BrowserTestUtils.removeTab(newTab); + + await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForCondition(() => { + return !content.document.querySelector("confirmation-dialog").hidden; + }, "waiting for confirmation-dialog to appear"); + Assert.ok( + !content.document.querySelector("confirmation-dialog").hidden, + "discard-changes confirmation-dialog should be visible after logging in to a site with a modified login present in the form" + ); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js b/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js new file mode 100644 index 0000000000..ca054e449a --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js @@ -0,0 +1,165 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test() { + info( + `updatechannel: ${UpdateUtils.getUpdateChannel(false)}; platform: ${ + AppConstants.platform + }` + ); + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + Assert.ok( + true, + `skipping test since oskeystore cannot be automated in this environment` + ); + return; + } + + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + + registerCleanupFunction(function () { + Services.logins.removeAllUserFacingLogins(); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); + + // Show OS auth dialog when Reveal Password checkbox is checked if not on a new login + let osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(false); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await osAuthDialogShown; + info("OS auth dialog shown and canceled"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + Assert.ok( + !revealCheckbox.checked, + "reveal checkbox should be unchecked if OS auth dialog canceled" + ); + }); + osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await osAuthDialogShown; + info("OS auth dialog shown and authenticated"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + Assert.ok( + revealCheckbox.checked, + "reveal checkbox should be checked if OS auth dialog authenticated" + ); + }); + + info("'Edit' shouldn't show the prompt since the user has authenticated now"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + !loginItem.dataset.editing, + "Not in edit mode before clicking 'Edit'" + ); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "waiting for 'edit' mode" + ); + Assert.ok(loginItem.dataset.editing, "In edit mode"); + }); + + info("Test that the OS auth prompt is shown after about:logins is reopened"); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + + // Show OS auth dialog since the page has been reloaded. + osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(false); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await osAuthDialogShown; + info("OS auth dialog shown and canceled"); + + // Show OS auth dialog since the previous attempt was canceled + osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + info("clicking on reveal checkbox to hide the password"); + revealCheckbox.click(); + }); + await osAuthDialogShown; + info("OS auth dialog shown and passed"); + + // Show OS auth dialog since the timeout will have expired + osAuthDialogShown = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + info("clicking on reveal checkbox to reveal password"); + revealCheckbox.click(); + }); + info("waiting for os auth dialog"); + await osAuthDialogShown; + info("OS auth dialog shown and passed after timeout expiration"); + + // Disable the OS auth feature and confirm the prompt doesn't appear + await SpecialPowers.pushPrefEnv({ + set: [["signon.management.page.os-auth.enabled", false]], + }); + info("Reload about:logins to reset the timeout"); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + + info("'Edit' shouldn't show the prompt since the feature has been disabled"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + !loginItem.dataset.editing, + "Not in edit mode before clicking 'Edit'" + ); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "waiting for 'edit' mode" + ); + Assert.ok(loginItem.dataset.editing, "In edit mode"); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_primaryPassword.js b/browser/components/aboutlogins/tests/browser/browser_primaryPassword.js new file mode 100644 index 0000000000..79a1e9a1da --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_primaryPassword.js @@ -0,0 +1,282 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function waitForLoginCountToReach(browser, loginCount) { + return SpecialPowers.spawn( + browser, + [loginCount], + async expectedLoginCount => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition(() => { + return loginList._loginGuidsSortedOrder.length == expectedLoginCount; + }); + return loginList._loginGuidsSortedOrder.length; + } + ); +} + +add_setup(async function () { + await addLogin(TEST_LOGIN1); + registerCleanupFunction(() => { + Services.logins.removeAllUserFacingLogins(); + LoginTestUtils.primaryPassword.disable(); + }); +}); + +add_task(async function test() { + // Confirm that the mocking of the OS auth dialog isn't enabled so the + // test will timeout if a real OS auth dialog is shown. We don't show + // the OS auth dialog when Primary Password is enabled. + Assert.equal( + Services.prefs.getStringPref( + "toolkit.osKeyStore.unofficialBuildOnlyLogin", + "" + ), + "", + "Pref should be set to default value of empty string to start the test" + ); + LoginTestUtils.primaryPassword.enable(); + + let mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("cancel"); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + await mpDialogShown; + + let browser = gBrowser.selectedBrowser; + let logins = await waitForLoginCountToReach(browser, 0); + Assert.equal( + logins, + 0, + "No logins should be displayed when MP is set and unauthenticated" + ); + + let notification; + await TestUtils.waitForCondition( + () => + (notification = gBrowser + .getNotificationBox() + .getNotificationWithValue("primary-password-login-required")), + "waiting for primary-password-login-required notification" + ); + + Assert.ok( + notification, + "primary-password-login-required notification should be visible" + ); + + let buttons = notification.buttonContainer.querySelectorAll( + ".notification-button" + ); + Assert.equal(buttons.length, 1, "Should have one button."); + + let refreshPromise = BrowserTestUtils.browserLoaded(browser); + // Sign in with the Primary Password this time the dialog is shown + mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate"); + // Click the button to reload the page. + buttons[0].click(); + await refreshPromise; + info("Page reloaded"); + + await mpDialogShown; + info("Primary Password dialog shown and authenticated"); + + logins = await waitForLoginCountToReach(browser, 1); + Assert.equal( + logins, + 1, + "Logins should be displayed when MP is set and authenticated" + ); + + // Show MP dialog when Copy Password button clicked + mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("cancel"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector( + ".copy-password-button" + ); + copyButton.click(); + }); + await mpDialogShown; + info("Primary Password dialog shown and canceled"); + mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate"); + info("Clicking copy password button again"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector( + ".copy-password-button" + ); + copyButton.click(); + }); + await mpDialogShown; + info("Primary Password dialog shown and authenticated"); + await SpecialPowers.spawn(browser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector( + ".copy-password-button" + ); + await ContentTaskUtils.waitForCondition(() => { + return copyButton.disabled; + }, "Waiting for copy button to be disabled"); + info("Password was copied to clipboard"); + }); + + // Show MP dialog when Reveal Password checkbox is checked if not on a new login + mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("cancel"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await mpDialogShown; + info("Primary Password dialog shown and canceled"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + Assert.ok( + !revealCheckbox.checked, + "reveal checkbox should be unchecked if MP dialog canceled" + ); + }); + mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await mpDialogShown; + info("Primary Password dialog shown and authenticated"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + Assert.ok( + revealCheckbox.checked, + "reveal checkbox should be checked if MP dialog authenticated" + ); + }); + + info("Test toggling the password visibility on a new login"); + await SpecialPowers.spawn(browser, [], async function createNewToggle() { + let createButton = content.document + .querySelector("login-list") + .shadowRoot.querySelector(".create-login-button"); + createButton.click(); + + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let passwordField = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + Assert.ok(ContentTaskUtils.is_visible(revealCheckbox), "Toggle visible"); + Assert.ok(!revealCheckbox.checked, "Not revealed initially"); + Assert.equal(passwordField.type, "password", "type is password"); + revealCheckbox.click(); + + await ContentTaskUtils.waitForCondition(() => { + return passwordField.type == "text"; + }, "Waiting for type='text'"); + Assert.ok(revealCheckbox.checked, "Not revealed after click"); + + let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button"); + cancelButton.click(); + }); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + const loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + + const loginFilter = Cu.waiveXrays( + loginList.shadowRoot.querySelector("login-filter") + ); + loginFilter.value = "pass1"; + Assert.equal( + loginList._list.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ).length, + 1, + "login-list should show corresponding result when primary password is enabled" + ); + loginFilter.value = ""; + Assert.equal( + loginList._list.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ).length, + 1, + "login-list should show all results since the filter is empty" + ); + }); + LoginTestUtils.primaryPassword.disable(); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + Cu.waiveXrays(content).AboutLoginsUtils.primaryPasswordEnabled = false; + const loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + const loginFilter = Cu.waiveXrays( + loginList.shadowRoot.querySelector("login-filter") + ); + loginFilter.value = "pass1"; + Assert.equal( + loginList._list.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ).length, + 1, + "login-list should show login with matching password since MP is disabled" + ); + }); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +add_task(async function test_login_item_after_successful_auth() { + // Confirm that the mocking of the OS auth dialog isn't enabled so the + // test will timeout if a real OS auth dialog is shown. We don't show + // the OS auth dialog when Primary Password is enabled. + Assert.equal( + Services.prefs.getStringPref( + "toolkit.osKeyStore.unofficialBuildOnlyLogin", + "" + ), + "", + "Pref should be set to default value of empty string to start the test" + ); + LoginTestUtils.primaryPassword.enable(); + + let mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate"); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + await mpDialogShown; + + let browser = gBrowser.selectedBrowser; + let logins = await waitForLoginCountToReach(browser, 1); + Assert.equal( + logins, + 1, + "Logins should be displayed when MP is set and authenticated" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + !loginItem.classList.contains("no-logins"), + "Login item should have content after MP is authenticated" + ); + }); + + LoginTestUtils.primaryPassword.disable(); + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_removeAllDialog.js b/browser/components/aboutlogins/tests/browser/browser_removeAllDialog.js new file mode 100644 index 0000000000..41503e2b4d --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_removeAllDialog.js @@ -0,0 +1,555 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +const OS_REAUTH_PREF = "signon.management.page.os-auth.enabled"; + +async function openRemoveAllDialog(browser) { + await SimpleTest.promiseFocus(browser); + await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser); + await SpecialPowers.spawn(browser, [], async () => { + let menuButton = content.document.querySelector("menu-button"); + let menu = menuButton.shadowRoot.querySelector("ul.menu"); + await ContentTaskUtils.waitForCondition(() => !menu.hidden); + }); + function getRemoveAllMenuButton() { + let menuButton = window.document.querySelector("menu-button"); + return menuButton.shadowRoot.querySelector(".menuitem-remove-all-logins"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getRemoveAllMenuButton, + {}, + browser + ); + info("remove all dialog should be opened"); +} + +async function activateLoginItemEdit(browser) { + await SimpleTest.promiseFocus(browser); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok(loginItem, "Login item should exist"); + }); + function getLoginItemEditButton() { + let loginItem = window.document.querySelector("login-item"); + return loginItem.shadowRoot.querySelector(".edit-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getLoginItemEditButton, + {}, + browser + ); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + loginItem.shadowRoot.querySelector(".edit-button").click(); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "Waiting for login-item to enter edit mode" + ); + }); + info("login-item should be in edit mode"); +} + +async function activateCreateNewLogin(browser) { + await SimpleTest.promiseFocus(browser); + function getCreateNewLoginButton() { + let loginList = window.document.querySelector("login-list"); + return loginList.shadowRoot.querySelector(".create-login-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getCreateNewLoginButton, + {}, + browser + ); +} + +async function waitForRemoveAllLogins() { + return new Promise(resolve => { + Services.obs.addObserver(function observer(subject, topic, changeType) { + if (changeType != "removeAllLogins") { + return; + } + + Services.obs.removeObserver(observer, "passwordmgr-storage-changed"); + resolve(); + }, "passwordmgr-storage-changed"); + }); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [[OS_REAUTH_PREF, false]], + }); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + await SpecialPowers.popPrefEnv(); + }); + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); +}); + +add_task(async function test_remove_all_dialog_l10n() { + Assert.ok(TEST_LOGIN1, "test_login1"); + let browser = gBrowser.selectedBrowser; + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + const EventUtils = ContentTaskUtils.getEventUtils(content); + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + Assert.ok(!dialog.hidden); + let title = dialog.shadowRoot.querySelector(".title"); + let message = dialog.shadowRoot.querySelector(".message"); + let label = dialog.shadowRoot.querySelector(".checkbox-text"); + let cancelButton = dialog.shadowRoot.querySelector(".cancel-button"); + let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button"); + await content.document.l10n.translateElements([ + title, + message, + label, + cancelButton, + removeAllButton, + ]); + Assert.equal( + title.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-title", + "Title contents should match l10n-id attribute set on element" + ); + Assert.equal( + message.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-message", + "Message contents should match l10n-id attribute set on element" + ); + Assert.equal( + label.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-checkbox-label", + "Label contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + cancelButton.dataset.l10nId, + "confirmation-dialog-cancel-button", + "Cancel button contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + removeAllButton.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-confirm-button-label", + "Remove all button contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + JSON.parse(title.dataset.l10nArgs).count, + 1, + "Title contents should match l10n-args attribute set on element" + ); + Assert.equal( + JSON.parse(message.dataset.l10nArgs).count, + 1, + "Message contents should match l10n-args attribute set on element" + ); + Assert.equal( + JSON.parse(label.dataset.l10nArgs).count, + 1, + "Label contents should match l10n-id attribute set on outer element" + ); + EventUtils.synthesizeMouseAtCenter( + dialog.shadowRoot.querySelector(".cancel-button"), + {}, + content + ); + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden after clicking cancel button" + ); + }); +}); + +add_task(async function test_remove_all_dialog_keyboard_navigation() { + let browser = gBrowser.selectedBrowser; + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + const EventUtils = ContentTaskUtils.getEventUtils(content); + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + let cancelButton = dialog.shadowRoot.querySelector(".cancel-button"); + let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button"); + Assert.equal( + removeAllButton.disabled, + true, + "Remove all should be disabled on dialog open" + ); + await EventUtils.synthesizeKey(" ", {}, content); + Assert.equal( + removeAllButton.disabled, + false, + "Remove all should be enabled when activating the checkbox" + ); + await EventUtils.synthesizeKey(" ", {}, content); + Assert.equal( + removeAllButton.disabled, + true, + "Remove all should be disabled after deactivating the checkbox" + ); + await EventUtils.synthesizeKey("KEY_Tab", {}, content); + Assert.equal( + dialog.shadowRoot.activeElement, + cancelButton, + "Cancel button should be the next element in tab order" + ); + await EventUtils.synthesizeKey(" ", {}, content); + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden after activating cancel button via Space key" + ); + }); + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + await EventUtils.synthesizeKey("KEY_Escape", {}, content); + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden after activating Escape key" + ); + }); + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + let dismissButton = dialog.shadowRoot.querySelector(".dismiss-button"); + await EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }, content); + Assert.equal( + dialog.shadowRoot.activeElement, + dismissButton, + "dismiss button should be focused" + ); + await EventUtils.synthesizeKey(" ", {}, content); + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden after activating X button" + ); + }); +}); + +add_task(async function test_remove_all_dialog_remove_logins() { + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + let browser = gBrowser.selectedBrowser; + let removeAllPromise = waitForRemoveAllLogins(); + + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + let title = dialog.shadowRoot.querySelector(".title"); + let message = dialog.shadowRoot.querySelector(".message"); + let label = dialog.shadowRoot.querySelector(".checkbox-text"); + let cancelButton = dialog.shadowRoot.querySelector(".cancel-button"); + let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button"); + + let checkbox = dialog.shadowRoot.querySelector(".checkbox"); + + await content.document.l10n.translateElements([ + title, + message, + cancelButton, + removeAllButton, + label, + checkbox, + ]); + Assert.equal( + dialog.shadowRoot.activeElement, + checkbox, + "Checkbox should be the focused element on dialog open" + ); + Assert.equal( + title.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-title", + "Title contents should match l10n-id attribute set on element" + ); + Assert.equal( + JSON.parse(title.dataset.l10nArgs).count, + 2, + "Title contents should match l10n-args attribute set on element" + ); + Assert.equal( + message.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-message", + "Message contents should match l10n-id attribute set on element" + ); + Assert.equal( + JSON.parse(message.dataset.l10nArgs).count, + 2, + "Message contents should match l10n-args attribute set on element" + ); + Assert.equal( + label.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-checkbox-label", + "Label contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + JSON.parse(label.dataset.l10nArgs).count, + 2, + "Label contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + cancelButton.dataset.l10nId, + "confirmation-dialog-cancel-button", + "Cancel button contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + removeAllButton.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-confirm-button-label", + "Remove all button contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + removeAllButton.disabled, + true, + "Remove all button should be disabled on dialog open" + ); + }); + function activateConfirmCheckbox() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".checkbox"); + } + + await BrowserTestUtils.synthesizeMouseAtCenter( + activateConfirmCheckbox, + {}, + browser + ); + + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button"); + Assert.equal( + removeAllButton.disabled, + false, + "Remove all should be enabled after clicking the checkbox" + ); + }); + function getDialogRemoveAllButton() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".confirm-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getDialogRemoveAllButton, + {}, + browser + ); + await removeAllPromise; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + await ContentTaskUtils.waitForCondition( + () => content.document.documentElement.classList.contains("no-logins"), + "Waiting for no logins view since all logins should be deleted" + ); + await ContentTaskUtils.waitForCondition( + () => + !content.document.documentElement.classList.contains("login-selected"), + "Waiting for the FxA Sync illustration to reappear" + ); + await ContentTaskUtils.waitForCondition( + () => loginList.classList.contains("no-logins"), + "Waiting for login-list to be in no logins view as all logins should be deleted" + ); + }); + await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser); + await SpecialPowers.spawn(browser, [], async () => { + let menuButton = content.document.querySelector("menu-button"); + let removeAllMenuButton = menuButton.shadowRoot.querySelector( + ".menuitem-remove-all-logins" + ); + Assert.ok( + removeAllMenuButton.disabled, + "Remove all logins menu button is disabled if there are no logins" + ); + }); + await SpecialPowers.spawn(browser, [], async () => { + let menuButton = Cu.waiveXrays( + content.document.querySelector("menu-button") + ); + let menu = menuButton.shadowRoot.querySelector("ul.menu"); + await EventUtils.synthesizeKey("KEY_Escape", {}, content); + await ContentTaskUtils.waitForCondition( + () => menu.hidden, + "Waiting for menu to close" + ); + }); +}); + +add_task(async function test_edit_mode_resets_on_remove_all_with_login() { + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + let removeAllPromise = waitForRemoveAllLogins(); + let browser = gBrowser.selectedBrowser; + await activateLoginItemEdit(browser); + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + loginItem.dataset.editing, + "Login item is still in edit mode when the remove all dialog opens" + ); + }); + function getDialogCancelButton() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".cancel-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getDialogCancelButton, + {}, + browser + ); + await TestUtils.waitForTick(); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + loginItem.dataset.editing, + "Login item should be in editing mode after activating the cancel button in the remove all dialog" + ); + }); + + await openRemoveAllDialog(browser); + function activateConfirmCheckbox() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".checkbox"); + } + + await BrowserTestUtils.synthesizeMouseAtCenter( + activateConfirmCheckbox, + {}, + browser + ); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button"); + Assert.equal( + removeAllButton.disabled, + false, + "Remove all should be enabled after clicking the checkbox" + ); + }); + function getDialogRemoveAllButton() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".confirm-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getDialogRemoveAllButton, + {}, + browser + ); + await TestUtils.waitForTick(); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + !loginItem.dataset.editing, + "Login item should not be in editing mode after activating the confirm button in the remove all dialog" + ); + }); + await removeAllPromise; +}); + +add_task(async function test_remove_all_when_creating_new_login() { + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + let removeAllPromise = waitForRemoveAllLogins(); + let browser = gBrowser.selectedBrowser; + await activateCreateNewLogin(browser); + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + loginItem.dataset.editing, + "Login item should be in edit mode when the remove all dialog opens" + ); + Assert.ok( + loginItem.dataset.isNewLogin, + "Login item should be in the 'new login' state when the remove all dialog opens" + ); + }); + function getDialogCancelButton() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".cancel-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getDialogCancelButton, + {}, + browser + ); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + loginItem.dataset.editing, + "Login item is still in edit mode after cancelling out of the remove all dialog" + ); + Assert.ok( + loginItem.dataset.isNewLogin, + "Login item should be in the 'newLogin' state after cancelling out of the remove all dialog" + ); + }); + + await openRemoveAllDialog(browser); + function activateConfirmCheckbox() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".checkbox"); + } + + await BrowserTestUtils.synthesizeMouseAtCenter( + activateConfirmCheckbox, + {}, + browser + ); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button"); + Assert.equal( + removeAllButton.disabled, + false, + "Remove all should be enabled after clicking the checkbox" + ); + }); + function getDialogRemoveAllButton() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".confirm-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getDialogRemoveAllButton, + {}, + browser + ); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + !loginItem.dataset.editing, + "Login item should not be in editing mode after activating the confirm button in the remove all dialog" + ); + Assert.ok( + !loginItem.dataset.isNewLogin, + "Login item should not be in 'new login' mode after activating the confirm button in the remove all dialog" + ); + }); + await removeAllPromise; +}); + +add_task(async function test_ensure_icons_are_not_draggable() { + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + let browser = gBrowser.selectedBrowser; + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = content.document.querySelector("remove-logins-dialog"); + let warningIcon = dialog.shadowRoot.querySelector(".warning-icon"); + Assert.ok(!warningIcon.draggable, "Warning icon should not be draggable"); + let dismissIcon = dialog.shadowRoot.querySelector(".dismiss-icon"); + Assert.ok(!dismissIcon.draggable, "Dismiss icon should not be draggable"); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_sessionRestore.js b/browser/components/aboutlogins/tests/browser/browser_sessionRestore.js new file mode 100644 index 0000000000..5ab03f9867 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_sessionRestore.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function checkLoginDisplayed(browser, testGuid) { + await SpecialPowers.spawn(browser, [testGuid], async function (guid) { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._loginGuidsSortedOrder.length == 1 && + loginList._loginGuidsSortedOrder[0] == guid + ); + }, "Waiting for login to be displayed in page"); + Assert.ok(loginFound, "Confirming that login is displayed in page"); + }); +} + +add_task(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + registerCleanupFunction(() => { + Services.logins.removeAllUserFacingLogins(); + }); + + const testGuid = TEST_LOGIN1.guid; + const tab = BrowserTestUtils.addTab(gBrowser, "about:logins"); + const browser = gBrowser.getBrowserForTab(tab); + await BrowserTestUtils.browserLoaded(browser); + + await checkLoginDisplayed(browser, testGuid); + + BrowserTestUtils.removeTab(tab); + info("Adding a lazy about:logins tab..."); + let lazyTab = BrowserTestUtils.addTab(gBrowser, "about:logins", { + createLazyBrowser: true, + }); + + Assert.equal(lazyTab.linkedPanel, "", "Tab is lazy"); + let tabLoaded = new Promise(resolve => { + gBrowser.addTabsProgressListener({ + async onLocationChange(aBrowser) { + if (lazyTab.linkedBrowser == aBrowser) { + gBrowser.removeTabsProgressListener(this); + await Promise.resolve(); + resolve(); + } + }, + }); + }); + + info("Switching tab to cause it to get restored"); + const browserLoaded = BrowserTestUtils.browserLoaded(lazyTab.linkedBrowser); + await BrowserTestUtils.switchTab(gBrowser, lazyTab); + + await tabLoaded; + await browserLoaded; + + let lazyBrowser = lazyTab.linkedBrowser; + await checkLoginDisplayed(lazyBrowser, testGuid); + + BrowserTestUtils.removeTab(lazyTab); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_tabKeyNav.js b/browser/components/aboutlogins/tests/browser/browser_tabKeyNav.js new file mode 100644 index 0000000000..0305107d23 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_tabKeyNav.js @@ -0,0 +1,276 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +EXPECTED_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.example.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", +}; + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_tab_key_nav() { + const browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + // Helper function for getting the resulting DOM element given a list of selectors possibly inside shadow DOM + const selectWithShadowRootIfNeeded = (document, selectorsArray) => + selectorsArray.reduce( + (selectionSoFar, currentSelector) => + selectionSoFar.shadowRoot + ? selectionSoFar.shadowRoot.querySelector(currentSelector) + : selectionSoFar.querySelector(currentSelector), + document + ); + + const EventUtils = ContentTaskUtils.getEventUtils(content); + // list [selector, shadow root selector] for each element + // in the order we expect them to be navigated. + const expectedElementsInOrder = [ + ["login-list", "login-filter", "input"], + ["login-list", "button.create-login-button"], + ["login-list", "select#login-sort"], + ["login-list", "ol"], + ["login-item", "button.edit-button"], + ["login-item", "button.delete-button"], + ["login-item", "a.origin-input"], + ["login-item", "button.copy-username-button"], + ["login-item", "input.reveal-password-checkbox"], + ["login-item", "button.copy-password-button"], + ]; + + const firstElement = selectWithShadowRootIfNeeded( + content.document, + expectedElementsInOrder.at(0) + ); + + const lastElement = selectWithShadowRootIfNeeded( + content.document, + expectedElementsInOrder.at(-1) + ); + + async function tab() { + EventUtils.synthesizeKey("KEY_Tab", {}, content); + await new Promise(resolve => content.requestAnimationFrame(resolve)); + // The following line can help with focus trap debugging: + // await new Promise(resolve => content.window.setTimeout(resolve, 500)); + } + async function shiftTab() { + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }, content); + await new Promise(resolve => content.requestAnimationFrame(resolve)); + // await new Promise(resolve => content.window.setTimeout(resolve, 500)); + } + + // Getting focused shadow DOM element itself instead of shadowRoot, + // using recursion for any component-nesting level, as in: + // document.activeElement.shadowRoot.activeElement.shadowRoot.activeElement + function getFocusedElement() { + let element = content.document.activeElement; + const getShadowRootFocus = e => { + if (e.shadowRoot) { + return getShadowRootFocus(e.shadowRoot.activeElement); + } + return e; + }; + return getShadowRootFocus(element); + } + + // Ensure the test starts in a valid state + firstElement.focus(); + // Assert that we tab navigate correctly + for (let expectedSelector of expectedElementsInOrder) { + const expectedElement = selectWithShadowRootIfNeeded( + content.document, + expectedSelector + ); + + // By default, MacOS will skip over certain text controls, such as links. + if ( + content.window.navigator.platform.toLowerCase().includes("mac") && + expectedElement.tagName === "A" + ) { + continue; + } + + const actualElement = getFocusedElement(); + + Assert.equal( + actualElement, + expectedElement, + "Actual focused element should equal the expected focused element" + ); + await tab(); + } + + lastElement.focus(); + + // Assert that we shift + tab navigate correctly starting from the last ordered element + for (let expectedSelector of expectedElementsInOrder.reverse()) { + const expectedElement = selectWithShadowRootIfNeeded( + content.document, + expectedSelector + ); + // By default, MacOS will skip over certain text controls, such as links. + if ( + content.window.navigator.platform.toLowerCase().includes("mac") && + expectedElement.tagName === "A" + ) { + continue; + } + + const actualElement = getFocusedElement(); + Assert.equal( + actualElement, + expectedElement, + "Actual focused element should equal the expected focused element" + ); + await shiftTab(); + } + await tab(); // tab back to the first element + }); +}); + +add_task(async function test_tab_to_create_button() { + const browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + const EventUtils = ContentTaskUtils.getEventUtils(content); + + function waitForAnimationFrame() { + return new Promise(resolve => content.requestAnimationFrame(resolve)); + } + + async function tab() { + EventUtils.synthesizeKey("KEY_Tab", {}, content); + await waitForAnimationFrame(); + } + + const loginList = content.document.querySelector("login-list"); + const loginFilter = loginList.shadowRoot.querySelector("login-filter"); + const loginSort = loginList.shadowRoot.getElementById("login-sort"); + const loginListbox = loginList.shadowRoot.querySelector("ol"); + const createButton = loginList.shadowRoot.querySelector( + ".create-login-button" + ); + + const getFocusedElement = () => loginList.shadowRoot.activeElement; + Assert.equal(getFocusedElement(), loginFilter, "login-filter is focused"); + + await tab(); + Assert.equal(getFocusedElement(), createButton, "create button is focused"); + + await tab(); + Assert.equal(getFocusedElement(), loginSort, "login sort is focused"); + + await tab(); + Assert.equal(getFocusedElement(), loginListbox, "listbox is focused next"); + + await tab(); + Assert.equal(getFocusedElement(), null, "login-list isn't focused again"); + }); +}); + +add_task(async function test_tab_to_edit_button() { + TEST_LOGIN3 = await addLogin(TEST_LOGIN3); + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn( + browser, + [[TEST_LOGIN1.guid, TEST_LOGIN3.guid]], + async ([testLoginNormalGuid, testLoginBreachedGuid]) => { + const EventUtils = ContentTaskUtils.getEventUtils(content); + + function waitForAnimationFrame() { + return new Promise(resolve => content.requestAnimationFrame(resolve)); + } + + async function tab() { + EventUtils.synthesizeKey("KEY_Tab", {}, content); + await waitForAnimationFrame(); + } + + const loginList = content.document.querySelector("login-list"); + const loginItem = content.document.querySelector("login-item"); + const loginFilter = loginList.shadowRoot.querySelector("login-filter"); + const createButton = loginList.shadowRoot.querySelector( + ".create-login-button" + ); + const loginSort = loginList.shadowRoot.getElementById("login-sort"); + const loginListbox = loginList.shadowRoot.querySelector("ol"); + const editButton = loginItem.shadowRoot.querySelector(".edit-button"); + const breachAlert = loginItem.shadowRoot.querySelector(".breach-alert"); + const getFocusedElement = () => { + if (content.document.activeElement == loginList) { + return loginList.shadowRoot.activeElement; + } + if (content.document.activeElement == loginItem) { + return loginItem.shadowRoot.activeElement; + } + if (content.document.activeElement == loginFilter) { + return loginFilter.shadowRoot.activeElement; + } + Assert.ok( + false, + "not expecting a different element to get focused in this test: " + + content.document.activeElement.outerHTML + ); + return undefined; + }; + + for (let guidToSelect of [testLoginNormalGuid, testLoginBreachedGuid]) { + let loginListItem = loginList.shadowRoot.querySelector( + `.login-list-item[data-guid="${guidToSelect}"]` + ); + loginListItem.click(); + await ContentTaskUtils.waitForCondition(() => { + let waivedLoginItem = Cu.waiveXrays(loginItem); + return ( + waivedLoginItem._login && + waivedLoginItem._login.guid == guidToSelect + ); + }, "waiting for login-item to show the selected login"); + + Assert.equal( + breachAlert.hidden, + guidToSelect == testLoginNormalGuid, + ".breach-alert should be hidden if the login is not breached. current login breached? " + + (guidToSelect == testLoginBreachedGuid) + ); + + createButton.focus(); + Assert.equal( + getFocusedElement(), + createButton, + "create button is focused" + ); + + await tab(); + Assert.equal(getFocusedElement(), loginSort, "login sort is focused"); + + await tab(); + Assert.equal( + getFocusedElement(), + loginListbox, + "listbox is focused next" + ); + + await tab(); + Assert.equal(getFocusedElement(), editButton, "edit button is focused"); + } + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_updateLogin.js b/browser/components/aboutlogins/tests/browser/browser_updateLogin.js new file mode 100644 index 0000000000..efa8bbdd7b --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_updateLogin.js @@ -0,0 +1,421 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { CONCEALED_PASSWORD_TEXT } = ChromeUtils.importESModule( + "chrome://browser/content/aboutlogins/aboutLoginsUtils.mjs" +); + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_show_logins() { + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [TEST_LOGIN1.guid], async loginGuid => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._loginGuidsSortedOrder.length == 1 && + loginList._loginGuidsSortedOrder[0] == loginGuid + ); + }, "Waiting for login to be displayed"); + Assert.ok( + loginFound, + "Stored logins should be displayed upon loading the page" + ); + }); +}); + +add_task(async function test_login_item() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + return; + } + + async function test_discard_dialog( + login, + exitPointSelector, + concealedPasswordText + ) { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "Entering edit mode" + ); + await Promise.resolve(); + + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + let passwordInput = loginItem._passwordInput; + usernameInput.value += "-undome"; + passwordInput.value += "-undome"; + + let dialog = content.document.querySelector("confirmation-dialog"); + Assert.ok(dialog.hidden, "Confirm dialog should initially be hidden"); + + let exitPoint = + loginItem.shadowRoot.querySelector(exitPointSelector) || + loginList.shadowRoot.querySelector(exitPointSelector); + exitPoint.click(); + + Assert.ok(!dialog.hidden, "Confirm dialog should be visible"); + + let confirmDiscardButton = + dialog.shadowRoot.querySelector(".confirm-button"); + await content.document.l10n.translateElements([ + dialog.shadowRoot.querySelector(".title"), + dialog.shadowRoot.querySelector(".message"), + confirmDiscardButton, + ]); + + confirmDiscardButton.click(); + + Assert.ok( + dialog.hidden, + "Confirm dialog should be hidden after confirming" + ); + + await Promise.resolve(); + + let loginListItem = Cu.waiveXrays( + loginList.shadowRoot.querySelector(".login-list-item[data-guid]") + ); + + loginListItem.click(); + + await ContentTaskUtils.waitForCondition( + () => usernameInput.value == login.username + ); + + Assert.equal( + usernameInput.value, + login.username, + "Username change should be reverted" + ); + Assert.equal( + passwordInput.value, + login.password, + "Password change should be reverted" + ); + let passwordDisplayInput = loginItem._passwordDisplayInput; + Assert.equal( + passwordDisplayInput.value, + concealedPasswordText, + "Password change should be reverted for display" + ); + Assert.ok( + !passwordInput.hasAttribute("value"), + "Password shouldn't be exposed in @value" + ); + Assert.equal( + passwordInput.style.width, + login.password.length + "ch", + "Password field width shouldn't have changed" + ); + } + + let browser = gBrowser.selectedBrowser; + let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn( + browser, + [LoginHelper.loginToVanillaObject(TEST_LOGIN1)], + async login => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let loginListItem = Cu.waiveXrays( + loginList.shadowRoot.querySelector(".login-list-item[data-guid]") + ); + loginListItem.click(); + + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let loginItemPopulated = await ContentTaskUtils.waitForCondition(() => { + return ( + loginItem._login.guid == loginListItem.dataset.guid && + loginItem._login.guid == login.guid + ); + }, "Waiting for login item to get populated"); + Assert.ok(loginItemPopulated, "The login item should get populated"); + + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + } + ); + info("waiting for oskeystore auth #1"); + await reauthObserved; + await SpecialPowers.spawn( + browser, + [ + LoginHelper.loginToVanillaObject(TEST_LOGIN1), + ".create-login-button", + CONCEALED_PASSWORD_TEXT, + ], + test_discard_dialog + ); + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + }); + info("waiting for oskeystore auth #2"); + await reauthObserved; + await SpecialPowers.spawn( + browser, + [ + LoginHelper.loginToVanillaObject(TEST_LOGIN1), + ".cancel-button", + CONCEALED_PASSWORD_TEXT, + ], + test_discard_dialog + ); + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + }); + info("waiting for oskeystore auth #3"); + await reauthObserved; + await SpecialPowers.spawn( + browser, + [LoginHelper.loginToVanillaObject(TEST_LOGIN1), CONCEALED_PASSWORD_TEXT], + async (login, concealedPasswordText) => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "Entering edit mode" + ); + await Promise.resolve(); + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + let passwordInput = loginItem._passwordInput; + let passwordDisplayInput = loginItem._passwordDisplayInput; + + Assert.ok( + loginItem.dataset.editing, + "LoginItem should be in 'edit' mode" + ); + Assert.equal( + passwordInput.type, + "password", + "Password should still be hidden before revealed in edit mode" + ); + + passwordDisplayInput.focus(); + + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + Assert.ok( + revealCheckbox.checked, + "reveal-checkbox should be checked when password input is focused" + ); + + Assert.equal( + passwordInput.type, + "text", + "Password should be shown as text when focused" + ); + let saveChangesButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + + saveChangesButton.click(); + + await ContentTaskUtils.waitForCondition(() => { + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + return !editButton.disabled; + }, "Waiting to exit edit mode"); + + Assert.ok( + !revealCheckbox.checked, + "reveal-checkbox should be unchecked after saving changes" + ); + Assert.ok( + !loginItem.dataset.editing, + "LoginItem should not be in 'edit' mode after saving" + ); + Assert.equal( + passwordInput.type, + "password", + "Password should be hidden after exiting edit mode" + ); + Assert.equal( + usernameInput.value, + login.username, + "Username change should be reverted" + ); + Assert.equal( + passwordInput.value, + login.password, + "Password change should be reverted" + ); + Assert.equal( + passwordDisplayInput.value, + concealedPasswordText, + "Password change should be reverted for display" + ); + Assert.ok( + !passwordInput.hasAttribute("value"), + "Password shouldn't be exposed in @value" + ); + Assert.equal( + passwordInput.style.width, + login.password.length + "ch", + "Password field width shouldn't have changed" + ); + } + ); + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + }); + info("waiting for oskeystore auth #4"); + await reauthObserved; + await SpecialPowers.spawn( + browser, + [LoginHelper.loginToVanillaObject(TEST_LOGIN1)], + async login => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "Entering edit mode" + ); + await Promise.resolve(); + + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + Assert.ok( + revealCheckbox.checked, + "reveal-checkbox should be checked after clicking" + ); + + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + let passwordInput = loginItem._passwordInput; + + usernameInput.value += "-saveme"; + passwordInput.value += "-saveme"; + + Assert.ok( + loginItem.dataset.editing, + "LoginItem should be in 'edit' mode" + ); + + let saveChangesButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + saveChangesButton.click(); + + await ContentTaskUtils.waitForCondition(() => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let guid = loginList._loginGuidsSortedOrder[0]; + let updatedLogin = loginList._logins[guid].login; + return ( + updatedLogin && + updatedLogin.username == usernameInput.value && + updatedLogin.password == passwordInput.value + ); + }, "Waiting for corresponding login in login list to update"); + + Assert.ok( + !revealCheckbox.checked, + "reveal-checkbox should be unchecked after saving changes" + ); + Assert.ok( + !loginItem.dataset.editing, + "LoginItem should not be in 'edit' mode after saving" + ); + Assert.equal( + passwordInput.style.width, + passwordInput.value.length + "ch", + "Password field width should be correctly updated" + ); + } + ); + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + }); + info("waiting for oskeystore auth #5"); + await reauthObserved; + await SpecialPowers.spawn( + browser, + [LoginHelper.loginToVanillaObject(TEST_LOGIN1)], + async login => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "Entering edit mode" + ); + await Promise.resolve(); + + Assert.ok( + loginItem.dataset.editing, + "LoginItem should be in 'edit' mode" + ); + let deleteButton = loginItem.shadowRoot.querySelector(".delete-button"); + deleteButton.click(); + let confirmDeleteDialog = Cu.waiveXrays( + content.document.querySelector("confirmation-dialog") + ); + let confirmDeleteButton = + confirmDeleteDialog.shadowRoot.querySelector(".confirm-button"); + confirmDeleteButton.click(); + + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let loginListItem = Cu.waiveXrays( + loginList.shadowRoot.querySelector(".login-list-item[data-guid]") + ); + await ContentTaskUtils.waitForCondition(() => { + loginListItem = loginList.shadowRoot.querySelector( + ".login-list-item[data-guid]" + ); + return !loginListItem; + }, "Waiting for login to be removed from list"); + + Assert.ok( + !loginItem.dataset.editing, + "LoginItem should not be in 'edit' mode after deleting" + ); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_vulnerableLoginAddedInSecondaryWindow.js b/browser/components/aboutlogins/tests/browser/browser_vulnerableLoginAddedInSecondaryWindow.js new file mode 100644 index 0000000000..fac3e91af4 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_vulnerableLoginAddedInSecondaryWindow.js @@ -0,0 +1,223 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +EXPECTED_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.example.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", +}; + +let tabInSecondWindow; + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + TEST_LOGIN3 = await addLogin(TEST_LOGIN3); + + let breaches = await LoginBreaches.getPotentialBreachesByLoginGUID([ + TEST_LOGIN3, + ]); + Assert.ok(breaches.size, "TEST_LOGIN3 should be marked as breached"); + + // Remove the breached login so the 'alerts' option + // is hidden when opening about:logins. + Services.logins.removeLogin(TEST_LOGIN3); + + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + tabInSecondWindow = await BrowserTestUtils.openNewForegroundTab({ + gBrowser: newWin.gBrowser, + url: "about:logins", + }); + + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + await BrowserTestUtils.closeWindow(newWin); + }); +}); + +add_task(async function test_new_login_marked_vulnerable_in_both_windows() { + const ORIGIN_FOR_NEW_VULNERABLE_LOGIN = "https://vulnerable"; + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + Assert.ok( + loginList.shadowRoot.querySelector("#login-sort").namedItem("alerts") + .hidden, + "The 'alerts' option should be hidden before adding a vulnerable login to the list" + ); + }); + + await SpecialPowers.spawn( + tabInSecondWindow.linkedBrowser, + [[TEST_LOGIN3.password, ORIGIN_FOR_NEW_VULNERABLE_LOGIN]], + async ([passwordOfBreachedAccount, originForNewVulnerableLogin]) => { + let loginList = content.document.querySelector("login-list"); + + await ContentTaskUtils.waitForCondition( + () => + loginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]") + .length == 2, + "waiting for all two initials logins to get added to login-list" + ); + + let loginSort = loginList.shadowRoot.querySelector("#login-sort"); + Assert.ok( + loginSort.namedItem("alerts").hidden, + "The 'alerts' option should be hidden when there are no breached or vulnerable logins in the list" + ); + + let createButton = loginList.shadowRoot.querySelector( + ".create-login-button" + ); + createButton.click(); + + let loginItem = content.document.querySelector("login-item"); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.isNewLogin, + "waiting for create login form to be visible" + ); + + let originInput = loginItem.shadowRoot.querySelector( + "input[name='origin']" + ); + originInput.value = originForNewVulnerableLogin; + let passwordInput = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + passwordInput.value = passwordOfBreachedAccount; + + let saveButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + saveButton.click(); + + await ContentTaskUtils.waitForCondition( + () => + loginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]") + .length == 3, + "waiting for new login to get added to login-list" + ); + + let vulnerableLoginGuid = Cu.waiveXrays(loginItem)._login.guid; + let vulnerableListItem = loginList.shadowRoot.querySelector( + `.login-list-item[data-guid="${vulnerableLoginGuid}"]` + ); + + Assert.ok( + vulnerableListItem.classList.contains("vulnerable"), + "vulnerable login list item should be marked as such" + ); + Assert.ok( + !loginItem.shadowRoot.querySelector(".vulnerable-alert").hidden, + "vulnerable alert on login-item should be visible" + ); + + Assert.ok( + !loginSort.namedItem("alerts").hidden, + "The 'alerts' option should be visible after adding a vulnerable login to the list" + ); + } + ); + console.log("xxxxxxx ---- 0"); + + tabInSecondWindow.linkedBrowser.reload(); + await BrowserTestUtils.browserLoaded( + tabInSecondWindow.linkedBrowser, + false, + url => url.includes("about:logins") + ); + + console.log("xxxxxxx ---- 1"); + + await SpecialPowers.spawn(tabInSecondWindow.linkedBrowser, [], async () => { + let loginList = content.document.querySelector("login-list"); + let loginSort = loginList.shadowRoot.querySelector("#login-sort"); + + await ContentTaskUtils.waitForCondition( + () => loginSort.value == "alerts", + "waiting for sort to get updated to 'alerts'" + ); + + Assert.equal( + loginSort.value, + "alerts", + "The login list should be sorted by Alerts" + ); + let loginListItems = loginList.shadowRoot.querySelectorAll( + ".login-list-item[data-guid]" + ); + for (let i = 1; i < loginListItems.length; i++) { + if (loginListItems[i].matches(".vulnerable, .breached")) { + Assert.ok( + loginListItems[i - 1].matches(".vulnerable, .breached"), + `The previous login-list-item must be vulnerable or breached if the current one is (second window, i=${i})` + ); + } + } + }); + console.log("xxxxxxx ---- 2"); + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [ORIGIN_FOR_NEW_VULNERABLE_LOGIN], + async originForNewVulnerableLogin => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let vulnerableListItem; + await ContentTaskUtils.waitForCondition(() => { + let entry = Object.entries(loginList._logins).find( + ([guid, { login, listItem }]) => + login.origin == originForNewVulnerableLogin + ); + vulnerableListItem = entry[1].listItem; + return !!entry; + }, "waiting for vulnerable list item to get added to login-list"); + Assert.ok( + vulnerableListItem.classList.contains("vulnerable"), + "vulnerable login list item should be marked as such" + ); + + Assert.ok( + !loginList.shadowRoot.querySelector("#login-sort").namedItem("alerts") + .hidden, + "The 'alerts' option should be visible after adding a vulnerable login to the list" + ); + } + ); + gBrowser.selectedBrowser.reload(); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, false, url => + url.includes("about:logins") + ); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + await ContentTaskUtils.waitForCondition( + () => loginList.shadowRoot.querySelector("#login-sort").value == "alerts", + "waiting for sort to get updated to 'alerts'" + ); + let loginListItems = loginList.shadowRoot.querySelectorAll( + ".login-list-item[data-guid]" + ); + for (let i = 1; i < loginListItems.length; i++) { + if (loginListItems[i].matches(".vulnerable, .breached")) { + Assert.ok( + loginListItems[i - 1].matches(".vulnerable, .breached"), + `The previous login-list-item must be vulnerable or breached if the current one is (first window, i=${i})` + ); + } + } + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/head.js b/browser/components/aboutlogins/tests/browser/head.js new file mode 100644 index 0000000000..2aec0e632a --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/head.js @@ -0,0 +1,225 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +let { LoginBreaches } = ChromeUtils.importESModule( + "resource:///modules/LoginBreaches.sys.mjs" +); +let { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); +let { _AboutLogins } = ChromeUtils.importESModule( + "resource:///actors/AboutLoginsParent.sys.mjs" +); +let { OSKeyStoreTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/OSKeyStoreTestUtils.sys.mjs" +); +var { LoginTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" +); + +let nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); + +let TEST_LOGIN1 = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "user1", + "pass1", + "username", + "password" +); +let TEST_LOGIN2 = new nsLoginInfo( + "https://2.example.com", + "https://2.example.com", + null, + "user2", + "pass2", + "username", + "password" +); + +let TEST_LOGIN3 = new nsLoginInfo( + "https://breached.example.com", + "https://breached.example.com", + null, + "breachedLogin1", + "pass3", + "breachedLogin", + "password" +); +TEST_LOGIN3.QueryInterface(Ci.nsILoginMetaInfo).timePasswordChanged = 123456; + +async function addLogin(login) { + const result = await Services.logins.addLoginAsync(login); + registerCleanupFunction(() => { + let matchData = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag2 + ); + matchData.setPropertyAsAUTF8String("guid", result.guid); + + let logins = Services.logins.searchLogins(matchData); + if (!logins.length) { + return; + } + // Use the login that was returned from searchLogins + // in case the initial login object was changed by the test code, + // since removeLogin makes sure that the login argument exactly + // matches the login that it will be removing. + Services.logins.removeLogin(logins[0]); + }); + return result; +} + +let EXPECTED_BREACH = null; +let EXPECTED_ERROR_MESSAGE = null; +add_setup(async function setup_head() { + const db = RemoteSettings(LoginBreaches.REMOTE_SETTINGS_COLLECTION).db; + if (EXPECTED_BREACH) { + await db.create(EXPECTED_BREACH, { + useRecordId: true, + }); + } + await db.importChanges({}, Date.now()); + if (EXPECTED_BREACH) { + await RemoteSettings(LoginBreaches.REMOTE_SETTINGS_COLLECTION).emit( + "sync", + { data: { current: [EXPECTED_BREACH] } } + ); + } + + SpecialPowers.registerConsoleListener(function onConsoleMessage(msg) { + if (msg.isWarning || !msg.errorMessage) { + // Ignore warnings and non-errors. + return; + } + + if (msg.errorMessage.includes('Unknown event: ["jsonfile", "load"')) { + // Ignore telemetry errors from JSONFile.sys.mjs. + return; + } + + if ( + msg.errorMessage == "Refreshing device list failed." || + msg.errorMessage == "Skipping device list refresh; not signed in" + ) { + // Ignore errors from browser-sync.js. + return; + } + if ( + msg.errorMessage.includes( + "ReferenceError: MigrationWizard is not defined" + ) + ) { + // todo(Bug 1587237): Ignore error when loading the Migration Wizard in automation. + return; + } + if ( + msg.errorMessage.includes("Error detecting Chrome profiles") || + msg.errorMessage.includes( + "Library/Application Support/Chromium/Local State (No such file or directory)" + ) || + msg.errorMessage.includes( + "Library/Application Support/Google/Chrome/Local State (No such file or directory)" + ) + ) { + // Ignore errors that can occur when the migrator is looking for a + // Chrome/Chromium profile + return; + } + if (msg.errorMessage.includes("Can't find profile directory.")) { + // Ignore error messages for no profile found in old XULStore.jsm + return; + } + if (msg.errorMessage.includes("Error reading typed URL history")) { + // The Migrator when opened can log this exception if there is no Edge + // history on the machine. + return; + } + if (msg.errorMessage.includes(EXPECTED_ERROR_MESSAGE)) { + return; + } + if (msg.errorMessage == "FILE_FORMAT_ERROR") { + // Ignore errors handled by the error message dialog. + return; + } + if ( + msg.errorMessage == + "NotFoundError: No such JSWindowActor 'MarionetteEvents'" + ) { + // Ignore MarionetteEvents error (Bug 1730837, Bug 1710079). + return; + } + Assert.ok(false, msg.message || msg.errorMessage); + }); + + registerCleanupFunction(async () => { + EXPECTED_ERROR_MESSAGE = null; + await db.clear(); + Services.telemetry.clearEvents(); + SpecialPowers.postConsoleSentinel(); + }); +}); + +/** + * Waits for the primary password prompt and performs an action. + * @param {string} action Set to "authenticate" to log in or "cancel" to + * close the dialog without logging in. + */ +function waitForMPDialog(action, aWindow = window) { + const BRAND_BUNDLE = Services.strings.createBundle( + "chrome://branding/locale/brand.properties" + ); + const BRAND_FULL_NAME = BRAND_BUNDLE.GetStringFromName("brandFullName"); + let dialogShown = TestUtils.topicObserved("common-dialog-loaded"); + return dialogShown.then(function ([subject]) { + let dialog = subject.Dialog; + let expected = "Password Required - " + BRAND_FULL_NAME; + Assert.equal( + dialog.args.title, + expected, + "Dialog is the Primary Password dialog" + ); + if (action == "authenticate") { + SpecialPowers.wrap(dialog.ui.password1Textbox).setUserInput( + LoginTestUtils.primaryPassword.primaryPassword + ); + dialog.ui.button0.click(); + } else if (action == "cancel") { + dialog.ui.button1.click(); + } + return BrowserTestUtils.waitForEvent(aWindow, "DOMModalDialogClosed"); + }); +} + +/** + * Allows for tests to reset the MP auth expiration and + * return a promise that will resolve after the MP dialog has + * been presented. + * + * @param {string} action Set to "authenticate" to log in or "cancel" to + * close the dialog without logging in. + * @returns {Promise} Resolves after the MP dialog has been presented and actioned upon + */ +function forceAuthTimeoutAndWaitForMPDialog(action, aWindow = window) { + const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes (duplicated from AboutLoginsParent.sys.mjs) + _AboutLogins._authExpirationTime -= AUTH_TIMEOUT_MS + 1; + return waitForMPDialog(action, aWindow); +} + +/** + * Allows for tests to reset the OS auth expiration and + * return a promise that will resolve after the OS auth dialog has + * been presented. + * + * @param {bool} loginResult True if the auth prompt should pass, otherwise false will fail + * @returns {Promise} Resolves after the OS auth dialog has been presented + */ +function forceAuthTimeoutAndWaitForOSKeyStoreLogin({ loginResult }) { + const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes (duplicated from AboutLoginsParent.sys.mjs) + _AboutLogins._authExpirationTime -= AUTH_TIMEOUT_MS + 1; + return OSKeyStoreTestUtils.waitForOSKeyStoreLogin(loginResult); +} diff --git a/browser/components/aboutlogins/tests/chrome/.eslintrc.js b/browser/components/aboutlogins/tests/chrome/.eslintrc.js new file mode 100644 index 0000000000..9b6510bdd2 --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/.eslintrc.js @@ -0,0 +1,16 @@ +/* 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/. */ + +"use strict"; + +module.exports = { + overrides: [ + { + files: ["test_login_item.html"], + parserOptions: { + sourceType: "module", + }, + }, + ], +}; diff --git a/browser/components/aboutlogins/tests/chrome/aboutlogins_common.js b/browser/components/aboutlogins/tests/chrome/aboutlogins_common.js new file mode 100644 index 0000000000..d24c962da0 --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/aboutlogins_common.js @@ -0,0 +1,97 @@ +"use strict"; + +/* exported asyncElementRendered, importDependencies */ + +/** + * A helper to await on while waiting for an asynchronous rendering of a Custom + * Element. + * @returns {Promise} + */ +function asyncElementRendered() { + return Promise.resolve(); +} + +/** + * Import the templates from the real page to avoid duplication in the tests. + * @param {HTMLIFrameElement} templateFrame - Frame to copy the resources from + * @param {HTMLElement} destinationEl - Where to append the copied resources + */ +function importDependencies(templateFrame, destinationEl) { + let promises = []; + for (let template of templateFrame.contentDocument.querySelectorAll( + "template" + )) { + let imported = document.importNode(template, true); + destinationEl.appendChild(imported); + // Preload the styles in the actual page, to ensure they're loaded on time. + for (let element of imported.content.querySelectorAll( + "link[rel='stylesheet']" + )) { + let clone = element.cloneNode(true); + promises.push( + new Promise(resolve => { + clone.onload = function () { + resolve(); + clone.remove(); + }; + }) + ); + destinationEl.appendChild(clone); + } + } + return Promise.all(promises); +} + +Object.defineProperty(document, "l10n", { + configurable: true, + writable: true, + value: { + connectRoot() {}, + translateElements() { + return Promise.resolve(); + }, + getAttributes(element) { + return { + id: element.getAttribute("data-l10n-id"), + args: element.getAttribute("data-l10n-args") + ? JSON.parse(element.getAttribute("data-l10n-args")) + : {}, + }; + }, + setAttributes(element, id, args) { + element.setAttribute("data-l10n-id", id); + if (args) { + element.setAttribute("data-l10n-args", JSON.stringify(args)); + } else { + element.removeAttribute("data-l10n-args"); + } + }, + }, +}); + +Object.defineProperty(window, "AboutLoginsUtils", { + configurable: true, + writable: true, + value: { + getLoginOrigin(uriString) { + return uriString; + }, + setFocus(element) { + return element.focus(); + }, + async promptForPrimaryPassword(resolve, messageId) { + resolve(true); + }, + doLoginsMatch(login1, login2) { + return ( + login1.origin == login2.origin && + login1.username == login2.username && + login1.password == login2.password + ); + }, + fileImportEnabled: SpecialPowers.getBoolPref( + "signon.management.page.fileImport.enabled" + ), + primaryPasswordEnabled: false, + }, +}); diff --git a/browser/components/aboutlogins/tests/chrome/chrome.ini b/browser/components/aboutlogins/tests/chrome/chrome.ini new file mode 100644 index 0000000000..ac1ba7076c --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/chrome.ini @@ -0,0 +1,13 @@ +[DEFAULT] +scheme = https +prefs = + identity.fxaccounts.enabled=true +support-files = + aboutlogins_common.js + +[test_confirm_delete_dialog.html] +[test_fxaccounts_button.html] +[test_login_filter.html] +[test_login_item.html] +[test_login_list.html] +[test_menu_button.html] diff --git a/browser/components/aboutlogins/tests/chrome/test_confirm_delete_dialog.html b/browser/components/aboutlogins/tests/chrome/test_confirm_delete_dialog.html new file mode 100644 index 0000000000..68a58aee4f --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/test_confirm_delete_dialog.html @@ -0,0 +1,127 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test the confirmation-dialog component +--> +<head> + <meta charset="utf-8"> + <title>Test the confirmation-dialog component</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/confirmation-dialog.mjs"></script> + <script src="aboutlogins_common.js"></script> + + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + <p id="display"> + </p> +<div id="content" style="display: none"> + <iframe id="templateFrame" src="chrome://browser/content/aboutlogins/aboutLogins.html" + sandbox="allow-same-origin"></iframe> +</div> +<pre id="test"> +</pre> +<script> +/** Test the confirmation-dialog component **/ + +let options = { + title: "confirm-delete-dialog-title", + message: "confirm-delete-dialog-message", + confirmButtonLabel: "confirm-delete-dialog-confirm-button" +}; +let cancelButton, confirmButton, gConfirmationDialog; +add_setup(async () => { + let templateFrame = document.getElementById("templateFrame"); + let displayEl = document.getElementById("display"); + await importDependencies(templateFrame, displayEl); + + gConfirmationDialog = document.createElement("confirmation-dialog"); + displayEl.appendChild(gConfirmationDialog); + ok(gConfirmationDialog, "The dialog should exist"); + + cancelButton = gConfirmationDialog.shadowRoot.querySelector(".cancel-button"); + confirmButton = gConfirmationDialog.shadowRoot.querySelector(".confirm-button"); + ok(cancelButton, "The cancel button should exist"); + ok(confirmButton, "The confirm button should exist"); +}); + +add_task(async function test_escape_key_to_cancel() { + gConfirmationDialog.show(options); + ok(!gConfirmationDialog.hidden, "The dialog should be visible"); + sendKey("ESCAPE"); + ok(gConfirmationDialog.hidden, "The dialog should be hidden after hitting Escape"); + gConfirmationDialog.hide(); +}); + +add_task(async function test_initial_focus() { + gConfirmationDialog.show(options); + ok(!gConfirmationDialog.hidden, "The dialog should be visible"); + is(gConfirmationDialog.shadowRoot.activeElement, confirmButton, + "After initially opening the dialog, the confirm button should be focused"); + gConfirmationDialog.hide(); +}); + +add_task(async function test_tab_focus() { + gConfirmationDialog.show(options); + ok(!gConfirmationDialog.hidden, "The dialog should be visible"); + sendKey("TAB"); + is(gConfirmationDialog.shadowRoot.activeElement, cancelButton, + "After opening the dialog and tabbing once, the cancel button should be focused"); + gConfirmationDialog.hide(); +}); + +add_task(async function test_enter_key_to_cancel() { + let showPromise = gConfirmationDialog.show(options); + ok(!gConfirmationDialog.hidden, "The dialog should be visible"); + sendKey("RETURN"); + try { + await showPromise; + ok(true, "The dialog Promise should resolve after hitting Return with the confirm button focused"); + } catch (ex) { + ok(false, "The dialog Promise should not reject after hitting Return with the confirm button focused"); + } +}); + +add_task(async function test_enter_key_to_confirm() { + let showPromise = gConfirmationDialog.show(options); + ok(!gConfirmationDialog.hidden, "The dialog should be visible"); + sendKey("TAB"); + sendKey("RETURN"); + try { + await showPromise; + ok(false, "The dialog Promise should not resolve after hitting Return with the cancel button focused"); + } catch (ex) { + ok(true, "The dialog Promise should reject after hitting Return with the cancel button focused"); + } +}); + +add_task(async function test_dialog_focus_trap() { + let displayEl = document.getElementById("display"); + let displayElChildSpan = document.createElement("span"); + displayElChildSpan.tabIndex = 0; + displayElChildSpan.id = "display-child"; + displayEl.appendChild(displayElChildSpan); + + gConfirmationDialog.show(options); + + ok(!gConfirmationDialog.hidden, "The dialog should be visible"); + ok(displayElChildSpan.tabIndex === -1, "The tabIndex value for elements with a hardcoded tabIndex attribute should be reset to '-1'.") + ok(displayElChildSpan.dataset.oldTabIndex === "0", "Existing tabIndex values should be stored in `dataset.oldTabIndex`.") + + const isActiveElemDialogOrHTMLorBODY = (elemTagName) => { + return (["HTML", "BODY", "CONFIRMATION-DIALOG"].includes(elemTagName)); + } + + let iterator = 0; + while(iterator < 20) { + sendKey("TAB"); + isnot(document.activeElement.id, "display-child", "The display-child element should not gain focus when the dialog is showing"); + ok(isActiveElemDialogOrHTMLorBODY(document.activeElement.tagName), "The confirmation-dialog should always have focus when the dialog is showing"); + iterator++; + } +}); + +</script> +</body> +</html> diff --git a/browser/components/aboutlogins/tests/chrome/test_fxaccounts_button.html b/browser/components/aboutlogins/tests/chrome/test_fxaccounts_button.html new file mode 100644 index 0000000000..ce6046bf2a --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/test_fxaccounts_button.html @@ -0,0 +1,96 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test the fxaccounts-button component +--> +<head> + <meta charset="utf-8"> + <title>Test the fxaccounts-button component</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/fxaccounts-button.mjs"></script> + <script src="aboutlogins_common.js"></script> + + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + <p id="display"> + </p> +<div id="content" style="display: none"> + <iframe id="templateFrame" src="chrome://browser/content/aboutlogins/aboutLogins.html" + sandbox="allow-same-origin"></iframe> +</div> +<pre id="test"> +</pre> +<script> +/** Test the fxaccounts-button component **/ + +const TEST_AVATAR_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; + +let gFxAccountsButton; +add_setup(async () => { + let templateFrame = document.getElementById("templateFrame"); + let displayEl = document.getElementById("display"); + await importDependencies(templateFrame, displayEl); + + gFxAccountsButton = document.createElement("fxaccounts-button"); + displayEl.appendChild(gFxAccountsButton); +}); + +add_task(async function test_default_state() { + ok(gFxAccountsButton, "FxAccountsButton exists"); + ok(!isHidden(gFxAccountsButton.shadowRoot.querySelector(".logged-out-view")), + "logged-out-view view is visible by default"); + ok(isHidden(gFxAccountsButton.shadowRoot.querySelector(".logged-in-view")), + "logged-in-view view is hidden by default"); +}); + +add_task(async function test_logged_in_without_login_syncing() { + gFxAccountsButton.updateState({ + fxAccountsEnabled: true, + loggedIn: true, + loginSyncingEnabled: false, + }); + + ok(isHidden(gFxAccountsButton.shadowRoot.querySelector(".logged-out-view")), + "logged-out-view view is hidden"); + ok(!isHidden(gFxAccountsButton.shadowRoot.querySelector(".logged-in-view")), + "logged-in-view view is visible"); +}); + +add_task(async function test_logged_in_with_login_syncing() { + const TEST_EMAIL = "test@example.com"; + + gFxAccountsButton.updateState({ + fxAccountsEnabled: true, + loggedIn: true, + loginSyncingEnabled: true, + email: TEST_EMAIL, + avatarURL: TEST_AVATAR_URL, + }); + + ok(isHidden(gFxAccountsButton.shadowRoot.querySelector(".logged-out-view")), + "logged-out-view view is hidden"); + ok(!isHidden(gFxAccountsButton.shadowRoot.querySelector(".logged-in-view")), + "logged-in-view view is visible"); + is(gFxAccountsButton.shadowRoot.querySelector(".fxaccount-email").textContent, + TEST_EMAIL, + "email should be shown"); + is(gFxAccountsButton.shadowRoot.querySelector(".fxaccounts-avatar-button").style.getPropertyValue("--avatar-url"), + `url(${TEST_AVATAR_URL})`, + "--avatar-url should be set"); +}); + +add_task(async function test_fxaccounts_disabled() { + gFxAccountsButton.updateState({ + fxAccountsEnabled: false, + }); + + ok(isHidden(gFxAccountsButton), + "the whole button is hidden when fxaccounts are disabled"); +}); + +</script> + +</body> +</html> diff --git a/browser/components/aboutlogins/tests/chrome/test_login_filter.html b/browser/components/aboutlogins/tests/chrome/test_login_filter.html new file mode 100644 index 0000000000..00e0a96a51 --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/test_login_filter.html @@ -0,0 +1,178 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test the login-filter component +--> +<head> + <meta charset="utf-8"> + <title>Test the login-filter component</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/login-filter.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/login-list.mjs"></script> + <script src="aboutlogins_common.js"></script> + + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + <p id="display"> + </p> +<div id="content" style="display: none"> + <iframe id="templateFrame" src="chrome://browser/content/aboutlogins/aboutLogins.html" + sandbox="allow-same-origin"></iframe> +</div> +<pre id="test"> +</pre> +<script> +/** Test the login-filter component **/ + +let gLoginFilter; +let gLoginList; +add_setup(async () => { + let templateFrame = document.getElementById("templateFrame"); + let displayEl = document.getElementById("display"); + importDependencies(templateFrame, displayEl); + + gLoginFilter = document.createElement("login-filter"); + displayEl.appendChild(gLoginFilter); + + gLoginList = document.createElement("login-list"); + displayEl.appendChild(gLoginList); +}); + +add_task(async function test_empty_filter() { + ok(gLoginFilter, "loginFilter exists"); + is(gLoginFilter.shadowRoot.querySelector("input").value, "", "Initially empty"); +}); + +add_task(async function test_input_events() { + let filterEvent = null; + window.addEventListener("AboutLoginsFilterLogins", event => filterEvent = event); + let input = SpecialPowers.wrap(gLoginFilter.shadowRoot.querySelector("input")); + input.setUserInput("test"); + ok(filterEvent, "Filter event received"); + is(filterEvent.detail, "test", "Event includes input value"); +}); + +add_task(async function test_list_filtered() { + const LOGINS = [{ + guid: "123456789", + origin: "https://example.com", + username: "user1", + password: "pass1", + }, { + guid: "987654321", + origin: "https://example.com", + username: "user2", + password: "pass2", + }]; + gLoginList.setLogins(LOGINS); + + let tests = [ + ["", 2], + [LOGINS[0].username, 1], + [LOGINS[0].username + "-notfound", 0], + [LOGINS[0].username.substr(2, 3), 1], + ["", 2], + // The password is also used for search when MP is disabled. + [LOGINS[0].password, 1], + [LOGINS[0].password + "-notfound", 0], + [LOGINS[0].password.substr(2, 3), 1], + ["", 2], + [LOGINS[0].origin, 2], + [LOGINS[0].origin + "-notfound", 0], + [LOGINS[0].origin.substr(2, 3), 2], + ["", 2], + // The guid is not used for search. + [LOGINS[0].guid, 0], + [LOGINS[0].guid + "-notfound", 0], + [LOGINS[0].guid.substr(0, 2), 0], + ["", 2], + ]; + + let loginFilterInput = gLoginFilter.shadowRoot.querySelector("input"); + loginFilterInput.focus(); + + for (let i = 0; i < tests.length; i++) { + info("Testcase: " + i); + + let testObj = { + testCase: i, + query: tests[i][0], + resultExpectedCount: tests[i][1], + }; + + let filterLength = loginFilterInput.value.length; + while (filterLength-- > 0) { + sendKey("BACK_SPACE"); + } + sendString(testObj.query); + + await SimpleTest.promiseWaitForCondition(() => { + let countElement = gLoginList.shadowRoot.querySelector(".count"); + return countElement.hasAttribute("data-l10n-args") && + JSON.parse(countElement.getAttribute("data-l10n-args")).count == testObj.resultExpectedCount; + }, `Waiting for the search result count to update to ${testObj.resultExpectedCount} (tc#${testObj.testCase})`); + } +}); + + add_task(async function test_keys_in_filter() { + const LOGINS = [{ + guid: "123456789", + origin: "https://example.com", + username: "user1", + password: "pass1", + }, { + guid: "987654321", + origin: "https://example.com", + username: "user2", + password: "pass2", + }, { + guid: "333333333", + origin: "https://example.com", + username: "user3", + password: "pass3", + }]; + gLoginList.setLogins(LOGINS); + + const ol = gLoginList.shadowRoot.querySelector("ol"); + const loginFilterInput = gLoginFilter.shadowRoot.querySelector("input"); + loginFilterInput.focus(); + + // Up/down keys must select previous/next item in the list + function pressKeyAndExpectSelection(key, selectedIndex) { + sendKey(key); + ok(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[selectedIndex]?.classList?.contains("keyboard-selected"), + `item ${selectedIndex} should be marked as keyboard-selected`); + is(ol.querySelector(".selected").dataset.guid, LOGINS[selectedIndex].guid, `item ${selectedIndex} must be selected`); + } + + pressKeyAndExpectSelection("DOWN", 1); + pressKeyAndExpectSelection("DOWN", 2); + + // ENTER key in search box must click on selected item in the list + sendKey("RETURN"); + is(ol.querySelector(".selected").dataset.guid, LOGINS[2].guid, "item 2 must still be selected"); + + pressKeyAndExpectSelection("DOWN", 2); + pressKeyAndExpectSelection("UP", 1); + pressKeyAndExpectSelection("UP", 0); + pressKeyAndExpectSelection("UP", 0); + + // ESC must clear search box + async function expectItemCount(count) { + await SimpleTest.promiseWaitForCondition(() => + JSON.parse(gLoginList.shadowRoot.querySelector(".count").getAttribute("data-l10n-args"))?.count == count, + `Waiting for the search result count to update to ${count}`); + } + + sendString("unique string"); + await expectItemCount(0); + sendKey("Escape"); + ok(!loginFilterInput.value, "ESC must clear filter input"); + await expectItemCount(LOGINS.length); + }); +</script> + +</body> +</html> diff --git a/browser/components/aboutlogins/tests/chrome/test_login_item.html b/browser/components/aboutlogins/tests/chrome/test_login_item.html new file mode 100644 index 0000000000..a7946a0618 --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/test_login_item.html @@ -0,0 +1,481 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test the login-item component +--> +<head> + <meta charset="utf-8"> + <title>Test the login-item component</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/login-item.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/confirmation-dialog.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/login-timeline.mjs"></script> + <script src="aboutlogins_common.js"></script> + + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + <p id="display"> + </p> +<div id="content" style="display: none"> + <iframe id="templateFrame" src="chrome://browser/content/aboutlogins/aboutLogins.html" + sandbox="allow-same-origin"></iframe> +</div> +<pre id="test"> +</pre> +<script type="module"> + +import { CONCEALED_PASSWORD_TEXT } from "chrome://browser/content/aboutlogins/aboutLoginsUtils.mjs"; + +/** Test the login-item component **/ + +let gLoginItem, gConfirmationDialog; +const TEST_LOGIN_1 = { + guid: "123456789", + origin: "https://example.com", + username: "user1", + password: "pass1", + timeCreated: "1000", + timePasswordChanged: "2000", + timeLastUsed: "4000", +}; + +const TEST_LOGIN_2 = { + guid: "987654321", + origin: "https://example.com", + username: "user2", + password: "pass2", + timeCreated: "2000", + timePasswordChanged: "4000", + timeLastUsed: "8000", +}; + +const TEST_BREACH = { + Name: "Test-Breach", + breachAlertURL: "https://monitor.firefox.com/breach-details/Test-Breach", +}; + +const TEST_BREACHES_MAP = new Map(); +TEST_BREACHES_MAP.set(TEST_LOGIN_1.guid, TEST_BREACH); + +const TEST_VULNERABLE_MAP = new Map(); +TEST_VULNERABLE_MAP.set(TEST_LOGIN_2.guid, true); + +const getLoginTimeline = loginItem => + loginItem.shadowRoot.querySelector("login-timeline"); + +const verifyTimelineActions = (actions, expectedActions) => { + is( + actions.length, + expectedActions.length, + `Number timeline actions length is correct. Actual: ${actions.length}. Expected: ${expectedActions.length}` + ); + + actions.forEach((point, index) => { + let actionId = document.l10n.getAttributes(point).id; + let expectedAction = expectedActions[index]; + + is( + actionId, + expectedAction, + `Rendered action is correct. Actual: ${actionId}. Expected: ${expectedAction}` + ); + }); +}; + +add_setup(async () => { + let templateFrame = document.getElementById("templateFrame"); + let displayEl = document.getElementById("display"); + await importDependencies(templateFrame, displayEl); + + gLoginItem = document.createElement("login-item"); + displayEl.appendChild(gLoginItem); + + gConfirmationDialog = document.createElement("confirmation-dialog"); + gConfirmationDialog.hidden = true; + displayEl.appendChild(gConfirmationDialog); +}); + +add_task(async function test_empty_item() { + ok(gLoginItem, "loginItem exists"); + is(gLoginItem.shadowRoot.querySelector("a[name='origin']").getAttribute("href"), "", "origin should be blank"); + is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, "", "username should be blank"); + is(gLoginItem._passwordInput.value, "", "password should be blank"); + ok(!gLoginItem._passwordInput.isConnected, "Real password input should be disconnected"); + is(gLoginItem._passwordDisplayInput.value, "", "password display should be blank"); + ok(!isHidden(gLoginItem._passwordDisplayInput), "Password display input should be visible") + ok(isHidden(getLoginTimeline(gLoginItem)), "Timeline should be hidden"); +}); + +add_task(async function test_set_login() { + gLoginItem.setLogin(TEST_LOGIN_1); + await asyncElementRendered(); + + ok(!gLoginItem.dataset.editing, "loginItem should not be in 'edit' mode"); + ok(!gLoginItem.dataset.isNewLogin, "loginItem should not be in 'isNewLogin' mode"); + ok(isHidden(gLoginItem._originInput), "Origin input should be hidden when not in edit mode"); + ok(!isHidden(gLoginItem._originDisplayInput), "Origin display link should be visible when not in edit mode"); + let originLink = gLoginItem.shadowRoot.querySelector("a[name='origin']"); + is(originLink.getAttribute("href"), TEST_LOGIN_1.origin, "origin should be populated"); + let usernameInput = gLoginItem.shadowRoot.querySelector("input[name='username']"); + is(usernameInput.value, TEST_LOGIN_1.username, "username should be populated"); + is(document.l10n.getAttributes(usernameInput).id, "about-logins-login-item-username", "username field should have default placeholder when not editing"); + + let passwordInput = gLoginItem._passwordInput; + is(passwordInput.value, TEST_LOGIN_1.password, "password should be populated"); + ok(!passwordInput.hasAttribute("value"), "Password shouldn't be exposed in @value"); + ok(!gLoginItem._passwordInput.isConnected, "Real password input should be disconnected"); + let passwordDisplayInput = gLoginItem._passwordDisplayInput; + is(passwordDisplayInput.value, CONCEALED_PASSWORD_TEXT, "password display should be populated"); + ok(!isHidden(passwordDisplayInput), "Password display input should be visible"); + + let timeline = getLoginTimeline(gLoginItem); + ok(!isHidden(timeline), "Timeline should be visible"); + let actions = timeline.shadowRoot.querySelectorAll(".action"); + verifyTimelineActions(actions, [ + "login-item-timeline-action-created", + "login-item-timeline-action-updated", + "login-item-timeline-action-used", + ]); + + let copyButtons = [...gLoginItem.shadowRoot.querySelectorAll(".copy-button")]; + ok(copyButtons.every(button => !isHidden(button)), "The copy buttons should be visible when viewing a login"); + + let loginNoUsername = Object.assign({}, TEST_LOGIN_1, {username: ""}); + gLoginItem.setLogin(loginNoUsername); + ok(!gLoginItem.dataset.editing, "loginItem should not be in 'edit' mode"); + is(document.l10n.getAttributes(usernameInput).id, "about-logins-login-item-username", "username field should have default placeholder when username is not present and not editing"); + let copyUsernameButton = gLoginItem.shadowRoot.querySelector(".copy-username-button"); + ok(copyUsernameButton.disabled, "The copy-username-button should be disabled if there is no username"); + + usernameInput.placeholder = "dummy placeholder"; + gLoginItem.shadowRoot.querySelector(".edit-button").click(); + await asyncElementRendered(); + is( + document.l10n.getAttributes(usernameInput).id, + null, + "there should be no placeholder id on the username input in edit mode" + ); + is(usernameInput.placeholder, "", "there should be no placeholder on the username input in edit mode"); +}); + +add_task(async function test_update_breaches() { + gLoginItem.setLogin(TEST_LOGIN_1); + gLoginItem.setBreaches(TEST_BREACHES_MAP); + await asyncElementRendered(); + + let breachAlert = gLoginItem.shadowRoot.querySelector(".breach-alert"); + ok(!isHidden(breachAlert), "Breach alert should be visible"); + is(breachAlert.querySelector(".alert-link").href, TEST_LOGIN_1.origin + "/", "Link in the text should point to the login origin"); + let vulernableAlert = gLoginItem.shadowRoot.querySelector(".vulnerable-alert"); + ok(isHidden(vulernableAlert), "Vulnerable alert should not be visible on a non-vulnerable login."); +}); + +add_task(async function test_breach_alert_is_correctly_hidden() { + gLoginItem.setLogin(TEST_LOGIN_2); + gLoginItem.setBreaches(TEST_BREACHES_MAP); + await asyncElementRendered(); + + let breachAlert = gLoginItem.shadowRoot.querySelector(".breach-alert"); + ok(isHidden(breachAlert), "Breach alert should not be visible on login without an associated breach."); + let vulernableAlert = gLoginItem.shadowRoot.querySelector(".vulnerable-alert"); + ok(isHidden(vulernableAlert), "Vulnerable alert should not be visible on a non-vulnerable login."); +}); + +add_task(async function test_update_vulnerable() { + gLoginItem.setLogin(TEST_LOGIN_2); + gLoginItem.setVulnerableLogins(TEST_VULNERABLE_MAP); + await asyncElementRendered(); + + let breachAlert = gLoginItem.shadowRoot.querySelector(".breach-alert"); + ok(isHidden(breachAlert), "Breach alert should not be visible on login without an associated breach."); + let vulernableAlert = gLoginItem.shadowRoot.querySelector(".vulnerable-alert"); + ok(!isHidden(vulernableAlert), "Vulnerable alert should be visible"); + is(vulernableAlert.querySelector(".alert-link").href, TEST_LOGIN_2.origin + "/", "Link in the text should point to the login origin"); +}); + +add_task(async function test_vulnerable_alert_is_correctly_hidden() { + gLoginItem.setLogin(TEST_LOGIN_1); + gLoginItem.setVulnerableLogins(TEST_VULNERABLE_MAP); + gLoginItem.setBreaches(new Map()); + await asyncElementRendered(); + + let breachAlert = gLoginItem.shadowRoot.querySelector(".breach-alert"); + ok(isHidden(breachAlert), "Breach alert should not be visible on login without an associated breach."); + let vulernableAlert = gLoginItem.shadowRoot.querySelector(".vulnerable-alert"); + ok(isHidden(vulernableAlert), "Vulnerable alert should not be visible on a non-vulnerable login."); +}); + +add_task(async function test_edit_login() { + gLoginItem.setLogin(TEST_LOGIN_1); + let usernameInput = gLoginItem.shadowRoot.querySelector("input[name='username']"); + usernameInput.placeholder = "dummy placeholder"; + gLoginItem.shadowRoot.querySelector(".edit-button").click(); + await asyncElementRendered(); + await asyncElementRendered(); + + ok(gLoginItem.dataset.editing, "loginItem should be in 'edit' mode"); + ok(isHidden(gLoginItem.shadowRoot.querySelector(".edit-button")), "edit button should be hidden in 'edit' mode"); + ok(!gLoginItem.dataset.isNewLogin, "loginItem should not be in 'isNewLogin' mode"); + let deleteButton = gLoginItem.shadowRoot.querySelector(".delete-button"); + ok(!deleteButton.disabled, "Delete button should be enabled when editing a login"); + ok(isHidden(gLoginItem._originInput), "Origin input should be hidden in edit mode"); + ok(!isHidden(gLoginItem._originDisplayInput), "Origin display link should be visible in edit mode"); + is(gLoginItem.shadowRoot.querySelector("a[name='origin']").getAttribute("href"), TEST_LOGIN_1.origin, "origin should be populated"); + is(usernameInput.value, TEST_LOGIN_1.username, "username should be populated"); + is(usernameInput, document.activeElement?.shadowRoot?.activeElement, "username is focused"); + is(usernameInput.selectionStart, 0, "username value is selected from start"); + is(usernameInput.selectionEnd, usernameInput.value.length, "username value is selected to the end"); + is( + document.l10n.getAttributes(usernameInput).id, + null, + "there should be no placeholder id on the username input in edit mode" + ); + is(usernameInput.placeholder, "", "there should be no placeholder on the username input in edit mode"); + is(gLoginItem._passwordInput.value, TEST_LOGIN_1.password, "password should be populated"); + is(gLoginItem._passwordDisplayInput.value, CONCEALED_PASSWORD_TEXT, "password display should be populated"); + + let timeline = getLoginTimeline(gLoginItem); + ok(!isHidden(timeline), "Timeline should be visible"); + + let copyButtons = [...gLoginItem.shadowRoot.querySelectorAll(".copy-button")]; + ok(copyButtons.every(button => isHidden(button)), "The copy buttons should be hidden when editing a login"); + + usernameInput.value = "newUsername"; + gLoginItem._passwordInput.value = "newPassword"; + + let updateEventDispatched = false; + document.addEventListener("AboutLoginsUpdateLogin", event => { + is(event.detail.guid, TEST_LOGIN_1.guid, "event should include guid"); + is(event.detail.origin, TEST_LOGIN_1.origin, "event should include origin"); + is(event.detail.username, "newUsername", "event should include new username"); + is(event.detail.password, "newPassword", "event should include new password"); + updateEventDispatched = true; + }, {once: true}); + gLoginItem.shadowRoot.querySelector(".save-changes-button").click(); + ok(updateEventDispatched, "Clicking the .save-changes-button should dispatch the AboutLoginsUpdateLogin event"); +}); + +add_task(async function test_edit_login_cancel() { + gLoginItem.setLogin(TEST_LOGIN_1); + gLoginItem.shadowRoot.querySelector(".edit-button").click(); + await asyncElementRendered(); + + ok(gLoginItem.dataset.editing, "loginItem should be in 'edit' mode"); + is(!!gLoginItem.dataset.isNewLogin, false, + "loginItem should not be in 'isNewLogin' mode"); + + gLoginItem.shadowRoot.querySelector(".cancel-button").click(); + gConfirmationDialog.shadowRoot.querySelector(".confirm-button").click(); + + await SimpleTest.promiseWaitForCondition( + () => gConfirmationDialog.hidden, + "waiting for confirmation dialog to hide" + ); + + ok(!gLoginItem.dataset.editing, "loginItem should not be in 'edit' mode"); + ok(!gLoginItem.dataset.isNewLogin, "loginItem should not be in 'isNewLogin' mode"); +}); + +add_task(async function test_reveal_password_change_selected_login() { + gLoginItem.setLogin(TEST_LOGIN_1); + let revealCheckbox = gLoginItem.shadowRoot.querySelector(".reveal-password-checkbox"); + let passwordInput = gLoginItem._passwordInput; + + ok(!revealCheckbox.checked, "reveal-checkbox should not be checked by default"); + is(passwordInput.type, "password", "Password should be masked by default"); + revealCheckbox.click(); + ok(revealCheckbox.checked, "reveal-checkbox should be checked after clicking"); + await SimpleTest.promiseWaitForCondition(() => passwordInput.type == "text", + "waiting for password input type to change after checking for primary password"); + is(passwordInput.type, "text", "Password should be unmasked when checkbox is clicked"); + ok(!isHidden(passwordInput), "Password input should be visible"); + + let editButton = gLoginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + await asyncElementRendered(); + ok(!isHidden(passwordInput), "Password input should still be visible"); + ok(revealCheckbox.checked, "reveal-checkbox should remain checked when entering 'edit' mode"); + gLoginItem.shadowRoot.querySelector(".cancel-button").click(); + ok(!revealCheckbox.checked, "reveal-checkbox should be unchecked after canceling 'edit' mode"); + revealCheckbox.click(); + ok(isHidden(passwordInput), "Password input should be hidden"); + ok(!isHidden(gLoginItem._passwordDisplayInput), "Password display should be visible"); + gLoginItem.setLogin(TEST_LOGIN_2); + ok(!revealCheckbox.checked, "reveal-checkbox should be unchecked when changing logins"); + is(passwordInput.type, "password", "Password should be masked by default when switching logins"); + ok(isHidden(passwordInput), "Password input should be hidden"); + ok(!isHidden(gLoginItem._passwordDisplayInput), "Password display should be visible"); +}); + +add_task(async function test_set_login_empty() { + gLoginItem.setLogin({}); + await asyncElementRendered(); + + ok(gLoginItem.dataset.editing, "loginItem should be in 'edit' mode"); + ok(isHidden(gLoginItem.shadowRoot.querySelector(".edit-button")), "edit button should be hidden in 'edit' mode"); + ok(gLoginItem.dataset.isNewLogin, "loginItem should be in 'isNewLogin' mode"); + let deleteButton = gLoginItem.shadowRoot.querySelector(".delete-button"); + ok(deleteButton.disabled, "Delete button should be disabled when creating a login"); + ok(!isHidden(gLoginItem._originInput), "Origin input should be visible in new login edit mode"); + ok(isHidden(gLoginItem._originDisplayInput), "Origin display should be hidden in new login edit mode"); + is(gLoginItem.shadowRoot.querySelector("input[name='origin']").value, "", "origin should be empty"); + is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, "", "username should be empty"); + is(gLoginItem._passwordInput.value, "", "password should be empty"); + ok(!isHidden(gLoginItem._passwordInput), "Real password input should be visible in edit mode"); + ok(isHidden(gLoginItem._passwordDisplayInput), "Password display should be hidden in edit mode"); + ok(!gLoginItem._passwordInput.hasAttribute("value"), "Password shouldn't be exposed in @value"); + + let timeline = getLoginTimeline(gLoginItem); + ok(isHidden(timeline), "Timeline should be visible"); + + let copyButtons = [...gLoginItem.shadowRoot.querySelectorAll(".copy-button")]; + ok(copyButtons.every(button => isHidden(button)), "The copy buttons should be hidden when creating a login"); + + let createEventDispatched = false; + document.addEventListener("AboutLoginsCreateLogin", event => { + createEventDispatched = true; + }, {once: true}); + gLoginItem.shadowRoot.querySelector(".save-changes-button").click(); + ok(!createEventDispatched, "Clicking the .save-changes-button shouldn't dispatch the event when fields are invalid"); + let originInput = gLoginItem.shadowRoot.querySelector("input[name='origin']"); + ok(originInput.matches(":invalid"), "origin value is required"); + is(originInput.value, "", "origin input should be blank at start"); + + for (let originTuple of [ + ["ftp://ftp.example.com/", "ftp://ftp.example.com/"], + ["https://example.com/", "https://example.com/"], + ["http://example.com/", "http://example.com/"], + ["www.example.com/bar", "https://www.example.com/bar"], + ["example.com/foo", "https://example.com/foo"], + ]) { + originInput.value = originTuple[0]; + sendKey("TAB"); + is(originInput.value, originTuple[1], + "origin input should have https:// prefix when not provided by user"); + // Return focus back to the origin input + synthesizeKey("VK_TAB", { shiftKey: true }); + } + + gLoginItem.shadowRoot.querySelector("input[name='username']").value = "user1"; + gLoginItem._passwordInput.value = "pass1"; + + document.addEventListener("AboutLoginsCreateLogin", event => { + is(event.detail.guid, undefined, "event should not include guid"); + is(event.detail.origin, "https://example.com/foo", "event should include origin"); + is(event.detail.username, "user1", "event should include new username"); + is(event.detail.password, "pass1", "event should include new password"); + createEventDispatched = true; + }, {once: true}); + gLoginItem.shadowRoot.querySelector(".save-changes-button").click(); + ok(createEventDispatched, "Clicking the .save-changes-button should dispatch the AboutLoginsCreateLogin event"); +}); + +add_task(async function test_different_login_modified() { + gLoginItem.setLogin(TEST_LOGIN_1); + let otherLogin = Object.assign({}, TEST_LOGIN_1, {username: "fakeuser", guid: "fakeguid"}); + gLoginItem.loginModified(otherLogin); + await asyncElementRendered(); + + is(gLoginItem.shadowRoot.querySelector("a[name='origin']").getAttribute("href"), TEST_LOGIN_1.origin, "origin should be unchanged"); + is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, TEST_LOGIN_1.username, "username should be unchanged"); + is(gLoginItem._passwordInput.value, TEST_LOGIN_1.password, "password should be unchanged"); + ok(!gLoginItem._passwordInput.hasAttribute("value"), "Password shouldn't be exposed in @value"); + ok(!gLoginItem._passwordInput.isConnected, "Real password input should be disconnected in masked non-edit mode"); + ok(!isHidden(gLoginItem._passwordDisplayInput), "Password display should be visible in masked non-edit mode"); +}); + +add_task(async function test_different_login_removed() { + gLoginItem.setLogin(TEST_LOGIN_1); + let otherLogin = Object.assign({}, TEST_LOGIN_1, {username: "fakeuser", guid: "fakeguid"}); + gLoginItem.loginRemoved(otherLogin); + await asyncElementRendered(); + + is(gLoginItem.shadowRoot.querySelector("a[name='origin']").getAttribute("href"), TEST_LOGIN_1.origin, "origin should be unchanged"); + is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, TEST_LOGIN_1.username, "username should be unchanged"); + is(gLoginItem._passwordInput.value, TEST_LOGIN_1.password, "password should be unchanged"); + ok(!gLoginItem._passwordInput.hasAttribute("value"), "Password shouldn't be exposed in @value"); + ok(!gLoginItem._passwordInput.isConnected, "Real password input should be disconnected in masked non-edit mode"); + ok(!isHidden(gLoginItem._passwordDisplayInput), "Password display should be visible in masked non-edit mode"); +}); + +add_task(async function test_login_modified() { + gLoginItem.setLogin(TEST_LOGIN_1); + let modifiedLogin = Object.assign({}, TEST_LOGIN_1, {username: "updateduser"}); + gLoginItem.loginModified(modifiedLogin); + await asyncElementRendered(); + + is(gLoginItem.shadowRoot.querySelector("a[name='origin']").getAttribute("href"), modifiedLogin.origin, "origin should be updated"); + is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, modifiedLogin.username, "username should be updated"); + is(gLoginItem._passwordInput.value, modifiedLogin.password, "password should be updated"); + ok(!gLoginItem._passwordInput.hasAttribute("value"), "Password shouldn't be exposed in @value"); + ok(!gLoginItem._passwordInput.isConnected, "Real password input should be disconnected in masked non-edit mode"); + ok(!isHidden(gLoginItem._passwordDisplayInput), "Password display should be visible in masked non-edit mode"); +}); + +add_task(async function test_login_removed() { + gLoginItem.setLogin(TEST_LOGIN_1); + gLoginItem.loginRemoved(TEST_LOGIN_1); + await asyncElementRendered(); + + is(gLoginItem.shadowRoot.querySelector("input[name='origin']").value, "", "origin should be cleared"); + is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, "", "username should be cleared"); + is(gLoginItem._passwordInput.value, "", "password should be cleared"); + ok(!gLoginItem._passwordInput.hasAttribute("value"), "Password shouldn't be exposed in @value"); + + let timeline = getLoginTimeline(gLoginItem); + ok(isHidden(timeline), "Timeline should be visible"); +}); + +add_task(async function test_login_long_username_scrollLeft_reset() { + let loginLongUsername = Object.assign({}, TEST_LOGIN_1, {username: "user2longnamelongnamelongnamelongnamelongname"}); + gLoginItem.setLogin(loginLongUsername); + gLoginItem.shadowRoot.querySelector(".edit-button").click(); + await asyncElementRendered(); + await asyncElementRendered(); + let usernameInput = gLoginItem.shadowRoot.querySelector("input[name='username']"); + usernameInput.scrollLeft = usernameInput.scrollLeftMax; + gLoginItem.shadowRoot.querySelector(".cancel-button").click(); + is(usernameInput.scrollLeft, 0, "username input should be scrolled horizontally to the beginning"); +}); + +add_task(async function test_copy_button_state() { + gLoginItem.setLogin(TEST_LOGIN_1); + await asyncElementRendered(); + + let copyUsernameButton = gLoginItem.shadowRoot.querySelector(".copy-username-button"); + ok(!copyUsernameButton.disabled, "The copy-username-button should be enabled"); + + let copyPasswordButton = gLoginItem.shadowRoot.querySelector(".copy-password-button"); + ok(!copyPasswordButton.disabled, "The copy-passwoed-button should be enabled"); + + copyUsernameButton.click(); + ok(copyUsernameButton.disabled, "The copy-username-button should be disabled when it is clicked"); + ok(!copyPasswordButton.disabled, "The copy-passwoed-button should be enabled when the copy-username-button is clicked"); + + copyPasswordButton.click(); + await SimpleTest.promiseWaitForCondition(() => copyPasswordButton.disabled, + "waiting for copy-password-button to become disabled after checking for primary password"); + + ok(copyPasswordButton.disabled, "The copy-passwoed-button should be disabled when it is clicked"); + ok(!copyUsernameButton.disabled, "The copy-username-button should be enabled when the copy-password-button is clicked"); + + let loginNoUsername = Object.assign({}, TEST_LOGIN_2, {username: ""}); + gLoginItem.setLogin(loginNoUsername); + + ok(copyUsernameButton.disabled, "The copy-username-button should be disabled when the username is empty"); + ok(!copyPasswordButton.disabled, "The copy-passwoed-button should be enabled"); + + copyPasswordButton.click(); + await SimpleTest.promiseWaitForCondition(() => copyPasswordButton.disabled, + "waiting for copy-password-button to become disabled after checking for primary password"); + + ok(copyPasswordButton.disabled, "The copy-passwoed-button should be disabled when it is clicked"); + ok(copyUsernameButton.disabled, "The copy-username-button should still be disabled after clicking the password button when the username is empty"); +}); + +</script> + +</body> +</html> diff --git a/browser/components/aboutlogins/tests/chrome/test_login_list.html b/browser/components/aboutlogins/tests/chrome/test_login_list.html new file mode 100644 index 0000000000..98342978fb --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/test_login_list.html @@ -0,0 +1,697 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test the login-list component +--> +<head> + <meta charset="utf-8"> + <title>Test the login-list component</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/login-list.mjs"></script> + <script src="aboutlogins_common.js"></script> + + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + <p id="display"> + </p> +<div id="content" style="display: none"> + <iframe id="templateFrame" src="chrome://browser/content/aboutlogins/aboutLogins.html" + sandbox="allow-same-origin"></iframe> +</div> +<pre id="test"> +</pre> +<script> +/** Test the login-list component **/ + +let gLoginList; +const TEST_LOGIN_1 = { + guid: "123456789", + origin: "https://abc.example.com", + httpRealm: null, + username: "user1", + password: "pass1", + title: "abc.example.com", + // new Date("December 13, 2018").getTime() + timeLastUsed: 1544677200000, + timePasswordChanged: 1544677200000, +}; +const TEST_LOGIN_2 = { + guid: "987654321", + origin: "https://example.com", + httpRealm: null, + username: "user2", + password: "pass2", + title: "example.com", + // new Date("June 1, 2019").getTime() + timeLastUsed: 1559361600000, + timePasswordChanged: 1559361600000, +}; +const TEST_LOGIN_3 = { + guid: "1111122222", + origin: "https://def.example.com", + httpRealm: null, + username: "", + password: "pass3", + title: "def.example.com", + // new Date("June 1, 2019").getTime() + timeLastUsed: 1559361600000, + timePasswordChanged: 1559361600000, +}; +const TEST_HTTP_AUTH_LOGIN_1 = { + guid: "8675309", + origin: "https://httpauth.example.com", + httpRealm: "My Realm", + username: "http_auth_user", + password: "pass4", + title: "httpauth.example.com (My Realm)", + // new Date("June 1, 2019").getTime() + timeLastUsed: 1559361600000, + timePasswordChanged: 1559361600000, +}; + +const TEST_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-11", + Domain: "abc.example.com", + Name: "ABC Example", + PwnCount: 1643100, + DataClasses: ["Usernames", "Passwords"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", + breachAlertURL: "https://monitor.firefox.com/breach-details/ABC-Example", +}; + + +const TEST_BREACHES_MAP = new Map(); +TEST_BREACHES_MAP.set(TEST_LOGIN_1.guid, TEST_BREACH); + +add_setup(async () => { + let templateFrame = document.getElementById("templateFrame"); + let displayEl = document.getElementById("display"); + await importDependencies(templateFrame, displayEl); + + gLoginList = document.createElement("login-list"); + displayEl.appendChild(gLoginList); +}); + +add_task(async function test_empty_list() { + ok(gLoginList, "loginList exists"); + is(gLoginList.textContent, "", "Initially empty"); + gLoginList.classList.add("no-logins"); + let loginListBox = gLoginList.shadowRoot.querySelector("ol"); + let introText = gLoginList.shadowRoot.querySelector(".intro"); + let emptySearchText = gLoginList.shadowRoot.querySelector(".empty-search-message"); + ok(isHidden(loginListBox), "The login-list ol should be hidden when there are no logins"); + ok(!isHidden(introText), "The intro text should be visible when the list is empty"); + ok(isHidden(emptySearchText), "The empty-search text should be hidden when there are no logins"); + + gLoginList.classList.add("create-login-selected"); + ok(!isHidden(loginListBox), "The login-list ol should be visible when the create-login mode is active"); + ok(isHidden(introText), "The intro text should be hidden when the create-login mode is active"); + ok(isHidden(emptySearchText), "The empty-search text should be hidden when the create-login mode is active"); + gLoginList.classList.remove("create-login-selected"); + + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "foo", + })); + ok(isHidden(loginListBox), "The login-list ol should be hidden when there are no logins"); + ok(!isHidden(introText), "The intro text should be visible when the list is empty"); + ok(isHidden(emptySearchText), "The empty-search text should be hidden when there are no logins even if a filter is applied"); + + // Clean up state for next test + gLoginList.classList.remove("no-logins"); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "", + })); +}); + +add_task(async function test_keyboard_navigation() { + let logins = []; + for (let i = 0; i < 20; i++) { + let suffix = i % 2 ? "odd" : "even"; + logins.push(Object.assign({}, TEST_LOGIN_1, { + guid: "" + i, + username: `testuser-${suffix}-${i}`, + password: `testpass-${suffix}-${i}`, + })); + } + gLoginList.setLogins(logins); + let ol = gLoginList.shadowRoot.querySelector("ol"); + is(ol.querySelectorAll(".login-list-item[data-guid]").length, 20, "there should be 20 logins in the list"); + is(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])").length, 20, "all logins should be visible"); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "odd", + })); + is(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])").length, 10, "half of the logins in the list"); + + while (document.activeElement != gLoginList && + gLoginList.shadowRoot.querySelector("#login-sort") != gLoginList.shadowRoot.activeElement) { + sendKey("TAB"); + await new Promise(resolve => requestAnimationFrame(resolve)); + } + sendKey("TAB"); + let loginSort = gLoginList.shadowRoot.querySelector("#login-sort"); + await SimpleTest.promiseWaitForCondition(() => loginSort == gLoginList.shadowRoot.activeElement, + "waiting for login-sort to get focus"); + ok(loginSort == gLoginList.shadowRoot.activeElement, "#login-sort should be focused after tabbing to it"); + + sendKey("TAB"); + await SimpleTest.promiseWaitForCondition(() => ol.matches(":focus"), + "waiting for 'ol' to get focus"); + ok(ol.matches(":focus"), "'ol' should be focused after tabbing to it"); + + let selectedGuid = ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[0].dataset.guid; + let loginSelectedEvent = null; + gLoginList.addEventListener("AboutLoginsLoginSelected", event => loginSelectedEvent = event, {once: true}); + sendKey("RETURN"); + is(ol.querySelector(".selected").dataset.guid, selectedGuid, "item should be marked as selected"); + ok(loginSelectedEvent, "AboutLoginsLoginSelected event should be dispatched on pressing Enter"); + is(loginSelectedEvent.detail.guid, selectedGuid, "event should have expected login attached"); + + for (let [keyFwd, keyRev] of [["LEFT", "RIGHT"], ["DOWN", "UP"]]) { + sendKey(keyFwd); + await SimpleTest.promiseWaitForCondition(() => ol.getAttribute("aria-activedescendant") == ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[1].id, + `waiting for second item in list to get focused (${keyFwd})`); + ok(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[1].classList.contains("keyboard-selected"), `second item should be marked as keyboard-selected (${keyFwd})`); + + sendKey(keyRev); + await SimpleTest.promiseWaitForCondition(() => ol.getAttribute("aria-activedescendant") == ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[0].id, + `waiting for first item in list to get focused (${keyRev})`); + ok(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[0].classList.contains("keyboard-selected"), `first item should be marked as keyboard-selected (${keyRev})`); + } + + sendKey("DOWN"); + await SimpleTest.promiseWaitForCondition(() => ol.getAttribute("aria-activedescendant") == ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[1].id, + `waiting for second item in list to get focused (DOWN)`); + ok(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[1].classList.contains("keyboard-selected"), `second item should be marked as keyboard-selected (DOWN)`); + selectedGuid = ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[1].dataset.guid; + + synthesizeKey("VK_DOWN", { repeat: 5 }); + ok(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[6].classList.contains("keyboard-selected"), `sixth item should be marked as keyboard-selected after 5 DOWN repeats`); + synthesizeKey("VK_UP", { repeat: 5 }); + ok(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[1].classList.contains("keyboard-selected"), `second item should be marked as keyboard-selected again after 5 UP repeats`); + + loginSelectedEvent = null; + gLoginList.addEventListener("AboutLoginsLoginSelected", event => loginSelectedEvent = event, {once: true}); + sendKey("RETURN"); + is(ol.querySelector(".selected").dataset.guid, selectedGuid, "item should be marked as selected"); + ok(loginSelectedEvent, "AboutLoginsLoginSelected event should be dispatched on pressing Enter"); + is(loginSelectedEvent.detail.guid, selectedGuid, "event should have expected login attached"); + + // Clean up state for next test + gLoginList.classList.remove("no-logins"); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "", + })); +}); + +add_task(async function test_empty_login_username_in_list() { + // Clear the selection so the 'new' login will be in the list too. + window.dispatchEvent(new CustomEvent("AboutLoginsLoginSelected", { + detail: {}, + })); + + gLoginList.setLogins([TEST_LOGIN_3]); + let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + is(loginListItems.length, 1, "The one stored login should be displayed"); + is(loginListItems[0].dataset.guid, TEST_LOGIN_3.guid, "login-list-item should have correct guid attribute"); + let loginUsername = loginListItems[0].querySelector(".username"); + is(loginUsername.getAttribute("data-l10n-id"), "login-list-item-subtitle-missing-username", "login should show missing username text"); +}); + +add_task(async function test_populated_list() { + gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2]); + let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + is(loginListItems.length, 2, "The two stored logins should be displayed"); + is(loginListItems[0].getAttribute("role"), "option", "Each login-list-item should have role='option'"); + is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "login-list-item should have correct guid attribute"); + is(loginListItems[0].querySelector(".title").textContent, TEST_LOGIN_1.title, + "login-list-item origin should match"); + is(loginListItems[0].querySelector(".username").textContent, TEST_LOGIN_1.username, + "login-list-item username should match"); + ok(loginListItems[0].classList.contains("selected"), "The first item should be selected by default"); + ok(!loginListItems[1].classList.contains("selected"), "The second item should not be selected by default"); + loginListItems[0].click(); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + is(loginListItems.length, 2, "After selecting one, only the two stored logins should be displayed"); + ok(loginListItems[0].classList.contains("selected"), "The first item should be selected"); + ok(!loginListItems[1].classList.contains("selected"), "The second item should still not be selected"); +}); + +add_task(async function test_breach_indicator() { + gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2, Object.assign({}, TEST_LOGIN_3, {password: TEST_LOGIN_1.password})]); + gLoginList.setBreaches(TEST_BREACHES_MAP); + let vulnerableLogins = new Map(); + vulnerableLogins.set(TEST_LOGIN_1.guid, true); + vulnerableLogins.set(TEST_LOGIN_3.guid, true); + gLoginList.setVulnerableLogins(vulnerableLogins); + let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + let alertIcon = loginListItems[0].querySelector(".alert-icon"); + is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "The first login should be TEST_LOGIN_1"); + ok(!loginListItems[0].classList.contains("vulnerable"), "The first login should not have the .vulnerable class"); + ok(loginListItems[0].classList.contains("breached"), "The first login should have the .breached class."); + is(alertIcon.src, "chrome://browser/content/aboutlogins/icons/breached-website.svg", "The alert icon should be the breach warning icon"); + is(loginListItems[1].dataset.guid, TEST_LOGIN_3.guid, "The second login should be TEST_LOGIN_3"); + ok(loginListItems[1].classList.contains("vulnerable"), "The second login should have the .vulnerable class"); + ok(!loginListItems[1].classList.contains("breached"), "The second login should not have the .breached class"); + alertIcon = loginListItems[1].querySelector(".alert-icon"); + is(alertIcon.src, "chrome://browser/content/aboutlogins/icons/vulnerable-password.svg", "The alert icon should be the vulnerable password icon"); + is(loginListItems[2].dataset.guid, TEST_LOGIN_2.guid, "The third login should be TEST_LOGIN_2"); + alertIcon = loginListItems[2].querySelector(".alert-icon"); + ok(!loginListItems[2].classList.contains("vulnerable"), "The third login should not have the .vulnerable class"); + ok(!loginListItems[2].classList.contains("breached"), "The third login should not have the .breached class"); + is(alertIcon.src, "chrome://mochitests/content/chrome/browser/components/aboutlogins/tests/chrome/test_login_list.html", "The alert icon src should be empty"); +}); + +function assertCount({ count, total }) { + const countSpan = gLoginList.shadowRoot.querySelector(".count"); + const actual = JSON.parse(countSpan.getAttribute("data-l10n-args")); + isDeeply(actual, { count, total }, "Login count updated"); +} + +add_task(async function test_filtered_list() { + function findItemFromUsername(list, username) { + for (let item of list) { + if ((item._cachedUsername || (item._cachedUsername = item.querySelector('.username').textContent)) == username) { + return item; + } + } + ok(false, `The ${username} wasn't in the list of logins.`) + return list[0]; + } + gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2]); + let emptySearchText = gLoginList.shadowRoot.querySelector(".empty-search-message"); + ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list"); + is(gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])").length, 2, "Both logins should be visible"); + + assertCount({ count: 2, total: 2 }); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "user1", + })); + assertCount({ count: 1, total: 2 }); + ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list"); + let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]"); + is(loginListItems[0].querySelector(".username").textContent, "user1", "user1 is expected first"); + ok(!loginListItems[0].hidden, "user1 should remain visible"); + ok(loginListItems[1].hidden, "user2 should be hidden"); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "user2", + })); + assertCount({ count: 1, total: 2 }); + ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list"); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]"); + ok(findItemFromUsername(loginListItems, 'user1').hidden, "user1 should be hidden"); + ok(!findItemFromUsername(loginListItems, 'user2').hidden, "user2 should be visible"); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "user", + })); + assertCount({ count: 2, total: 2 }); + ok(!gLoginList._sortSelect.disabled, "The sort should be enabled when there are visible logins in the list"); + ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list"); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]"); + ok(!findItemFromUsername(loginListItems, 'user1').hidden, "user1 should be visible"); + ok(!findItemFromUsername(loginListItems, 'user2').hidden, "user2 should be visible"); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "foo", + })); + assertCount({ count: 0, total: 2 }); + ok(gLoginList._sortSelect.disabled, "The sort should be disabled when there are no visible logins in the list"); + ok(!isHidden(emptySearchText), "The empty search text should be visible when there are no results in the list"); + isnot(gLoginList.shadowRoot.querySelector(".container > ol").getAttribute("aria-activedescendant"), "new-login-list-item", "new-login-list-item shouldn't be the active descendant"); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]"); + ok(findItemFromUsername(loginListItems, 'user1').hidden, "user1 should be hidden"); + ok(findItemFromUsername(loginListItems, 'user2').hidden, "user2 should be hidden"); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "", + })); + ok(!gLoginList._sortSelect.disabled, "The sort should be re-enabled when there are visible logins in the list"); + ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list"); + assertCount({ count: 2, total: 2 }); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]"); + ok(!findItemFromUsername(loginListItems, 'user1').hidden, "user1 should be visible"); + ok(!findItemFromUsername(loginListItems, 'user2').hidden, "user2 should be visible"); + + info("Add an HTTP Auth login"); + gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2, TEST_HTTP_AUTH_LOGIN_1]); + await asyncElementRendered(); + assertCount({ count: 3, total: 3 }); + info("Filter by httpRealm"); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "realm", + })); + assertCount({ count: 1, total: 3 }); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]"); + ok(findItemFromUsername(loginListItems, 'user1').hidden, "user1 should be hidden"); + ok(findItemFromUsername(loginListItems, 'user2').hidden, "user2 should be hidden"); + ok(!findItemFromUsername(loginListItems, 'http_auth_user').hidden, "http_auth_user should be visible"); + + gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2]); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "", + })); + await asyncElementRendered(); +}); + +add_task(async function test_initial_empty_results() { + // Create a new instance to reset state + gLoginList.remove(); + gLoginList = document.createElement("login-list"); + document.getElementById("display").appendChild(gLoginList); + await asyncElementRendered(); + + let emptySearchText = gLoginList.shadowRoot.querySelector(".empty-search-message"); + + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "foo", + })); + assertCount({ count: 0, total: 0 }); + ok(gLoginList._sortSelect.disabled, "The sort should be disabled when there are no visible logins in the list"); + ok(!isHidden(emptySearchText), "The empty search text should be visible when there are no results in the list"); + isnot(gLoginList.shadowRoot.querySelector(".container > ol").getAttribute("aria-activedescendant"), "new-login-list-item", "new-login-list-item shouldn't be the active descendant"); + ok(gLoginList.shadowRoot.querySelector("#new-login-list-item").hidden, "new-login-list-item should be @hidden"); + + gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2]); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "", + })); + await asyncElementRendered(); +}); + +add_task(async function test_login_modified() { + let modifiedLogin = Object.assign(TEST_LOGIN_1, {username: "user11"}); + gLoginList.loginModified(modifiedLogin); + await asyncElementRendered(); + let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]:not([hidden])"); + is(loginListItems.length, 2, "Both logins should be displayed"); + is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "login-list-item should have correct guid attribute"); + is(loginListItems[0].querySelector(".title").textContent, TEST_LOGIN_1.title, + "login-list-item origin should match"); + is(loginListItems[0].querySelector(".username").textContent, modifiedLogin.username, + "login-list-item username should have been updated"); + is(loginListItems[1].querySelector(".username").textContent, TEST_LOGIN_2.username, + "login-list-item2 username should remain unchanged"); +}); + +add_task(async function test_login_added() { + info("selected sort: " + gLoginList.shadowRoot.getElementById("login-sort").selectedIndex); + + let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + is(loginListItems.length, 2, "Should have two logins at start of test"); + let newLogin = Object.assign({}, TEST_LOGIN_1, {title: "example2.example.com", guid: "111222"}); + gLoginList.loginAdded(newLogin); + await asyncElementRendered(); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + is(loginListItems.length, 3, "New login should be added to the list"); + is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "login-list-item1 should have correct guid attribute"); + is(loginListItems[1].dataset.guid, TEST_LOGIN_2.guid, "login-list-item2 should have correct guid attribute"); + is(loginListItems[2].dataset.guid, newLogin.guid, "login-list-item3 should have correct guid attribute"); + is(loginListItems[2].querySelector(".title").textContent, newLogin.title, + "login-list-item origin should match"); + is(loginListItems[2].querySelector(".username").textContent, newLogin.username, + "login-list-item username should have been updated"); +}); + +add_task(async function test_login_removed() { + gLoginList.loginRemoved({guid: "111222"}); + await asyncElementRendered(); + let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + is(loginListItems.length, 2, "New login should be removed from the list"); + is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "login-list-item1 should have correct guid attribute"); + is(loginListItems[1].dataset.guid, TEST_LOGIN_2.guid, "login-list-item2 should have correct guid attribute"); +}); + +add_task(async function test_login_added_filtered() { + assertCount({ count: 2, total: 2 }); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + detail: "user1", + })); + assertCount({ count: 1, total: 2 }); + + let newLogin = Object.assign({}, TEST_LOGIN_1, {title: "example2.example.com", username: "user22", guid: "111222"}); + gLoginList.loginAdded(newLogin); + await asyncElementRendered(); + let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]"); + is(loginListItems.length, 3, "New login should be added to the list"); + is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "login-list-item1 should have correct guid attribute"); + is(loginListItems[1].dataset.guid, TEST_LOGIN_2.guid, "login-list-item2 should have correct guid attribute"); + is(loginListItems[2].dataset.guid, newLogin.guid, "login-list-item3 should have correct guid attribute"); + ok(!loginListItems[0].hidden, "login-list-item1 should be visible"); + ok(loginListItems[1].hidden, "login-list-item2 should be hidden"); + ok(loginListItems[2].hidden, "login-list-item3 should be hidden"); + assertCount({ count: 1, total: 3 }); +}); + +add_task(async function test_sorted_list() { + function dispatchChangeEvent(target) { + let event = document.createEvent("UIEvent"); + event.initEvent("change", true, true); + target.dispatchEvent(event); + } + + // Clear the filter + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + detail: "", + })); + + // Clear the selection so the 'new' login will be in the list too. + window.dispatchEvent(new CustomEvent("AboutLoginsLoginSelected", { + detail: {}, + })); + + // make sure that the logins have distinct orderings based on sort order + let [guid1, guid2, guid3] = gLoginList._loginGuidsSortedOrder; + gLoginList._logins[guid1].login.timeLastUsed = 0; + gLoginList._logins[guid2].login.timeLastUsed = 1; + gLoginList._logins[guid3].login.timeLastUsed = 2; + gLoginList._logins[guid1].login.title = "a"; + gLoginList._logins[guid2].login.title = "b"; + gLoginList._logins[guid3].login.title = "c"; + gLoginList._logins[guid1].login.username = "a"; + gLoginList._logins[guid2].login.username = "b"; + gLoginList._logins[guid3].login.username = "c"; + gLoginList._logins[guid1].login.timePasswordChanged = 1; + gLoginList._logins[guid2].login.timePasswordChanged = 2; + gLoginList._logins[guid3].login.timePasswordChanged = 0; + + // sort by last used + let loginSort = gLoginList.shadowRoot.getElementById("login-sort"); + loginSort.value = "last-used"; + dispatchChangeEvent(loginSort); + let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + is(loginListItems.length, 3, "The list should contain the three stored logins"); + let timeUsed1 = gLoginList._logins[loginListItems[0].dataset.guid].login.timeLastUsed; + let timeUsed2 = gLoginList._logins[loginListItems[1].dataset.guid].login.timeLastUsed; + let timeUsed3 = gLoginList._logins[loginListItems[2].dataset.guid].login.timeLastUsed; + is(timeUsed1 > timeUsed2, true, "Logins sorted by timeLastUsed. First: " + timeUsed1 + "; Second: " + timeUsed2); + is(timeUsed2 > timeUsed3, true, "Logins sorted by timeLastUsed. Second: " + timeUsed2 + "; Third: " + timeUsed3); + + // sort by title + loginSort.value = "name"; + dispatchChangeEvent(loginSort); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + let title1 = gLoginList._logins[loginListItems[0].dataset.guid].login.title; + let title2 = gLoginList._logins[loginListItems[1].dataset.guid].login.title; + let title3 = gLoginList._logins[loginListItems[2].dataset.guid].login.title; + is(title1.localeCompare(title2), -1, "Logins sorted by title. First: " + title1 + "; Second: " + title2); + is(title2.localeCompare(title3), -1, "Logins sorted by title. Second: " + title2 + "; Third: " + title3); + + // sort by title in reverse alphabetical order + loginSort.value = "name-reverse"; + dispatchChangeEvent(loginSort); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + title1 = gLoginList._logins[loginListItems[0].dataset.guid].login.title; + title2 = gLoginList._logins[loginListItems[1].dataset.guid].login.title; + title3 = gLoginList._logins[loginListItems[2].dataset.guid].login.title; + let testDescription = "Logins sorted by title in reverse alphabetical order." + is(title1.localeCompare(title2), 1, `${testDescription} First: ${title2}; Second: ${title1}`); + is(title2.localeCompare(title3), 1, `${testDescription} Second: ${title3}; Third: ${title2}`); + + // sort by last changed + loginSort.value = "last-changed"; + dispatchChangeEvent(loginSort); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + let pwChanged1 = gLoginList._logins[loginListItems[0].dataset.guid].login.timePasswordChanged; + let pwChanged2 = gLoginList._logins[loginListItems[1].dataset.guid].login.timePasswordChanged; + let pwChanged3 = gLoginList._logins[loginListItems[2].dataset.guid].login.timePasswordChanged; + is(pwChanged1 > pwChanged2, true, "Logins sorted by timePasswordChanged. First: " + pwChanged1 + "; Second: " + pwChanged2); + is(pwChanged2 > pwChanged3, true, "Logins sorted by timePasswordChanged. Second: " + pwChanged2 + "; Third: " + pwChanged3); + + // sort by breached when there are breached logins + gLoginList.setBreaches(TEST_BREACHES_MAP); + loginSort.value = "alerts"; + let vulnerableLogins = new Map(); + gLoginList.setVulnerableLogins(vulnerableLogins); + dispatchChangeEvent(loginSort); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + is(loginListItems[0].classList.contains("breached"), true, "Breached login should be displayed at top of list"); + is(!loginListItems[1].classList.contains("breached"), true, "Non-breached login should be displayed below breached"); + + // sort by username + loginSort.value = "username"; + dispatchChangeEvent(loginSort); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + let username1 = gLoginList._logins[loginListItems[0].dataset.guid].login.username; + let username2 = gLoginList._logins[loginListItems[1].dataset.guid].login.username; + let username3 = gLoginList._logins[loginListItems[2].dataset.guid].login.username; + is(username1.localeCompare(username2), -1, "Logins sorted by username. First: " + username1 + "; Second: " + username2); + is(username2.localeCompare(username3), -1, "Logins sorted by username. Second: " + username2 + "; Third: " + username3); + + // sort by username in reverse alphabetical order + loginSort.value = "username-reverse"; + dispatchChangeEvent(loginSort); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + username1 = gLoginList._logins[loginListItems[0].dataset.guid].login.username; + username2 = gLoginList._logins[loginListItems[1].dataset.guid].login.username; + username3 = gLoginList._logins[loginListItems[2].dataset.guid].login.username; + testDescription = "Logins sorted by username in reverse alphabetical order."; + is(username3.localeCompare(username2), -1, `${testDescription} First: ${username3} Second: ${username2}`); + is(username2.localeCompare(username1), -1, `${testDescription} Second: ${username2} Third: ${username1}`); + + // sort by name when there are no breached logins + gLoginList.setBreaches(new Map()); + loginSort.value = "alerts"; + dispatchChangeEvent(loginSort); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + title1 = gLoginList._logins[loginListItems[0].dataset.guid].login.title; + title2 = gLoginList._logins[loginListItems[1].dataset.guid].login.title; + is(title1.localeCompare(title2), -1, "Logins should be sorted alphabetically by hostname"); +}); + +add_task(async function test_login_list_item_removed_next_selected() { + let logins = []; + for (let i = 0; i < 12; i++) { + let group = i % 2 ? "BB" : "AA"; + // Create logins of the form `jared0AAa@example.com`, + // `jared1BBb@example.com`, `jared2AAc@example.com`, etc. + logins.push({ + guid: `${i}`, + username: `jared${i}${group}${String.fromCharCode(97 + i)}@example.com`, + password: "omgsecret!!1", + origin: "https://www.example.com", + }); + } + + gLoginList.setLogins(logins); + let visibleLogins = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]:not([hidden])"); + await SimpleTest.promiseWaitForCondition(() => { + return visibleLogins.length == 12; + }, "Waiting for all logins to be visible"); + is(gLoginList._selectedGuid, logins[0].guid, "login0 should be selected by default"); + + window.dispatchEvent( + new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "BB", + }) + ); + + await SimpleTest.promiseWaitForCondition(() => { + visibleLogins = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]:not([hidden])"); + return visibleLogins.length == 6; + }, "Only logins with BB in the username should be visible, visible count: " + visibleLogins.length); + + is(gLoginList._selectedGuid, logins[0].guid, "login0 should still be selected after filtering"); + + gLoginList.loginRemoved({guid: logins[0].guid}); + + await SimpleTest.promiseWaitForCondition(() => { + return gLoginList._loginGuidsSortedOrder.length == 11; + }, "Waiting for login to get removed"); + + await SimpleTest.promiseWaitForCondition(() => { + visibleLogins = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]:not([hidden])"); + return visibleLogins.length == 6; + }, "the number of visible logins should not change, got " + visibleLogins.length); + is(gLoginList._selectedGuid, logins[1].guid, + "login1 should be selected after delete since the deleted login was not visible and login1 was the first in the list"); + + let loginToSwitchTo = gLoginList._logins[visibleLogins[1].dataset.guid].login; + window.dispatchEvent( + new CustomEvent("AboutLoginsLoginSelected", { + bubbles: true, + detail: loginToSwitchTo, + }) + ); + is(gLoginList._selectedGuid, loginToSwitchTo.guid, "login3 should be selected"); + + gLoginList.loginRemoved({guid: logins[3].guid}); + + await SimpleTest.promiseWaitForCondition(() => { + return gLoginList._loginGuidsSortedOrder.length == 10; + }, "Waiting for login to get removed"); + + await SimpleTest.promiseWaitForCondition(() => { + visibleLogins = gLoginList.shadowRoot.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ); + return visibleLogins.length == 5; + }, "the number of filtered logins should decrease by 1"); + is(gLoginList._selectedGuid, visibleLogins[0].dataset.guid, "the first login should now be selected"); + + gLoginList.loginRemoved({guid: logins[1].guid}); + + await SimpleTest.promiseWaitForCondition(() => { + return gLoginList._loginGuidsSortedOrder.length == 9; + }, "Waiting for login to get removed"); + + await SimpleTest.promiseWaitForCondition(() => { + visibleLogins = gLoginList.shadowRoot.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ); + return visibleLogins.length == 4; + }, "the number of filtered logins should decrease by 1"); + is(gLoginList._selectedGuid, visibleLogins[0].dataset.guid, "the first login should now still be selected"); + + loginToSwitchTo = gLoginList._logins[visibleLogins[3].dataset.guid].login; + window.dispatchEvent( + new CustomEvent("AboutLoginsLoginSelected", { + bubbles: true, + detail: loginToSwitchTo, + }) + ); + is(gLoginList._selectedGuid, visibleLogins[3].dataset.guid, "the last login should now still be selected"); + + gLoginList.loginRemoved({guid: logins[10].guid}); + + await SimpleTest.promiseWaitForCondition(() => { + return gLoginList._loginGuidsSortedOrder.length == 8; + }, "Waiting for login to get removed"); + + await SimpleTest.promiseWaitForCondition(() => { + visibleLogins = gLoginList.shadowRoot.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ); + return visibleLogins.length == 4; + }, "the number of filtered logins should decrease by 1"); + is(gLoginList._selectedGuid, visibleLogins[3].dataset.guid, "the last login should now be selected"); + + loginToSwitchTo = gLoginList._logins[visibleLogins[2].dataset.guid].login; + window.dispatchEvent( + new CustomEvent("AboutLoginsLoginSelected", { + bubbles: true, + detail: loginToSwitchTo, + }) + ); + is(gLoginList._selectedGuid, visibleLogins[2].dataset.guid, "the last login should now still be selected"); +}); +</script> + +</body> +</html> diff --git a/browser/components/aboutlogins/tests/chrome/test_menu_button.html b/browser/components/aboutlogins/tests/chrome/test_menu_button.html new file mode 100644 index 0000000000..2beede09f1 --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/test_menu_button.html @@ -0,0 +1,260 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test the menu-button component +--> +<head> + <meta charset="utf-8"> + <title>Test the menu-button component</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/menu-button.mjs"></script> + <script src="aboutlogins_common.js"></script> + + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + <p id="display"> + </p> +<div id="content" style="display: none"> + <iframe id="templateFrame" src="chrome://browser/content/aboutlogins/aboutLogins.html" + sandbox="allow-same-origin"></iframe> +</div> +<pre id="test"> +</pre> +<script> +/** Test the menu-button component **/ + +let gMenuButton; +add_setup(async () => { + let templateFrame = document.getElementById("templateFrame"); + let displayEl = document.getElementById("display"); + await importDependencies(templateFrame, displayEl); + + gMenuButton = document.createElement("menu-button"); + displayEl.appendChild(gMenuButton); + gMenuButton.style.marginInlineStart = "100px"; + + isnot(document.activeElement, gMenuButton, "menu-button should not be focused by default"); + while (document.activeElement != gMenuButton) { + sendKey("TAB"); + await new Promise(resolve => requestAnimationFrame(resolve)); + } +}); + +add_task(async function test_menu_click_button () { + let menu = gMenuButton.shadowRoot.querySelector(".menu"); + let menuButton = gMenuButton.shadowRoot.querySelector(".menu-button"); + ok(menu.hidden, "menu should be hidden before being clicked"); + await synthesizeMouseAtCenter(menuButton, {}); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(!menu.hidden, "menu should be visible after clicked"); + + let menuListSeparators = gMenuButton.shadowRoot.querySelectorAll(".menuitem-separator"); + await synthesizeMouseAtCenter(menuListSeparators[0], {}); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(!menu.hidden, "menu should still be visible after menu separator has been clicked"); + + let menuListButtons = gMenuButton.shadowRoot.querySelectorAll(".menuitem-button"); + await synthesizeMouseAtCenter(menuListButtons[0], {}); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(menu.hidden, "menu should be hidden after a button has been clicked"); +}); + +add_task(async function test_menu_click_outside () { + let menu = gMenuButton.shadowRoot.querySelector(".menu"); + let menuButton = gMenuButton.shadowRoot.querySelector(".menu-button"); + ok(menu.hidden, "menu should be hidden before being clicked"); + await synthesizeMouseAtCenter(menuButton, {}); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(!menu.hidden, "menu should be visible after clicked"); + + let menuListSeparators = gMenuButton.shadowRoot.querySelectorAll(".menuitem-separator"); + await synthesizeMouseAtCenter(menuListSeparators[0], {}); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(!menu.hidden, "menu should still be visible after menu separator has been clicked"); + + let outsideEl = document.getElementById("test"); + await synthesizeMouseAtCenter(outsideEl, {}); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(menu.hidden, "menu should be hidden after a click outside of the menu has been clicked"); + + for (let key of ["KEY_ArrowDown", "KEY_ArrowUp"]) { + synthesizeKey(key); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(menu.hidden, `menu should still be hidden when ${key} is entered`); + } +}); + +add_task(async function test_menu_esc_after_click_disabled_item () { + let menu = gMenuButton.shadowRoot.querySelector(".menu"); + let menuButton = gMenuButton.shadowRoot.querySelector(".menu-button"); + ok(menu.hidden, "menu should be hidden before being clicked"); + await synthesizeMouseAtCenter(menuButton, {}); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(!menu.hidden, "menu should be visible after clicked"); + + let menuListSeparators = gMenuButton.shadowRoot.querySelectorAll(".menuitem-separator"); + await synthesizeMouseAtCenter(menuListSeparators[0], {}); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(!menu.hidden, "menu should still be visible after menu separator has been clicked"); + + sendKey("ESCAPE"); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(menu.hidden, "menu should be hidden after pressing 'escape'"); +}); + +add_task(async function test_menu_open_close() { + is(document.activeElement, gMenuButton, "menu-button should be focused to start the test"); + + let menu = gMenuButton.shadowRoot.querySelector(".menu"); + is(true, menu.hidden, "menu should be hidden before pressing 'space'"); + sendKey("SPACE"); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(!menu.hidden, "menu should be visible after pressing 'space'"); + + sendKey("ESCAPE"); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(menu.hidden, "menu should be hidden after pressing 'escape'"); + is(gMenuButton.shadowRoot.activeElement, gMenuButton.shadowRoot.querySelector(".menu-button"), + "the .menu-button should be focused after closing the menu via keyboard"); + + sendKey("RETURN"); + let firstVisibleItem = gMenuButton.shadowRoot.querySelector(".menuitem-button:not([hidden])"); + await SimpleTest.promiseWaitForCondition(() => firstVisibleItem.matches(":focus"), + "waiting for firstVisibleItem to get focus"); + + ok(!menu.hidden, "menu should be visible after pressing 'return'"); + ok(firstVisibleItem.matches(":focus"), "firstVisibleItem should be focused after opening popup"); + + synthesizeKey("VK_TAB", { shiftKey: true }); + await SimpleTest.promiseWaitForCondition(() => !firstVisibleItem.matches(":focus"), + "waiting for firstVisibleItem to lose focus"); + ok(!firstVisibleItem.matches(":focus"), "firstVisibleItem should lose focus after tabbing away from it"); + sendKey("TAB"); + await SimpleTest.promiseWaitForCondition(() => firstVisibleItem.matches(":focus"), + "waiting for firstVisibleItem to get focus again"); + ok(firstVisibleItem.matches(":focus"), "firstVisibleItem should be focused after tabbing to it again"); + if (SpecialPowers.getBoolPref("signon.management.page.fileImport.enabled")) { + sendKey("TAB"); // Import from file + } + sendKey("TAB"); // Export + sendKey("TAB"); // Remove All Logins + + if (navigator.platform == "Win32" || navigator.platform == "MacIntel") { + // The Import menuitem is only visible on Windows/macOS, where we will need another Tab + // press to get to the Preferences item. + let preferencesItem = gMenuButton.shadowRoot.querySelector(".menuitem-preferences"); + sendKey("DOWN"); + await SimpleTest.promiseWaitForCondition(() => preferencesItem.matches(":focus"), + "waiting for preferencesItem to gain focus"); + ok(preferencesItem.matches(":focus"), `.menuitem-preferences should be now be focused (DOWN)`); + sendKey("UP"); + await SimpleTest.promiseWaitForCondition(() => !preferencesItem.matches(":focus"), + `waiting for preferencesItem to lose focus (UP)`); + ok(!preferencesItem.matches(":focus"), `.menuitem-preferences should lose focus after pressing up`); + + sendKey("TAB"); + await SimpleTest.promiseWaitForCondition(() => preferencesItem.matches(":focus"), + "waiting for preferencesItem to get focus"); + ok(preferencesItem.matches(":focus"), ".menuitem-preferences should be focused after tabbing to it"); + } + + let openPreferencesEvent = null; + ok(!menu.hidden, "menu should be visible before pressing 'space' on .menuitem-preferences"); + window.addEventListener( + "AboutLoginsOpenPreferences", + event => openPreferencesEvent = event, + {once: true} + ); + sendKey("SPACE"); + ok(openPreferencesEvent, "AboutLoginsOpenPreferences event should be dispatched after pressing 'space' on .menuitem-preferences"); + ok(menu.hidden, "menu should be hidden after pressing 'space' on .menuitem-preferences"); + + // Clean up task + sendKey("TAB"); + synthesizeKey("VK_TAB", { shiftKey: true }); +}); + +add_task(async function test_menu_keyboard_cycling() { + function waitForElementFocus(selector) { + return SimpleTest.promiseWaitForCondition( + () => gMenuButton.shadowRoot.querySelector(selector).matches(":focus"), + `waiting for ${selector} to be focused` + ); + } + + function getFocusedMenuItem() { + return gMenuButton.shadowRoot.querySelector(".menuitem-button:focus"); + } + + let allItems = [ + "menuitem-export", + "menuitem-remove-all-logins", + "menuitem-preferences", + "menuitem-help", + ]; + if (SpecialPowers.getBoolPref("signon.management.page.fileImport.enabled")) { + allItems = ["menuitem-import-file", ...allItems]; + } + if (navigator.platform == "Win32" || navigator.platform == "MacIntel") { + allItems = ["menuitem-import-browser", ...allItems]; + } + + let menu = gMenuButton.shadowRoot.querySelector(".menu"); + + is(document.activeElement, gMenuButton, "menu-button should be focused to start the test"); + is(true, menu.hidden, "menu should be hidden before pressing 'space'"); + + sendKey("RETURN"); + + await SimpleTest.promiseWaitForCondition(() => !menu.hidden, "waiting for menu to show"); + + ok(!menu.hidden, "menu should be visible after pressing 'enter'"); + + for (let item of allItems) { + await waitForElementFocus("." + item); + ok( + getFocusedMenuItem().classList.contains(item), + `.${item} should be selected after key is pressed` + ); + sendKey("DOWN"); + } + + + await waitForElementFocus("." + allItems[0]); + ok( + getFocusedMenuItem().classList.contains(allItems[0]), + "Focused item should not change if left arrow is pressed" + ) + sendKey("LEFT"); + + await waitForElementFocus("." + allItems[0]); + ok( + getFocusedMenuItem().classList.contains(allItems[0]), + "Focused item should not change if right arrow is pressed" + ) + sendKey("RIGHT"); + + await waitForElementFocus("." + allItems[0]); + ok( + getFocusedMenuItem().classList.contains(allItems[0]), + "Last item should cycle back to first item" + ); + + sendKey("UP"); + + let reversedItems = allItems.reverse(); + for (let item of reversedItems) { + await waitForElementFocus("." + item); + ok( + getFocusedMenuItem().classList.contains(item), + `.${item} should be selected after up key is pressed` + ); + sendKey("UP"); + } +}); +</script> + +</body> +</html> diff --git a/browser/components/aboutlogins/tests/unit/head.js b/browser/components/aboutlogins/tests/unit/head.js new file mode 100644 index 0000000000..938e06e3c0 --- /dev/null +++ b/browser/components/aboutlogins/tests/unit/head.js @@ -0,0 +1,22 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { LoginTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" +); +const { LoginHelper } = ChromeUtils.importESModule( + "resource://gre/modules/LoginHelper.sys.mjs" +); + +const TestData = LoginTestUtils.testData; +const newPropertyBag = LoginHelper.newPropertyBag; + +/** + * All the tests are implemented with add_task, this starts them automatically. + */ +function run_test() { + do_get_profile(); + run_next_test(); +} diff --git a/browser/components/aboutlogins/tests/unit/test_getPotentialBreachesByLoginGUID.js b/browser/components/aboutlogins/tests/unit/test_getPotentialBreachesByLoginGUID.js new file mode 100644 index 0000000000..a868572a6a --- /dev/null +++ b/browser/components/aboutlogins/tests/unit/test_getPotentialBreachesByLoginGUID.js @@ -0,0 +1,327 @@ +/** + * Test LoginBreaches.getPotentialBreachesByLoginGUID + */ + +"use strict"; + +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + +// Initializing BrowserGlue requires a profile on Windows. +do_get_profile(); + +const gBrowserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver +); + +ChromeUtils.defineESModuleGetters(this, { + LoginBreaches: "resource:///modules/LoginBreaches.sys.mjs", +}); + +const TEST_BREACHES = [ + { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", + }, + { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached-subdomain.host.com", + Name: "Only a Sub-Domain was Breached", + PwnCount: 2754200, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0044", + last_modified: "1541615610052", + schema: "1541615609018", + }, + { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached-site-without-passwords.com", + Name: "Breached Site without passwords", + PwnCount: 987654, + DataClasses: ["Email addresses", "Usernames", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0045", + last_modified: "1541615610052", + schema: "1541615609018", + }, +]; + +const CRASHING_URI_LOGIN = LoginTestUtils.testData.formLogin({ + origin: "chrome://grwatcher", + formActionOrigin: "https://www.example.com", + username: "username", + password: "password", + timePasswordChanged: new Date("2018-12-15").getTime(), +}); +const NOT_BREACHED_LOGIN = LoginTestUtils.testData.formLogin({ + origin: "https://www.example.com", + formActionOrigin: "https://www.example.com", + username: "username", + password: "password", + timePasswordChanged: new Date("2018-12-15").getTime(), +}); +const BREACHED_LOGIN = LoginTestUtils.testData.formLogin({ + origin: "https://www.breached.com", + formActionOrigin: "https://www.breached.com", + username: "username", + password: "password", + timePasswordChanged: new Date("2018-12-15").getTime(), +}); +const NOT_BREACHED_SUBDOMAIN_LOGIN = LoginTestUtils.testData.formLogin({ + origin: "https://not-breached-subdomain.host.com", + formActionOrigin: "https://not-breached-subdomain.host.com", + username: "username", + password: "password", +}); +const BREACHED_SUBDOMAIN_LOGIN = LoginTestUtils.testData.formLogin({ + origin: "https://breached-subdomain.host.com", + formActionOrigin: "https://breached-subdomain.host.com", + username: "username", + password: "password", + timePasswordChanged: new Date("2018-12-15").getTime(), +}); +const LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS = + LoginTestUtils.testData.formLogin({ + origin: "https://breached-site-without-passwords.com", + formActionOrigin: "https://breached-site-without-passwords.com", + username: "username", + password: "password", + timePasswordChanged: new Date("2018-12-15").getTime(), + }); +const LOGIN_WITH_NON_STANDARD_URI = LoginTestUtils.testData.formLogin({ + origin: "someApp://random/path/to/login", + formActionOrigin: "someApp://random/path/to/login", + username: "username", + password: "password", + timePasswordChanged: new Date("2018-12-15").getTime(), +}); + +add_task(async function test_notBreachedLogin() { + await Services.logins.addLoginAsync(NOT_BREACHED_LOGIN); + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [NOT_BREACHED_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 0, + "Should be 0 breached logins." + ); +}); + +add_task(async function test_breachedLogin() { + await Services.logins.addLoginAsync(BREACHED_LOGIN); + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [NOT_BREACHED_LOGIN, BREACHED_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Should be 1 breached login: " + BREACHED_LOGIN.origin + ); + Assert.strictEqual( + breachesByLoginGUID.get(BREACHED_LOGIN.guid).breachAlertURL, + "https://monitor.firefox.com/breach-details/Breached?utm_source=firefox-desktop&utm_medium=referral&utm_campaign=about-logins&utm_content=about-logins", + "Breach alert link should be equal to the breachAlertURL" + ); +}); + +add_task(async function test_breachedLoginAfterCrashingUriLogin() { + await Services.logins.addLoginAsync(CRASHING_URI_LOGIN); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [CRASHING_URI_LOGIN, BREACHED_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Should be 1 breached login: " + BREACHED_LOGIN.origin + ); + Assert.strictEqual( + breachesByLoginGUID.get(BREACHED_LOGIN.guid).breachAlertURL, + "https://monitor.firefox.com/breach-details/Breached?utm_source=firefox-desktop&utm_medium=referral&utm_campaign=about-logins&utm_content=about-logins", + "Breach alert link should be equal to the breachAlertURL" + ); +}); + +add_task(async function test_notBreachedSubdomain() { + await Services.logins.addLoginAsync(NOT_BREACHED_SUBDOMAIN_LOGIN); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [NOT_BREACHED_LOGIN, NOT_BREACHED_SUBDOMAIN_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 0, + "Should be 0 breached logins." + ); +}); + +add_task(async function test_breachedSubdomain() { + await Services.logins.addLoginAsync(BREACHED_SUBDOMAIN_LOGIN); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [NOT_BREACHED_SUBDOMAIN_LOGIN, BREACHED_SUBDOMAIN_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Should be 1 breached login: " + BREACHED_SUBDOMAIN_LOGIN.origin + ); +}); + +add_task(async function test_breachedSiteWithoutPasswords() { + await Services.logins.addLoginAsync( + LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS + ); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 0, + "Should be 0 breached login: " + + LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS.origin + ); +}); + +add_task(async function test_breachAlertHiddenAfterDismissal() { + BREACHED_LOGIN.guid = "{d2de5ac1-4de6-e544-a7af-1f75abcba92b}"; + + await Services.logins.initializationPromise; + const storageJSON = Services.logins.wrappedJSObject._storage.wrappedJSObject; + + storageJSON.recordBreachAlertDismissal(BREACHED_LOGIN.guid); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [BREACHED_LOGIN, NOT_BREACHED_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 0, + "Should be 0 breached logins after dismissal: " + BREACHED_LOGIN.origin + ); + + info("Clear login storage"); + Services.logins.removeAllUserFacingLogins(); + + const breachesByLoginGUID2 = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [BREACHED_LOGIN, NOT_BREACHED_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID2.size, + 1, + "Breached login should re-appear after clearing storage: " + + BREACHED_LOGIN.origin + ); +}); + +add_task(async function test_newBreachAfterDismissal() { + TEST_BREACHES[0].AddedDate = new Date().toISOString(); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [BREACHED_LOGIN, NOT_BREACHED_LOGIN], + TEST_BREACHES + ); + + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Should be 1 breached login after new breach following the dismissal of a previous breach: " + + BREACHED_LOGIN.origin + ); +}); + +add_task(async function test_ExceptionsThrownByNonStandardURIsAreCaught() { + await Services.logins.addLoginAsync(LOGIN_WITH_NON_STANDARD_URI); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [LOGIN_WITH_NON_STANDARD_URI, BREACHED_LOGIN], + TEST_BREACHES + ); + + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Exceptions thrown by logins with non-standard URIs should be caught." + ); +}); + +add_task(async function test_setBreachesFromRemoteSettingsSync() { + const login = NOT_BREACHED_SUBDOMAIN_LOGIN; + const nowExampleIsInBreachedRecords = [ + { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "not-breached-subdomain.host.com", + Name: "not-breached-subdomain.host.com is now breached!", + PwnCount: 1643100, + DataClasses: [ + "Email addresses", + "Usernames", + "Passwords", + "IP addresses", + ], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0044", + last_modified: "1541615610052", + schema: "1541615609018", + }, + ]; + async function emitSync() { + await RemoteSettings(LoginBreaches.REMOTE_SETTINGS_COLLECTION).emit( + "sync", + { data: { current: nowExampleIsInBreachedRecords } } + ); + } + + const beforeSyncBreachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID([login]); + Assert.strictEqual( + beforeSyncBreachesByLoginGUID.size, + 0, + "Should be 0 breached login before not-breached-subdomain.host.com is added to fxmonitor-breaches collection and synced: " + ); + gBrowserGlue.observe(null, "browser-glue-test", "add-breaches-sync-handler"); + const db = RemoteSettings(LoginBreaches.REMOTE_SETTINGS_COLLECTION).db; + await db.importChanges({}, Date.now(), [nowExampleIsInBreachedRecords[0]]); + await emitSync(); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID([login]); + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Should be 1 breached login after not-breached-subdomain.host.com is added to fxmonitor-breaches collection and synced: " + ); +}); diff --git a/browser/components/aboutlogins/tests/unit/xpcshell.ini b/browser/components/aboutlogins/tests/unit/xpcshell.ini new file mode 100644 index 0000000000..e827d6d688 --- /dev/null +++ b/browser/components/aboutlogins/tests/unit/xpcshell.ini @@ -0,0 +1,7 @@ +[DEFAULT] +skip-if = toolkit == 'android' # bug 1730213 +head = head.js +firefox-appdir = browser + +[test_getPotentialBreachesByLoginGUID.js] +tags = remote-settings |