mirror of https://github.com/keeweb/keeweb
usb 101
parent
73ac7496e2
commit
035a4485b7
|
@ -0,0 +1 @@
|
|||
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M465.69 311.917V200.083H363.175v111.834zm-69.897-18.639a4.66 4.66 0 01-4.659-4.659v-65.237a4.66 4.66 0 014.659-4.66h37.278a4.66 4.66 0 014.66 4.66v65.237a4.66 4.66 0 01-4.66 4.66h-37.278z"/><path d="M400.453 251.34v-23.299h27.959v23.299zm0 32.618v-23.299h27.959v23.299zM46.31 187.656v136.688c0 8.564 6.968 15.532 15.532 15.532h21.746V204.742a4.66 4.66 0 019.319 0v135.134h245.415c8.565 0 15.533-6.968 15.533-15.532V187.656c0-8.564-6.968-15.532-15.533-15.532H61.842c-8.565 0-15.532 6.968-15.532 15.532zm241.936 68.6c.122 9.637-7.529 17.293-17.166 17.416-9.359-.354-17.016-8.006-16.662-17.366-.122-9.636 7.529-17.293 16.889-16.938 9.637-.124 17.293 7.528 16.939 16.888zm-41.921-28.528a4.658 4.658 0 011.679 6.372c-3.865 6.631-5.909 14.266-5.909 22.082v.014c-.143 8.152 1.746 15.601 5.603 22.206a4.66 4.66 0 01-8.048 4.7c-4.73-8.099-7.057-17.196-6.873-26.984v-.021c0-9.381 2.48-18.637 7.176-26.69a4.657 4.657 0 016.372-1.679zm-23.7-17.48a4.659 4.659 0 011.395 6.441c-7.725 11.993-11.78 25.622-11.613 39.406-.164 14.287 3.794 28.031 11.327 39.791a4.66 4.66 0 01-7.849 5.027c-8.511-13.288-12.909-28.806-12.799-44.815-.13-15.503 4.387-30.927 13.099-44.455a4.66 4.66 0 016.44-1.395zm-22.433-16.741a4.656 4.656 0 011.272 6.466c-11.136 16.59-17.074 35.936-17.174 55.945-.244 20.163 5.497 39.619 16.606 56.34a4.66 4.66 0 01-7.762 5.158c-11.901-17.914-18.172-38.71-18.172-60.224 0-.452.003-.901.008-1.354.108-21.815 6.594-42.941 18.756-61.059a4.66 4.66 0 016.466-1.272z"/></svg>
|
After Width: | Height: | Size: 1.5 KiB |
|
@ -5,6 +5,7 @@ import { AppRightsChecker } from 'comp/app/app-rights-checker';
|
|||
import { ExportApi } from 'comp/app/export-api';
|
||||
import { SingleInstanceChecker } from 'comp/app/single-instance-checker';
|
||||
import { Updater } from 'comp/app/updater';
|
||||
import { UsbListener } from 'comp/app/usb-listener';
|
||||
import { AuthReceiver } from 'comp/browser/auth-receiver';
|
||||
import { FeatureTester } from 'comp/browser/feature-tester';
|
||||
import { FocusDetector } from 'comp/browser/focus-detector';
|
||||
|
@ -88,7 +89,6 @@ ready(() => {
|
|||
|
||||
function initModules() {
|
||||
KeyHandler.init();
|
||||
IdleTracker.init();
|
||||
PopupNotifier.init();
|
||||
KdbxwebInit.init();
|
||||
FocusDetector.init();
|
||||
|
@ -157,6 +157,7 @@ ready(() => {
|
|||
});
|
||||
} else {
|
||||
showView();
|
||||
return new Promise(resolve => requestAnimationFrame(resolve));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -165,7 +166,11 @@ ready(() => {
|
|||
Updater.init();
|
||||
SingleInstanceChecker.init();
|
||||
AppRightsChecker.init();
|
||||
setTimeout(() => PluginManager.runAutoUpdate(), Timeouts.AutoUpdatePluginsAfterStart);
|
||||
IdleTracker.init();
|
||||
UsbListener.init();
|
||||
setTimeout(() => {
|
||||
PluginManager.runAutoUpdate();
|
||||
}, Timeouts.AutoUpdatePluginsAfterStart);
|
||||
}
|
||||
|
||||
function showView() {
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import { Collection } from 'framework/collection';
|
||||
import { ExternalEntryModel } from 'models/external/external-entry-model';
|
||||
|
||||
class ExternalEntryCollection extends Collection {
|
||||
static model = ExternalEntryModel;
|
||||
}
|
||||
|
||||
export { ExternalEntryCollection };
|
|
@ -1,8 +1,8 @@
|
|||
import { Collection } from 'framework/collection';
|
||||
import { FileModel } from 'models/file-model';
|
||||
import { Model } from 'framework/model';
|
||||
|
||||
class FileCollection extends Collection {
|
||||
static model = FileModel;
|
||||
static model = Model;
|
||||
|
||||
hasOpenFiles() {
|
||||
return this.some(file => file.active);
|
||||
|
|
|
@ -0,0 +1,112 @@
|
|||
import EventEmitter from 'events';
|
||||
import { Events } from 'framework/events';
|
||||
import { Logger } from 'util/logger';
|
||||
import { Launcher } from 'comp/launcher';
|
||||
import { AppSettingsModel } from 'models/app-settings-model';
|
||||
|
||||
const logger = new Logger('usb-listener');
|
||||
|
||||
// https://support.yubico.com/support/solutions/articles/15000028104-yubikey-usb-id-values
|
||||
const YubiKeyVendorId = 0x1050;
|
||||
|
||||
const UsbListener = {
|
||||
attachedYubiKeys: 0,
|
||||
|
||||
init() {
|
||||
if (!Launcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
AppSettingsModel.on('change:enableUsb', (model, enabled) => {
|
||||
if (enabled) {
|
||||
this.start();
|
||||
} else {
|
||||
this.stop();
|
||||
}
|
||||
});
|
||||
|
||||
if (AppSettingsModel.enableUsb) {
|
||||
this.start();
|
||||
}
|
||||
},
|
||||
|
||||
start() {
|
||||
logger.info('Starting USB listener');
|
||||
|
||||
if (this.usb) {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
try {
|
||||
const ts = logger.ts();
|
||||
|
||||
const usb = Launcher.req(`@keeweb/keeweb-native-modules/usb.${process.platform}.node`);
|
||||
|
||||
Object.keys(EventEmitter.prototype).forEach(key => {
|
||||
usb[key] = EventEmitter.prototype[key];
|
||||
});
|
||||
|
||||
this.usb = usb;
|
||||
|
||||
this.listen();
|
||||
|
||||
this.attachedYubiKeys = usb
|
||||
.getDeviceList()
|
||||
.filter(this.isYubiKey)
|
||||
.map(device => ({ device }));
|
||||
|
||||
if (this.attachedYubiKeys.length > 0) {
|
||||
logger.info(`${this.attachedYubiKeys.length} YubiKey(s) found`, logger.ts(ts));
|
||||
Events.emit('usb-devices-changed');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error loading USB module', e);
|
||||
}
|
||||
},
|
||||
|
||||
stop() {
|
||||
logger.info('Stopping USB listener');
|
||||
|
||||
if (this.usb) {
|
||||
this.usb._disableHotplugEvents();
|
||||
|
||||
if (this.attachedYubiKeys.length) {
|
||||
this.attachedYubiKeys = [];
|
||||
Events.emit('usb-devices-changed');
|
||||
}
|
||||
|
||||
this.usb = null;
|
||||
}
|
||||
},
|
||||
|
||||
listen() {
|
||||
this.usb.on('attach', device => {
|
||||
if (this.isYubiKey(device)) {
|
||||
this.attachedYubiKeys.push({ device });
|
||||
logger.info(`YubiKey attached, total: ${this.attachedYubiKeys.length}`, device);
|
||||
Events.emit('usb-devices-changed');
|
||||
}
|
||||
});
|
||||
|
||||
this.usb.on('detach', device => {
|
||||
if (this.isYubiKey(device)) {
|
||||
const index = this.attachedYubiKeys.findIndex(
|
||||
yk => yk.device.deviceAddress === device.deviceAddress
|
||||
);
|
||||
if (index >= 0) {
|
||||
this.attachedYubiKeys.splice(index, 1);
|
||||
logger.info(`YubiKey detached, total: ${this.attachedYubiKeys.length}`, device);
|
||||
Events.emit('usb-devices-changed');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.usb._enableHotplugEvents();
|
||||
},
|
||||
|
||||
isYubiKey(device) {
|
||||
return device.deviceDescriptor.idVendor === YubiKeyVendorId;
|
||||
}
|
||||
};
|
||||
|
||||
export { UsbListener };
|
|
@ -242,13 +242,13 @@ const Launcher = {
|
|||
stdout = stdout.trim();
|
||||
stderr = stderr.trim();
|
||||
const msg = 'spawn ' + config.cmd + ': ' + code + ', ' + logger.ts(ts);
|
||||
if (code) {
|
||||
if (code !== 0) {
|
||||
logger.error(msg + '\n' + stdout + '\n' + stderr);
|
||||
} else {
|
||||
logger.info(msg + (stdout ? '\n' + stdout : ''));
|
||||
logger.info(msg + (stdout && !config.noStdOutLogging ? '\n' + stdout : ''));
|
||||
}
|
||||
if (complete) {
|
||||
complete(code ? 'Exit code ' + code : null, stdout, code);
|
||||
complete(code !== 0 ? 'Exit code ' + code : null, stdout, code);
|
||||
complete = null;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -2,7 +2,7 @@ import Handlebars from 'hbs';
|
|||
|
||||
Handlebars.registerHelper('svg', (name, cls) => {
|
||||
const icon = require(`svg/${name}.svg`).default;
|
||||
if (cls) {
|
||||
if (typeof cls === 'string') {
|
||||
return `<svg class="${cls}"` + icon.substr(4);
|
||||
}
|
||||
return icon;
|
||||
|
|
|
@ -56,9 +56,11 @@
|
|||
"ctrlKey": "ctrl",
|
||||
"shiftKey": "shift",
|
||||
"altKey": "alt",
|
||||
"error": "error",
|
||||
|
||||
"cache": "cache",
|
||||
"file": "file",
|
||||
"device": "device",
|
||||
"webdav": "WebDAV",
|
||||
"dropbox": "Dropbox",
|
||||
"gdrive": "Google Drive",
|
||||
|
@ -69,6 +71,7 @@
|
|||
"menuTrash": "Trash",
|
||||
"menuSetGeneral": "General",
|
||||
"menuSetAbout": "About",
|
||||
"menuSetDevices": "Devices",
|
||||
"menuAlertNoTags": "No tags",
|
||||
"menuAlertNoTagsBody": "You can add new tags while editing fields, in the Tags section.",
|
||||
"menuEmptyTrash": "Empty Trash",
|
||||
|
@ -314,6 +317,10 @@
|
|||
"detOtpQrErrorBody": "Sorry, we could not read the QR code, please try once again or contact the app authors with error details.",
|
||||
"detOtpQrWrong": "Wrong QR code",
|
||||
"detOtpQrWrongBody": "Your QR code was successfully scanned but it doesn't contain one-time password data.",
|
||||
"detOtpField": "One-time code",
|
||||
"detOtpClickToTouch": "Click to generate",
|
||||
"detOtpGenerating": "Generating...",
|
||||
"detOtpTouch": "Touch your {}",
|
||||
"detLockField": "Lock this field, so its content isn't searchable and visible. Displaying the content requires explicitly clicking it.",
|
||||
"detUnlockField": "Unlock this field, making its content searchable and visible immediately",
|
||||
"detRevealField": "Reveal",
|
||||
|
@ -572,6 +579,9 @@
|
|||
"setPlAutoUpdate": "Update automatically",
|
||||
"setPlLoadGallery": "Load plugin gallery",
|
||||
|
||||
"setDevicesTitle": "Devices",
|
||||
"setDevicesEnableUsb": "Enable interaction with USB devices",
|
||||
|
||||
"setAboutTitle": "About",
|
||||
"setAboutBuilt": "This app is built with these awesome tools",
|
||||
"setAboutLic": "License",
|
||||
|
|
|
@ -12,6 +12,7 @@ import { EntryModel } from 'models/entry-model';
|
|||
import { FileInfoModel } from 'models/file-info-model';
|
||||
import { FileModel } from 'models/file-model';
|
||||
import { GroupModel } from 'models/group-model';
|
||||
import { YubiKeyOtpModel } from 'models/external/yubikey-otp-model';
|
||||
import { MenuModel } from 'models/menu/menu-model';
|
||||
import { PluginManager } from 'plugins/plugin-manager';
|
||||
import { Features } from 'util/features';
|
||||
|
@ -229,7 +230,7 @@ class AppModel {
|
|||
}
|
||||
|
||||
renameTag(from, to) {
|
||||
this.files.forEach(file => file.renameTag(from, to));
|
||||
this.files.forEach(file => file.renameTag && file.renameTag(from, to));
|
||||
this.updateTags();
|
||||
}
|
||||
|
||||
|
@ -259,7 +260,7 @@ class AppModel {
|
|||
}
|
||||
|
||||
emptyTrash() {
|
||||
this.files.forEach(file => file.emptyTrash());
|
||||
this.files.forEach(file => file.emptyTrash && file.emptyTrash());
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
|
@ -316,7 +317,7 @@ class AppModel {
|
|||
|
||||
addTrashGroups(collection) {
|
||||
this.files.forEach(file => {
|
||||
const trashGroup = file.getTrashGroup();
|
||||
const trashGroup = file.getTrashGroup && file.getTrashGroup();
|
||||
if (trashGroup) {
|
||||
trashGroup.getOwnSubGroups().forEach(group => {
|
||||
collection.unshift(GroupModel.fromGroup(group, file, trashGroup));
|
||||
|
@ -388,9 +389,10 @@ class AppModel {
|
|||
getEntryTemplates() {
|
||||
const entryTemplates = [];
|
||||
this.files.forEach(file => {
|
||||
file.forEachEntryTemplate(entry => {
|
||||
entryTemplates.push({ file, entry });
|
||||
});
|
||||
file.forEachEntryTemplate &&
|
||||
file.forEachEntryTemplate(entry => {
|
||||
entryTemplates.push({ file, entry });
|
||||
});
|
||||
});
|
||||
return entryTemplates;
|
||||
}
|
||||
|
@ -1176,6 +1178,21 @@ class AppModel {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
openOtpDevice(callback) {
|
||||
const device = new YubiKeyOtpModel({
|
||||
id: 'yubikey',
|
||||
name: 'YubiKey 5',
|
||||
active: true
|
||||
});
|
||||
device.open(err => {
|
||||
if (!err) {
|
||||
this.addFile(device);
|
||||
}
|
||||
callback(err);
|
||||
});
|
||||
return device;
|
||||
}
|
||||
}
|
||||
|
||||
export { AppModel };
|
||||
|
|
|
@ -64,6 +64,7 @@ AppSettingsModel.defineModelProperties(
|
|||
cacheConfigSettings: false,
|
||||
allowIframes: false,
|
||||
useGroupIconForEntries: false,
|
||||
enableUsb: true,
|
||||
|
||||
canOpen: true,
|
||||
canOpenDemo: true,
|
||||
|
@ -75,6 +76,7 @@ AppSettingsModel.defineModelProperties(
|
|||
canExportXml: true,
|
||||
canExportHtml: true,
|
||||
canSaveTo: true,
|
||||
canOpenOtpDevice: true,
|
||||
|
||||
dropbox: true,
|
||||
webdav: true,
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import { Model } from 'framework/model';
|
||||
import { ExternalEntryCollection } from 'collections/external-entry-collection';
|
||||
|
||||
class ExternalDeviceModel extends Model {
|
||||
entries = new ExternalEntryCollection();
|
||||
groups = [];
|
||||
|
||||
get external() {
|
||||
return true;
|
||||
}
|
||||
|
||||
close() {}
|
||||
|
||||
forEachEntry(filter, callback) {
|
||||
for (const entry of this.entries.filter(entry =>
|
||||
entry.title.toLowerCase().includes(filter.textLower)
|
||||
)) {
|
||||
callback(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExternalDeviceModel.defineModelProperties({
|
||||
id: '',
|
||||
active: false,
|
||||
entries: undefined,
|
||||
groups: undefined,
|
||||
name: undefined,
|
||||
shortName: undefined
|
||||
});
|
||||
|
||||
export { ExternalDeviceModel };
|
|
@ -0,0 +1,22 @@
|
|||
import { Model } from 'framework/model';
|
||||
|
||||
class ExternalEntryModel extends Model {
|
||||
tags = [];
|
||||
fields = {};
|
||||
|
||||
get external() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
ExternalEntryModel.defineModelProperties({
|
||||
id: '',
|
||||
device: undefined,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
fields: undefined,
|
||||
icon: undefined,
|
||||
tags: undefined
|
||||
});
|
||||
|
||||
export { ExternalEntryModel };
|
|
@ -0,0 +1,17 @@
|
|||
import { ExternalDeviceModel } from 'models/external/external-device-model';
|
||||
|
||||
class ExternalOtpDeviceModel extends ExternalDeviceModel {
|
||||
open(callback) {
|
||||
throw 'Not implemented';
|
||||
}
|
||||
|
||||
cancelOpen() {
|
||||
throw 'Not implemented';
|
||||
}
|
||||
|
||||
getOtp(callback) {
|
||||
throw 'Not implemented';
|
||||
}
|
||||
}
|
||||
|
||||
export { ExternalOtpDeviceModel };
|
|
@ -0,0 +1,28 @@
|
|||
import { ExternalEntryModel } from 'models/external/external-entry-model';
|
||||
|
||||
class ExternalOtpEntryModel extends ExternalEntryModel {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.description = props.user;
|
||||
}
|
||||
|
||||
initOtpGenerator() {
|
||||
this.otpGenerator = {
|
||||
next: callback => {
|
||||
this.otpState = this.device.getOtp(this, callback);
|
||||
},
|
||||
cancel: () => {
|
||||
this.device.cancelGetOtp(this, this.otpState);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
ExternalOtpEntryModel.defineModelProperties({
|
||||
user: undefined,
|
||||
otpGenerator: undefined,
|
||||
needsTouch: false,
|
||||
otpState: null
|
||||
});
|
||||
|
||||
export { ExternalOtpEntryModel };
|
|
@ -0,0 +1,83 @@
|
|||
import { ExternalOtpDeviceModel } from 'models/external/external-otp-device-model';
|
||||
import { ExternalOtpEntryModel } from 'models/external/external-otp-entry-model';
|
||||
import { Launcher } from 'comp/launcher';
|
||||
|
||||
class YubiKeyOtpModel extends ExternalOtpDeviceModel {
|
||||
constructor(props) {
|
||||
super({
|
||||
shortName: 'YubiKey',
|
||||
...props
|
||||
});
|
||||
}
|
||||
|
||||
open(callback) {
|
||||
this.openProcess = Launcher.spawn({
|
||||
cmd: 'ykman',
|
||||
args: ['oath', 'code'],
|
||||
noStdOutLogging: true,
|
||||
complete: (err, stdout, code) => {
|
||||
this.openProcess = null;
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
for (const line of stdout.split('\n')) {
|
||||
const match = line.match(/^(.*?):(.*?)\s+(.*)$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const [, title, user, code] = match;
|
||||
const needsTouch = !code.match(/^\d+$/);
|
||||
|
||||
this.entries.push(
|
||||
new ExternalOtpEntryModel({
|
||||
id: title + ':' + user,
|
||||
device: this,
|
||||
icon: 'clock-o',
|
||||
title,
|
||||
user,
|
||||
needsTouch
|
||||
})
|
||||
);
|
||||
}
|
||||
callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancelOpen() {
|
||||
this.openAborted = true;
|
||||
if (this.openProcess) {
|
||||
this.openProcess.kill();
|
||||
}
|
||||
}
|
||||
|
||||
getOtp(entry, callback) {
|
||||
const msPeriod = 30000;
|
||||
const timeLeft = msPeriod - (Date.now() % msPeriod) + 500;
|
||||
return Launcher.spawn({
|
||||
cmd: 'ykman',
|
||||
args: ['oath', 'code', '--single', `${entry.title}:${entry.user}`],
|
||||
noStdOutLogging: true,
|
||||
complete: (err, stdout) => {
|
||||
if (err) {
|
||||
return callback(err, null, timeLeft);
|
||||
}
|
||||
const otp = stdout.trim();
|
||||
callback(null, otp, timeLeft);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancelGetOtp(entry, ps) {
|
||||
if (ps) {
|
||||
ps.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
YubiKeyOtpModel.defineModelProperties({
|
||||
openProcess: null,
|
||||
openAborted: false
|
||||
});
|
||||
|
||||
export { YubiKeyOtpModel };
|
|
@ -7,6 +7,7 @@ import { GroupsMenuModel } from 'models/menu/groups-menu-model';
|
|||
import { MenuSectionModel } from 'models/menu/menu-section-model';
|
||||
import { StringFormat } from 'util/formatting/string-format';
|
||||
import { Locale } from 'util/locale';
|
||||
import { Launcher } from 'comp/launcher';
|
||||
|
||||
class MenuModel extends Model {
|
||||
constructor() {
|
||||
|
@ -73,6 +74,11 @@ class MenuModel extends Model {
|
|||
this.pluginsSection = new MenuSectionModel([
|
||||
{ locTitle: 'plugins', icon: 'puzzle-piece', page: 'plugins' }
|
||||
]);
|
||||
if (Launcher) {
|
||||
this.devicesSection = new MenuSectionModel([
|
||||
{ locTitle: 'menuSetDevices', icon: 'usb', page: 'devices' }
|
||||
]);
|
||||
}
|
||||
this.aboutSection = new MenuSectionModel([
|
||||
{ locTitle: 'menuSetAbout', icon: 'info', page: 'about' }
|
||||
]);
|
||||
|
@ -81,14 +87,17 @@ class MenuModel extends Model {
|
|||
]);
|
||||
this.filesSection = new MenuSectionModel();
|
||||
this.filesSection.set({ scrollable: true, grow: true });
|
||||
this.menus.settings = new MenuSectionCollection([
|
||||
this.generalSection,
|
||||
this.shortcutsSection,
|
||||
this.pluginsSection,
|
||||
this.aboutSection,
|
||||
this.helpSection,
|
||||
this.filesSection
|
||||
]);
|
||||
this.menus.settings = new MenuSectionCollection(
|
||||
[
|
||||
this.generalSection,
|
||||
this.shortcutsSection,
|
||||
this.pluginsSection,
|
||||
this.devicesSection,
|
||||
this.aboutSection,
|
||||
this.helpSection,
|
||||
this.filesSection
|
||||
].filter(s => s)
|
||||
);
|
||||
this.sections = this.menus.app;
|
||||
|
||||
Events.on('set-locale', this._setLocale.bind(this));
|
||||
|
|
|
@ -12,8 +12,11 @@ EntryPresenter.prototype = {
|
|||
present(item) {
|
||||
if (item.entry) {
|
||||
this.entry = item;
|
||||
} else {
|
||||
} else if (item.group) {
|
||||
this.group = item;
|
||||
} else if (item.external) {
|
||||
this.entry = item;
|
||||
this.external = true;
|
||||
}
|
||||
return this;
|
||||
},
|
||||
|
@ -68,6 +71,9 @@ EntryPresenter.prototype = {
|
|||
if (!this.entry) {
|
||||
return '[' + Locale.listGroup + ']';
|
||||
}
|
||||
if (this.external) {
|
||||
return this.entry.description;
|
||||
}
|
||||
switch (this.descField) {
|
||||
case 'website':
|
||||
return this.url || '(' + Locale.listNoWebsite + ')';
|
||||
|
|
|
@ -56,7 +56,7 @@ Otp.prototype.next = function(callback) {
|
|||
this.hmac(data, (sig, err) => {
|
||||
if (!sig) {
|
||||
logger.error('OTP calculation error', err);
|
||||
return callback();
|
||||
return callback(err);
|
||||
}
|
||||
sig = new DataView(sig);
|
||||
const offset = sig.getInt8(sig.byteLength - 1) & 0xf;
|
||||
|
@ -67,7 +67,7 @@ Otp.prototype.next = function(callback) {
|
|||
} else {
|
||||
pass = Otp.hmacToDigits(hmac, this.digits);
|
||||
}
|
||||
callback(pass, timeLeft);
|
||||
callback(null, pass, timeLeft);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -152,184 +152,225 @@ class DetailsView extends View {
|
|||
|
||||
addFieldViews() {
|
||||
const model = this.model;
|
||||
if (model.isJustCreated && this.appModel.files.length > 1) {
|
||||
const fileNames = this.appModel.files.map(function(file) {
|
||||
return { id: file.id, value: file.name, selected: file === this.model.file };
|
||||
}, this);
|
||||
this.fileEditView = new FieldViewSelect({
|
||||
name: '$File',
|
||||
title: StringFormat.capFirst(Locale.file),
|
||||
value() {
|
||||
return fileNames;
|
||||
}
|
||||
});
|
||||
this.fieldViews.push(this.fileEditView);
|
||||
} else {
|
||||
this.fieldViews.push(
|
||||
const fieldViews = [];
|
||||
const fieldViewsAside = [];
|
||||
if (this.model.external) {
|
||||
fieldViewsAside.push(
|
||||
new FieldViewReadOnly({
|
||||
name: 'File',
|
||||
title: StringFormat.capFirst(Locale.file),
|
||||
name: 'Device',
|
||||
title: StringFormat.capFirst(Locale.device),
|
||||
value() {
|
||||
return model.fileName;
|
||||
return model.device.name;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
this.userEditView = new FieldViewAutocomplete({
|
||||
name: '$UserName',
|
||||
title: StringFormat.capFirst(Locale.user),
|
||||
value() {
|
||||
return model.user;
|
||||
},
|
||||
getCompletions: this.getUserNameCompletions.bind(this),
|
||||
sequence: '{USERNAME}'
|
||||
});
|
||||
this.fieldViews.push(this.userEditView);
|
||||
this.passEditView = new FieldViewText({
|
||||
name: '$Password',
|
||||
title: StringFormat.capFirst(Locale.password),
|
||||
canGen: true,
|
||||
value() {
|
||||
return model.password;
|
||||
},
|
||||
sequence: '{PASSWORD}'
|
||||
});
|
||||
this.fieldViews.push(this.passEditView);
|
||||
this.urlEditView = new FieldViewUrl({
|
||||
name: '$URL',
|
||||
title: StringFormat.capFirst(Locale.website),
|
||||
value() {
|
||||
return model.url;
|
||||
},
|
||||
sequence: '{URL}'
|
||||
});
|
||||
this.fieldViews.push(this.urlEditView);
|
||||
this.fieldViews.push(
|
||||
new FieldViewText({
|
||||
name: '$Notes',
|
||||
title: StringFormat.capFirst(Locale.notes),
|
||||
multiline: 'true',
|
||||
markdown: true,
|
||||
value() {
|
||||
return model.notes;
|
||||
},
|
||||
sequence: '{NOTES}'
|
||||
})
|
||||
);
|
||||
this.fieldViews.push(
|
||||
new FieldViewTags({
|
||||
name: 'Tags',
|
||||
title: StringFormat.capFirst(Locale.tags),
|
||||
tags: this.appModel.tags,
|
||||
value() {
|
||||
return model.tags;
|
||||
}
|
||||
})
|
||||
);
|
||||
this.fieldViews.push(
|
||||
new FieldViewDate({
|
||||
name: 'Expires',
|
||||
title: Locale.detExpires,
|
||||
lessThanNow: '(' + Locale.detExpired + ')',
|
||||
value() {
|
||||
return model.expires;
|
||||
}
|
||||
})
|
||||
);
|
||||
this.fieldViews.push(
|
||||
new FieldViewReadOnly({
|
||||
name: 'Group',
|
||||
title: Locale.detGroup,
|
||||
value() {
|
||||
return model.groupName;
|
||||
},
|
||||
tip() {
|
||||
return model.getGroupPath().join(' / ');
|
||||
}
|
||||
})
|
||||
);
|
||||
this.fieldViews.push(
|
||||
new FieldViewReadOnly({
|
||||
name: 'Created',
|
||||
title: Locale.detCreated,
|
||||
value() {
|
||||
return DateFormat.dtStr(model.created);
|
||||
}
|
||||
})
|
||||
);
|
||||
this.fieldViews.push(
|
||||
new FieldViewReadOnly({
|
||||
name: 'Updated',
|
||||
title: Locale.detUpdated,
|
||||
value() {
|
||||
return DateFormat.dtStr(model.updated);
|
||||
}
|
||||
})
|
||||
);
|
||||
this.fieldViews.push(
|
||||
new FieldViewHistory({
|
||||
name: 'History',
|
||||
title: StringFormat.capFirst(Locale.history),
|
||||
value() {
|
||||
return { length: model.historyLength, unsaved: model.unsaved };
|
||||
}
|
||||
})
|
||||
);
|
||||
this.otpEditView = null;
|
||||
for (const field of Object.keys(model.fields)) {
|
||||
if (field === 'otp' && this.model.otpGenerator) {
|
||||
this.otpEditView = new FieldViewOtp({
|
||||
name: '$' + field,
|
||||
title: field,
|
||||
fieldViews.push(
|
||||
new FieldViewReadOnly({
|
||||
name: '$UserName',
|
||||
title: StringFormat.capFirst(Locale.user),
|
||||
aside: false,
|
||||
value() {
|
||||
return model.otpGenerator;
|
||||
},
|
||||
sequence: '{TOTP}'
|
||||
return model.user;
|
||||
}
|
||||
})
|
||||
);
|
||||
this.otpEditView = new FieldViewOtp({
|
||||
name: '$otp',
|
||||
title: Locale.detOtpField,
|
||||
value() {
|
||||
return model.otpGenerator;
|
||||
},
|
||||
sequence: '{TOTP}',
|
||||
readonly: true,
|
||||
needsTouch: this.model.needsTouch,
|
||||
deviceShortName: this.model.device.shortName
|
||||
});
|
||||
fieldViews.push(this.otpEditView);
|
||||
} else {
|
||||
if (model.isJustCreated && this.appModel.files.length > 1) {
|
||||
const fileNames = this.appModel.files.map(function(file) {
|
||||
return { id: file.id, value: file.name, selected: file === this.model.file };
|
||||
}, this);
|
||||
this.fileEditView = new FieldViewSelect({
|
||||
name: '$File',
|
||||
title: StringFormat.capFirst(Locale.file),
|
||||
value() {
|
||||
return fileNames;
|
||||
}
|
||||
});
|
||||
this.fieldViews.push(this.otpEditView);
|
||||
fieldViews.push(this.fileEditView);
|
||||
} else {
|
||||
this.fieldViews.push(
|
||||
new FieldViewCustom({
|
||||
name: '$' + field,
|
||||
title: field,
|
||||
multiline: true,
|
||||
fieldViewsAside.push(
|
||||
new FieldViewReadOnly({
|
||||
name: 'File',
|
||||
title: StringFormat.capFirst(Locale.file),
|
||||
value() {
|
||||
return model.fields[field];
|
||||
},
|
||||
sequence: `{S:${field}}`
|
||||
return model.fileName;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
this.userEditView = new FieldViewAutocomplete({
|
||||
name: '$UserName',
|
||||
title: StringFormat.capFirst(Locale.user),
|
||||
value() {
|
||||
return model.user;
|
||||
},
|
||||
getCompletions: this.getUserNameCompletions.bind(this),
|
||||
sequence: '{USERNAME}'
|
||||
});
|
||||
fieldViews.push(this.userEditView);
|
||||
this.passEditView = new FieldViewText({
|
||||
name: '$Password',
|
||||
title: StringFormat.capFirst(Locale.password),
|
||||
canGen: true,
|
||||
value() {
|
||||
return model.password;
|
||||
},
|
||||
sequence: '{PASSWORD}'
|
||||
});
|
||||
fieldViews.push(this.passEditView);
|
||||
this.urlEditView = new FieldViewUrl({
|
||||
name: '$URL',
|
||||
title: StringFormat.capFirst(Locale.website),
|
||||
value() {
|
||||
return model.url;
|
||||
},
|
||||
sequence: '{URL}'
|
||||
});
|
||||
fieldViews.push(this.urlEditView);
|
||||
fieldViews.push(
|
||||
new FieldViewText({
|
||||
name: '$Notes',
|
||||
title: StringFormat.capFirst(Locale.notes),
|
||||
multiline: 'true',
|
||||
markdown: true,
|
||||
value() {
|
||||
return model.notes;
|
||||
},
|
||||
sequence: '{NOTES}'
|
||||
})
|
||||
);
|
||||
fieldViews.push(
|
||||
new FieldViewTags({
|
||||
name: 'Tags',
|
||||
title: StringFormat.capFirst(Locale.tags),
|
||||
tags: this.appModel.tags,
|
||||
value() {
|
||||
return model.tags;
|
||||
}
|
||||
})
|
||||
);
|
||||
fieldViews.push(
|
||||
new FieldViewDate({
|
||||
name: 'Expires',
|
||||
title: Locale.detExpires,
|
||||
lessThanNow: '(' + Locale.detExpired + ')',
|
||||
value() {
|
||||
return model.expires;
|
||||
}
|
||||
})
|
||||
);
|
||||
fieldViewsAside.push(
|
||||
new FieldViewReadOnly({
|
||||
name: 'Group',
|
||||
title: Locale.detGroup,
|
||||
value() {
|
||||
return model.groupName;
|
||||
},
|
||||
tip() {
|
||||
return model.getGroupPath().join(' / ');
|
||||
}
|
||||
})
|
||||
);
|
||||
fieldViewsAside.push(
|
||||
new FieldViewReadOnly({
|
||||
name: 'Created',
|
||||
title: Locale.detCreated,
|
||||
value() {
|
||||
return DateFormat.dtStr(model.created);
|
||||
}
|
||||
})
|
||||
);
|
||||
fieldViewsAside.push(
|
||||
new FieldViewReadOnly({
|
||||
name: 'Updated',
|
||||
title: Locale.detUpdated,
|
||||
value() {
|
||||
return DateFormat.dtStr(model.updated);
|
||||
}
|
||||
})
|
||||
);
|
||||
fieldViewsAside.push(
|
||||
new FieldViewHistory({
|
||||
name: 'History',
|
||||
title: StringFormat.capFirst(Locale.history),
|
||||
value() {
|
||||
return { length: model.historyLength, unsaved: model.unsaved };
|
||||
}
|
||||
})
|
||||
);
|
||||
this.otpEditView = null;
|
||||
for (const field of Object.keys(model.fields)) {
|
||||
if (field === 'otp' && this.model.otpGenerator) {
|
||||
this.otpEditView = new FieldViewOtp({
|
||||
name: '$' + field,
|
||||
title: field,
|
||||
value() {
|
||||
return model.otpGenerator;
|
||||
},
|
||||
sequence: '{TOTP}'
|
||||
});
|
||||
fieldViews.push(this.otpEditView);
|
||||
} else {
|
||||
fieldViews.push(
|
||||
new FieldViewCustom({
|
||||
name: '$' + field,
|
||||
title: field,
|
||||
multiline: true,
|
||||
value() {
|
||||
return model.fields[field];
|
||||
},
|
||||
sequence: `{S:${field}}`
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hideEmptyFields = AppSettingsModel.hideEmptyFields;
|
||||
|
||||
const fieldsMainEl = this.$el.find('.details__body-fields');
|
||||
const fieldsAsideEl = this.$el.find('.details__body-aside');
|
||||
this.fieldViews.forEach(fieldView => {
|
||||
fieldView.parent = fieldView.readonly ? fieldsAsideEl[0] : fieldsMainEl[0];
|
||||
fieldView.render();
|
||||
fieldView.on('change', this.fieldChanged.bind(this));
|
||||
fieldView.on('copy', this.fieldCopied.bind(this));
|
||||
fieldView.on('autotype', this.fieldAutoType.bind(this));
|
||||
if (hideEmptyFields) {
|
||||
const value = fieldView.model.value();
|
||||
if (!value || value.length === 0 || value.byteLength === 0) {
|
||||
if (
|
||||
this.model.isJustCreated &&
|
||||
['$UserName', '$Password'].indexOf(fieldView.model.name) >= 0
|
||||
) {
|
||||
return; // don't hide user for new records
|
||||
for (const views of [fieldViews, fieldViewsAside]) {
|
||||
for (const fieldView of views) {
|
||||
fieldView.parent = views === fieldViews ? fieldsMainEl[0] : fieldsAsideEl[0];
|
||||
fieldView.render();
|
||||
fieldView.on('change', this.fieldChanged.bind(this));
|
||||
fieldView.on('copy', this.fieldCopied.bind(this));
|
||||
fieldView.on('autotype', this.fieldAutoType.bind(this));
|
||||
if (hideEmptyFields) {
|
||||
const value = fieldView.model.value();
|
||||
if (!value || value.length === 0 || value.byteLength === 0) {
|
||||
if (
|
||||
this.model.isJustCreated &&
|
||||
['$UserName', '$Password'].indexOf(fieldView.model.name) >= 0
|
||||
) {
|
||||
return; // don't hide user for new records
|
||||
}
|
||||
fieldView.hide();
|
||||
}
|
||||
fieldView.hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
this.fieldViews = fieldViews.concat(fieldViewsAside);
|
||||
|
||||
this.moreView = new DetailsAddFieldView();
|
||||
this.moreView.render();
|
||||
this.moreView.on('add-field', this.addNewField.bind(this));
|
||||
this.moreView.on('more-click', this.toggleMoreOptions.bind(this));
|
||||
if (!this.model.external) {
|
||||
this.moreView = new DetailsAddFieldView();
|
||||
this.moreView.render();
|
||||
this.moreView.on('add-field', this.addNewField.bind(this));
|
||||
this.moreView.on('more-click', this.toggleMoreOptions.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
addNewField() {
|
||||
|
@ -504,6 +545,9 @@ class DetailsView extends View {
|
|||
}
|
||||
|
||||
toggleIcons() {
|
||||
if (this.model.external) {
|
||||
return;
|
||||
}
|
||||
if (this.views.sub && this.views.sub instanceof IconSelectView) {
|
||||
this.render();
|
||||
return;
|
||||
|
@ -819,6 +863,9 @@ class DetailsView extends View {
|
|||
}
|
||||
|
||||
editTitle() {
|
||||
if (this.model.external) {
|
||||
return;
|
||||
}
|
||||
const input = $('<input/>')
|
||||
.addClass('details__header-title-input')
|
||||
.attr({ autocomplete: 'off', spellcheck: 'false', placeholder: 'Title' })
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { Timeouts } from 'const/timeouts';
|
||||
import { FieldViewText } from 'views/fields/field-view-text';
|
||||
import { Locale } from 'util/locale';
|
||||
import { StringFormat } from 'util/formatting/string-format';
|
||||
|
||||
const MinOpacity = 0.1;
|
||||
|
||||
|
@ -11,10 +13,14 @@ class FieldViewOtp extends FieldViewText {
|
|||
otpTimeLeft = 0;
|
||||
otpValidUntil = 0;
|
||||
fieldOpacity = null;
|
||||
otpState = null;
|
||||
|
||||
constructor(model, options) {
|
||||
super(model, options);
|
||||
this.once('remove', () => this.resetOtp());
|
||||
this.once('remove', () => this.stopOtpUpdater());
|
||||
if (model.readonly) {
|
||||
this.readonly = true;
|
||||
}
|
||||
}
|
||||
|
||||
renderValue(value) {
|
||||
|
@ -23,10 +29,25 @@ class FieldViewOtp extends FieldViewText {
|
|||
return '';
|
||||
}
|
||||
if (value !== this.otpGenerator) {
|
||||
this.resetOtp();
|
||||
this.otpGenerator = value;
|
||||
this.requestOtpUpdate();
|
||||
}
|
||||
return this.otpValue;
|
||||
if (this.otpValue) {
|
||||
return this.otpValue;
|
||||
}
|
||||
switch (this.otpState) {
|
||||
case 'awaiting-command':
|
||||
return Locale.detOtpClickToTouch;
|
||||
case 'awaiting-touch':
|
||||
return Locale.detOtpTouch.replace('{}', this.model.deviceShortName);
|
||||
case 'error':
|
||||
return StringFormat.capFirst(Locale.error);
|
||||
case 'generating':
|
||||
return Locale.detOtpGenerating;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
getEditValue(value) {
|
||||
|
@ -48,6 +69,7 @@ class FieldViewOtp extends FieldViewText {
|
|||
this.otpValue = null;
|
||||
this.otpTimeLeft = 0;
|
||||
this.otpValidUntil = 0;
|
||||
this.otpState = null;
|
||||
if (this.otpTimeout) {
|
||||
clearTimeout(this.otpTimeout);
|
||||
this.otpTimeout = null;
|
||||
|
@ -60,11 +82,24 @@ class FieldViewOtp extends FieldViewText {
|
|||
|
||||
requestOtpUpdate() {
|
||||
if (this.value) {
|
||||
this.value.next(this.otpUpdated.bind(this));
|
||||
if (this.model.needsTouch) {
|
||||
this.otpState = 'awaiting-command';
|
||||
} else {
|
||||
this.otpState = 'generating';
|
||||
this.value.next(this.otpUpdated.bind(this));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
otpUpdated(pass, timeLeft) {
|
||||
otpUpdated(err, pass, timeLeft) {
|
||||
if (this.removed) {
|
||||
return;
|
||||
}
|
||||
if (err) {
|
||||
this.otpState = 'error';
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
if (!this.value || !pass) {
|
||||
this.resetOtp();
|
||||
return;
|
||||
|
@ -76,7 +111,21 @@ class FieldViewOtp extends FieldViewText {
|
|||
this.render();
|
||||
}
|
||||
if (this.otpValue && timeLeft) {
|
||||
this.otpTimeout = setTimeout(this.requestOtpUpdate.bind(this), timeLeft);
|
||||
this.otpTimeout = setTimeout(() => {
|
||||
this.requestOtpUpdate();
|
||||
if (this.otpTickInterval) {
|
||||
clearInterval(this.otpTickInterval);
|
||||
this.otpTickInterval = null;
|
||||
}
|
||||
if (this.model.needsTouch) {
|
||||
this.fieldOpacity = null;
|
||||
this.otpValue = null;
|
||||
this.otpValidUntil = 0;
|
||||
this.otpTimeLeft = 0;
|
||||
this.valueEl.css('opacity', 1);
|
||||
}
|
||||
this.render();
|
||||
}, timeLeft);
|
||||
if (!this.otpTickInterval) {
|
||||
this.otpTickInterval = setInterval(this.otpTick.bind(this), 300);
|
||||
}
|
||||
|
@ -102,6 +151,39 @@ class FieldViewOtp extends FieldViewText {
|
|||
this.fieldOpacity = opacity;
|
||||
this.valueEl.css('opacity', opacity);
|
||||
}
|
||||
|
||||
copyValue() {
|
||||
if (this.model.needsTouch) {
|
||||
if (this.otpValue) {
|
||||
return super.copyValue();
|
||||
}
|
||||
this.requestTouch(err => {
|
||||
if (!err) {
|
||||
super.copyValue();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
super.copyValue();
|
||||
}
|
||||
}
|
||||
|
||||
requestTouch(callback) {
|
||||
this.otpState = 'awaiting-touch';
|
||||
this.value.next((err, code, timeLeft) => {
|
||||
this.otpUpdated(err, code, timeLeft);
|
||||
callback(err);
|
||||
});
|
||||
this.render();
|
||||
}
|
||||
|
||||
stopOtpUpdater() {
|
||||
if (this.otpState === 'awaiting-touch' || this.otpState === 'generating') {
|
||||
if (this.value && this.value.cancel) {
|
||||
this.value.cancel();
|
||||
}
|
||||
}
|
||||
this.resetOtp();
|
||||
}
|
||||
}
|
||||
|
||||
export { FieldViewOtp };
|
||||
|
|
|
@ -8,6 +8,7 @@ import { KeyHandler } from 'comp/browser/key-handler';
|
|||
import { SecureInput } from 'comp/browser/secure-input';
|
||||
import { Launcher } from 'comp/launcher';
|
||||
import { Alerts } from 'comp/ui/alerts';
|
||||
import { UsbListener } from 'comp/app/usb-listener';
|
||||
import { Keys } from 'const/keys';
|
||||
import { Comparators } from 'util/data/comparators';
|
||||
import { Features } from 'util/features';
|
||||
|
@ -34,6 +35,7 @@ class OpenView extends View {
|
|||
'click .open__icon-open': 'openFile',
|
||||
'click .open__icon-new': 'createNew',
|
||||
'click .open__icon-demo': 'createDemo',
|
||||
'click .open__icon-otp-device': 'openOtpDevice',
|
||||
'click .open__icon-more': 'toggleMore',
|
||||
'click .open__icon-storage': 'openStorage',
|
||||
'click .open__icon-settings': 'openSettings',
|
||||
|
@ -70,6 +72,7 @@ class OpenView extends View {
|
|||
this.onKey(Keys.DOM_VK_DOWN, this.moveOpenFileSelectionDown, null, 'open');
|
||||
this.onKey(Keys.DOM_VK_UP, this.moveOpenFileSelectionUp, null, 'open');
|
||||
this.listenTo(Events, 'main-window-focus', this.windowFocused.bind(this));
|
||||
this.listenTo(Events, 'usb-devices-changed', this.usbDevicesChanged.bind(this));
|
||||
this.once('remove', () => {
|
||||
this.passwordInput.reset();
|
||||
});
|
||||
|
@ -103,6 +106,8 @@ class OpenView extends View {
|
|||
canOpenSettings: this.model.settings.canOpenSettings,
|
||||
canCreate: this.model.settings.canCreate,
|
||||
canRemoveLatest: this.model.settings.canRemoveLatest,
|
||||
canOpenOtpDevice:
|
||||
this.model.settings.canOpenOtpDevice && !!UsbListener.attachedYubiKeys.length,
|
||||
showMore,
|
||||
showLogo
|
||||
});
|
||||
|
@ -962,6 +967,40 @@ class OpenView extends View {
|
|||
});
|
||||
this.views.gen = generator;
|
||||
}
|
||||
|
||||
usbDevicesChanged() {
|
||||
const hasYubiKeys = !!UsbListener.attachedYubiKeys.length;
|
||||
this.$el.find('.open__icon-otp-device').toggleClass('hide', !hasYubiKeys);
|
||||
}
|
||||
|
||||
openOtpDevice() {
|
||||
return Events.emit('toggle-settings', 'devices');
|
||||
if (this.busy && this.otpDevice) {
|
||||
this.otpDevice.cancelOpen();
|
||||
}
|
||||
if (!this.busy) {
|
||||
this.busy = true;
|
||||
this.inputEl.attr('disabled', 'disabled');
|
||||
const icon = this.$el.find('.open__icon-otp-device');
|
||||
icon.toggleClass('flip3d', true);
|
||||
this.otpDevice = this.model.openOtpDevice(err => {
|
||||
if (err && !this.otpDevice.openAborted) {
|
||||
Alerts.error({
|
||||
header: Locale.openError,
|
||||
body:
|
||||
Locale.openErrorDescription +
|
||||
'<pre class="modal__pre">' +
|
||||
escape(err.toString()) +
|
||||
'</pre>'
|
||||
});
|
||||
}
|
||||
this.otpDevice = null;
|
||||
icon.toggleClass('flip3d', false);
|
||||
this.inputEl.removeAttr('disabled');
|
||||
this.busy = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { OpenView };
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import { View } from 'framework/views/view';
|
||||
import { AppSettingsModel } from 'models/app-settings-model';
|
||||
import template from 'templates/settings/settings-devices.hbs';
|
||||
|
||||
class SettingsDevicesView extends View {
|
||||
template = template;
|
||||
|
||||
events = {
|
||||
'change .settings__devices-enable-usb': 'changeEnableUsb'
|
||||
};
|
||||
|
||||
render() {
|
||||
super.render({
|
||||
enableUsb: AppSettingsModel.enableUsb
|
||||
});
|
||||
}
|
||||
|
||||
changeEnableUsb(e) {
|
||||
AppSettingsModel.enableUsb = e.target.checked;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
export { SettingsDevicesView };
|
|
@ -288,4 +288,8 @@
|
|||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
&__head-icon {
|
||||
margin-right: .2em;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,16 +13,21 @@
|
|||
<div class="open__icon-text">{{res 'openNew'}}</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="open__icon open__icon-otp-device svg-btn {{#unless canOpenOtpDevice}}hide{{/unless}}"
|
||||
tabindex="3" id="open__icon-otp-device">
|
||||
<div class="open__icon-svg">{{{svg 'usb-token'}}}</div>
|
||||
<div class="open__icon-text">YubiKey</div>
|
||||
</div>
|
||||
{{#if canOpenDemo}}
|
||||
{{#ifeq demoOpened false}}
|
||||
<div class="open__icon open__icon-demo" tabindex="3" id="open__icon-demo">
|
||||
<div class="open__icon open__icon-demo" tabindex="4" id="open__icon-demo">
|
||||
<i class="fa fa-magic open__icon-i"></i>
|
||||
<div class="open__icon-text">{{res 'openDemo'}}</div>
|
||||
</div>
|
||||
{{/ifeq}}
|
||||
{{/if}}
|
||||
{{#if showMore}}
|
||||
<div class="open__icon open__icon-more" tabindex="4" id="open__icon-more">
|
||||
<div class="open__icon open__icon-more" tabindex="5" id="open__icon-more">
|
||||
<i class="fa fa-ellipsis-h open__icon-i"></i>
|
||||
<div class="open__icon-text">{{res 'openMore'}}</div>
|
||||
</div>
|
||||
|
@ -36,7 +41,7 @@
|
|||
</div>
|
||||
<div class="open__icons open__icons--lower hide">
|
||||
{{#each storageProviders as |prv|}}
|
||||
<div class="open__icon open__icon-storage svg-btn" data-storage="{{prv.name}}" tabindex="{{add @index 5}}"
|
||||
<div class="open__icon open__icon-storage svg-btn" data-storage="{{prv.name}}" tabindex="{{add @index 6}}"
|
||||
id="open__icon-storage--{{prv.name}}">
|
||||
{{#if prv.icon}}<i class="fa fa-{{prv.icon}} open__icon-i"></i>{{/if}}
|
||||
{{#if prv.iconSvg}}<div class="open__icon-svg">{{{svg prv.iconSvg}}}</div>{{/if}}
|
||||
|