This commit is contained in:
antelle 2020-04-15 16:50:01 +02:00
parent 73ac7496e2
commit 035a4485b7
No known key found for this signature in database
GPG Key ID: 094A2F2D6136A4EE
34 changed files with 782 additions and 220 deletions

View File

@ -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

View File

@ -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() {

View File

@ -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 };

View File

@ -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);

View File

@ -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 };

View File

@ -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;
}
});

View File

@ -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;

View File

@ -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",

View File

@ -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 };

View File

@ -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,

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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));

View File

@ -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 + ')';

View File

@ -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);
});
};

View File

@ -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' })

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -288,4 +288,8 @@
font-weight: bold;
}
}
&__head-icon {
margin-right: .2em;
}
}

View File

@ -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}}
@ -45,18 +50,18 @@
{{/each}}
{{#if canOpenDemo}}
{{#if demoOpened}}
<div class="open__icon open__icon-demo" tabindex="11" id="open__icon-demo">
<div class="open__icon open__icon-demo" tabindex="20" id="open__icon-demo">
<i class="fa fa-magic open__icon-i"></i>
<div class="open__icon-text">{{res 'openDemo'}}</div>
</div>
{{/if}}
{{/if}}
<div class="open__icon open__icon-generate" tabindex="12" id="open__icon-generate">
<div class="open__icon open__icon-generate" tabindex="21" id="open__icon-generate">
<i class="fa fa-bolt open__icon-i"></i>
<div class="open__icon-text">{{res 'openGenerate'}}</div>
</div>
{{#if canOpenSettings}}
<div class="open__icon open__icon-settings" tabindex="12" id="open__icon-settings">
<div class="open__icon open__icon-settings" tabindex="22" id="open__icon-settings">
<i class="fa fa-cog open__icon-i"></i>
<div class="open__icon-text">{{res 'settings'}}</div>
</div>
@ -73,12 +78,12 @@
</div>
<div class="open__pass-field-wrap">
<input class="open__pass-input" name="password" type="password" size="30" autocomplete="new-password" maxlength="1024"
placeholder="{{#if canOpen}}{{res 'openClickToOpen'}}{{/if}}" readonly tabindex="13" />
<div class="open__pass-enter-btn" tabindex="14"><i class="fa fa-level-down fa-rotate-90"></i></div>
placeholder="{{#if canOpen}}{{res 'openClickToOpen'}}{{/if}}" readonly tabindex="23" />
<div class="open__pass-enter-btn" tabindex="24"><i class="fa fa-level-down fa-rotate-90"></i></div>
<div class="open__pass-opening-icon"><i class="fa fa-spinner fa-spin"></i></div>
</div>
<div class="open__settings">
<div class="open__settings-key-file hide" tabindex="15">
<div class="open__settings-key-file hide" tabindex="25">
<i class="fa fa-key open__settings-key-file-icon">
</i><span class="open__settings-key-file-name">{{res 'openKeyFile'}}</span>
{{#if canOpenKeyFromDropbox}}<span class="open__settings-key-file-dropbox"> {{res 'openKeyFileDropbox'}}</span>{{/if}}
@ -86,7 +91,7 @@
</div>
<div class="open__last">
{{#each lastOpenFiles as |file|}}
<div class="open__last-item" data-id="{{file.id}}" title="{{file.path}}" tabindex="{{add @index 16}}"
<div class="open__last-item" data-id="{{file.id}}" title="{{file.path}}" tabindex="{{add @index 26}}"
id="open__last-item--{{file.id}}">
{{#if file.icon}}<i class="fa fa-{{file.icon}} open__last-item-icon"></i>{{/if}}
{{#if file.iconSvg}}<div class="open__last-item-icon open__last-item-icon--svg">{{{svg file.iconSvg}}}</div>{{/if}}

View File

@ -1,5 +1,5 @@
<div class="settings__content">
<h1><i class="fa fa-info"></i> {{res 'setAboutTitle'}} KeeWeb v{{version}}</h1>
<h1><i class="fa fa-info settings__head-icon"></i> {{res 'setAboutTitle'}} KeeWeb v{{version}}</h1>
<p>{{#res 'setAboutFirst'}}<a href="https://antelle.net" target="_blank">Antelle</a>{{/res~}}&nbsp;
{{~#res 'setAboutSecond'}}<a href="{{licenseLink}}" target="_blank">MIT</a>{{/res}}
{{#res 'setAboutSource'}}<a href="{{repoLink}}" target="_blank">GitHub <i class="fa fa-github-alt"></i></a>{{/res}}</p>

View File

@ -0,0 +1,12 @@
<div class="settings__content">
<h1><i class="fa fa-usb settings__head-icon"></i> {{res 'setDevicesTitle'}}</h1>
<div>
<input type="checkbox" class="settings__input input-base settings__devices-enable-usb" id="settings__devices-enable-usb"
{{#if enableUsb}}checked{{/if}} />
<label for="settings__devices-enable-usb">{{res 'setDevicesEnableUsb'}}</label>
</div>
{{#if enableUsb}}
<h2>YubiKey</h2>
{{/if}}
</div>

View File

@ -1,5 +1,5 @@
<div class="settings__content">
<h1><i class="fa fa-lock"></i> {{name}}</h1>
<h1><i class="fa fa-lock settings__head-icon"></i> {{name}}</h1>
{{#if storage}}
{{#ifeq storage 'file'}}<p>{{res 'setFilePath'}}: {{path}}</p>{{/ifeq}}
{{#ifneq storage 'file'}}<p>{{#res 'setFileStorage'}}{{res storage}}{{/res}}</p>{{/ifneq}}

View File

@ -1,5 +1,5 @@
<div class="settings__content">
<h1><i class="fa fa-cog"></i> {{res 'setGenTitle'}}</h1>
<h1><i class="fa fa-cog settings__head-icon"></i> {{res 'setGenTitle'}}</h1>
{{#if updateWaitingReload}}
<h2 class="action-color">{{res 'setGenUpdate'}}</h2>
@ -66,12 +66,12 @@
</div>
{{#if supportsTitleBarStyles}}
<div>
<label for="settings__general-titlebar-style">{{res 'setGenTitlebarStyle'}}:</label>
<select class="settings__general-titlebar-style settings__select input-base" id="settings__general-titlebar-style">
<option value="default" {{#ifeq titlebarStyle 'default'}}selected{{/ifeq}}>{{res 'setGenTitlebarStyleDefault'}}</option>
<option value="hidden" {{#ifeq titlebarStyle 'hidden'}}selected{{/ifeq}}>{{res 'setGenTitlebarStyleHidden'}}</option>
<option value="hidden-inset" {{#ifeq titlebarStyle 'hidden-inset'}}selected{{/ifeq}}>{{res 'setGenTitlebarStyleHiddenInset'}}</option>
</select>
<label for="settings__general-titlebar-style">{{res 'setGenTitlebarStyle'}}:</label>
<select class="settings__general-titlebar-style settings__select input-base" id="settings__general-titlebar-style">
<option value="default" {{#ifeq titlebarStyle 'default'}}selected{{/ifeq}}>{{res 'setGenTitlebarStyleDefault'}}</option>
<option value="hidden" {{#ifeq titlebarStyle 'hidden'}}selected{{/ifeq}}>{{res 'setGenTitlebarStyleHidden'}}</option>
<option value="hidden-inset" {{#ifeq titlebarStyle 'hidden-inset'}}selected{{/ifeq}}>{{res 'setGenTitlebarStyleHiddenInset'}}</option>
</select>
</div>
{{/if}}
<div>

View File

@ -1,5 +1,5 @@
<div class="settings__content">
<h1><i class="fa fa-question"></i> {{res 'help'}}</h1>
<h1><i class="fa fa-question settings__head-icon"></i> {{res 'help'}}</h1>
<h2>{{res 'setHelpFormat'}}</h2>
<p>{{#res 'setHelpFormatBody'}}<a href="https://keepass.info/" target="_blank">KeePass</a>{{/res}}</p>
<h2>{{res 'setHelpProblems'}}</h2>

View File

@ -1,5 +1,5 @@
<div class="settings__content">
<h1><i class="fa fa-puzzle-piece"></i> {{res 'plugins'}}</h1>
<h1><i class="fa fa-puzzle-piece settings__head-icon"></i> {{res 'plugins'}}</h1>
<div>
{{res 'setPlDevelop'}} <a href="{{pluginDevLink}}" target="_blank">{{res 'setPlDevelopStart'}}</a>.
{{#res 'setPlTranslate'}}<a href="{{translateLink}}" target="_blank">{{res 'setPlTranslateLink'}}</a>{{/res}}.

View File

@ -1,5 +1,5 @@
<div class="settings__content">
<h1><i class="fa fa-keyboard-o"></i> {{res 'setShTitle'}}</h1>
<h1><i class="fa fa-keyboard-o settings__head-icon"></i> {{res 'setShTitle'}}</h1>
<div><span class="shortcut">{{{cmd}}}A</span> {{res 'or'}} <span class="shortcut">{{{alt}}}A</span> {{res 'setShShowAll'}}</div>
<div><span class="shortcut">{{{alt}}}C</span> {{res 'setShColors'}}</div>
<div><span class="shortcut">{{{alt}}}D</span> {{res 'setShTrash'}}</div>

View File

@ -31,16 +31,8 @@ const appSettingsFileName = path.join(userDataDir, 'app-settings.json');
const tempUserDataPath = path.join(userDataDir, 'temp');
const tempUserDataPathRand = Date.now().toString() + Math.random().toString();
let htmlPath = process.argv
.filter(arg => arg.startsWith('--htmlpath='))
.map(arg => arg.replace('--htmlpath=', ''))[0];
if (!htmlPath) {
htmlPath = 'file://' + path.join(__dirname, 'index.html');
}
const showDevToolsOnStart =
process.argv.some(arg => arg.startsWith('--devtools')) ||
process.env.KEEWEB_OPEN_DEVTOOLS === '1';
const htmlPath = process.env.KEEWEB_HTML_PATH || 'file://' + path.join(__dirname, 'index.html');
const showDevToolsOnStart = process.env.KEEWEB_OPEN_DEVTOOLS === '1';
const startMinimized = process.argv.some(arg => arg.startsWith('--minimized'));
@ -237,7 +229,7 @@ function createMainWindow() {
emitRemoteEvent('os-lock');
});
mainWindow.webContents.on('will-navigate', (e, url) => {
if (!url.startsWith('https://beta.keeweb.info/')) {
if (!url.startsWith('https://beta.keeweb.info/') && !url.startsWith(htmlPath)) {
emitRemoteEvent('log', { message: `Prevented navigation: ${url}` });
e.preventDefault();
}

4
package-lock.json generated
View File

@ -1386,6 +1386,10 @@
}
}
},
"@keeweb/keeweb-native-modules": {
"version": "https://github.com/keeweb/keeweb-native-modules/releases/download/0.1.6/keeweb-native-modules.tgz",
"integrity": "sha512-fPBfPB1iVZziG23YlZXw9jwd31veHtnkGARwu2JlosaGPFs//lcELmGirD2B0/31HcyzikG8GfIkEgfTBRij0A=="
},
"@sindresorhus/is": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz",

View File

@ -14,6 +14,7 @@
"@babel/plugin-external-helpers": "^7.8.3",
"@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/preset-env": "^7.9.5",
"@keeweb/keeweb-native-modules": "https://github.com/keeweb/keeweb-native-modules/releases/download/0.1.6/keeweb-native-modules.tgz",
"adm-zip": "^0.4.14",
"argon2-browser": "1.13.0",
"autoprefixer": "^9.7.6",
@ -100,7 +101,7 @@
"test": "grunt test",
"postinstall": "cd desktop && npm install",
"build-beta": "grunt --beta && cp dist/index.html ../keeweb-beta/index.html && cd ../keeweb-beta && git add index.html && git commit -a -m 'beta' && git push origin master",
"electron": "cross-env ELECTRON_DISABLE_SECURITY_WARNINGS=1 electron desktop --htmlpath=http://localhost:8085",
"electron": "cross-env ELECTRON_DISABLE_SECURITY_WARNINGS=1 KEEWEB_HTML_PATH=http://localhost:8085 electron desktop",
"dev": "grunt dev",
"babel-helpers": "babel-external-helpers -l 'slicedToArray,toConsumableArray,defineProperty,typeof' -t global > app/lib/babel-helpers.js"
},