From b852185bea01ed84f9bbdf6f927cab412d3413c3 Mon Sep 17 00:00:00 2001 From: varjolintu Date: Sun, 10 Jan 2021 11:16:03 +0200 Subject: [PATCH] TOTP Autocomplete Menu and support for filling TOTP from another database. --- .eslintrc | 4 +- keepassxc-browser/content/autocomplete.js | 466 +++++++++--------- .../content/credential-autocomplete.js | 45 ++ .../content/keepassxc-browser.js | 119 +++-- .../content/totp-autocomplete.js | 35 ++ keepassxc-browser/content/ui.js | 12 +- keepassxc-browser/manifest.json | 2 + 7 files changed, 414 insertions(+), 269 deletions(-) create mode 100644 keepassxc-browser/content/credential-autocomplete.js create mode 100644 keepassxc-browser/content/totp-autocomplete.js diff --git a/.eslintrc b/.eslintrc index 8973140..ff25a2e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -17,6 +17,7 @@ "block-scoped-var": "off", "brace-style": ["warn", "1tbs"], "camelcase": "warn", + "class-methods-use-this": "off", "comma-dangle": "off", "computed-property-spacing": ["error", "never"], "consistent-return": "off", @@ -91,7 +92,6 @@ "jQuery": true, "keepass": true, "kpxc": true, - "kpxcAutocomplete": true, "kpxcBanner": true, "kpxcDefine": true, "kpxcFields": true, @@ -99,7 +99,9 @@ "kpxcPasswordDialog": true, "kpxcPasswordIcons": true, "kpxcSites": true, + "kpxcTOTPAutocomplete": true, "kpxcTOTPIcons": true, + "kpxcUserAutocomplete": true, "kpxcUsernameIcons": true, "kpxcUI": true, "kpxcUsernameField": true, diff --git a/keepassxc-browser/content/autocomplete.js b/keepassxc-browser/content/autocomplete.js index 3b799dd..b0769ab 100644 --- a/keepassxc-browser/content/autocomplete.js +++ b/keepassxc-browser/content/autocomplete.js @@ -2,245 +2,261 @@ const MAX_AUTOCOMPLETE_NAME_LEN = 50; -const kpxcAutocomplete = {}; -kpxcAutocomplete.autoSubmit = false; -kpxcAutocomplete.elements = []; -kpxcAutocomplete.started = false; -kpxcAutocomplete.index = -1; -kpxcAutocomplete.input = undefined; -kpxcAutocomplete.shadowRoot = undefined; -kpxcAutocomplete.wrapper = undefined; - -kpxcAutocomplete.create = async function(input, showListInstantly = false, autoSubmit = false) { - if (input.readOnly) { - return; +class Autocomplete { + constructor() { + this.autoSubmit = false; + this.elements = []; + this.started = false; + this.index = -1; + this.input = undefined; + this.shadowRoot = undefined; + this.wrapper = undefined; } - kpxcAutocomplete.autoSubmit = autoSubmit; - kpxcAutocomplete.input = input; - kpxcAutocomplete.started = true; + clear() { + this.elements = []; + } - input.addEventListener('click', function(e) { + async click(e, input) { + + } + + async itemClick(e, item, input, uuid) { + + } + + async itemEnter(index, elements) { + + } + + async create(input, showListInstantly = false, autoSubmit = false) { + if (input.readOnly) { + return; + } + + this.autoSubmit = autoSubmit; + this.input = input; + this.started = true; + + input.addEventListener('click', ev => this.click(ev, this.input)); + input.addEventListener('keydown', ev => this.keyPress(ev)); + input.setAttribute('autocomplete', 'off'); + + if (showListInstantly) { + this.showList(input); + } + } + + mouseOver(e, div, item) { + this.removeItem(this.getAllItems()); + item.classList.add('kpxcAutocomplete-active'); + this.index = Array.from(div.childNodes).indexOf(item); + } + + mouseOut(e, item) { + item.classList.remove('kpxcAutocomplete-active'); + } + + async showList(inputField) { + this.closeList(); + this.input = inputField; + this.input.select(); + + const div = kpxcUI.createElement('div', 'kpxcAutocomplete-items', { 'id': 'kpxcAutocomplete-list' }); + initColorTheme(div); + + this.updatePosition(inputField, div); + div.style.zIndex = '2147483646'; + + const styleSheet = createStylesheet('css/autocomplete.css'); + const colorStyleSheet = createStylesheet('css/colors.css'); + const wrapper = kpxcUI.createElement('div'); + + this.shadowRoot = wrapper.attachShadow({ mode: 'closed' }); + this.shadowRoot.append(colorStyleSheet); + this.shadowRoot.append(styleSheet); + this.shadowRoot.append(div); + this.wrapper = wrapper; + document.body.append(wrapper); + + await kpxc.updateTOTPList(); + for (const c of this.elements) { + const item = document.createElement('div'); + item.textContent += c.label; + const itemInput = kpxcUI.createElement('input', '', { 'type': 'hidden', 'value': c.value }); + item.append(itemInput); + item.addEventListener('click', ev => this.itemClick(ev, item, inputField, c.uuid)); + + // These events prevent the double hover effect if both keyboard and mouse are used + item.addEventListener('mouseover', ev => this.mouseOver(ev, div, item)); + item.addEventListener('mouseout', ev => this.mouseOut(ev, item)); + + div.appendChild(item); + } + + // Add a footer message for auto-submit + if (this.autoSubmit) { + const footer = kpxcUI.createElement('footer', '', {}, tr('autocompleteSubmitMessage')); + div.appendChild(footer); + } + + // Activate the first item automatically + const items = this.getAllItems(); + this.index = 0; + this.activateItem(items); + } + + activateItem(item) { + if (!item || item.length === 0) { + return; + } + + this.removeItem(item); + if (this.index >= item.length) { + this.index = 0; + } + + if (this.index < 0) { + this.index = item.length - 1; + } + + if (item[this.index] !== undefined) { + item[this.index].classList.add('kpxcAutocomplete-active'); + } + } + + removeItem(items) { + for (const item of items) { + item.classList.remove('kpxcAutocomplete-active'); + } + } + + closeList(elem) { + if (!this.shadowRoot) { + return; + } + + const items = this.shadowSelectorAll('.kpxcAutocomplete-items'); + if (!items) { + return; + } + + for (const item of items) { + if (elem !== item && this.input) { + item.parentNode.removeChild(item); + } + } + } + + getAllItems() { + const list = this.shadowSelector('#kpxcAutocomplete-list'); + if (!list) { + return []; + } + + return list.getElementsByTagName('div'); + } + + /** + * Keyboard shortcuts for autocomplete menu: + * - ArrowDown shows the list or selects item below, or the first item (last is active) + * - ArrowUp selects item above, or the last item (first is active) + * - Enter or Tab selects the item + * - Backspace and Delete shows the list if input field is empty. First item is activated + */ + keyPress(e) { if (!e.isTrusted) { return; } - if (input.value !== '') { - input.select(); - } - kpxcAutocomplete.showList(input); - }); + const items = this.getAllItems(); + if (e.key === 'ArrowDown') { + // If the list is not visible, show it + if (items.length === 0) { + this.index = -1; + this.showList(this.input); + } else { + // Activate next item + ++this.index; + this.activateItem(items); + } + } else if (e.key === 'ArrowUp') { + --this.index; + this.activateItem(items); + } else if (e.key === 'Enter') { + if (this.input.value === '') { + e.preventDefault(); + } - input.addEventListener('keydown', kpxcAutocomplete.keyPress); - input.setAttribute('autocomplete', 'off'); + if (this.index >= 0 && items && items[this.index] !== undefined) { + e.preventDefault(); - if (showListInstantly) { - kpxcAutocomplete.showList(input); - } -}; - -kpxcAutocomplete.showList = function(inputField) { - kpxcAutocomplete.closeList(); - kpxcAutocomplete.input = inputField; - - const div = kpxcUI.createElement('div', 'kpxcAutocomplete-items', { 'id': 'kpxcAutocomplete-list' }); - initColorTheme(div); - - kpxcAutocomplete.updatePosition(inputField, div); - div.style.zIndex = '2147483646'; - - const styleSheet = createStylesheet('css/autocomplete.css'); - const colorStyleSheet = createStylesheet('css/colors.css'); - const wrapper = kpxcUI.createElement('div'); - - kpxcAutocomplete.shadowRoot = wrapper.attachShadow({ mode: 'closed' }); - kpxcAutocomplete.shadowRoot.append(colorStyleSheet); - kpxcAutocomplete.shadowRoot.append(styleSheet); - kpxcAutocomplete.shadowRoot.append(div); - kpxcAutocomplete.wrapper = wrapper; - document.body.append(wrapper); - - for (const c of kpxcAutocomplete.elements) { - const item = document.createElement('div'); - item.textContent += c.label; - const itemInput = kpxcUI.createElement('input', '', { 'type': 'hidden', 'value': c.value }); - item.append(itemInput); - - item.addEventListener('click', async function(e) { - if (!e.isTrusted) { + this.itemEnter(this.index, this.elements); + this.closeList(); + } + } else if (e.key === 'Tab') { + // Return if value is not in the list + if (this.input.value !== '' && !this.elements.some(c => c.value === this.input.value)) { + this.closeList(); return; } - // Save index for combination.loginId - const index = Array.prototype.indexOf.call(e.currentTarget.parentElement.childNodes, e.currentTarget); - await sendMessage('page_set_login_id', index); + this.index = this.elements.findIndex(c => c.value === this.input.value); + if (this.index >= 0) { + this.fillPassword(this.input.value, this.index, this.elements[this.index].uuid); + } - const usernameValue = this.getElementsByTagName('input')[0].value; - await kpxcAutocomplete.fillPassword(usernameValue, index, c.uuid); - kpxcAutocomplete.closeList(); - inputField.focus(); - }); - - // These events prevent the double hover effect if both keyboard and mouse are used - item.addEventListener('mouseover', function(e) { - kpxcAutocomplete.removeItem(kpxcAutocomplete.getAllItems()); - item.classList.add('kpxcAutocomplete-active'); - kpxcAutocomplete.index = Array.from(div.childNodes).indexOf(item); - }); - - item.addEventListener('mouseout', function(e) { - item.classList.remove('kpxcAutocomplete-active'); - }); - - div.appendChild(item); - } - - // Add a footer message for auto-submit - if (kpxcAutocomplete.autoSubmit) { - const footer = kpxcUI.createElement('footer', '', {}, tr('autocompleteSubmitMessage')); - div.appendChild(footer); - } - - // Activate the first item automatically - const items = kpxcAutocomplete.getAllItems(); - kpxcAutocomplete.index = 0; - kpxcAutocomplete.activateItem(items); -}; - -kpxcAutocomplete.activateItem = function(item) { - if (!item || item.length === 0) { - return; - } - - kpxcAutocomplete.removeItem(item); - if (kpxcAutocomplete.index >= item.length) { - kpxcAutocomplete.index = 0; - } - - if (kpxcAutocomplete.index < 0) { - kpxcAutocomplete.index = item.length - 1; - } - - if (item[kpxcAutocomplete.index] !== undefined) { - item[kpxcAutocomplete.index].classList.add('kpxcAutocomplete-active'); - } -}; - -kpxcAutocomplete.removeItem = function(items) { - for (const item of items) { - item.classList.remove('kpxcAutocomplete-active'); - } -}; - -kpxcAutocomplete.closeList = function(elem) { - if (!kpxcAutocomplete.shadowRoot) { - return; - } - - const items = kpxcAutocomplete.shadowSelectorAll('.kpxcAutocomplete-items'); - if (!items) { - return; - } - - for (const item of items) { - if (elem !== item && kpxcAutocomplete.input) { - item.parentNode.removeChild(item); + this.closeList(); + } else if (e.key === 'Escape') { + this.closeList(); + } else if ((e.key === 'Backspace' || e.key === 'Delete') && this.input.value === '') { + // Show menu when input field has no value and backspace is pressed + this.index = -1; + this.showList(this.input); } } -}; -kpxcAutocomplete.getAllItems = function() { - const list = kpxcAutocomplete.shadowSelector('#kpxcAutocomplete-list'); - if (!list) { - return []; - } - - return list.getElementsByTagName('div'); -}; - -/** - * Keyboard shortcuts for autocomplete menu: - * - ArrowDown shows the list or selects item below, or the first item (last is active) - * - ArrowUp selects item above, or the last item (first is active) - * - Enter or Tab selects the item - * - Backspace and Delete shows the list if input field is empty. First item is activated -*/ -kpxcAutocomplete.keyPress = function(e) { - if (!e.isTrusted) { - return; - } - - const items = kpxcAutocomplete.getAllItems(); - if (e.key === 'ArrowDown') { - // If the list is not visible, show it - if (items.length === 0) { - kpxcAutocomplete.index = -1; - kpxcAutocomplete.showList(kpxcAutocomplete.input); - } else { - // Activate next item - ++kpxcAutocomplete.index; - kpxcAutocomplete.activateItem(items); - } - } else if (e.key === 'ArrowUp') { - --kpxcAutocomplete.index; - kpxcAutocomplete.activateItem(items); - } else if (e.key === 'Enter') { - if (kpxcAutocomplete.input.value === '') { - e.preventDefault(); - } - - if (kpxcAutocomplete.index >= 0 && items && items[kpxcAutocomplete.index] !== undefined) { - e.preventDefault(); - const usernameValue = kpxcAutocomplete.elements[kpxcAutocomplete.index].value; - kpxcAutocomplete.fillPassword(usernameValue, kpxcAutocomplete.index, kpxcAutocomplete.elements[kpxcAutocomplete.index].uuid); - kpxcAutocomplete.closeList(); - } - } else if (e.key === 'Tab') { - // Return if value is not in the list - if (kpxcAutocomplete.input.value !== '' && !kpxcAutocomplete.elements.some(c => c.value === kpxcAutocomplete.input.value)) { - kpxcAutocomplete.closeList(); + updatePosition(inputField, elem) { + const div = elem || this.shadowSelector('.kpxcAutocomplete-items'); + if (!div) { return; } - kpxcAutocomplete.index = kpxcAutocomplete.elements.findIndex(c => c.value === kpxcAutocomplete.input.value); - if (kpxcAutocomplete.index >= 0) { - kpxcAutocomplete.fillPassword(kpxcAutocomplete.input.value, kpxcAutocomplete.index, kpxcAutocomplete.elements[kpxcAutocomplete.index].uuid); + const rect = inputField.getBoundingClientRect(); + div.style.minWidth = Pixels(inputField.offsetWidth); + + if (kpxcUI.bodyStyle.position.toLowerCase() === 'relative') { + div.style.top = Pixels(rect.top - kpxcUI.bodyRect.top + document.scrollingElement.scrollTop + inputField.offsetHeight); + div.style.left = Pixels(rect.left - kpxcUI.bodyRect.left + document.scrollingElement.scrollLeft); + } else { + div.style.top = Pixels(rect.top + document.scrollingElement.scrollTop + inputField.offsetHeight); + div.style.left = Pixels(rect.left + document.scrollingElement.scrollLeft); } - - kpxcAutocomplete.closeList(); - } else if (e.key === 'Escape') { - kpxcAutocomplete.closeList(); - } else if ((e.key === 'Backspace' || e.key === 'Delete') && kpxcAutocomplete.input.value === '') { - // Show menu when input field has no value and backspace is pressed - kpxcAutocomplete.index = -1; - kpxcAutocomplete.showList(kpxcAutocomplete.input); } -}; +} -kpxcAutocomplete.fillPassword = async function(value, index, uuid) { - const combination = await kpxcFields.getCombination(kpxcAutocomplete.input); - combination.loginId = index; - const manualFill = await sendMessage('page_get_manual_fill'); - await kpxc.fillInCredentials(combination, value, uuid, manualFill === ManualFill.PASSWORD); - kpxcAutocomplete.input.setAttribute('fetched', true); -}; - -kpxcAutocomplete.updatePosition = function(inputField, elem) { - const div = elem || kpxcAutocomplete.shadowSelector('.kpxcAutocomplete-items'); - if (!div) { +// Global handlers +const handleOutsideClick = function(e, autocompleteMenu) { + const list = autocompleteMenu.shadowRoot ? autocompleteMenu.shadowSelector('#kpxcAutocomplete-list') : undefined; + if (!list) { return; } - const rect = inputField.getBoundingClientRect(); - div.style.minWidth = Pixels(inputField.offsetWidth); + if (e.target !== autocompleteMenu.input + && !e.target.classList.contains('kpxc-username-icon') + && e.target.nodeName !== autocompleteMenu.input.nodeName) { + autocompleteMenu.closeList(e.target); - if (kpxcUI.bodyStyle.position.toLowerCase() === 'relative') { - div.style.top = Pixels(rect.top - kpxcUI.bodyRect.top + document.scrollingElement.scrollTop + inputField.offsetHeight); - div.style.left = Pixels(rect.left - kpxcUI.bodyRect.left + document.scrollingElement.scrollLeft); - } else { - div.style.top = Pixels(rect.top + document.scrollingElement.scrollTop + inputField.offsetHeight); - div.style.left = Pixels(rect.left + document.scrollingElement.scrollLeft); + if (autocompleteMenu.wrapper) { + document.body.removeChild(autocompleteMenu.wrapper); + } + } +}; + +const updatePosition = function(autocompleteMenu) { + if (autocompleteMenu.input) { + autocompleteMenu.updatePosition(autocompleteMenu.input); } }; @@ -250,20 +266,8 @@ document.addEventListener('click', function(e) { return; } - const list = kpxcAutocomplete.shadowRoot ? kpxcAutocomplete.shadowSelector('#kpxcAutocomplete-list') : undefined; - if (!list) { - return; - } - - if (e.target !== kpxcAutocomplete.input - && !e.target.classList.contains('kpxc-username-icon') - && e.target.nodeName !== kpxcAutocomplete.input.nodeName) { - kpxcAutocomplete.closeList(e.target); - - if (kpxcAutocomplete.wrapper) { - document.body.removeChild(kpxcAutocomplete.wrapper); - } - } + handleOutsideClick(e, kpxcUserAutocomplete); + handleOutsideClick(e, kpxcTOTPAutocomplete); }); // Handle autocomplete position on window resize @@ -272,9 +276,8 @@ window.addEventListener('resize', function() { return; } - if (kpxcAutocomplete.input) { - kpxcAutocomplete.updatePosition(kpxcAutocomplete.input); - } + updatePosition(kpxcUserAutocomplete); + updatePosition(kpxcTOTPAutocomplete); }); // Handle autocomplete position on scroll @@ -283,7 +286,6 @@ window.addEventListener('scroll', function() { return; } - if (kpxcAutocomplete.input) { - kpxcAutocomplete.updatePosition(kpxcAutocomplete.input); - } + updatePosition(kpxcUserAutocomplete); + updatePosition(kpxcTOTPAutocomplete); }); diff --git a/keepassxc-browser/content/credential-autocomplete.js b/keepassxc-browser/content/credential-autocomplete.js new file mode 100644 index 0000000..d62d0df --- /dev/null +++ b/keepassxc-browser/content/credential-autocomplete.js @@ -0,0 +1,45 @@ +'use strict'; + +class CredentialAutocomplete extends Autocomplete {} +CredentialAutocomplete.prototype.click = async function(e, input) { + if (!e.isTrusted) { + return; + } + + if (input.value !== '') { + input.select(); + } + + this.showList(input); +}; + +CredentialAutocomplete.prototype.itemClick = async function(e, item, input, uuid) { + if (!e.isTrusted) { + return; + } + + const index = Array.prototype.indexOf.call(e.currentTarget.parentElement.childNodes, e.currentTarget); + const usernameValue = item.getElementsByTagName('input')[0].value; + await this.fillPassword(usernameValue, index, uuid); + + this.closeList(); + input.focus(); +}; + +CredentialAutocomplete.prototype.itemEnter = async function(index, elements) { + const usernameValue = elements[index].value; + this.fillPassword(usernameValue, index, elements[index].uuid); +}; + +CredentialAutocomplete.prototype.fillPassword = async function(value, index, uuid) { + const combination = await kpxcFields.getCombination(this.input); + combination.loginId = index; + + await sendMessage('page_set_login_id', index); + + const manualFill = await sendMessage('page_get_manual_fill'); + await kpxc.fillInCredentials(combination, value, uuid, manualFill === ManualFill.PASSWORD); + this.input.setAttribute('fetched', true); +}; + +const kpxcUserAutocomplete = new CredentialAutocomplete(); diff --git a/keepassxc-browser/content/keepassxc-browser.js b/keepassxc-browser/content/keepassxc-browser.js index ea1fd01..f6c8709 100755 --- a/keepassxc-browser/content/keepassxc-browser.js +++ b/keepassxc-browser/content/keepassxc-browser.js @@ -155,7 +155,7 @@ kpxcForm.getCredentialFieldsFromForm = function(form) { // Get the form submit button instead if action URL is same as the page itself kpxcForm.getFormSubmitButton = function(form) { - if (!form.action || typeof form.action !== 'string') { + if (!form || !form.action || typeof form.action !== 'string') { return; } @@ -675,11 +675,11 @@ kpxc.addToSitePreferences = async function() { kpxc.clearAllFromPage = function() { kpxc.credentials = []; kpxc.inputs = []; - kpxcAutocomplete.elements = []; + kpxcUserAutocomplete.clear(); _called.retrieveCredentials = false; if (kpxc.settings.autoCompleteUsernames) { - kpxcAutocomplete.closeList(); + kpxcUserAutocomplete.closeList(); } // Switch back to default popup @@ -773,7 +773,7 @@ kpxc.fillInFromActiveElement = async function(passOnly = false) { if (kpxc.credentials.length > 1) { // More than one credential -> show autocomplete list - kpxcAutocomplete.showList(field); + kpxcUserAutocomplete.showList(field); return; } else { // Just one credential -> fill the first combination found @@ -825,37 +825,53 @@ kpxc.fillFromPopup = async function(id, uuid) { await sendMessage('page_set_login_id', id); kpxc.fillInCredentials(kpxc.combinations[0], kpxc.credentials[id].login, uuid); - kpxcAutocomplete.closeList(); + kpxcUserAutocomplete.closeList(); }; // Fill requested from TOTP icon kpxc.fillFromTOTP = async function(target) { const el = target || document.activeElement; - let index = await sendMessage('page_get_login_id'); + const credentialList = await kpxc.updateTOTPList(); - // Use the first credential available if not set - if (index === undefined) { - index = 0; + if (credentialList.length === 0) { + kpxcUI.createNotification('warning', tr('credentialsNoTOTPFound')); + return; } - if (index >= 0 && kpxc.credentials[index]) { - // Check the value from StringFields - if (kpxc.credentials[index].totp && kpxc.credentials[index].totp.length > 0) { - // Retrieve a new TOTP value - const totp = await sendMessage('get_totp', [ kpxc.credentials[index].uuid, kpxc.credentials[index].totp ]); - if (!totp) { - kpxcUI.createNotification('warning', tr('credentialsNoTOTPFound')); - return; - } + if (credentialList.length === 1) { + kpxc.fillTOTPFromUuid(el, credentialList[0].uuid); + return; + } - kpxc.setValue(el, totp); - } else if (kpxc.credentials[index].stringFields && kpxc.credentials[index].stringFields.length > 0) { - const stringFields = kpxc.credentials[index].stringFields; - for (const s of stringFields) { - const val = s['KPH: {TOTP}']; - if (val) { - kpxc.setValue(el, val); - } + kpxcTOTPAutocomplete.showList(el, true); +}; + +// Fill TOTP with matching uuid +kpxc.fillTOTPFromUuid = async function(el, uuid) { + if (!el || !uuid) { + return; + } + + const user = kpxc.credentials.find(c => c.uuid === uuid); + if (!user) { + return; + } + + if (user.totp && user.totp.length > 0) { + // Retrieve a new TOTP value + const totp = await sendMessage('get_totp', [ user.uuid, user.totp ]); + if (!totp) { + kpxcUI.createNotification('warning', tr('credentialsNoTOTPFound')); + return; + } + + kpxc.setValue(el, totp); + } else if (user.stringFields && user.stringFields.length > 0) { + const stringFields = user.stringFields; + for (const s of stringFields) { + const val = s['KPH: {TOTP}']; + if (val) { + kpxc.setValue(el, val); } } } @@ -867,7 +883,7 @@ kpxc.fillFromUsernameIcon = async function(combination) { if (kpxc.credentials.length === 0) { return; } else if (kpxc.credentials.length > 1 && kpxc.settings.autoCompleteUsernames) { - kpxcAutocomplete.showList(combination.username || combination.password); + kpxcUserAutocomplete.showList(combination.username || combination.password); return; } @@ -930,7 +946,7 @@ kpxc.fillInCredentials = async function(combination, predefinedUsername, uuid, p } // Close autocomplete menu after fill - kpxcAutocomplete.closeList(); + kpxcUserAutocomplete.closeList(); // Reset ManualFill await sendMessage('page_set_manual_fill', ManualFill.NONE); @@ -940,7 +956,7 @@ kpxc.fillInCredentials = async function(combination, predefinedUsername, uuid, p const submitButton = kpxcForm.getFormSubmitButton(combination.form); if (submitButton !== undefined) { submitButton.click(); - } else { + } else if (combination.form) { combination.form.submit(); } } @@ -994,7 +1010,9 @@ kpxc.getFormActionUrl = function(combination) { } if (typeof(action) !== 'string' || action === '') { - action = document.location.origin + document.location.pathname; + // Firefox can report location.origin as 'null' with localHost files + const origin = document.location.origin === 'null' ? 'file://' : document.location.origin; + action = origin + document.location.pathname; } return action; @@ -1076,10 +1094,14 @@ kpxc.initAutocomplete = function() { for (const c of kpxc.combinations) { if (c.username) { - kpxcAutocomplete.create(c.username, false, kpxc.settings.autoSubmit); + kpxcUserAutocomplete.create(c.username, false, kpxc.settings.autoSubmit); } else if (!c.username && c.password) { // Single password field - kpxcAutocomplete.create(c.password, false, kpxc.settings.autoSubmit); + kpxcUserAutocomplete.create(c.password, false, kpxc.settings.autoSubmit); + } + + if (c.totp) { + kpxcTOTPAutocomplete.create(c.totp, false, kpxc.settings.autoSubmit); } } }; @@ -1185,14 +1207,14 @@ kpxc.initLoginPopup = function() { // Add usernames + descriptions to autocomplete-list and popup-list const usernames = []; - kpxcAutocomplete.elements = []; + kpxcUserAutocomplete.clear(); const showGroupNameInAutocomplete = kpxc.settings.showGroupNameInAutocomplete && (getUniqueGroupCount(kpxc.credentials) > 1); for (let i = 0; i < kpxc.credentials.length; i++) { const loginText = getLoginText(kpxc.credentials[i], showGroupNameInAutocomplete); usernames.push({ text: loginText, uuid: kpxc.credentials[i].uuid }); - kpxcAutocomplete.elements.push({ + kpxcUserAutocomplete.elements.push({ label: loginText, value: kpxc.credentials[i].login, uuid: kpxc.credentials[i].uuid, @@ -1462,6 +1484,35 @@ kpxc.updateDatabaseState = async function() { kpxc.databaseState = res.databaseClosed ? DatabaseState.LOCKED : DatabaseState.UNLOCKED; }; +// Updates the TOTP Autocomplete Menu +kpxc.updateTOTPList = async function() { + let index = await sendMessage('page_get_login_id'); + if (index < 0) { + // Credential haven't been selected + return; + } + + // Use the first credential available if not set + if (index === undefined) { + index = 0; + } + + if (index >= 0 && kpxc.credentials[index]) { + const username = kpxc.credentials[index].login; + const password = kpxc.credentials[index].password; + + // If no username is set, compare with a password + const credentialList = kpxc.credentials.filter(c => (c.totp || c.stringFields.some(s => s['KPH: {TOTP}'])) + && (c.login === username || (!username && c.password === password))); + + // Filter TOTP Autocomplete Menu with matching 2FA credentials + kpxcTOTPAutocomplete.elements = kpxcUserAutocomplete.elements.filter(e => credentialList.some(u => u.uuid === e.uuid)); + return credentialList; + } + + return []; +}; + /** * @Object kpxcObserverHelper diff --git a/keepassxc-browser/content/totp-autocomplete.js b/keepassxc-browser/content/totp-autocomplete.js new file mode 100644 index 0000000..463cb52 --- /dev/null +++ b/keepassxc-browser/content/totp-autocomplete.js @@ -0,0 +1,35 @@ +'use strict'; + +class TOTPAutocomplete extends Autocomplete {} +TOTPAutocomplete.prototype.click = async function(e, input) { + if (!e.isTrusted) { + return; + } + + await kpxc.updateTOTPList(); + this.showList(input, true); +}; + +TOTPAutocomplete.prototype.itemClick = async function(e, item, input, uuid) { + if (!e.isTrusted) { + return; + } + + const index = Array.prototype.indexOf.call(e.currentTarget.parentElement.childNodes, e.currentTarget); + this.fillTotp(index, uuid); + + this.closeList(); + input.focus(); +}; + +TOTPAutocomplete.prototype.itemEnter = async function(index, elements) { + this.fillTotp(index, elements[index].uuid); +}; + +TOTPAutocomplete.prototype.fillTotp = async function(index, uuid) { + const combination = await kpxcFields.getCombination(this.input); + combination.loginId = index; + kpxc.fillTOTPFromUuid(this.input, uuid); +}; + +const kpxcTOTPAutocomplete = new TOTPAutocomplete(); diff --git a/keepassxc-browser/content/ui.js b/keepassxc-browser/content/ui.js index 9fac03c..b6a6226 100644 --- a/keepassxc-browser/content/ui.js +++ b/keepassxc-browser/content/ui.js @@ -268,11 +268,19 @@ document.addEventListener('mousemove', function(e) { } }); -document.addEventListener('mousedown', function() { +document.addEventListener('mousedown', function(e) { + if (!e.isTrusted) { + return; + } + kpxcUI.mouseDown = true; }); -document.addEventListener('mouseup', function() { +document.addEventListener('mouseup', function(e) { + if (!e.isTrusted) { + return; + } + kpxcPasswordDialog.selected = null; kpxcDefine.selected = null; kpxcUI.mouseDown = false; diff --git a/keepassxc-browser/manifest.json b/keepassxc-browser/manifest.json index 03f94ea..42f11ea 100755 --- a/keepassxc-browser/manifest.json +++ b/keepassxc-browser/manifest.json @@ -56,9 +56,11 @@ "content/ui.js", "content/banner.js", "content/autocomplete.js", + "content/credential-autocomplete.js", "content/define.js", "content/keepassxc-browser.js", "content/pwgen.js", + "content/totp-autocomplete.js", "content/totp-field.js", "content/username-field.js" ],