diff --git a/app/scripts/comp/extension/protocol-impl.js b/app/scripts/comp/extension/protocol-impl.js index 996f9863..553d9490 100644 --- a/app/scripts/comp/extension/protocol-impl.js +++ b/app/scripts/comp/extension/protocol-impl.js @@ -15,6 +15,7 @@ import { RuntimeDataModel } from 'models/runtime-data-model'; import { AppSettingsModel } from 'models/app-settings-model'; import { Timeouts } from 'const/timeouts'; import { SelectEntryView } from 'views/select/select-entry-view'; +import { SelectEntryFieldView } from 'views/select/select-entry-field-view'; import { SelectEntryFilter } from 'comp/app/select-entry-filter'; const KeeWebAssociationId = 'KeeWeb'; @@ -289,6 +290,80 @@ function focusKeeWeb() { } } +async function findEntry(request, filterOptions) { + const payload = decryptRequest(request); + await checkContentRequestPermissions(request); + + if (!payload.url) { + throw new Error('Empty url'); + } + + const files = getAvailableFiles(request); + const client = getClient(request); + + const filter = new SelectEntryFilter( + { url: payload.url, title: payload.title }, + appModel, + files, + filterOptions + ); + filter.subdomains = false; + + let entries = filter.getEntries(); + + filter.subdomains = true; + + let entry; + + if (entries.length) { + if (entries.length === 1 && client.permissions.askGet === 'multiple') { + entry = entries[0]; + } + } else { + entries = filter.getEntries(); + + if (!entries.length) { + if (AppSettingsModel.extensionFocusIfEmpty) { + filter.useUrl = false; + if (filter.title) { + filter.useTitle = true; + entries = filter.getEntries(); + if (!entries.length) { + filter.useTitle = false; + } + } + } else { + throw makeError(Errors.noMatches); + } + } + } + + if (!entry) { + const extName = getHumanReadableExtensionName(client); + const topMessage = Locale.extensionSelectPasswordFor.replace('{}', extName); + const selectEntryView = new SelectEntryView({ filter, topMessage }); + + focusKeeWeb(); + + const inactivityTimer = setTimeout(() => { + selectEntryView.emit('result', undefined); + }, Timeouts.KeeWebConnectRequest); + + const result = await selectEntryView.showAndGetResult(); + + clearTimeout(inactivityTimer); + + entry = result?.entry; + if (!entry) { + throw makeError(Errors.userRejected); + } + } + + client.stats.passwordsRead++; + + return entry; +} + const ProtocolHandlers = { 'ping'({ data }) { return { data }; @@ -406,69 +481,7 @@ const ProtocolHandlers = { }, async 'get-logins'(request) { - const payload = decryptRequest(request); - await checkContentRequestPermissions(request); - - if (!payload.url) { - throw new Error('Empty url'); - } - - const files = getAvailableFiles(request); - const client = getClient(request); - - const filter = new SelectEntryFilter({ url: payload.url }, appModel, files); - filter.subdomains = false; - - let canReturnFirstEntry = false; - - let entries = filter.getEntries(); - - filter.subdomains = true; - - if (!entries.length) { - canReturnFirstEntry = false; - - entries = filter.getEntries(); - - if (!entries.length) { - if (AppSettingsModel.extensionFocusIfEmpty) { - filter.useUrl = false; - } else { - throw makeError(Errors.noMatches); - } - } - } - - let entry; - - if ( - canReturnFirstEntry && - entries.length === 1 && - client.permissions.askGet === 'multiple' - ) { - entry = entries[0]; - } else { - const extName = getHumanReadableExtensionName(client); - const topMessage = Locale.extensionSelectPasswordFor.replace('{}', extName); - const selectEntryView = new SelectEntryView({ filter, topMessage }); - - focusKeeWeb(); - - const inactivityTimer = setTimeout(() => { - selectEntryView.emit('result', undefined); - }, Timeouts.KeeWebConnectRequest); - - const result = await selectEntryView.showAndGetResult(); - - clearTimeout(inactivityTimer); - - entry = result?.entry; - if (!entry) { - throw makeError(Errors.userRejected); - } - } - - client.stats.passwordsRead++; + const entry = await findEntry(request); return encryptResponse(request, { success: 'true', @@ -490,6 +503,51 @@ const ProtocolHandlers = { }); }, + async 'get-totp-by-url'(request) { + const entry = await findEntry(request, { otp: true }); + + entry.initOtpGenerator(); + + if (!entry.otpGenerator) { + throw makeError(Errors.noMatches); + } + + let selectEntryFieldView; + if (entry.needsTouch) { + selectEntryFieldView = new SelectEntryFieldView({ + needsTouch: true, + deviceShortName: entry.device.shortName + }); + selectEntryFieldView.render(); + } + + const otpPromise = new Promise((resolve, reject) => { + selectEntryFieldView.on('result', () => reject(makeError(Errors.userRejected))); + entry.otpGenerator.next((err, otp) => { + if (otp) { + resolve(otp); + } else { + reject(err || makeError(Errors.userRejected)); + } + }); + }); + + let totp; + try { + totp = await otpPromise; + } finally { + if (selectEntryFieldView) { + selectEntryFieldView.remove(); + } + } + + return encryptResponse(request, { + success: 'true', + version: getVersion(request), + totp + }); + }, + async 'get-totp'(request) { decryptRequest(request); await checkContentRequestPermissions(request); diff --git a/app/scripts/locales/base.json b/app/scripts/locales/base.json index 4da3501e..502359f4 100644 --- a/app/scripts/locales/base.json +++ b/app/scripts/locales/base.json @@ -806,5 +806,7 @@ "selectEntryEnterHint": "use the highlighted entry", "selectEntryTypingHint": "Start typing to filter", "selectEntryContains": "Contains text", - "selectEntrySubdomains": "Subdomains" + "selectEntrySubdomains": "Subdomains", + "selectEntryFieldHeader": "Select field", + "selectEntryFieldTouch": "Press a button on your device to generate a one-time code." } diff --git a/app/scripts/models/otp-device/otp-device-entry-model.js b/app/scripts/models/otp-device/otp-device-entry-model.js index 63bbeea1..17aa0575 100644 --- a/app/scripts/models/otp-device/otp-device-entry-model.js +++ b/app/scripts/models/otp-device/otp-device-entry-model.js @@ -35,6 +35,10 @@ class OtpDeviceEntryModel extends Model { return this.fields; } + getAllUrls() { + return []; + } + getFieldValue(field) { return this.fields[field]; } diff --git a/app/scripts/util/entry-search.js b/app/scripts/util/entry-search.js index a58c23f3..2a3fe745 100644 --- a/app/scripts/util/entry-search.js +++ b/app/scripts/util/entry-search.js @@ -48,6 +48,15 @@ class EntrySearch { return false; } } + if (filter.otp) { + if ( + !this.model.fields.otp && + !this.model.fields['TOTP Seed'] && + this.model.backend !== 'otp-device' + ) { + return false; + } + } return true; } diff --git a/app/scripts/views/select/select-entry-field-view.js b/app/scripts/views/select/select-entry-field-view.js new file mode 100644 index 00000000..a81f2a9f --- /dev/null +++ b/app/scripts/views/select/select-entry-field-view.js @@ -0,0 +1,81 @@ +import { View } from 'framework/views/view'; +import { Events } from 'framework/events'; +import { Keys } from 'const/keys'; +import { Scrollable } from 'framework/views/scrollable'; +import template from 'templates/select/select-entry-field.hbs'; + +class SelectEntryFieldView extends View { + parent = 'body'; + modal = 'select-entry-field'; + + template = template; + + events = { + 'click .select-entry-field__item': 'itemClicked', + 'click .select-entry-field__cancel-btn': 'cancelClicked' + }; + + result = null; + + constructor(model) { + super(model); + this.initScroll(); + this.listenTo(Events, 'main-window-blur', this.mainWindowBlur); + this.setupKeys(); + } + + setupKeys() { + this.onKey(Keys.DOM_VK_ESCAPE, this.escPressed, false, 'select-entry-field'); + this.onKey(Keys.DOM_VK_RETURN, this.enterPressed, false, 'select-entry-field'); + } + + render() { + super.render(this.model); + + document.activeElement.blur(); + + const scrollRoot = this.el.querySelector('.select-entry-field__items'); + if (scrollRoot) { + this.createScroll({ + root: scrollRoot, + scroller: this.el.querySelector('.scroller'), + bar: this.el.querySelector('.scroller__bar') + }); + } + } + + cancelAndClose() { + this.result = null; + this.emit('result', this.result); + } + + escPressed() { + this.cancelAndClose(); + } + + enterPressed() { + this.closeWithResult(); + } + + mainWindowBlur() { + this.emit('result', undefined); + } + + showAndGetResult() { + this.render(); + return new Promise((resolve) => { + this.once('result', (result) => { + this.remove(); + resolve(result); + }); + }); + } + + cancelClicked() { + this.cancelAndClose(); + } +} + +Object.assign(SelectEntryFieldView.prototype, Scrollable); + +export { SelectEntryFieldView }; diff --git a/app/styles/areas/_select-entry.scss b/app/styles/areas/_select-entry.scss index f21ad289..e336ddf5 100644 --- a/app/styles/areas/_select-entry.scss +++ b/app/styles/areas/_select-entry.scss @@ -1,4 +1,5 @@ -.select-entry { +.select-entry, +.select-entry-field { @include position(absolute, 0 null null 0); @include size(100%); background-color: var(--background-color); @@ -64,6 +65,16 @@ justify-content: center; } } + &__large-text { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + &__large-icon { + font-size: $modal-icon-size; + } &__table { flex: 1; width: 100%; diff --git a/app/templates/select/select-entry-field.hbs b/app/templates/select/select-entry-field.hbs new file mode 100644 index 00000000..582fd19c --- /dev/null +++ b/app/templates/select/select-entry-field.hbs @@ -0,0 +1,23 @@ +
+ {{#if needsTouch}} +
+

{{#res 'detOtpTouch'}}{{deviceShortName}}{{/res}}

+
+
+ +

{{res 'selectEntryFieldTouch'}}

+
+ {{else}} +
+

{{res 'selectEntryFieldHeader'}}

+
+
+
+
+
+
+ {{/if}} +
+ +
+