396 lines
12 KiB
JavaScript
396 lines
12 KiB
JavaScript
module.exports = Interface;
|
|
|
|
var m = require("mithril");
|
|
var FuzzySort = require("fuzzysort");
|
|
var Moment = require("moment");
|
|
var SearchInterface = require("./searchinterface");
|
|
|
|
const LATEST_NATIVE_APP_VERSION = 3000003;
|
|
|
|
/**
|
|
* Popup main interface
|
|
*
|
|
* @since 3.0.0
|
|
*
|
|
* @param object settings Settings object
|
|
* @param array logins Array of available logins
|
|
* @return void
|
|
*/
|
|
function Interface(settings, logins) {
|
|
// public methods
|
|
this.attach = attach;
|
|
this.view = view;
|
|
this.search = search;
|
|
|
|
// fields
|
|
this.settings = settings;
|
|
this.logins = logins;
|
|
this.results = [];
|
|
this.currentDomainOnly = !settings.tab.url.match(/^(chrome|about):/);
|
|
this.searchPart = new SearchInterface(this);
|
|
|
|
// initialise with empty search
|
|
this.search("");
|
|
}
|
|
|
|
/**
|
|
* Attach the interface on the given element
|
|
*
|
|
* @since 3.0.0
|
|
*
|
|
* @param DOMElement element Target element
|
|
* @return void
|
|
*/
|
|
function attach(element) {
|
|
m.mount(element, this);
|
|
}
|
|
|
|
/**
|
|
* Generates vnodes for render
|
|
*
|
|
* @since 3.0.0
|
|
*
|
|
* @param function ctl Controller
|
|
* @param object params Runtime params
|
|
* @return []Vnode
|
|
*/
|
|
function view(ctl, params) {
|
|
var nodes = [];
|
|
nodes.push(m(this.searchPart));
|
|
|
|
nodes.push(
|
|
m(
|
|
"div.logins",
|
|
this.results.map(function(result) {
|
|
const storeBgColor = result.store.bgColor || result.store.settings.bgColor;
|
|
const storeColor = result.store.color || result.store.settings.color;
|
|
|
|
return m(
|
|
"div.part.login",
|
|
{
|
|
key: result.index,
|
|
tabindex: 0,
|
|
onclick: function(e) {
|
|
var action = e.target.getAttribute("action");
|
|
if (action) {
|
|
result.doAction(action);
|
|
} else {
|
|
result.doAction("fill");
|
|
}
|
|
},
|
|
onkeydown: keyHandler.bind(result)
|
|
},
|
|
[
|
|
m("div.name", [
|
|
m("div.line1", [
|
|
m(
|
|
"div.store.badge",
|
|
{
|
|
style: `background-color: ${storeBgColor};
|
|
color: ${storeColor}`
|
|
},
|
|
result.store.name
|
|
),
|
|
m("div.path", [m.trust(result.path)]),
|
|
result.recent.when > 0
|
|
? m("div.recent", {
|
|
title:
|
|
"Used here " +
|
|
result.recent.count +
|
|
" time" +
|
|
(result.recent.count > 1 ? "s" : "") +
|
|
", last " +
|
|
Moment(new Date(result.recent.when)).fromNow()
|
|
})
|
|
: null
|
|
]),
|
|
m("div.line2", [m.trust(result.display)])
|
|
]),
|
|
m("div.action.copy-password", {
|
|
tabindex: 0,
|
|
title: "Copy password",
|
|
action: "copyPassword"
|
|
}),
|
|
m("div.action.copy-user", {
|
|
tabindex: 0,
|
|
title: "Copy username",
|
|
action: "copyUsername"
|
|
})
|
|
]
|
|
);
|
|
})
|
|
)
|
|
);
|
|
|
|
if (this.settings.version < LATEST_NATIVE_APP_VERSION) {
|
|
nodes.push(
|
|
m("div.updates", [
|
|
m("span", "Update native host app: "),
|
|
m(
|
|
"a",
|
|
{
|
|
href: "https://github.com/browserpass/browserpass-native#installation",
|
|
target: "_blank"
|
|
},
|
|
"instructions"
|
|
)
|
|
])
|
|
);
|
|
}
|
|
|
|
return nodes;
|
|
}
|
|
|
|
/**
|
|
* Run a search
|
|
*
|
|
* @param string s Search string
|
|
* @return void
|
|
*/
|
|
function search(s) {
|
|
var self = this;
|
|
var fuzzyFirstWord = s.substr(0, 1) !== " ";
|
|
s = s.trim();
|
|
|
|
// get candidate list
|
|
var candidates = this.logins.map(candidate => {
|
|
let lastSlashIndex = candidate.login.lastIndexOf("/") + 1;
|
|
return Object.assign(candidate, {
|
|
path: candidate.login.substr(0, lastSlashIndex),
|
|
display: candidate.login.substr(lastSlashIndex)
|
|
});
|
|
});
|
|
var mostRecent = null;
|
|
if (this.currentDomainOnly) {
|
|
var recent = candidates.filter(function(login) {
|
|
if (login.recent.count > 0) {
|
|
// find most recently used login
|
|
if (!mostRecent || login.recent.when > mostRecent.recent.when) {
|
|
mostRecent = login;
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
var remainingInCurrentDomain = candidates.filter(
|
|
login => login.inCurrentDomain && !login.recent.count
|
|
);
|
|
candidates = recent.concat(remainingInCurrentDomain);
|
|
}
|
|
candidates.sort((a, b) => {
|
|
// show most recent first
|
|
if (a === mostRecent) {
|
|
return -1;
|
|
}
|
|
if (b === mostRecent) {
|
|
return 1;
|
|
}
|
|
|
|
// sort by frequency
|
|
var countDiff = b.recent.count - a.recent.count;
|
|
if (countDiff) {
|
|
return countDiff;
|
|
}
|
|
|
|
// sort by specificity, only if filtering for one domain
|
|
if (this.currentDomainOnly) {
|
|
var domainLevelsDiff =
|
|
(b.login.match(/\./g) || []).length - (a.login.match(/\./g) || []).length;
|
|
if (domainLevelsDiff) {
|
|
return domainLevelsDiff;
|
|
}
|
|
}
|
|
|
|
// sort alphabetically
|
|
return a.login.localeCompare(b.login);
|
|
});
|
|
|
|
if (s.length) {
|
|
let filter = s.split(/\s+/);
|
|
let fuzzyFilter = fuzzyFirstWord ? filter[0] : "";
|
|
let substringFilters = filter.slice(fuzzyFirstWord ? 1 : 0).map(w => w.toLowerCase());
|
|
|
|
// First reduce the list by running the substring search
|
|
substringFilters.forEach(function(word) {
|
|
candidates = candidates.filter(c => c.login.toLowerCase().indexOf(word) >= 0);
|
|
});
|
|
|
|
// Then run the fuzzy filter
|
|
let fuzzyResults = {};
|
|
if (fuzzyFilter) {
|
|
candidates = FuzzySort.go(fuzzyFilter, candidates, {
|
|
keys: ["login", "store.name"],
|
|
allowTypo: false
|
|
}).map(result => {
|
|
fuzzyResults[result.obj.login] = result;
|
|
return result.obj;
|
|
});
|
|
}
|
|
|
|
// Finally highlight all matches
|
|
candidates = candidates.map(c => highlightMatches(c, fuzzyResults, substringFilters));
|
|
}
|
|
|
|
// Prefix root entries with slash to let them have some visible path
|
|
candidates.forEach(c => {
|
|
c.path = c.path || "/";
|
|
});
|
|
|
|
this.results = candidates;
|
|
}
|
|
|
|
/**
|
|
* Highlight filter matches
|
|
*
|
|
* @since 3.0.0
|
|
*
|
|
* @param object entry password entry
|
|
* @param object fuzzyResults positions of fuzzy filter matches
|
|
* @param array substringFilters list of substring filters applied
|
|
* @return object entry with highlighted matches
|
|
*/
|
|
function highlightMatches(entry, fuzzyResults, substringFilters) {
|
|
// Add all positions of the fuzzy search to the array
|
|
let matches = (fuzzyResults[entry.login] && fuzzyResults[entry.login][0]
|
|
? fuzzyResults[entry.login][0].indexes
|
|
: []
|
|
).slice();
|
|
|
|
// Add all positions of substring searches to the array
|
|
let login = entry.login.toLowerCase();
|
|
for (let word of substringFilters) {
|
|
let startIndex = login.indexOf(word);
|
|
for (let i = 0; i < word.length; i++) {
|
|
matches.push(startIndex + i);
|
|
}
|
|
}
|
|
|
|
// Prepare the final array of matches before
|
|
matches = sortUnique(matches, (a, b) => a - b);
|
|
|
|
const OPEN = "<em>";
|
|
const CLOSE = "</em>";
|
|
let highlighted = "";
|
|
var matchesIndex = 0;
|
|
var opened = false;
|
|
for (var i = 0; i < entry.login.length; ++i) {
|
|
var char = entry.login[i];
|
|
|
|
if (i == entry.path.length) {
|
|
if (opened) {
|
|
highlighted += CLOSE;
|
|
}
|
|
var path = highlighted;
|
|
highlighted = "";
|
|
if (opened) {
|
|
highlighted += OPEN;
|
|
}
|
|
}
|
|
|
|
if (matches[matchesIndex] === i) {
|
|
matchesIndex++;
|
|
if (!opened) {
|
|
opened = true;
|
|
highlighted += OPEN;
|
|
}
|
|
} else {
|
|
if (opened) {
|
|
opened = false;
|
|
highlighted += CLOSE;
|
|
}
|
|
}
|
|
highlighted += char;
|
|
}
|
|
if (opened) {
|
|
opened = false;
|
|
highlighted += CLOSE;
|
|
}
|
|
let display = highlighted;
|
|
|
|
return Object.assign(entry, {
|
|
path: path,
|
|
display: display
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sort and remove duplicates
|
|
*
|
|
* @since 3.0.0
|
|
*
|
|
* @param array array items to sort
|
|
* @param function comparator sort comparator
|
|
* @return array sorted items without duplicates
|
|
*/
|
|
function sortUnique(array, comparator) {
|
|
return array
|
|
.sort(comparator)
|
|
.filter((elem, index, arr) => index == !arr.length || arr[index - 1] != elem);
|
|
}
|
|
|
|
/**
|
|
* Handle result key presses
|
|
*
|
|
* @param Event e Keydown event
|
|
* @param object this Result object
|
|
* @return void
|
|
*/
|
|
function keyHandler(e) {
|
|
e.preventDefault();
|
|
var login = e.target.classList.contains("login") ? e.target : e.target.closest(".login");
|
|
switch (e.code) {
|
|
case "Tab":
|
|
var partElement = e.target.closest(".part");
|
|
var targetElement = e.shiftKey ? "previousElementSibling" : "nextElementSibling";
|
|
if (partElement[targetElement] && partElement[targetElement].hasAttribute("tabindex")) {
|
|
partElement[targetElement].focus();
|
|
} else {
|
|
document.querySelector(".part.search input[type=text]").focus();
|
|
}
|
|
break;
|
|
case "ArrowDown":
|
|
if (login.nextElementSibling) {
|
|
login.nextElementSibling.focus();
|
|
}
|
|
break;
|
|
case "ArrowUp":
|
|
if (login.previousElementSibling) {
|
|
login.previousElementSibling.focus();
|
|
} else {
|
|
document.querySelector(".part.search input[type=text]").focus();
|
|
}
|
|
break;
|
|
case "ArrowRight":
|
|
if (e.target.classList.contains("login")) {
|
|
e.target.querySelector(".action").focus();
|
|
} else if (e.target.nextElementSibling) {
|
|
e.target.nextElementSibling.focus();
|
|
}
|
|
break;
|
|
case "ArrowLeft":
|
|
if (e.target.previousElementSibling.classList.contains("action")) {
|
|
e.target.previousElementSibling.focus();
|
|
} else {
|
|
login.focus();
|
|
}
|
|
break;
|
|
case "Enter":
|
|
if (e.target.hasAttribute("action")) {
|
|
this.doAction(e.target.getAttribute("action"));
|
|
} else {
|
|
this.doAction("fill");
|
|
}
|
|
break;
|
|
case "KeyC":
|
|
if (e.ctrlKey) {
|
|
this.doAction(e.shiftKey ? "copyUsername" : "copyPassword");
|
|
}
|
|
break;
|
|
case "KeyG":
|
|
if (e.ctrlKey) {
|
|
this.doAction(e.shiftKey ? "launchInNewTab" : "launch");
|
|
}
|
|
break;
|
|
}
|
|
}
|