From c6ce694dfe45a0736aa8b36d4856e047e95db2d6 Mon Sep 17 00:00:00 2001 From: antelle Date: Mon, 11 May 2020 17:24:06 +0200 Subject: [PATCH] displaying matching otp codes in entries --- .eslintrc | 7 ++- app/scripts/comp/format/kdbx-to-html.js | 2 - app/scripts/framework/views/view.js | 17 +++--- app/scripts/models/app-model.js | 59 ++++++++++++++++--- app/scripts/models/entry-model.js | 1 + .../models/external/external-device-model.js | 18 +++++- .../models/external/yubikey-otp-model.js | 13 +++- app/scripts/storage/storage-base.js | 6 +- app/scripts/views/details/details-fields.js | 38 ++++++++---- app/scripts/views/details/details-view.js | 17 +++++- app/scripts/views/import-csv-view.js | 1 - .../views/settings/settings-devices-view.js | 4 +- desktop/app.js | 44 ++++++-------- package-lock.json | 13 ++++ package.json | 1 + 15 files changed, 171 insertions(+), 70 deletions(-) diff --git a/.eslintrc b/.eslintrc index 8b4e6fc7..3fa0e794 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,5 +1,5 @@ { - "plugins": ["prettier", "import"], + "plugins": ["prettier", "import", "babel"], "extends": ["standard", "eslint:recommended", "plugin:prettier/recommended"], "rules": { "semi": ["error", "always"], @@ -15,7 +15,7 @@ "no-useless-escape": "off", "no-var": "error", "prefer-const": "error", - "no-unused-expressions": "error", + "no-unused-expressions": "off", "strict": ["error", "never"], "no-mixed-operators": "off", "prefer-promise-reject-errors": "off", @@ -49,7 +49,8 @@ "import/no-relative-parent-imports": "error", "import/first": "error", "import/no-namespace": "error", - "import/no-default-export": "error" + "import/no-default-export": "error", + "babel/no-unused-expressions": "error" }, "parserOptions": { "sourceType": "module", diff --git a/app/scripts/comp/format/kdbx-to-html.js b/app/scripts/comp/format/kdbx-to-html.js index afdd52fd..9aa19069 100644 --- a/app/scripts/comp/format/kdbx-to-html.js +++ b/app/scripts/comp/format/kdbx-to-html.js @@ -3,10 +3,8 @@ import kdbxweb from 'kdbxweb'; import { RuntimeInfo } from 'const/runtime-info'; import { Links } from 'const/links'; import { DateFormat } from 'util/formatting/date-format'; -import { MdToHtml } from 'util/formatting/md-to-html'; import { StringFormat } from 'util/formatting/string-format'; import { Locale } from 'util/locale'; -import { AppSettingsModel } from 'models/app-settings-model'; const Templates = { db: require('templates/export/db.hbs'), diff --git a/app/scripts/framework/views/view.js b/app/scripts/framework/views/view.js index 2538b1f3..aa62d672 100644 --- a/app/scripts/framework/views/view.js +++ b/app/scripts/framework/views/view.js @@ -59,7 +59,7 @@ class View extends EventEmitter { Tip.createTips(this.el); - this.debugLogger && this.debugLogger.debug('Render finished', this.debugLogger.ts(ts)); + this.debugLogger?.debug('Render finished', this.debugLogger.ts(ts)); return this; } @@ -129,7 +129,7 @@ class View extends EventEmitter { eventsMap[event].push({ selector, method }); } for (const [event, handlers] of Object.entries(eventsMap)) { - this.debugLogger && this.debugLogger.debug('Bind', 'view', event, handlers); + this.debugLogger?.debug('Bind', 'view', event, handlers); const listener = e => this.eventListener(e, handlers); this.eventListeners[event] = listener; this.el.addEventListener(event, listener); @@ -151,8 +151,7 @@ class View extends EventEmitter { this.unbindElementEvents(); for (const cfg of this.elementEventListeners) { const els = this.el.querySelectorAll(cfg.selector); - this.debugLogger && - this.debugLogger.debug('Bind', 'element', cfg.event, cfg.selector, els.length); + this.debugLogger?.debug('Bind', 'element', cfg.event, cfg.selector, els.length); cfg.listener = e => this.eventListener(e, [cfg]); for (const el of els) { el.addEventListener(cfg.event, cfg.listener); @@ -174,7 +173,7 @@ class View extends EventEmitter { } eventListener(e, handlers) { - this.debugLogger && this.debugLogger.debug('Listener fired', e.type); + this.debugLogger?.debug('Listener fired', e.type); for (const { selector, method } of handlers) { if (selector) { const closest = e.target.closest(selector); @@ -183,10 +182,10 @@ class View extends EventEmitter { } } if (!this[method]) { - this.debugLogger && this.debugLogger.debug('Method not defined', method); + this.debugLogger?.debug('Method not defined', method); continue; } - this.debugLogger && this.debugLogger.debug('Handling event', e.type, method); + this.debugLogger?.debug('Handling event', e.type, method); this[method](e); } } @@ -202,7 +201,7 @@ class View extends EventEmitter { this.el.remove(); this.removed = true; - this.debugLogger && this.debugLogger.debug('Remove'); + this.debugLogger?.debug('Remove'); } removeInnerViews() { @@ -236,7 +235,7 @@ class View extends EventEmitter { } toggle(visible) { - this.debugLogger && this.debugLogger.debug(visible ? 'Show' : 'Hide'); + this.debugLogger?.debug(visible ? 'Show' : 'Hide'); if (visible === undefined) { visible = this.hidden; } diff --git a/app/scripts/models/app-model.js b/app/scripts/models/app-model.js index b2945a7f..a3a3f080 100644 --- a/app/scripts/models/app-model.js +++ b/app/scripts/models/app-model.js @@ -302,9 +302,37 @@ class AppModel { getEntriesByFilter(filter) { const preparedFilter = this.prepareFilter(filter); const entries = new SearchResultCollection(); - this.files.forEach(file => { - file.forEachEntry(preparedFilter, entry => entries.push(entry)); - }); + + const devicesToMatchOtpEntries = this.files.filter(file => file.external); + + const matchedOtpEntrySet = this.settings.yubiKeyMatchEntries ? new Set() : undefined; + + this.files + .filter(file => !file.external) + .forEach(file => { + file.forEachEntry(preparedFilter, entry => { + if (matchedOtpEntrySet) { + for (const device of devicesToMatchOtpEntries) { + const matchingEntry = device.getMatchingEntry(entry); + if (matchingEntry) { + matchedOtpEntrySet.add(matchingEntry); + } + } + } + entries.push(entry); + }); + }); + + if (devicesToMatchOtpEntries.length) { + for (const device of devicesToMatchOtpEntries) { + device.forEachEntry(preparedFilter, entry => { + if (!matchedOtpEntrySet || !matchedOtpEntrySet.has(entry)) { + entries.push(entry); + } + }); + } + } + return entries; } @@ -382,14 +410,17 @@ class AppModel { getEntryTemplates() { const entryTemplates = []; this.files.forEach(file => { - file.forEachEntryTemplate && - file.forEachEntryTemplate(entry => { - entryTemplates.push({ file, entry }); - }); + file.forEachEntryTemplate?.(entry => { + entryTemplates.push({ file, entry }); + }); }); return entryTemplates; } + canCreateEntries() { + return this.files.some(f => f.active && !f.readOnly); + } + createNewEntry(args) { const sel = this.getFirstSelectedGroupForCreation(); if (args && args.template) { @@ -1185,8 +1216,18 @@ class AppModel { return device; } - canCreateEntries() { - return this.files.some(f => f.active && !f.readOnly); + getMatchingOtpEntry(entry) { + if (!this.settings.yubiKeyMatchEntries) { + return null; + } + for (const file of this.files) { + if (file.external) { + const matchingEntry = file.getMatchingEntry(entry); + if (matchingEntry) { + return matchingEntry; + } + } + } } } diff --git a/app/scripts/models/entry-model.js b/app/scripts/models/entry-model.js index 15511f31..d744c88c 100644 --- a/app/scripts/models/entry-model.js +++ b/app/scripts/models/entry-model.js @@ -58,6 +58,7 @@ class EntryModel extends Model { this.expires = entry.times.expires ? entry.times.expiryTime : undefined; this.expired = entry.times.expires && entry.times.expiryTime <= new Date(); this.historyLength = entry.history.length; + this.titleUserLower = `${this.title}:${this.user}`.toLowerCase(); this._buildCustomIcon(); this._buildSearchText(); this._buildSearchTags(); diff --git a/app/scripts/models/external/external-device-model.js b/app/scripts/models/external/external-device-model.js index 871699b8..6ace07a5 100644 --- a/app/scripts/models/external/external-device-model.js +++ b/app/scripts/models/external/external-device-model.js @@ -4,6 +4,7 @@ import { ExternalEntryCollection } from 'collections/external-entry-collection'; class ExternalDeviceModel extends Model { entries = new ExternalEntryCollection(); groups = []; + entryMap = {}; close() {} @@ -17,6 +18,20 @@ class ExternalDeviceModel extends Model { } } } + + entryId(title, user) { + return `${title}:${user}`.toLowerCase(); + } + + getMatchingEntry(entry) { + return this.entryMap[this.entryId(entry.title, entry.user)]; + } + + _buildEntryMap() { + for (const entry of this.entries) { + this.entryMap[entry.id.toLowerCase()] = entry; + } + } } ExternalDeviceModel.defineModelProperties({ @@ -28,7 +43,8 @@ ExternalDeviceModel.defineModelProperties({ groups: undefined, name: undefined, shortName: undefined, - deviceClassName: undefined + deviceClassName: undefined, + entryMap: undefined }); export { ExternalDeviceModel }; diff --git a/app/scripts/models/external/yubikey-otp-model.js b/app/scripts/models/external/yubikey-otp-model.js index 22c201e7..69d59bcd 100644 --- a/app/scripts/models/external/yubikey-otp-model.js +++ b/app/scripts/models/external/yubikey-otp-model.js @@ -100,6 +100,9 @@ class YubiKeyOtpModel extends ExternalOtpDeviceModel { if (yubiKeys && yubiKeys.length) { openNextYubiKey(); } else { + if (openSuccess) { + this._openComplete(); + } callback(openSuccess ? null : openErrors[0]); } }); @@ -135,7 +138,7 @@ class YubiKeyOtpModel extends ExternalOtpDeviceModel { this.entries.push( new ExternalOtpEntryModel({ - id: title + ':' + user, + id: this.entryId(title, user), device: this, deviceSubId: serial, icon: 'clock-o', @@ -145,8 +148,6 @@ class YubiKeyOtpModel extends ExternalOtpDeviceModel { }) ); } - this.active = true; - Events.on('usb-devices-changed', this.onUsbDevicesChanged); callback(); } }); @@ -187,6 +188,12 @@ class YubiKeyOtpModel extends ExternalOtpDeviceModel { }); } + _openComplete() { + this.active = true; + this._buildEntryMap(); + Events.on('usb-devices-changed', this.onUsbDevicesChanged); + } + cancelOpen() { logger.info('Cancel open'); Events.off('usb-devices-changed', this.onUsbDevicesChanged); diff --git a/app/scripts/storage/storage-base.js b/app/scripts/storage/storage-base.js index 602ec96d..2192c579 100644 --- a/app/scripts/storage/storage-base.js +++ b/app/scripts/storage/storage-base.js @@ -468,11 +468,11 @@ class StorageBase { if (token && token.error) { return callback && callback('OAuth code exchange error: ' + token.error); } - callback && callback(); + callback?.(); }, error: err => { this.logger.error('Error exchanging OAuth code', err); - callback && callback('OAuth code exchange error: ' + err); + callback?.('OAuth code exchange error: ' + err); } }); } @@ -507,7 +507,7 @@ class StorageBase { this._oauthToken = null; } this.logger.error('Error exchanging refresh token', err); - callback && callback('Error exchanging refresh token'); + callback?.('Error exchanging refresh token'); } }); } diff --git a/app/scripts/views/details/details-fields.js b/app/scripts/views/details/details-fields.js index 4270e397..82c3b4a5 100644 --- a/app/scripts/views/details/details-fields.js +++ b/app/scripts/views/details/details-fields.js @@ -16,6 +16,7 @@ import { FieldViewReadOnlyWithOptions } from 'views/fields/field-view-read-only- function createDetailsFields(detailsView) { const model = detailsView.model; + const otpEntry = detailsView.matchingOtpEntry; const fieldViews = []; const fieldViewsAside = []; @@ -185,18 +186,35 @@ function createDetailsFields(detailsView) { } }) ); + if (otpEntry) { + fieldViews.push( + new FieldViewOtp({ + name: '$otp', + title: Locale.detOtpField, + value() { + return otpEntry.otpGenerator; + }, + sequence: '{TOTP}', + readonly: true, + needsTouch: otpEntry.needsTouch, + deviceShortName: otpEntry.device.shortName + }) + ); + } for (const field of Object.keys(model.fields)) { if (field === 'otp' && model.otpGenerator) { - fieldViews.push( - FieldViewOtp({ - name: '$' + field, - title: field, - value() { - return model.otpGenerator; - }, - sequence: '{TOTP}' - }) - ); + if (!otpEntry) { + fieldViews.push( + FieldViewOtp({ + name: '$' + field, + title: field, + value() { + return model.otpGenerator; + }, + sequence: '{TOTP}' + }) + ); + } } else { fieldViews.push( new FieldViewCustom({ diff --git a/app/scripts/views/details/details-view.js b/app/scripts/views/details/details-view.js index c0744bd2..83893430 100644 --- a/app/scripts/views/details/details-view.js +++ b/app/scripts/views/details/details-view.js @@ -120,7 +120,6 @@ class DetailsView extends View { this.template = template; super.render(model); this.setSelectedColor(this.model.color); - this.model.initOtpGenerator(); this.addFieldViews(); this.createScroll({ root: this.$el.find('.details__body')[0], @@ -437,12 +436,25 @@ class DetailsView extends View { showEntry(entry) { this.model = entry; + this.initOtp(); this.render(); if (entry && !entry.title && entry.isJustCreated) { this.editTitle(); } } + initOtp() { + this.matchingOtpEntry = null; + if (!this.model || this.model.external) { + return; + } + + this.matchingOtpEntry = this.appModel.getMatchingOtpEntry(this.model); + + this.model.initOtpGenerator(); + this.matchingOtpEntry?.initOtpGenerator(); + } + copyKeyPress(editView) { if (!editView || this.isHidden()) { return false; @@ -948,7 +960,8 @@ class DetailsView extends View { autoType(sequence) { const entry = this.model; - if (entry.external && (!sequence || sequence.includes('{TOTP}'))) { + const hasOtp = sequence?.includes('{TOTP}') || (entry.external && !sequence); + if (hasOtp) { const otpField = this.getFieldView('$otp'); otpField.refreshOtp(err => { if (!err) { diff --git a/app/scripts/views/import-csv-view.js b/app/scripts/views/import-csv-view.js index da25bfbb..0b86d78b 100644 --- a/app/scripts/views/import-csv-view.js +++ b/app/scripts/views/import-csv-view.js @@ -3,7 +3,6 @@ import { View } from 'framework/views/view'; import { Scrollable } from 'framework/views/scrollable'; import template from 'templates/import-csv.hbs'; import { EntryModel } from 'models/entry-model'; -import { escape } from 'util/fn'; class ImportCsvView extends View { parent = '.app__body'; diff --git a/app/scripts/views/settings/settings-devices-view.js b/app/scripts/views/settings/settings-devices-view.js index 48e98201..02219437 100644 --- a/app/scripts/views/settings/settings-devices-view.js +++ b/app/scripts/views/settings/settings-devices-view.js @@ -1,9 +1,10 @@ +import { Events } from 'framework/events'; import { View } from 'framework/views/view'; import { AppSettingsModel } from 'models/app-settings-model'; import { YubiKeyOtpModel } from 'models/external/yubikey-otp-model'; -import template from 'templates/settings/settings-devices.hbs'; import { Links } from 'const/links'; import { UsbListener } from 'comp/app/usb-listener'; +import template from 'templates/settings/settings-devices.hbs'; class SettingsDevicesView extends View { template = template; @@ -61,6 +62,7 @@ class SettingsDevicesView extends View { changeYubiKeyMatchEntries(e) { AppSettingsModel.yubiKeyMatchEntries = e.target.checked; this.render(); + Events.emit('refresh'); } changeYubiKeyShowChalResp(e) { diff --git a/desktop/app.js b/desktop/app.js index ec48d1e1..15247110 100644 --- a/desktop/app.js +++ b/desktop/app.js @@ -20,7 +20,7 @@ if (!gotTheLock) { app.quit(); } -perfTimestamps && perfTimestamps.push({ name: 'single instance lock', ts: process.hrtime() }); +perfTimestamps?.push({ name: 'single instance lock', ts: process.hrtime() }); let openFile = process.argv.filter(arg => /\.kdbx$/i.test(arg))[0]; const userDataDir = @@ -52,7 +52,7 @@ const themeBgColors = { }; const defaultBgColor = '#282C34'; -perfTimestamps && perfTimestamps.push({ name: 'defining args', ts: process.hrtime() }); +perfTimestamps?.push({ name: 'defining args', ts: process.hrtime() }); setDevAppIcon(); setEnv(); @@ -71,7 +71,7 @@ app.on('window-all-closed', () => { } }); app.on('ready', () => { - perfTimestamps && perfTimestamps.push({ name: 'app on ready', ts: process.hrtime() }); + perfTimestamps?.push({ name: 'app on ready', ts: process.hrtime() }); appReady = true; setAppOptions(); setSystemAppearance(); @@ -155,7 +155,7 @@ app.setGlobalShortcuts = setGlobalShortcuts; function setAppOptions() { app.commandLine.appendSwitch('disable-background-timer-throttling'); - perfTimestamps && perfTimestamps.push({ name: 'setting app options', ts: process.hrtime() }); + perfTimestamps?.push({ name: 'setting app options', ts: process.hrtime() }); } function readAppSettings() { @@ -164,8 +164,7 @@ function readAppSettings() { } catch (e) { return null; } finally { - perfTimestamps && - perfTimestamps.push({ name: 'reading app settings', ts: process.hrtime() }); + perfTimestamps?.push({ name: 'reading app settings', ts: process.hrtime() }); } } @@ -175,8 +174,7 @@ function setSystemAppearance() { electron.systemPreferences.appLevelAppearance = 'dark'; } } - perfTimestamps && - perfTimestamps.push({ name: 'setting system appearance', ts: process.hrtime() }); + perfTimestamps?.push({ name: 'setting system appearance', ts: process.hrtime() }); } function createMainWindow() { @@ -198,17 +196,17 @@ function createMainWindow() { windowOptions.icon = path.join(__dirname, 'icon.png'); } mainWindow = new electron.BrowserWindow(windowOptions); - perfTimestamps && perfTimestamps.push({ name: 'creating main window', ts: process.hrtime() }); + perfTimestamps?.push({ name: 'creating main window', ts: process.hrtime() }); setMenu(); - perfTimestamps && perfTimestamps.push({ name: 'setting menu', ts: process.hrtime() }); + perfTimestamps?.push({ name: 'setting menu', ts: process.hrtime() }); mainWindow.loadURL(htmlPath); if (showDevToolsOnStart) { mainWindow.openDevTools({ mode: 'bottom' }); } mainWindow.once('ready-to-show', () => { - perfTimestamps && perfTimestamps.push({ name: 'main window ready', ts: process.hrtime() }); + perfTimestamps?.push({ name: 'main window ready', ts: process.hrtime() }); if (startMinimized) { emitRemoteEvent('launcher-started-minimized'); } else { @@ -216,7 +214,7 @@ function createMainWindow() { } ready = true; notifyOpenFile(); - perfTimestamps && perfTimestamps.push({ name: 'main window shown', ts: process.hrtime() }); + perfTimestamps?.push({ name: 'main window shown', ts: process.hrtime() }); reportStartProfile(); }); mainWindow.webContents.on('context-menu', onContextMenu); @@ -242,12 +240,10 @@ function createMainWindow() { mainWindow.on('session-end', () => { emitRemoteEvent('os-lock'); }); - perfTimestamps && - perfTimestamps.push({ name: 'configuring main window', ts: process.hrtime() }); + perfTimestamps?.push({ name: 'configuring main window', ts: process.hrtime() }); restoreMainWindowPosition(); - perfTimestamps && - perfTimestamps.push({ name: 'restoring main window position', ts: process.hrtime() }); + perfTimestamps?.push({ name: 'restoring main window position', ts: process.hrtime() }); } function restoreMainWindow() { @@ -464,8 +460,7 @@ function setGlobalShortcuts(appSettings) { } catch (e) {} } } - perfTimestamps && - perfTimestamps.push({ name: 'setting global shortcuts', ts: process.hrtime() }); + perfTimestamps?.push({ name: 'setting global shortcuts', ts: process.hrtime() }); } function subscribePowerEvents() { @@ -478,8 +473,7 @@ function subscribePowerEvents() { electron.powerMonitor.on('lock-screen', () => { emitRemoteEvent('os-lock'); }); - perfTimestamps && - perfTimestamps.push({ name: 'subscribing to power events', ts: process.hrtime() }); + perfTimestamps?.push({ name: 'subscribing to power events', ts: process.hrtime() }); } function setEnv() { @@ -491,7 +485,7 @@ function setEnv() { // https://github.com/electron/electron/issues/9046 process.env.XDG_CURRENT_DESKTOP = 'Unity'; } - perfTimestamps && perfTimestamps.push({ name: 'setting env', ts: process.hrtime() }); + perfTimestamps?.push({ name: 'setting env', ts: process.hrtime() }); } function restorePreferences() { @@ -524,7 +518,7 @@ function restorePreferences() { } } - perfTimestamps && perfTimestamps.push({ name: 'restoring preferences', ts: process.hrtime() }); + perfTimestamps?.push({ name: 'restoring preferences', ts: process.hrtime() }); } function deleteOldTempFiles() { @@ -541,8 +535,7 @@ function deleteOldTempFiles() { } app.oldTempFilesDeleted = true; // this is added to prevent file deletion on restart }, 1000); - perfTimestamps && - perfTimestamps.push({ name: 'deleting old temp files', ts: process.hrtime() }); + perfTimestamps?.push({ name: 'deleting old temp files', ts: process.hrtime() }); } function deleteRecursive(dir) { @@ -577,8 +570,7 @@ function hookRequestHeaders() { } callback({ requestHeaders: details.requestHeaders }); }); - perfTimestamps && - perfTimestamps.push({ name: 'setting request handlers', ts: process.hrtime() }); + perfTimestamps?.push({ name: 'setting request handlers', ts: process.hrtime() }); } // If a display is disconnected while KeeWeb is minimized, Electron does not diff --git a/package-lock.json b/package-lock.json index 48fc8c7e..bf077ef8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5519,6 +5519,14 @@ } } }, + "eslint-plugin-babel": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-babel/-/eslint-plugin-babel-5.3.0.tgz", + "integrity": "sha512-HPuNzSPE75O+SnxHIafbW5QB45r2w78fxqwK3HmjqIUoPfPzVrq6rD+CINU3yzoDSzEhUkX07VUphbF73Lth/w==", + "requires": { + "eslint-rule-composer": "^0.3.0" + } + }, "eslint-plugin-es": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-3.0.0.tgz", @@ -5637,6 +5645,11 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-standard/-/eslint-plugin-standard-4.0.1.tgz", "integrity": "sha512-v/KBnfyaOMPmZc/dmc6ozOdWqekGp7bBGq4jLAecEfPGmfKiWS4sA8sC0LqiV9w5qmXAtXVn4M3p1jSyhY85SQ==" }, + "eslint-rule-composer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz", + "integrity": "sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg==" + }, "eslint-scope": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.0.0.tgz", diff --git a/package.json b/package.json index 62ca11f2..b46f7b96 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "eslint": "^6.8.0", "eslint-config-prettier": "^6.11.0", "eslint-config-standard": "^14.1.1", + "eslint-plugin-babel": "^5.3.0", "eslint-plugin-import": "^2.20.2", "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^3.1.3",