diff --git a/keepassxc-browser/content/keepassxc-browser.js b/keepassxc-browser/content/keepassxc-browser.js index ce56235..611c000 100755 --- a/keepassxc-browser/content/keepassxc-browser.js +++ b/keepassxc-browser/content/keepassxc-browser.js @@ -317,6 +317,7 @@ kpxcForm.saveForm = function(form, combination) { username: combination.username, password: combination.password, totp: combination.totp, + totpInputs: Array.from(form.elements).filter(e => e.nodeName === 'INPUT' && kpxcTOTPIcons.isValid(e)), passwordInputs: Array.from(form.elements).filter(e => e.nodeName === 'INPUT' && e.type === 'password') }); }; @@ -376,6 +377,46 @@ kpxcFields.getAllCombinations = async function(inputs) { combinations.push(combination); } + // Check for multiple segmented TOTP fields + if (combinations.length === 0) { + kpxcFields.getSegmentedTOTPFields(inputs, combinations); + } + + return combinations; +}; + +// Adds segmented TOTP fields to the combination if found +kpxcFields.getSegmentedTOTPFields = function(inputs, combinations) { + const addTotpFieldsToCombination = function(inputFields) { + const totpInputs = Array.from(inputFields).filter(e => e.nodeName === 'INPUT' && e.type !== 'password'); + if (totpInputs.length === 6) { + const combination = { + form: form, + totpInputs: totpInputs + }; + + combinations.push(combination); + + // Create an icon to the right side of the segmented fields + kpxcTOTPIcons.newIcon(totpInputs[totpInputs.length - 1], kpxc.databaseState, true); + kpxcIcons.icons.push({ + field: totpInputs[totpInputs.length - 1], + iconType: kpxcIcons.iconTypes.TOTP, + segmented: true + }); + } + }; + + const form = inputs.length > 0 ? inputs[0].form : undefined; + if (form && (acceptedOTPFields.some(f => form.className.includes(f) || form.id.includes(f) || form.name.includes(f)) + || form.length === 6)) { + // Use the form's elements + addTotpFieldsToCombination(form.elements); + } else if (inputs.length === 6 && inputs.every(i => i.inputMode === 'numeric' && i.pattern.includes('0-9'))) { + // No form is found, but input fields are possibly segmented TOTP fields + addTotpFieldsToCombination(inputs); + } + return combinations; }; @@ -420,8 +461,8 @@ kpxcFields.getCombination = async function(field, givenType) { for (const combination of kpxc.combinations) { if (!givenType && Object.values(combination).find(c => c === field)) { return combination; - } else if (givenType) { - if (combination[givenType] === field) { + } else if (givenType && combination[givenType]) { + if (combination[givenType] === field || combination[givenType].includes(field)) { return combination; } } @@ -664,7 +705,7 @@ kpxcFields.useCustomLoginFields = async function() { // Handle custom TOTP field if (totp) { totp.setAttribute('kpxc-defined', 'totp'); - kpxcTOTPIcons.newIcon(totp, kpxc.databaseState, true); + kpxcTOTPIcons.newIcon(totp, kpxc.databaseState); } // If not all expected fields are identified, return an empty combination @@ -910,12 +951,12 @@ kpxc.fillFromTOTP = async function(target) { const el = target || document.activeElement; const credentialList = await kpxc.updateTOTPList(); - if (credentialList.length === 0) { + if (credentialList && credentialList.length === 0) { kpxcUI.createNotification('warning', tr('credentialsNoTOTPFound')); return; } - if (credentialList.length === 1) { + if (credentialList && credentialList.length === 1) { kpxc.fillTOTPFromUuid(el, credentialList[0].uuid); return; } @@ -942,18 +983,45 @@ kpxc.fillTOTPFromUuid = async function(el, uuid) { return; } - kpxc.setValue(el, totp); + kpxc.setTOTPValue(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); + kpxc.setTOTPValue(el, val); } } } }; +// Set normal or segmented TOTP value +kpxc.setTOTPValue = function(elem, val) { + if (kpxc.combinations.length === 0) { + return; + } + + for (const comb of kpxc.combinations) { + if (comb.totpInputs && comb.totpInputs.length === 6) { + kpxc.fillSegmentedTotp(elem, val, comb.totpInputs); + return; + } + } + + kpxc.setValue(elem, val); +}; + +// Fill TOTP in parts +kpxc.fillSegmentedTotp = function(elem, val, totpInputs) { + if (!totpInputs.includes(elem)) { + return; + } + + for (let i = 0; i < 6; ++i) { + kpxc.setValue(totpInputs[i], val[i]); + } +}; + // Fill requested from username icon kpxc.fillFromUsernameIcon = async function(combination) { await kpxc.receiveCredentialsIfNecessary(); diff --git a/keepassxc-browser/content/totp-autocomplete.js b/keepassxc-browser/content/totp-autocomplete.js index 463cb52..0ed6de7 100644 --- a/keepassxc-browser/content/totp-autocomplete.js +++ b/keepassxc-browser/content/totp-autocomplete.js @@ -27,7 +27,8 @@ TOTPAutocomplete.prototype.itemEnter = async function(index, elements) { }; TOTPAutocomplete.prototype.fillTotp = async function(index, uuid) { - const combination = await kpxcFields.getCombination(this.input); + const combination = await kpxcFields.getCombination(this.input, 'totp') + || await kpxcFields.getCombination(this.input, 'totpInputs'); combination.loginId = index; kpxc.fillTOTPFromUuid(this.input, uuid); }; diff --git a/keepassxc-browser/content/totp-field.js b/keepassxc-browser/content/totp-field.js index b2968c6..e4af242 100644 --- a/keepassxc-browser/content/totp-field.js +++ b/keepassxc-browser/content/totp-field.js @@ -13,14 +13,15 @@ const acceptedOTPFields = [ 'otp', 'token', 'twofa', + 'two-factor', 'twofactor' ]; var kpxcTOTPIcons = {}; kpxcTOTPIcons.icons = []; -kpxcTOTPIcons.newIcon = function(field, databaseState = DatabaseState.DISCONNECTED, forced = false) { - kpxcTOTPIcons.icons.push(new TOTPFieldIcon(field, databaseState, forced)); +kpxcTOTPIcons.newIcon = function(field, databaseState = DatabaseState.DISCONNECTED, segmented = false) { + kpxcTOTPIcons.icons.push(new TOTPFieldIcon(field, databaseState, segmented)); }; kpxcTOTPIcons.switchIcon = function(state) { @@ -75,25 +76,25 @@ kpxcTOTPIcons.isValid = function(field, forced) { }; class TOTPFieldIcon extends Icon { - constructor(field, databaseState = DatabaseState.DISCONNECTED, forced = false) { - super(field, databaseState); + constructor(field, databaseState = DatabaseState.DISCONNECTED, segmented = false) { + super(field, databaseState, segmented); - this.initField(field, forced); + this.initField(field, segmented); kpxcUI.monitorIconPosition(this); } } -TOTPFieldIcon.prototype.initField = function(field, forced) { +TOTPFieldIcon.prototype.initField = function(field, segmented) { // Observer the visibility if (this.observer) { this.observer.observe(field); } - this.createIcon(field); + this.createIcon(field, segmented); this.inputField = field; }; -TOTPFieldIcon.prototype.createIcon = function(field) { +TOTPFieldIcon.prototype.createIcon = function(field, segmented = false) { const className = (isFirefox() ? 'moz' : 'default'); // Size the icon dynamically, but not greater than 24 or smaller than 14 @@ -125,7 +126,7 @@ TOTPFieldIcon.prototype.createIcon = function(field) { kpxc.fillFromTOTP(field); }); - kpxcUI.setIconPosition(icon, field, this.rtl); + kpxcUI.setIconPosition(icon, field, this.rtl, segmented); this.icon = icon; const styleSheet = document.createElement('link'); diff --git a/keepassxc-browser/content/ui.js b/keepassxc-browser/content/ui.js index b6a6226..b8018d2 100644 --- a/keepassxc-browser/content/ui.js +++ b/keepassxc-browser/content/ui.js @@ -23,11 +23,12 @@ const Pixels = function(value) { // Basic icon class class Icon { - constructor(field, databaseState = DatabaseState.DISCONNECTED) { + constructor(field, databaseState = DatabaseState.DISCONNECTED, segmented = false) { this.databaseState = databaseState; this.icon = null; this.inputField = null; this.rtl = kpxcUI.isRTL(field); + this.segmented = segmented; try { this.observer = new IntersectionObserver((entries) => { @@ -103,7 +104,7 @@ kpxcUI.monitorIconPosition = function(iconClass) { kpxcUI.updateIconPosition = function(iconClass) { if (iconClass.inputField && iconClass.icon) { - kpxcUI.setIconPosition(iconClass.icon, iconClass.inputField, iconClass.rtl); + kpxcUI.setIconPosition(iconClass.icon, iconClass.inputField, iconClass.rtl, iconClass.segmented); } }; @@ -112,13 +113,18 @@ kpxcUI.calculateIconOffset = function(field, size) { return (offset < 0) ? 0 : offset; }; -kpxcUI.setIconPosition = function(icon, field, rtl = false) { +kpxcUI.setIconPosition = function(icon, field, rtl = false, segmented = false) { const rect = field.getBoundingClientRect(); const size = Number(icon.getAttribute('size')); const offset = kpxcUI.calculateIconOffset(field, size); - const left = kpxcUI.bodyStyle.position.toLowerCase() === 'relative' ? rect.left - kpxcUI.bodyRect.left : rect.left; + let left = kpxcUI.bodyStyle.position.toLowerCase() === 'relative' ? rect.left - kpxcUI.bodyRect.left : rect.left; const top = kpxcUI.bodyStyle.position.toLowerCase() === 'relative' ? rect.top - kpxcUI.bodyRect.top : rect.top; + // Add more space for the icon to show it at the right side of the field if TOTP fields are segmented + if (segmented) { + left += size + 10; + } + icon.style.top = Pixels(top + document.scrollingElement.scrollTop + offset + 1); icon.style.left = rtl ? Pixels((left + document.scrollingElement.scrollLeft) + offset) @@ -181,7 +187,7 @@ kpxcUI.updateFromIntersectionObserver = function(iconClass, entries) { // Wait for possible DOM animations setTimeout(() => { - kpxcUI.setIconPosition(iconClass.icon, entry.target, iconClass.rtl); + kpxcUI.setIconPosition(iconClass.icon, entry.target, iconClass.rtl, iconClass.segmented); }, 400); } }