diff --git a/.gitignore b/.gitignore
index 3a1b85bd..49fceaae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@
*.pem
*.crx
+/.vscode
diff --git a/Makefile b/Makefile
index c3c8bd24..2b9f0ee8 100644
--- a/Makefile
+++ b/Makefile
@@ -18,6 +18,7 @@ EXTENSION_FILES := \
src/*.svg \
src/fonts/* \
src/popup/*.html \
+ src/popup/*.gif \
src/popup/*.svg \
src/options/*.html
EXTENSION_FILES := \
diff --git a/src/background.js b/src/background.js
index 046e061e..5caaf635 100644
--- a/src/background.js
+++ b/src/background.js
@@ -19,6 +19,11 @@ var defaultSettings = {
username: null,
theme: "dark",
enableOTP: false,
+ caps: {
+ save: false,
+ delete: false,
+ tree: false,
+ },
};
var authListeners = {};
@@ -259,7 +264,7 @@ async function saveRecent(settings, login, remove = false) {
* @return array list of filled fields
*/
async function dispatchFill(settings, request, allFrames, allowForeign, allowNoSecret) {
- request = Object.assign(deepCopy(request), {
+ request = Object.assign(helpers.deepCopy(request), {
allowForeign: allowForeign,
allowNoSecret: allowNoSecret,
foreignFills: settings.foreignFills[settings.origin] || {},
@@ -304,7 +309,7 @@ async function dispatchFill(settings, request, allFrames, allowForeign, allowNoS
* @return void
*/
async function dispatchFocusOrSubmit(settings, request, allFrames, allowForeign) {
- request = Object.assign(deepCopy(request), {
+ request = Object.assign(helpers.deepCopy(request), {
allowForeign: allowForeign,
foreignFills: settings.foreignFills[settings.origin] || {},
});
@@ -468,7 +473,7 @@ async function fillFields(settings, login, fields) {
* @return object Local settings from the extension
*/
function getLocalSettings() {
- var settings = deepCopy(defaultSettings);
+ var settings = helpers.deepCopy(defaultSettings);
for (var key in settings) {
var value = localStorage.getItem(key);
if (value !== null) {
@@ -488,7 +493,7 @@ function getLocalSettings() {
*/
async function getFullSettings() {
var settings = getLocalSettings();
- var configureSettings = Object.assign(deepCopy(settings), {
+ var configureSettings = Object.assign(helpers.deepCopy(settings), {
defaultStore: {},
});
var response = await hostAction(configureSettings, "configure");
@@ -496,6 +501,12 @@ async function getFullSettings() {
settings.hostError = response;
}
settings.version = response.version;
+ const EDIT_VERSION = 3 * 1000000 + 1 * 1000 + 0;
+
+ // host capabilities
+ settings.caps.save = settings.version >= EDIT_VERSION;
+ settings.caps.delete = settings.version >= EDIT_VERSION;
+ settings.caps.tree = settings.version >= EDIT_VERSION;
// Fill store settings, only makes sense if 'configure' succeeded
if (response.status === "ok") {
@@ -562,15 +573,22 @@ async function getFullSettings() {
}
/**
- * Deep copy an object
- *
- * @since 3.0.0
+ * Get most relevant setting value
*
- * @param object obj an object to copy
- * @return object a new deep copy
+ * @param string key Setting key
+ * @param object login Login object
+ * @param object settings Settings object
+ * @return object Setting value
*/
-function deepCopy(obj) {
- return JSON.parse(JSON.stringify(obj));
+function getSetting(key, login, settings) {
+ if (typeof login.settings[key] !== "undefined") {
+ return login.settings[key];
+ }
+ if (typeof settings.stores[login.store.id].settings[key] !== "undefined") {
+ return settings.stores[login.store.id].settings[key];
+ }
+
+ return settings[key];
}
/**
@@ -654,7 +672,8 @@ async function handleMessage(settings, message, sendResponse) {
// fetch file & parse fields if a login entry is present
try {
- if (typeof message.login !== "undefined") {
+ // do not fetch file for new login entries
+ if (typeof message.login !== "undefined" && message.action != "add") {
await parseFields(settings, message.login);
}
} catch (e) {
@@ -695,7 +714,62 @@ async function handleMessage(settings, message, sendResponse) {
} catch (e) {
sendResponse({
status: "error",
- message: "Unable to enumerate password files" + e.toString(),
+ message: "Unable to enumerate password files. " + e.toString(),
+ });
+ }
+ break;
+ case "listDirs":
+ try {
+ var response = await hostAction(settings, "tree");
+ if (response.status != "ok") {
+ throw new Error(JSON.stringify(response));
+ }
+ let dirs = response.data.directories;
+ sendResponse({ status: "ok", dirs });
+ } catch (e) {
+ sendResponse({
+ status: "error",
+ message: "Unable to enumerate directory trees. " + e.toString(),
+ });
+ }
+ break;
+ case "add":
+ case "save":
+ try {
+ var response = await hostAction(settings, "save", {
+ storeId: message.login.store.id,
+ file: `${message.login.login}.gpg`,
+ contents: message.params.rawContents,
+ });
+
+ if (response.status != "ok") {
+ alert(`Save failed: ${response.params.message}`);
+ throw new Error(JSON.stringify(response)); // TODO handle host error
+ }
+ sendResponse({ status: "ok" });
+ } catch (e) {
+ sendResponse({
+ status: "error",
+ message: "Unable to save password file" + e.toString(),
+ });
+ }
+ break;
+ case "delete":
+ try {
+ var response = await hostAction(settings, "delete", {
+ storeId: message.login.store.id,
+ file: `${message.login.login}.gpg`,
+ });
+
+ if (response.status != "ok") {
+ alert(`Delete failed: ${response.params.message}`);
+ throw new Error(JSON.stringify(response));
+ }
+ sendResponse({ status: "ok" });
+ } catch (e) {
+ sendResponse({
+ status: "error",
+ message: "Unable to delete password file" + e.toString(),
});
}
break;
@@ -882,13 +956,7 @@ async function parseFields(settings, login) {
login.raw = response.data.contents;
// parse lines
- login.fields = {
- secret: ["secret", "password", "pass"],
- login: ["login", "username", "user"],
- openid: ["openid"],
- otp: ["otp", "totp"],
- url: ["url", "uri", "website", "site", "link", "launch"],
- };
+ login.fields = helpers.deepCopy(helpers.fieldsPrefix);
login.settings = {
autoSubmit: { name: "autosubmit", type: "bool" },
};
@@ -1055,7 +1123,7 @@ async function clearUsageData() {
* @return void
*/
async function saveSettings(settings) {
- let settingsToSave = deepCopy(settings);
+ let settingsToSave = helpers.deepCopy(settings);
// 'default' is our reserved name for the default store
delete settingsToSave.stores.default;
diff --git a/src/helpers.js b/src/helpers.js
index 65f8bd8c..14079637 100644
--- a/src/helpers.js
+++ b/src/helpers.js
@@ -5,20 +5,144 @@ const FuzzySort = require("fuzzysort");
const sha1 = require("sha1");
const ignore = require("ignore");
const hash = require("hash.js");
+const m = require("mithril");
+const notify = require("./popup/notifications");
const Authenticator = require("otplib").authenticator.Authenticator;
const BrowserpassURL = require("@browserpass/url");
+const fieldsPrefix = {
+ secret: ["secret", "password", "pass"],
+ login: ["login", "username", "user"],
+ openid: ["openid"],
+ otp: ["otp", "totp"],
+ url: ["url", "uri", "website", "site", "link", "launch"],
+};
+
+const containsNumbersRegEx = RegExp(/[0-9]/);
+const containsSymbolsRegEx = RegExp(/[\p{P}\p{S}]/, "u");
+const LATEST_NATIVE_APP_VERSION = 3001000;
+
module.exports = {
- prepareLogins,
+ containsSymbolsRegEx,
+ fieldsPrefix,
+ LATEST_NATIVE_APP_VERSION,
+ deepCopy,
filterSortLogins,
+ handleError,
+ highlight,
getSetting,
ignoreFiles,
makeTOTP,
+ prepareLogin,
+ prepareLogins,
+ withLogin,
};
//----------------------------------- Function definitions ----------------------------------//
/**
+ * Deep copy an object
+ *
+ * Firefox requires data to be serializable,
+ * this removes everything offending such as functions
+ *
+ * @since 3.0.0 moved to helpers.js 3.8.0
+ *
+ * @param object obj an object to copy
+ * @return object a new deep copy
+ */
+function deepCopy(obj) {
+ return JSON.parse(JSON.stringify(obj));
+}
+
+/**
+ * Handle an error
+ *
+ * @since 3.0.0
+ *
+ * @param Error error Error object
+ * @param string type Error type
+ */
+function handleError(error, type = "error") {
+ switch (type) {
+ case "error":
+ console.log(error);
+ // disable error timeout, to allow necessary user action
+ notify.errorMsg(error.toString(), 0);
+ break;
+
+ case "warning":
+ notify.warningMsg(error.toString());
+ break;
+
+ case "success":
+ notify.successMsg(error.toString());
+ break;
+
+ case "info":
+ default:
+ notify.infoMsg(error.toString());
+ break;
+ }
+}
+
+/**
+ * Do a login action
+ *
+ * @since 3.0.0
+ *
+ * @param string action Action to take
+ * @param object params Action parameters
+ * @return void
+ */
+async function withLogin(action, params = {}) {
+ try {
+ switch (action) {
+ case "fill":
+ handleError("Filling login details...", "info");
+ break;
+ case "launch":
+ handleError("Launching URL...", "info");
+ break;
+ case "launchInNewTab":
+ handleError("Launching URL in a new tab...", "info");
+ break;
+ case "copyPassword":
+ handleError("Copying password to clipboard...", "info");
+ break;
+ case "copyUsername":
+ handleError("Copying username to clipboard...", "info");
+ break;
+ case "copyOTP":
+ handleError("Copying OTP token to clipboard...", "info");
+ break;
+ default:
+ handleError("Please wait...", "info");
+ break;
+ }
+
+ const login = deepCopy(this.login);
+
+ // hand off action to background script
+ var response = await chrome.runtime.sendMessage({ action, login, params });
+ if (response.status != "ok") {
+ throw new Error(response.message);
+ } else {
+ if (response.login && typeof response.login === "object") {
+ response.login.doAction = withLogin.bind({
+ settings: this.settings,
+ login: response.login,
+ });
+ } else {
+ window.close();
+ }
+ }
+ } catch (e) {
+ handleError(e);
+ }
+}
+
+/*
* Get most relevant setting value
*
* @param string key Setting key
@@ -87,56 +211,7 @@ function prepareLogins(files, settings) {
for (let storeId in files) {
for (let key in files[storeId]) {
// set login fields
- const loginPath = files[storeId][key];
- // remove the file-type extension
- const loginName = loginPath.replace(/\.[^.]+$/u, "");
- const login = {
- index: index++,
- store: settings.stores[storeId],
- login: loginName,
- loginPath: loginPath,
- allowFill: true,
- };
-
- // extract url info from path
- let pathInfo = pathToInfo(storeId + "/" + login.login, origin);
- if (pathInfo) {
- // set assumed host
- login.host = pathInfo.port
- ? `${pathInfo.hostname}:${pathInfo.port}`
- : pathInfo.hostname;
-
- // check whether extracted path info matches the current origin
- login.inCurrentHost = origin.hostname === pathInfo.hostname;
-
- // check whether the current origin is subordinate to extracted path info, meaning:
- // - that the path info is not a single level domain (e.g. com, net, local)
- // - and that the current origin is a subdomain of that path info
- if (
- pathInfo.hostname.includes(".") &&
- origin.hostname.endsWith(`.${pathInfo.hostname}`)
- ) {
- login.inCurrentHost = true;
- }
-
- // filter out entries with a non-matching port
- if (pathInfo.port && pathInfo.port !== origin.port) {
- login.inCurrentHost = false;
- }
- } else {
- login.host = null;
- login.inCurrentHost = false;
- }
-
- // update recent counter
- login.recent =
- settings.recent[sha1(settings.origin + sha1(login.store.id + sha1(login.login)))];
- if (!login.recent) {
- login.recent = {
- when: 0,
- count: 0,
- };
- }
+ const login = prepareLogin(settings, storeId, files[storeId][key], index++, origin);
logins.push(login);
}
@@ -145,6 +220,89 @@ function prepareLogins(files, settings) {
return logins;
}
+/**
+ * Prepare a single login based settings, storeId, and path
+ *
+ * @since 3.8.0
+ *
+ * @param string settings Settings object
+ * @param string storeId Store ID alphanumeric ID
+ * @param string file Relative path in store to password
+ * @param number index An array index for login, if building an array of logins (optional, default: 0)
+ * @param object origin Instance of BrowserpassURL (optional, default: new BrowserpassURL(settings.origin))
+ * @return object of login
+ */
+function prepareLogin(settings, storeId, file, index = 0, origin = undefined) {
+ const login = {
+ index: index > -1 ? parseInt(index) : 0,
+ store: settings.stores[storeId],
+ // remove the file-type extension
+ login: file.replace(/\.[^.]+$/u, ""),
+ loginPath: file,
+ allowFill: true,
+ };
+
+ origin = BrowserpassURL.prototype.isPrototypeOf(origin)
+ ? origin
+ : new BrowserpassURL(settings.origin);
+
+ // extract url info from path
+ let pathInfo = pathToInfo(storeId + "/" + login.login, origin);
+ if (pathInfo) {
+ // set assumed host
+ login.host = pathInfo.port ? `${pathInfo.hostname}:${pathInfo.port}` : pathInfo.hostname;
+
+ // check whether extracted path info matches the current origin
+ login.inCurrentHost = origin.hostname === pathInfo.hostname;
+
+ // check whether the current origin is subordinate to extracted path info, meaning:
+ // - that the path info is not a single level domain (e.g. com, net, local)
+ // - and that the current origin is a subdomain of that path info
+ if (pathInfo.hostname.includes(".") && origin.hostname.endsWith(`.${pathInfo.hostname}`)) {
+ login.inCurrentHost = true;
+ }
+
+ // filter out entries with a non-matching port
+ if (pathInfo.port && pathInfo.port !== origin.port) {
+ login.inCurrentHost = false;
+ }
+ } else {
+ login.host = null;
+ login.inCurrentHost = false;
+ }
+
+ // update recent counter
+ login.recent =
+ settings.recent[sha1(settings.origin + sha1(login.store.id + sha1(login.login)))];
+ if (!login.recent) {
+ login.recent = {
+ when: 0,
+ count: 0,
+ };
+ }
+
+ return login;
+}
+
+/**
+ * Highlight password characters
+ *
+ * @since 3.8.0
+ *
+ * @param {string} secret a string to be split by character
+ * @return {array} mithril vnodes to be rendered
+ */
+function highlight(secret = "") {
+ return secret.split("").map((c) => {
+ if (c.match(containsNumbersRegEx)) {
+ return m("span.char.num", c);
+ } else if (c.match(containsSymbolsRegEx)) {
+ return m("span.char.punct", c);
+ }
+ return m("span.char", c);
+ });
+}
+
/**
* Filter and sort logins
*
diff --git a/src/popup/addEditInterface.js b/src/popup/addEditInterface.js
new file mode 100644
index 00000000..0595f807
--- /dev/null
+++ b/src/popup/addEditInterface.js
@@ -0,0 +1,651 @@
+const m = require("mithril");
+const Login = require("./models/Login");
+const Settings = require("./models/Settings");
+const Tree = require("./models/Tree");
+const notify = require("./notifications");
+const helpers = require("../helpers");
+const layout = require("./layoutInterface");
+const dialog = require("./modalDialog");
+
+module.exports = AddEditInterface;
+
+var persistSettingsModel = {};
+
+function AddEditInterface(settingsModel) {
+ persistSettingsModel = settingsModel;
+
+ /**
+ * AddEditView
+ *
+ * @since 3.8.0
+ *
+ * @param object vnode current vnode object
+ */
+ return function (vnode) {
+ // do some basic initialization
+ var canTree = false,
+ editing = false,
+ loginObj = {},
+ passwordGenerated = false,
+ passwordLength = 16,
+ settings = {},
+ storeDirs = [],
+ storePath = "",
+ stores = [],
+ storeTree = new Tree(),
+ symbols = true,
+ viewSettingsModel = persistSettingsModel;
+
+ /**
+ * Auto re-generate password
+ *
+ * If the generatebutton has been clicked during the current
+ * add/edit view, auto update when options are changed
+ *
+ * @since 3.8.0
+ */
+ function autoUpdatePassword() {
+ if (passwordGenerated) {
+ loginObj.setPassword(loginObj.generateSecret(passwordLength, symbols));
+ }
+ }
+
+ /**
+ * Event handler for onkeydown, browse and select listed directory
+ * options for login file path.
+ *
+ * @since 3.8.0
+ *
+ * @param {object} e key event
+ */
+ function pathKeyHandler(e) {
+ let inputEl = document.querySelector("input.filePath");
+
+ switch (e.code) {
+ // Tab already handled
+ case "ArrowUp":
+ e.preventDefault();
+ if (
+ e.target.classList.contains("directory") &&
+ e.target.previousElementSibling
+ ) {
+ e.target.previousElementSibling.focus();
+ } else if (e.target != inputEl) {
+ inputEl.focus();
+ }
+ break;
+ case "ArrowDown":
+ e.preventDefault();
+ let paths = document.querySelector(".directory");
+
+ if (e.target == inputEl && paths != null) {
+ paths.focus();
+ } else if (
+ e.target.classList.contains("directory") &&
+ e.target.nextElementSibling
+ ) {
+ e.target.nextElementSibling.focus();
+ }
+ break;
+ case "Enter":
+ e.preventDefault();
+ if (e.target.classList.contains("directory")) {
+ // replace search term with selected directory
+ inputEl.value = `${addDirToLoginPath(
+ loginObj.login,
+ e.target.getAttribute("value")
+ )}/`;
+ this.state.setLogin(inputEl.value);
+ inputEl.focus();
+ }
+ break;
+ case "Home":
+ case "End":
+ // only handle when list has focus
+ if (e.target.classList.contains("directory")) {
+ e.preventDefault();
+ const dirs = e.target.parentElement.children;
+ dirs.item(e.code == "End" ? dirs.length - 1 : 0).focus();
+ }
+ break;
+ case "PageUp":
+ case "PageDown":
+ // only handle when list has focus
+ if (e.target.classList.contains("directory")) {
+ e.preventDefault();
+ const dirs = Array.from(e.target.parentElement.children);
+ const current = dirs.findIndex(
+ (element) => element.innerText == e.target.innerText
+ );
+ let next = 0;
+ if (e.code == "PageUp") {
+ next = Math.max(0, current - 10);
+ } else {
+ next = Math.min(dirs.length - 1, current + 10);
+ }
+ dirs[next].focus();
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Event handler for click, insert selected directory
+ * for login file path.
+ *
+ * @since 3.8.0
+ *
+ * @param {object} e key event
+ */
+ function clickDirectoryHandler(e) {
+ e.preventDefault();
+ var inputEl = document.querySelector("input.filePath");
+
+ // replace search term with selected directory
+ inputEl.value = `${addDirToLoginPath(loginObj.login, e.target.getAttribute("value"))}/`;
+ this.state.setLogin(inputEl.value);
+ inputEl.focus();
+ }
+
+ /**
+ * Rebuilds login file path given a selected directory to add.
+ * @since 3.8.0
+ *
+ * @param {string} currentPath current value of loginObj.login
+ * @param {string} dir selected directory to append to login file path
+ * @returns {string} new login path
+ */
+ function addDirToLoginPath(currentPath, dir) {
+ let parts = currentPath.split("/");
+ let length = parts.length;
+ if (length > 0) {
+ parts[length - 1] = dir;
+ return parts.join("/");
+ }
+ return dir;
+ }
+
+ /**
+ * Reset or clear array of directory list for login path.
+ *
+ * Used in onclick or onfocus for elements not associated
+ * with the login path or list of directories in the
+ * password store tree.
+ *
+ * @since 3.8.0
+ */
+ function clearStoreDirs(e) {
+ if (storeDirs.length > 0) {
+ storeDirs = [];
+ m.redraw();
+ }
+ }
+
+ /**
+ * Build css style using store color settings
+ *
+ * @since 3.8.0
+ *
+ * @param {object} store to retrieve color settings from
+ * @returns {string}
+ */
+ function getStoreStyle(store = {}) {
+ let style = ``;
+
+ if (store.hasOwnProperty("color")) {
+ style = `color: ${store.color};`;
+ }
+
+ if (store.hasOwnProperty("bgColor")) {
+ style = `${style} background-color: ${store.bgColor};`;
+ }
+
+ return style;
+ }
+
+ /**
+ * Build css width details for login file path
+ *
+ * @since 3.8.0
+ *
+ * @param {object} login current secret details
+ * @returns {string} css width of file path input
+ */
+ function getPathWidth(login = {}) {
+ let length = 0;
+ if (Login.prototype.isLogin(login)) {
+ length = login.login.length;
+ }
+ return `min-width: 65px; max-width: 442px; width: ${length * 8}px;`;
+ }
+
+ return {
+ oncreate: function (vnode) {
+ let elems = ["div.title", "div.location div.store", "div.contents"];
+ elems.forEach((selector) => {
+ let el = document.querySelector(selector);
+ if (el != null) {
+ // add capturing event listener, not bubbling
+ el.addEventListener("click", clearStoreDirs.bind(vnode), true);
+ }
+ });
+ },
+ oninit: async function (vnode) {
+ tmpLogin = layout.getCurrentLogin();
+ settings = await viewSettingsModel.get();
+
+ // if native host does not support add/edit/save, redirect to previous page and notify user to update
+ if (
+ settings.version < helpers.LATEST_NATIVE_APP_VERSION ||
+ !Settings.prototype.canSave(settings)
+ ) {
+ notify.warningMsg(
+ m.trust(
+ `The currently installed native-host application does not support adding or editing passwords. ` +
+ `Please visit github.com/browserpass-native#install ` +
+ `for instructions on how to install the latest version.`
+ ),
+ 0
+ );
+
+ // redirect to previous page
+ if (tmpLogin !== null && tmpLogin.login == vnode.attrs.context.login) {
+ m.route.set(
+ `/details/${vnode.attrs.context.storeid}/${encodeURIComponent(
+ vnode.attrs.context.login
+ )}`
+ );
+ } else {
+ m.route.set("/list");
+ }
+ }
+
+ Object.keys(settings.stores).forEach((k) => {
+ stores.push(settings.stores[k]);
+ });
+
+ // Show existing login
+ if (vnode.attrs.context.login !== undefined) {
+ if (tmpLogin !== null && tmpLogin.login == vnode.attrs.context.login) {
+ // use existing decrypted login
+ loginObj = tmpLogin;
+ } else {
+ // no match, must re-decrypt login
+ loginObj = await Login.prototype.get(
+ settings,
+ vnode.attrs.context.storeid,
+ vnode.attrs.context.login
+ );
+ }
+ editing = true;
+ } else {
+ // view instance should be a Login
+ loginObj = new Login(settings);
+ }
+
+ // set the storePath and get tree dirs
+ canTree = Settings.prototype.canTree(settings);
+ this.setStorePath();
+
+ // trigger redraw after retrieving details
+ if (
+ (editing && Login.prototype.isLogin(loginObj)) ||
+ Settings.prototype.isSettings(settings)
+ ) {
+ // update default password options based on current password
+ const password = loginObj.getPassword();
+ // use current password length for default length
+ if (password.length > 0) {
+ this.setPasswordLength(password.length);
+
+ // if not blank and not using symbols, disable them for initial options
+ this.setSymbols(password.search(helpers.containsSymbolsRegEx) > -1);
+ }
+ m.redraw();
+ }
+ },
+ /**
+ * Update login path.
+ * Used in onchange: m.withAttr("value", ...)
+ *
+ * @since 3.8.0
+ *
+ * @param {string} path
+ */
+ setLogin: function (path) {
+ loginObj.login = path;
+ if (canTree) {
+ storeDirs = storeTree.search(path);
+ } else {
+ storeDirs = [];
+ }
+ },
+ /**
+ * Update pass length when generating secret in view.
+ * Used onchange: m.withAttr("value", ...)
+ *
+ * @since 3.8.0
+ *
+ * @param {int} length
+ */
+ setPasswordLength: function (length) {
+ passwordLength = length > 0 ? length : 1;
+ autoUpdatePassword();
+ },
+ /**
+ * Update login raw text and secret when "raw text" changes.
+ * Used oninput: m.withAttr("value", ...)
+ *
+ * @since 3.8.0
+ *
+ * @param {string} text
+ */
+ setRawDetails: function (text) {
+ loginObj.setRawDetails(text);
+ },
+ /**
+ * Update login secret and raw text when "secret" changes.
+ * Used oninput: m.withAttr("value", ...)
+ *
+ * @since 3.8.0
+ *
+ * @param {string} secret
+ */
+ setSecret: function (secret) {
+ loginObj.setPassword(secret);
+ },
+ /**
+ * Update login store id.
+ * Used in onchange: m.withAttr("value", ...)
+ *
+ * @since 3.8.0
+ *
+ * @param {string} storeId
+ */
+ setStorePath: function (storeId) {
+ if (editing) {
+ storePath = loginObj.store.path;
+ storeTree = canTree ? layout.getStoreTree(loginObj.store.id) : null;
+ stores = new Array(loginObj.store);
+ } else if (Settings.prototype.isSettings(settings)) {
+ if (typeof storeId == "string") {
+ loginObj.store = settings.stores[storeId];
+ } else {
+ loginObj.store = stores[0];
+ }
+ storePath = loginObj.store.path;
+ storeTree = canTree ? layout.getStoreTree(loginObj.store.id) : null;
+ } else {
+ storePath = "~/.password-store";
+ }
+ },
+ /**
+ * Toggle checked on/off, determines if symbols
+ * are used when generating a new random password.
+ * Used in onchange: m.withAttr("value", ...)
+ *
+ * @since 3.8.0
+ *
+ * @param {int} checked value 1 or 0 for checked
+ */
+ setSymbols: function (checked) {
+ symbols = checked;
+ autoUpdatePassword();
+ },
+ /**
+ * Mithril component view
+ * @param {object} vnode
+ * @returns {array} children vnodes
+ */
+ view: function (vnode) {
+ var nodes = [];
+ nodes.push(
+ m("div.title", [
+ m("div.btn.back", {
+ title: editing ? "Back to details" : "Back to list",
+ onclick: () => {
+ if (editing) {
+ m.route.set(
+ `/details/${loginObj.store.id}/${encodeURIComponent(
+ loginObj.login
+ )}`
+ );
+ } else {
+ m.route.set("/list");
+ }
+ },
+ }),
+ m("span", editing ? "Edit credentials" : "Add credentials"),
+ // html alignment element makes centering title span easier
+ m("div.btn.alignment"),
+ ]),
+ m("div.location", [
+ m("div.store", [
+ m(
+ "select",
+ {
+ disabled: editing,
+ title: "Select which password-store to save credentials in.",
+ onchange: m.withAttr("value", this.setStorePath),
+ onfocus: clearStoreDirs,
+ style: Login.prototype.getStoreStyle(loginObj),
+ },
+ stores.map(function (store) {
+ return m(
+ "option",
+ {
+ value: store.id,
+ selected: store.id == vnode.attrs.storeid,
+ style: getStoreStyle(store),
+ },
+ store.name
+ );
+ })
+ ),
+ m("div.storePath", storePath),
+ ]),
+ m("div.path", [
+ m("input[type=text].filePath", {
+ disabled: editing,
+ title: "File path of credentials within password-store.",
+ placeholder: "filename",
+ value: loginObj.login,
+ style: `${getPathWidth(loginObj)}`,
+ oninput: m.withAttr("value", this.setLogin),
+ onfocus: m.withAttr("value", this.setLogin),
+ onkeydown: pathKeyHandler.bind(vnode),
+ }),
+ m(`div.suffix${editing ? ".disabled" : ""}`, ".gpg"),
+ ]),
+ canTree && storeDirs.length > 0
+ ? m(
+ "div#tree-dirs",
+ m(
+ "div.dropdown",
+ storeDirs.map(function (dirText) {
+ return m(
+ "a.directory",
+ {
+ tabindex: 0,
+ value: dirText,
+ onkeydown: pathKeyHandler.bind(vnode),
+ onclick: clickDirectoryHandler.bind(vnode),
+ },
+ `${dirText}/`
+ );
+ })
+ )
+ )
+ : null,
+ ]),
+ m("div.contents", [
+ m("div.password", [
+ m("label", { for: "secret" }, "Secret"),
+ m(
+ "div.chars",
+ loginObj.hasOwnProperty("fields")
+ ? helpers.highlight(loginObj.fields.secret)
+ : ""
+ ),
+ m("div.btn.generate", {
+ title: "Generate password",
+ onclick: () => {
+ loginObj.setPassword(
+ loginObj.generateSecret(passwordLength, symbols)
+ );
+ passwordGenerated = true;
+ },
+ }),
+ ]),
+ m("div.options", [
+ m("label", { for: "include_symbols" }, "Symbols"),
+ m("input[type=checkbox]", {
+ id: "include_symbols",
+ checked: symbols,
+ onchange: m.withAttr("checked", this.setSymbols),
+ onclick: (e) => {
+ // disable redraw, otherwise check is cleared too fast
+ e.redraw = false;
+ },
+ title: "Include symbols in generated password",
+ value: 1,
+ }),
+ m("label", { for: "length" }, "Length"),
+ m("input[type=number]", {
+ id: "length",
+ title: "Length of generated password",
+ value: passwordLength,
+ oninput: m.withAttr("value", this.setPasswordLength),
+ }),
+ ]),
+ m(
+ "div.details",
+ m("textarea", {
+ placeholder: `yourSecretPassword
+
+user: johnsmith`,
+ value: loginObj.raw || "", //.trim(),
+ oninput: m.withAttr("value", this.setRawDetails),
+ })
+ ),
+ ])
+ );
+
+ if (
+ Settings.prototype.canDelete(settings) ||
+ Settings.prototype.canSave(settings)
+ ) {
+ nodes.push(
+ m(
+ "div.actions",
+ {
+ oncreate: (vnode) => {
+ // create capturing events, not bubbling
+ document
+ .querySelector("div.actions")
+ .addEventListener(
+ "click",
+ clearStoreDirs.bind(vnode),
+ true
+ );
+ },
+ },
+ [
+ editing && Settings.prototype.canDelete(settings)
+ ? m(
+ "button.delete",
+ {
+ title: "Delete credentials",
+ onclick: (e) => {
+ e.preventDefault();
+
+ dialog.open(
+ `Are you sure you want to delete the file from ${loginObj.store.name}? ${loginObj.login}`,
+ async (remove) => {
+ if (!remove) {
+ return;
+ }
+
+ const uuid = notify.warningMsg(
+ m.trust(
+ `Please wait, while we delete: ${loginObj.login}`
+ )
+ );
+ await Login.prototype.delete(loginObj);
+ notify.removeMsg(uuid);
+ notify.successMsg(
+ m.trust(
+ `Deleted password entry, ${loginObj.login}, from ${loginObj.store.name}.`
+ )
+ );
+ setTimeout(window.close, 3000);
+ m.route.set("/list");
+ }
+ );
+ },
+ },
+ "Delete"
+ )
+ : null,
+ Settings.prototype.canSave(settings)
+ ? m(
+ "button.save",
+ {
+ title: "Save credentials",
+ onclick: async (e) => {
+ e.preventDefault();
+
+ if (!Login.prototype.isValid(loginObj)) {
+ notify.errorMsg(
+ "Credentials are incomplete, please fix and try again."
+ );
+ return;
+ }
+
+ // when adding, make sure file doesn't already exist
+ if (
+ !editing &&
+ layout.storeIncludesLogin(
+ loginObj.store.id,
+ loginObj.login
+ )
+ ) {
+ notify.errorMsg(
+ m.trust(
+ `Cannot add login, same filename already exists in ${loginObj.store.name}. Please use edit instead.`
+ )
+ );
+ return;
+ }
+
+ const uuid = notify.infoMsg(
+ m.trust(
+ `Please wait, while we save: ${loginObj.login}`
+ )
+ );
+ await Login.prototype.save(loginObj);
+ notify.removeMsg(uuid);
+ notify.successMsg(
+ m.trust(
+ `Password entry, ${loginObj.login}, has been saved to ${loginObj.store.name}.`
+ )
+ );
+ setTimeout(window.close, 3000);
+ m.route.set("/list");
+ },
+ },
+ "Save"
+ )
+ : null,
+ ]
+ )
+ );
+ }
+
+ return m("div.addEdit", nodes);
+ },
+ };
+ };
+}
diff --git a/src/popup/colors-dark.less b/src/popup/colors-dark.less
index 768da75c..9c51588e 100644
--- a/src/popup/colors-dark.less
+++ b/src/popup/colors-dark.less
@@ -20,6 +20,32 @@
@snack-color: #525252,
@snack-label-color: #afafaf,
@progress-color: #bd861a,
- @edit-bg-color: #4a4a4a
+ @edit-bg-color: #4a4a4a,
+
+ // tree browser colors
+ @treeopt-bg-color: #2e2e2e,
+
+ // notifications
+ @ntfy-hover-shadow: rgba(245, 245, 245, 0.7),
+ @ntfy-info-color: #aaf3ff,
+ @ntfy-info-bgcolor: #0c525e,
+ @ntfy-info-border: #bee5eb,
+ @ntfy-warning-color: #fff3d1,
+ @ntfy-warning-bgcolor: #684e03,
+ @ntfy-warning-border: #ffeeba,
+ @ntfy-error-color: #ffd6d9,
+ @ntfy-error-bgcolor: #540000,
+ @ntfy-error-border: #f5c6cb,
+ @ntfy-success-color: #dcffe4,
+ @ntfy-success-bgcolor: #186029,
+ @ntfy-success-border: #c3e6cb
);
+
+ .details {
+ .loading {
+ background: url(/popup/page-loader-dark.gif) center no-repeat;
+ background-size: 150px;
+ background-position-y: 50px;
+ }
+ }
}
diff --git a/src/popup/colors-light.less b/src/popup/colors-light.less
index ab7df917..e8903911 100644
--- a/src/popup/colors-light.less
+++ b/src/popup/colors-light.less
@@ -20,14 +20,38 @@
@snack-color: #7a7a7a,
@snack-label-color: #7a7a7a,
@progress-color: #c7d5ff,
- @edit-bg-color: #ffffff
+ @edit-bg-color: #ffffff,
+
+ // tree browser colors
+ @treeopt-bg-color: #464545,
+
+ // notifications
+ @ntfy-hover-shadow: rgba(0, 0, 0, 0.3),
+ @ntfy-info-color: #0c525e,
+ @ntfy-info-bgcolor: #d1ecf1,
+ @ntfy-info-border: #bee5eb,
+ @ntfy-warning-color: #684e03,
+ @ntfy-warning-bgcolor: #fff3cd,
+ @ntfy-warning-border: #ffeeba,
+ @ntfy-error-color: #721c24,
+ @ntfy-error-bgcolor: #f8d7da,
+ @ntfy-error-border: #f5c6cb,
+ @ntfy-success-color: #155624,
+ @ntfy-success-bgcolor: #d4edda,
+ @ntfy-success-border: #c3e6cb
);
.part.login .name .line1 .recent,
.part.login .action.copy-password,
.part.login .action.copy-user,
.part.login .action.details,
- .part.details .action.copy {
+ .part.login .action.edit,
+ .part.login .action.save,
+ .part.login .action.delete,
+ .part.details .action.copy,
+ .title .btn.back,
+ .title .btn.save,
+ .title .btn.edit {
filter: invert(85%);
}
@@ -38,7 +62,13 @@
.part.login .action.copy-user:focus,
.part.login .action.copy-user:hover,
.part.login .action.details:focus,
- .part.login .action.details:hover {
+ .part.login .action.details:hover,
+ .title .btn.back:focus,
+ .title .btn.back:hover,
+ .title .btn.save:focus,
+ .title .btn.save:hover,
+ .title .btn.edit:focus,
+ .title .btn.edit:hover {
// colour such that invert(85%) ~= @hover-bg-color
background-color: #0c0804;
}
@@ -54,4 +84,12 @@
height: 34px;
}
}
+
+ .details {
+ .loading {
+ background: url(/popup/page-loader.gif) center no-repeat;
+ background-size: 150px;
+ background-position-y: 50px;
+ }
+ }
}
diff --git a/src/popup/colors.less b/src/popup/colors.less
index 34e17246..53cee572 100644
--- a/src/popup/colors.less
+++ b/src/popup/colors.less
@@ -17,7 +17,23 @@
@snack-color,
@snack-label-color,
@progress-color,
- @edit-bg-color) {
+ @edit-bg-color,
+ // tree browser colors
+ @treeopt-bg-color,
+ // notification colors
+ @ntfy-hover-shadow,
+ @ntfy-info-color,
+ @ntfy-info-bgcolor,
+ @ntfy-info-border,
+ @ntfy-warning-color,
+ @ntfy-warning-bgcolor,
+ @ntfy-warning-border,
+ @ntfy-error-color,
+ @ntfy-error-bgcolor,
+ @ntfy-error-border,
+ @ntfy-success-color,
+ @ntfy-success-bgcolor,
+ @ntfy-success-border) {
html,
body {
background-color: @bg-color;
@@ -28,10 +44,89 @@
background-color: @badge-color;
}
+ .addEdit,
+ .details {
+ .btn {
+ &:hover,
+ &:focus {
+ background-color: @hover-bg-color;
+ }
+
+ &.generate {
+ background-color: @edit-bg-color;
+ }
+ }
+
+ .contents {
+ .password {
+ background-color: @edit-bg-color;
+ }
+
+ .password,
+ .details {
+ border-color: @snack-color;
+ }
+ }
+
+ .location {
+ select {
+ background-color: @input-bg-color;
+ color: @input-text-color;
+ }
+
+ .path {
+ background-color: @edit-bg-color;
+ border-color: @snack-color;
+ color: @text-color;
+ }
+ }
+
+ .actions {
+ .save {
+ background-color: @ntfy-success-bgcolor;
+ border: 2px solid @ntfy-success-border;
+ color: @ntfy-success-color;
+ }
+ .delete {
+ background-color: @ntfy-error-bgcolor;
+ border: 2px solid @ntfy-error-border;
+ color: @ntfy-error-color;
+ }
+ .save,
+ .delete {
+ &:hover {
+ box-shadow: 1px 1px 2px @ntfy-hover-shadow;
+ }
+ }
+ }
+
+ label {
+ background-color: @snack-label-color;
+ color: @invert-text-color;
+ }
+
+ input[type="number"],
+ input[type="text"],
+ textarea {
+ background-color: @edit-bg-color;
+ border-color: @snack-color;
+ color: @text-color;
+ }
+ }
+
.part.error {
color: @error-text-color;
}
+ .part.add {
+ background-color: @bg-color;
+ &:hover,
+ &:focus {
+ outline: none;
+ background-color: @hover-bg-color;
+ }
+ }
+
.part.details {
.part {
&.snack {
@@ -41,10 +136,6 @@
background-color: @snack-label-color;
color: @invert-text-color;
}
- .char.num,
- .char.punct {
- color: @match-text-color;
- }
.progress-container {
background: transparent;
.progress {
@@ -60,6 +151,11 @@
}
}
+ .char.num,
+ .char.punct {
+ color: @match-text-color;
+ }
+
.part.search {
background-color: @input-bg-color;
}
@@ -107,4 +203,67 @@
color: @error-text-color;
}
}
+
+ .m-notifications {
+ .m-notification {
+ &:hover {
+ box-shadow: 1px 1px 2px @ntfy-hover-shadow;
+ }
+
+ &.info {
+ color: @ntfy-info-color;
+ background-color: @ntfy-info-bgcolor;
+ border: 1px solid @ntfy-info-border;
+ }
+
+ &.warning {
+ color: @ntfy-warning-color;
+ background-color: @ntfy-warning-bgcolor;
+ border: 1px solid @ntfy-warning-border;
+ }
+
+ &.error {
+ color: @ntfy-error-color;
+ background-color: @ntfy-error-bgcolor;
+ border: 1px solid @ntfy-error-border;
+ }
+
+ &.success {
+ color: @ntfy-success-color;
+ background-color: @ntfy-success-bgcolor;
+ border: 1px solid @ntfy-success-border;
+ }
+ }
+ }
+
+ div#tree-dirs {
+ div.dropdown {
+ a {
+ background-color: @treeopt-bg-color;
+
+ color: #fff;
+ &:hover,
+ &:focus-visible {
+ filter: invert(15%);
+ outline: none;
+ }
+ }
+ }
+ }
+
+ dialog#browserpass-modal {
+ color: @ntfy-info-color;
+ background-color: @ntfy-info-bgcolor;
+ border: 3px solid @ntfy-info-border;
+
+ button {
+ &:hover {
+ box-shadow: 1px 1px 2px @ntfy-hover-shadow;
+ }
+
+ color: @ntfy-warning-color;
+ background-color: @ntfy-warning-bgcolor;
+ border: 1px solid @ntfy-warning-border;
+ }
+ }
}
diff --git a/src/popup/detailsInterface.js b/src/popup/detailsInterface.js
index f3f0c9dc..2d36f1bc 100644
--- a/src/popup/detailsInterface.js
+++ b/src/popup/detailsInterface.js
@@ -3,142 +3,221 @@ module.exports = DetailsInterface;
const m = require("mithril");
const Moment = require("moment");
const helpers = require("../helpers");
+const layout = require("./layoutInterface");
+const Login = require("./models/Login");
+const Settings = require("./models/Settings");
+const notify = require("./notifications");
+
+var persistSettingsModel = {};
/**
* Login details interface
*
- * @since 3.6.0
+ * @since 3.8.0
*
- * @param object settings Settings object
- * @param array login Target login object
- * @return void
+ * @param object settings Settings model object
+ * @return function View component
*/
-function DetailsInterface(settings, login) {
- // public methods
- this.attach = attach;
- this.view = view;
+function DetailsInterface(settingsModel) {
+ persistSettingsModel = settingsModel;
- //fields
- this.settings = settings;
- this.login = login;
+ /**
+ * DetailsView
+ *
+ * @since 3.8.0
+ *
+ * @param object vnode current vnode object
+ */
+ return function (vnode) {
+ // set default state
+ var settings = {},
+ loading = true,
+ loginObj = new Login(persistSettingsModel, {
+ basename: "",
+ dirname: "",
+ store: {},
+ settings: settings,
+ }),
+ viewSettingsModel = persistSettingsModel;
- // get basename & dirname of entry
- this.login.basename = this.login.login.substr(this.login.login.lastIndexOf("/") + 1);
- this.login.dirname = this.login.login.substr(0, this.login.login.lastIndexOf("/")) + "/";
-}
+ return {
+ // public methods
+ /**
+ * Initialize compoenent: get settings and login
+ *
+ * @since 3.8.0
+ *
+ * @param object vnode current vnode instance
+ */
+ oninit: async function (vnode) {
+ settings = await viewSettingsModel.get();
+ try {
+ let tmpLogin = layout.getCurrentLogin();
+ if (
+ tmpLogin != null &&
+ Login.prototype.isLogin(tmpLogin) &&
+ tmpLogin.store.id == vnode.attrs.context.storeid &&
+ tmpLogin.login == vnode.attrs.context.login
+ ) {
+ // when returning from edit page
+ loginObj = tmpLogin;
+ } else {
+ loginObj = await Login.prototype.get(
+ settings,
+ vnode.attrs.context.storeid,
+ vnode.attrs.context.login
+ );
+ }
+ } catch (error) {
+ console.log(error);
+ notify.errorMsg(error.toString(), 0);
+ m.route.set("/list");
+ }
-/**
- * Attach the interface on the given element
- *
- * @since 3.6.0
- *
- * @param DOMElement element Target element
- * @return void
- */
-function attach(element) {
- m.mount(element, this);
-}
+ // get basename & dirname of entry
+ loginObj.basename = loginObj.login.substr(loginObj.login.lastIndexOf("/") + 1);
+ loginObj.dirname = loginObj.login.substr(0, loginObj.login.lastIndexOf("/")) + "/";
-/**
- * Generates vnodes for render
- *
- * @since 3.6.0
- *
- * @param function ctl Controller
- * @param object params Runtime params
- * @return []Vnode
- */
-function view(ctl, params) {
- const login = this.login;
- const storeBgColor = login.store.bgColor || login.store.settings.bgColor;
- const storeColor = login.store.color || login.store.settings.color;
- const passChars = login.fields.secret.split("").map((c) => {
- if (c.match(/[0-9]/)) {
- return m("span.char.num", c);
- } else if (c.match(/[^\w\s]/)) {
- return m("span.char.punct", c);
- }
- return m("span.char", c);
- });
+ // trigger redraw after retrieving details
+ layout.setCurrentLogin(loginObj);
+ loading = false;
+ m.redraw();
+ },
+ /**
+ * Generates vnodes for render
+ *
+ * @since 3.6.0
+ *
+ * @param object vnode
+ * @return []Vnode
+ */
+ view: function (vnode) {
+ const login = loginObj;
+ const storeBgColor = Login.prototype.getStore(loginObj, "bgColor");
+ const storeColor = Login.prototype.getStore(loginObj, "color");
+ const secret =
+ (loginObj.hasOwnProperty("fields") ? loginObj.fields.secret : null) || "";
+ const passChars = helpers.highlight(secret);
- var nodes = [];
- nodes.push(
- m("div.part.login.details-header", [
- m("div.name", [
- m("div.line1", [
- m(
- "div.store.badge",
- {
- style: `background-color: ${storeBgColor};
+ var nodes = [];
+ nodes.push(
+ m("div.title", [
+ m("div.btn.back", {
+ title: "Back to list",
+ onclick: () => {
+ m.route.set("/list");
+ },
+ }),
+ m("span", "View credentials"),
+ m("div.btn.edit", {
+ title: `Edit ${login.basename}`,
+ oncreate: m.route.link,
+ href: `/edit/${loginObj.store.id}/${encodeURIComponent(
+ loginObj.login
+ )}`,
+ }),
+ ]),
+ m("div.part.login.details-header", [
+ m("div.name", [
+ m("div.line1", [
+ m(
+ "div.store.badge",
+ {
+ style: `background-color: ${storeBgColor};
color: ${storeColor}`,
- },
- login.store.name
- ),
- m("div.path", [m.trust(login.dirname)]),
- login.recent.when > 0
- ? m("div.recent", {
- title:
- "Used here " +
- login.recent.count +
- " time" +
- (login.recent.count > 1 ? "s" : "") +
- ", last " +
- Moment(new Date(login.recent.when)).fromNow(),
- })
- : null,
- ]),
- m("div.line2", [m.trust(login.basename)]),
- ]),
- ]),
- m("div.part.details", [
- m("div.part.snack.line-secret", [
- m("div.label", "Secret"),
- m("div.chars", passChars),
- m("div.action.copy", { onclick: () => login.doAction("copyPassword") }),
- ]),
- m("div.part.snack.line-login", [
- m("div.label", "Login"),
- m("div", login.fields.login),
- m("div.action.copy", { onclick: () => login.doAction("copyUsername") }),
- ]),
- (() => {
- if (
- helpers.getSetting("enableOTP", login, this.settings) &&
- login.fields.otp &&
- login.fields.otp.params.type === "totp"
- ) {
- // update progress
- let progress = this.progress;
- let updateProgress = (vnode) => {
- let period = login.fields.otp.params.period;
- let remaining = period - ((Date.now() / 1000) % period);
- vnode.dom.style.transition = "none";
- vnode.dom.style.width = `${(remaining / period) * 100}%`;
- setTimeout(function () {
- vnode.dom.style.transition = `width linear ${remaining}s`;
- vnode.dom.style.width = "0%";
- }, 100);
- setTimeout(function () {
- m.redraw();
- }, remaining);
- };
- let progressNode = m("div.progress", {
- oncreate: updateProgress,
- onupdate: updateProgress,
- });
+ },
+ login.store.name
+ ),
+ m("div.path", [m.trust(login.dirname)]),
+ login.recent.when > 0
+ ? m("div.recent", {
+ title:
+ "Used here " +
+ login.recent.count +
+ " time" +
+ (login.recent.count > 1 ? "s" : "") +
+ ", last " +
+ Moment(new Date(login.recent.when)).fromNow(),
+ })
+ : null,
+ ]),
+ m("div.line2", [m.trust(login.basename)]),
+ ]),
+ ]),
+ m("div.part.details", [
+ m("div.part.snack.line-secret", [
+ m("div.label", "Secret"),
+ m("div.chars", passChars),
+ m("div.action.copy", {
+ title: "Copy Password",
+ onclick: () => login.doAction("copyPassword"),
+ }),
+ ]),
+ m("div.part.snack.line-login", [
+ m("div.label", "Login"),
+ m("div", login.hasOwnProperty("fields") ? login.fields.login : ""),
+ m("div.action.copy", {
+ title: "Copy Username",
+ onclick: () => login.doAction("copyUsername"),
+ }),
+ ]),
+ (() => {
+ if (
+ Settings.prototype.isSettings(settings) &&
+ Login.prototype.getStore(login, "enableOTP") &&
+ login.fields.otp &&
+ login.fields.otp.params.type === "totp"
+ ) {
+ // update progress
+ // let progress = progress;
+ let updateProgress = (vnode) => {
+ let period = login.fields.otp.params.period;
+ let remaining = period - ((Date.now() / 1000) % period);
+ vnode.dom.style.transition = "none";
+ vnode.dom.style.width = `${(remaining / period) * 100}%`;
+ setTimeout(function () {
+ vnode.dom.style.transition = `width linear ${remaining}s`;
+ vnode.dom.style.width = "0%";
+ }, 100);
+ setTimeout(function () {
+ m.redraw();
+ }, remaining);
+ };
+ let progressNode = m("div.progress", {
+ oncreate: updateProgress,
+ onupdate: updateProgress,
+ });
- // display otp snack
- return m("div.part.snack.line-otp", [
- m("div.label", "Token"),
- m("div.progress-container", progressNode),
- m("div", helpers.makeTOTP(login.fields.otp.params)),
- m("div.action.copy", { onclick: () => login.doAction("copyOTP") }),
- ]);
- }
- })(),
- m("div.part.raw", m("textarea", login.raw.trim())),
- ])
- );
+ // display otp snack
+ return m("div.part.snack.line-otp", [
+ m("div.label", "Token"),
+ m("div.progress-container", progressNode),
+ m("div", helpers.makeTOTP(login.fields.otp.params)),
+ m("div.action.copy", {
+ title: "Copy OTP",
+ onclick: () => login.doAction("copyOTP"),
+ }),
+ ]);
+ }
+ })(),
+ m(
+ "div.part.raw",
+ m(
+ "textarea",
+ {
+ disabled: true,
+ },
+ login.raw || ""
+ )
+ ),
+ ])
+ );
- return nodes;
+ return m(
+ "div.details",
+ loading ? m(".loading", m("p", "Loading please wait ...")) : nodes
+ );
+ },
+ };
+ };
}
diff --git a/src/popup/icon-back.svg b/src/popup/icon-back.svg
new file mode 100644
index 00000000..e11e7ef2
--- /dev/null
+++ b/src/popup/icon-back.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/src/popup/icon-delete.svg b/src/popup/icon-delete.svg
new file mode 100644
index 00000000..8bdb2740
--- /dev/null
+++ b/src/popup/icon-delete.svg
@@ -0,0 +1,10 @@
+
+
+
diff --git a/src/popup/icon-edit.svg b/src/popup/icon-edit.svg
new file mode 100644
index 00000000..76a69273
--- /dev/null
+++ b/src/popup/icon-edit.svg
@@ -0,0 +1,23 @@
+
+
+
diff --git a/src/popup/icon-generate.svg b/src/popup/icon-generate.svg
new file mode 100644
index 00000000..f8aed931
--- /dev/null
+++ b/src/popup/icon-generate.svg
@@ -0,0 +1,8 @@
+
+
+
diff --git a/src/popup/icon-save.svg b/src/popup/icon-save.svg
new file mode 100644
index 00000000..64811497
--- /dev/null
+++ b/src/popup/icon-save.svg
@@ -0,0 +1,24 @@
+
+
+
diff --git a/src/popup/interface.js b/src/popup/interface.js
index d96c7528..4054e45a 100644
--- a/src/popup/interface.js
+++ b/src/popup/interface.js
@@ -3,9 +3,9 @@ module.exports = Interface;
const m = require("mithril");
const Moment = require("moment");
const SearchInterface = require("./searchinterface");
+const layout = require("./layoutInterface");
const helpers = require("../helpers");
-
-const LATEST_NATIVE_APP_VERSION = 3000003;
+const Settings = require("./models/Settings");
/**
* Popup main interface
@@ -20,13 +20,16 @@ function Interface(settings, logins) {
// public methods
this.attach = attach;
this.view = view;
+ this.renderMainView = renderMainView;
this.search = search;
// fields
this.settings = settings;
this.logins = logins;
+
this.results = [];
- this.currentDomainOnly = !settings.tab.url.match(/^(chrome|about):/);
+ // check for chromium based browsers setting tab
+ this.currentDomainOnly = !settings.tab.url.match(/^(chrome|brave|edge|opera|vivaldi|about):/);
this.searchPart = new SearchInterface(this);
// initialise with empty search
@@ -55,6 +58,32 @@ function attach(element) {
* @return []Vnode
*/
function view(ctl, params) {
+ const nodes = [];
+ // clear last viewed login
+ layout.setCurrentLogin(null);
+
+ nodes.push(...this.renderMainView(ctl, params));
+
+ if (this.settings.version < helpers.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;
+}
+
+function renderMainView(ctl, params) {
var nodes = [];
nodes.push(m(this.searchPart));
@@ -68,44 +97,53 @@ function view(ctl, params) {
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),
+ tabindex: 0,
},
[
- m("div.name", { title: "Fill username / password | " }, [
- m("div.line1", [
- m(
- "div.store.badge",
- {
- style: `background-color: ${storeBgColor};
+ m(
+ "div.name",
+ {
+ key: result.index,
+ tabindex: 0,
+ title: "Fill username / password | ",
+ onclick: function (e) {
+ result.doAction("fill");
+ },
+ onkeydown: keyHandler.bind(result),
+ },
+ [
+ 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)]),
- ]),
+ },
+ 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-user", {
tabindex: 0,
title: "Copy username | ",
@@ -119,30 +157,67 @@ function view(ctl, params) {
m("div.action.details", {
tabindex: 0,
title: "Open Details | ",
- action: "getDetails",
+ oncreate: m.route.link,
+ onupdate: m.route.link,
+ href: `/details/${result.store.id}/${encodeURIComponent(
+ result.loginPath
+ )}`,
}),
]
);
})
+ ),
+ m(
+ "div.part.add",
+ {
+ tabindex: 0,
+ title: "Add new login | ",
+ oncreate: m.route.link,
+ onupdate: m.route.link,
+ href: `/add`,
+ onkeydown: (e) => {
+ e.preventDefault();
+
+ function goToElement(element) {
+ element.focus();
+ element.scrollIntoView();
+ }
+
+ let lastLogin = document.querySelector(".logins").lastChild;
+ let searchInput = document.querySelector(".part.search input[type=text]");
+ switch (e.code) {
+ case "Tab":
+ if (e.shiftKey) {
+ goToElement(lastLogin);
+ } else {
+ goToElement(searchInput);
+ }
+ break;
+ case "Home":
+ goToElement(searchInput);
+ break;
+ case "ArrowUp":
+ goToElement(lastLogin);
+ break;
+ case "ArrowDown":
+ goToElement(searchInput);
+ break;
+ case "Enter":
+ e.target.click();
+ case "KeyA":
+ if (e.ctrlKey) {
+ e.target.click();
+ }
+ break;
+ default:
+ break;
+ }
+ },
+ },
+ "Add credentials"
)
);
- 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;
}
@@ -166,12 +241,15 @@ function search(searchQuery) {
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 if (e.target == document.querySelector(".logins").lastChild) {
+ document.querySelector(".part.add").focus();
} else {
document.querySelector(".part.search input[type=text]").focus();
}
@@ -179,6 +257,8 @@ function keyHandler(e) {
case "ArrowDown":
if (login.nextElementSibling) {
login.nextElementSibling.focus();
+ } else {
+ document.querySelector(".part.add").focus();
}
break;
case "ArrowUp":
@@ -194,7 +274,7 @@ function keyHandler(e) {
} else if (e.target.nextElementSibling) {
e.target.nextElementSibling.focus();
} else {
- this.doAction("getDetails");
+ e.target.click();
}
break;
case "ArrowLeft":
@@ -207,10 +287,17 @@ function keyHandler(e) {
case "Enter":
if (e.target.hasAttribute("action")) {
this.doAction(e.target.getAttribute("action"));
+ } else if (e.target.classList.contains("details")) {
+ e.target.click();
} else {
this.doAction("fill");
}
break;
+ case "KeyA":
+ if (e.ctrlKey) {
+ document.querySelector(".part.add").click();
+ }
+ break;
case "KeyC":
if (e.ctrlKey) {
if (e.shiftKey || document.activeElement.classList.contains("copy-user")) {
@@ -227,7 +314,7 @@ function keyHandler(e) {
break;
case "KeyO":
if (e.ctrlKey) {
- this.doAction("getDetails");
+ e.target.querySelector("div.action.details").click();
}
break;
case "Home": {
@@ -240,9 +327,9 @@ function keyHandler(e) {
let logins = document.querySelectorAll(".login");
if (logins.length) {
let target = logins.item(logins.length - 1);
- target.focus();
target.scrollIntoView();
}
+ document.querySelector(".part.add").focus();
break;
}
}
diff --git a/src/popup/layoutInterface.js b/src/popup/layoutInterface.js
new file mode 100644
index 00000000..aeafb681
--- /dev/null
+++ b/src/popup/layoutInterface.js
@@ -0,0 +1,175 @@
+// libs
+const m = require("mithril");
+// components
+const dialog = require("./modalDialog");
+const Notifications = require("./notifications");
+// models
+const Settings = require("./models/Settings");
+const Tree = require("./models/Tree");
+
+/**
+ * Page / layout wrapper component. Used to share global
+ * message and ui component functionality with any child
+ * components.
+ *
+ * Also maintain a pseudo session state.
+ */
+
+let session = {
+ // current decrypted login object
+ current: null,
+ // map of store key to array of login files
+ logins: {},
+ // settings
+ settings: null,
+ // Tree instances with storeId as key
+ trees: null,
+};
+
+/**
+ * Page layout component
+ * @since 3.8.0
+ */
+let LayoutInterface = {
+ oncreate: async function (vnode) {
+ if (
+ Settings.prototype.isSettings(session.settings) &&
+ Settings.prototype.canTree(session.settings)
+ ) {
+ session.trees = await Tree.prototype.getAll(session.settings);
+ }
+ document.addEventListener("keydown", esacpeKeyHandler);
+ },
+ view: function (vnode) {
+ vnode.children.push(m(Notifications));
+ vnode.children.push(m(dialog));
+
+ return m(".layout", vnode.children);
+ },
+};
+
+/**
+ * Set login on details page after successful decrpytion
+ * @since 3.8.0
+ *
+ * @param {object} login set session login object
+ */
+function setCurrentLogin(login) {
+ session.current = login;
+}
+
+/**
+ * Get current login on edit page to avoid 2nd decryption request
+ * @since 3.8.0
+ *
+ * @returns {object} current login object
+ */
+function getCurrentLogin() {
+ return session.current;
+}
+
+/**
+ * Respond with boolean if combination of store id and login currently exist.
+ *
+ * @since 3.8.0
+ *
+ * @param {string} storeId unique store id
+ * @param {string} login relative file path without file extension
+ * @returns {boolean}
+ */
+function storeIncludesLogin(storeId, login) {
+ if (!session.logins[storeId]) {
+ return false;
+ }
+
+ if (!session.logins[storeId].length) {
+ return false;
+ }
+
+ search = `${login.trim().trimStart("/")}.gpg`;
+ return session.logins[storeId].includes(search);
+}
+
+/**
+ * Set session object containing list of password files.
+ *
+ * @since 3.8.0
+ *
+ * @param {object} logins raw untouched object with store id
+ * as keys each an array containing list of files for respective
+ * password store.
+ */
+function setStoreLogins(logins = {}) {
+ if (Object.prototype.isPrototypeOf(logins)) {
+ session.logins = logins;
+ }
+}
+
+/**
+ * Store a single settings object for the current session
+ * @since 3.8.0
+ *
+ * @param {object} settings settings object
+ */
+function setSessionSettings(settings) {
+ if (Settings.prototype.isSettings(settings)) {
+ session.settings = settings;
+ }
+}
+
+/**
+ * Get settings object for the current session
+ * @since 3.8.0
+ *
+ * @returns {object} settings object
+ */
+function getSessionSettings() {
+ return session.settings;
+}
+
+function getStoreTree(storeId) {
+ return session.trees[storeId];
+}
+
+/**
+ * Handle all keydown events on the dom for the Escape key
+ *
+ * @since 3.8.0
+ *
+ * @param {object} e keydown event
+ */
+function esacpeKeyHandler(e) {
+ switch (e.code) {
+ case "Escape":
+ // stop escape from closing pop up
+ e.preventDefault();
+ let path = m.route.get();
+
+ if (path == "/add") {
+ if (document.querySelector("#tree-dirs") == null) {
+ // dir tree already hidden, go to previous page
+ m.route.set("/list");
+ } else {
+ // trigger click on an element other than input filename
+ // which does not have a click handler, to close drop down
+ document.querySelector(".store .storePath").click();
+ }
+ } else if (path.startsWith("/details")) {
+ m.route.set("/list");
+ } else if (path.startsWith("/edit")) {
+ m.route.set(path.replace(/^\/edit/, "/details"));
+ }
+ break;
+ }
+}
+
+module.exports = {
+ LayoutInterface,
+ getCurrentLogin,
+ getSessionSettings,
+ getStoreTree,
+ setCurrentLogin,
+ setStoreLogins,
+ setSessionSettings,
+ storeIncludesLogin,
+};
diff --git a/src/popup/modalDialog.js b/src/popup/modalDialog.js
new file mode 100644
index 00000000..9d394b1b
--- /dev/null
+++ b/src/popup/modalDialog.js
@@ -0,0 +1,105 @@
+const m = require("mithril");
+
+const modalId = "browserpass-modal";
+const CANCEL = "Cancel";
+const CONFIRM = "Confirm";
+
+/**
+ * Basic mirthil dialog component. Shows modal dialog with
+ * provided message content and passes back boolean
+ * user response via a callback function.
+ */
+
+var callBackFn = null,
+ cancelButtonText = null,
+ confirmButtonText = null,
+ modalElement = null,
+ modalContent = null;
+
+/**
+ * Handle modal button click.
+ *
+ * Trigger callback with boolean response, hide modal, clear values.
+ *
+ * @since 3.8.0
+ *
+ */
+function buttonClick(response = false) {
+ // run action handler
+ if (typeof callBackFn === "function") {
+ callBackFn(response);
+ }
+
+ // close and clear modal content state
+ modalElement.close();
+ callBackFn = null;
+ modalContent = null;
+}
+
+let Modal = {
+ view: (node) => {
+ return m("dialog", { id: modalId }, [
+ m(".modal-content", {}, m.trust(modalContent)),
+ m(".modal-actions", {}, [
+ m(
+ "button.cancel",
+ {
+ onclick: () => {
+ buttonClick(false);
+ },
+ },
+ cancelButtonText
+ ),
+ m(
+ "button.confirm",
+ {
+ onclick: () => {
+ buttonClick(true);
+ },
+ },
+ confirmButtonText
+ ),
+ ]),
+ ]);
+ },
+ /**
+ * Show dialog component after args validation
+ *
+ * @since 3.8.0
+ *
+ * @param {string} message message or html to render in main body of dialog
+ * @param {function} callback function which accepts a single boolean argument
+ * @param {string} cancelText text to display on the negative response button
+ * @param {string} confirmText text to display on the positive response button
+ */
+ open: (
+ message = "",
+ callback = (resp = false) => {},
+ cancelText = CANCEL,
+ confirmText = CONFIRM
+ ) => {
+ if (!message.length || typeof callback !== "function") {
+ return null;
+ }
+
+ if (typeof cancelText == "string" && cancelText.length) {
+ cancelButtonText = cancelText;
+ } else {
+ cancelButtonText = CANCEL;
+ }
+
+ if (typeof confirmText == "string" && confirmText.length) {
+ confirmButtonText = confirmText;
+ } else {
+ confirmButtonText = CONFIRM;
+ }
+
+ modalElement = document.getElementById(modalId);
+ callBackFn = callback;
+ modalContent = message;
+ modalElement.showModal();
+ m.redraw();
+ },
+};
+
+module.exports = Modal;
diff --git a/src/popup/models/Login.js b/src/popup/models/Login.js
new file mode 100644
index 00000000..78b6614f
--- /dev/null
+++ b/src/popup/models/Login.js
@@ -0,0 +1,451 @@
+"use strict";
+
+require("chrome-extension-async");
+const sha1 = require("sha1");
+const helpers = require("../../helpers");
+const Settings = require("./Settings");
+
+// Search for one of the secret prefixes
+// from Array helpers.fieldsPrefix.secret
+const
+ multiLineSecretRegEx = RegExp(`^(${helpers.fieldsPrefix.secret.join("|")}): `, 'mi')
+;
+
+/**
+ * Login Constructor()
+ *
+ * @since 3.8.0
+ *
+ * @param {object} settings
+ * @param {object} login (optional) Extend an existing
+ * login object to be backwards and forwards compatible.
+ */
+function Login(settings, login = {}) {
+ if (Login.prototype.isLogin(login)) {
+ // content sha used to determine if login has changes, see Login.prototype.isNew
+ this.contentSha = sha1(login.login + sha1(login.raw || ''));
+ } else {
+ this.allowFill = true;
+ this.fields = {};
+ this.host = null;
+ this.login = '';
+ this.recent = {
+ when: 0,
+ count: 0,
+ };
+ // a null content sha identifies this a new entry, see Login.prototype.isNew
+ this.contentSha = null;
+ }
+
+ // Set object properties
+ let setRaw = false;
+ for (const prop in login) {
+ this[prop] = login[prop];
+ if (prop === 'raw' && login[prop].length > 0) {
+ // update secretPrefix after everything else
+ setRaw = true;
+ }
+ }
+
+ if (setRaw) {
+ this.setRawDetails(login['raw']);
+ }
+
+ this.settings = settings;
+ // This ensures doAction works in detailInterface,
+ // and any other view in which it is necessary.
+ this.doAction = helpers.withLogin.bind({
+ settings: settings, login: login
+ });
+}
+
+
+/**
+ * Determines if the login object is new or not
+ *
+ * @since 3.8.0
+ *
+ * @returns {boolean}
+ */
+Login.prototype.isNew = function (login) {
+ return login.hasOwnProperty("contentSha") && login.contentSha === null;
+}
+
+/**
+ * Remove login entry
+ *
+ * @since 3.8.0
+ *
+ * @param {object} login Login entry to be deleted
+ * @returns {object} Response or an empty object
+ */
+Login.prototype.delete = async function (login) {
+ if (Login.prototype.isValid(login)) {
+ const request = helpers.deepCopy(login);
+
+ let response = await chrome.runtime.sendMessage({
+ action: "delete", login: request
+ });
+
+ if (response.status != "ok") {
+ throw new Error(response.message);
+ }
+ return response;
+ }
+ return {};
+}
+
+/**
+ * Generate a new password
+ *
+ * @since 3.8.0
+ *
+ * @param {int} length New secret length
+ * @param {boolean} symbols Use symbols or not, default: false
+ * @return string
+ */
+Login.prototype.generateSecret = function (
+ length = 16,
+ useSymbols = false
+) {
+ let
+ secret = "",
+ value = new Uint8Array(1),
+ alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890",
+ // double quote and backslash are at the end and escaped
+ symbols = "!#$%&'()*+,-./:;<=>?@[]^_`{|}~.\"\\",
+ options = ""
+ ;
+
+ options = (Boolean(useSymbols)) ? `${alphabet}${symbols}` : alphabet;
+
+ while (secret.length < length) {
+ crypto.getRandomValues(value);
+ if (value[0] < options.length) {
+ secret += options[value[0]];
+ }
+ }
+ return secret;
+}
+
+/**
+ * Request a list of all login files and then
+ * extend them with Login.prototype.
+ *
+ * @since 3.8.0
+ * @throws {error} host response errors
+ *
+ * @param {object} settings Settings object
+ * @returns {array} Logins
+ */
+Login.prototype.getAll = async function(settings) {
+ // get list of logins
+ let response = await chrome.runtime.sendMessage({ action: "listFiles" });
+ if (response.status != "ok") {
+ throw new Error(response.message);
+ }
+
+ let logins = []
+ helpers.prepareLogins(response.files, settings).forEach(obj => {
+ logins.push(new Login(settings, obj));
+ });
+
+ return { raw: response.files, processed: logins };
+}
+
+/**
+ * Request decrypted details of login from host for store id.
+ *
+ * @since 3.8.0
+ * @throws {error} host response errors
+ *
+ * @param {object} settings Settings object
+ * @param {string} storeid store id
+ * @param {string} lpath relative file path, with extension, of login in store
+ * @returns Login object
+ */
+Login.prototype.get = async function(settings, storeid, lpath) {
+ let login = helpers.prepareLogin(settings, storeid, lpath);
+
+ var response = await chrome.runtime.sendMessage({
+ action: "getDetails", login: login, params: {}
+ });
+
+ if (response.status != "ok") {
+ throw new Error(response.message);
+ }
+
+ return new Login(settings, response.login);
+}
+
+/**
+ * Returns fields.secret or first line from fields.raw
+ *
+ * See also Login.prototype.getRawPassword and the
+ * functions: setSecret(), setRawDetails() in src/popup/addEditInterface.js
+ *
+ * @since 3.8.0
+ *
+ * @returns {string} secret
+ */
+Login.prototype.getPassword = function() {
+ if (typeof this.fields == 'object' && this.fields.hasOwnProperty("secret")) {
+ return this.fields.secret;
+ }
+ return this.getRawPassword();
+}
+
+/**
+ * Return only password from fields.raw
+ *
+ * Is used with in combination with Login.prototype.getPassword and the
+ * functions: setSecret(), setRawDetails() in src/popup/addEditInterface.js
+ *
+ * @since 3.8.0
+ *
+ * @returns {string} secret
+ */
+Login.prototype.getRawPassword = function() {
+ if (typeof this.raw == 'string' && this.raw.length > 0) {
+ const text = this.raw;
+
+ return this.getSecretDetails(text).password;
+ }
+ return "";
+}
+
+/**
+ * Extract secret password and prefix from raw text string
+ * Private
+ * @param {string} text
+ * @returns {object}
+ */
+function getSecretDetails(text = "") {
+ let results = {
+ prefix: null,
+ password: "",
+ }
+
+ if (typeof text == 'string' && text.length > 0) {
+ let index = text.search(multiLineSecretRegEx);
+
+ // assume first line
+ if (index == -1) {
+ results.password = text.split(/[\n\r]+/, 1)[0].trim();
+ } else {
+ const secret = text.substring(index).split(/[\n\r]+/, 1)[0].trim();
+ // only take first instance of "prefix: "
+ index = secret.search(": ");
+ results.prefix = secret.substring(0, index);
+ results.password = secret.substring(index+2);
+ }
+ }
+
+ return results;
+}
+
+/**
+ * Retrieve store object. Can optionally return only sub path value.
+ *
+ * @since 3.8.0
+ *
+ * @param {object} login Login object
+ * @param {string} property (optional) store sub property path value to return
+ */
+Login.prototype.getStore = function(login, property = "") {
+ let
+ settingsValue = Settings.prototype.getStore(login.settings, property),
+ store = (login.hasOwnProperty("store")) ? login.store : {},
+ value = null
+ ;
+
+ switch (property) {
+ case "color":
+ case "bgColor":
+ if (store.hasOwnProperty(property)) {
+ value = store[property];
+ }
+ break;
+
+ default:
+ if (property != "" && store.hasOwnProperty(property)) {
+ value = store[property];
+ }
+ break;
+ }
+
+ return value || settingsValue;
+}
+
+/**
+ * Build style string for a login's store colors with which
+ * to apply to an html element
+ *
+ * @since 3.8.0
+ *
+ * @param {object} login to pull store color settings from
+ * @returns {string}
+ */
+Login.prototype.getStoreStyle = function (login) {
+ if (!Login.prototype.isLogin(login)) {
+ return "";
+ }
+ const color = Login.prototype.getStore(login, "color");
+ const bgColor = Login.prototype.getStore(login, "bgColor");
+
+ return `color: ${color}; background-color: ${bgColor};`
+}
+
+/**
+ * Determine if secretPrefix property has been set for
+ * the current Login object: "this"
+ *
+ * @since 3.8.0
+ *
+ * @returns {boolean}
+ */
+Login.prototype.hasSecretPrefix = function () {
+ let results = [];
+
+ results.push(this.hasOwnProperty('secretPrefix'));
+ results.push(Boolean(this.secretPrefix));
+ results.push(helpers.fieldsPrefix.secret.includes(this.secretPrefix));
+
+ return results.every(Boolean);
+}
+
+/**
+ * Returns a boolean indication on if object passed
+ * has the minimum required login propteries,
+ * Login.prototype.isPrototypeOf(login) IS NOT the goal of this.
+ * @since 3.8.0
+ *
+ * @param {object} login Login object
+ * @returns Boolean
+ */
+Login.prototype.isLogin = function(login) {
+ if (typeof login == 'undefined') {
+ return false;
+ }
+
+ let results = [];
+
+ results.push(login.hasOwnProperty('allowFill') && typeof login.allowFill == 'boolean');
+ results.push(login.hasOwnProperty('login') && typeof login.login == 'string');
+ results.push(login.hasOwnProperty('store') && typeof login.store == 'object');
+ results.push(login.hasOwnProperty('host'));
+ results.push(login.hasOwnProperty('recent') && typeof login.recent == 'object');
+
+ return results.every(Boolean);
+}
+
+/**
+ * Validation, determine if object passed is a
+ * Login.prototype and is ready to be saved.
+ *
+ * @since 3.8.0
+ *
+ * @param {object} login Login object to validated
+ */
+Login.prototype.isValid = function(login) {
+ let results = [];
+
+ results.push(Login.prototype.isLogin(login));
+ results.push(Login.prototype.isPrototypeOf(login));
+ results.push(login.hasOwnProperty('login') && login.login.length > 0);
+ results.push(login.hasOwnProperty('raw') && typeof login.raw == 'string' && login.raw.length > 0);
+
+ return results.every(Boolean);
+}
+
+/**
+ * Calls validation for Login and if it passes,
+ * then calls chrome.runtime.sendMessage()
+ * with {action: "add/save"} for new/existing secrets.
+ *
+ * @since 3.8.0
+ *
+ * @param {object} login Login object to be saved.
+ * @returns {object} Response or an empty object.
+ */
+Login.prototype.save = async function(login) {
+ if (Login.prototype.isValid(login)) {
+ const request = helpers.deepCopy(login);
+ const action = (this.isNew(login)) ? "add" : "save";
+
+ let response = await chrome.runtime.sendMessage({
+ action: action, login: request, params: { rawContents: request.raw }
+ });
+
+ if (response.status != "ok") {
+ throw new Error(response.message);
+ }
+ return response;
+ }
+ return {};
+}
+
+/**
+ * Sets password on Login.fields.secret and Login.raw,
+ * leave the secretPrefix unchanged.
+ *
+ * @since 3.8.0
+ *
+ * @param {string} password Value of password to be assgined.
+ */
+Login.prototype.setPassword = function(password = "") {
+ // secret is either entire raw text or defaults to blank string
+ let secret = this.raw || ""
+
+ // if user has secret prefix make sure it persists
+ const combined = (this.hasSecretPrefix()) ? `${this.secretPrefix}: ${password}` : password
+
+ // check for an existing prefix + password
+ const start = secret.search(multiLineSecretRegEx)
+ if (start > -1) {
+ // multi line, update the secret/password, not the prefix
+ const remaining = secret.substring(start)
+ let end = remaining.search(/[\n\r]/);
+ end = (end > -1) ? end : remaining.length; // when no newline after pass
+
+ const parts = [
+ secret.substring(0, start),
+ combined,
+ secret.substring(start + end)
+ ]
+ secret = parts.join("");
+ } else if (secret.length > 0) {
+ // replace everything in first line except ending
+ secret = secret.replace(
+ /^.*((?:\n\r?))?/,
+ combined + "$1"
+ );
+ } else {
+ // when secret is already empty just set password
+ secret = combined;
+ }
+
+ this.fields.secret = password;
+ this.raw = secret;
+}
+
+/**
+ * Update the raw text details, password, and also the secretPrefix.
+ *
+ * @since 3.8.0
+ *
+ * @param {string} text Full text details of secret to be updated
+ */
+Login.prototype.setRawDetails = function (text = "") {
+ const results = getSecretDetails(text);
+
+ if (results.prefix) {
+ this.secretPrefix = results.prefix;
+ } else {
+ delete this.secretPrefix;
+ }
+ this.fields.secret = results.password;
+ this.raw = text;
+}
+
+module.exports = Login;
diff --git a/src/popup/models/Settings.js b/src/popup/models/Settings.js
new file mode 100644
index 00000000..28f935ec
--- /dev/null
+++ b/src/popup/models/Settings.js
@@ -0,0 +1,143 @@
+"use strict";
+
+require("chrome-extension-async");
+
+/**
+ * Settings Constructor()
+ * @since 3.8.0
+ *
+ * @param {object} settingsObj (optional) Extend an existing
+ * settings object to be backwards and forwards compatible.
+ */
+function Settings(settingsObj = {}) {
+ if (Object.prototype.isPrototypeOf(settingsObj)) {
+ // Set object properties
+ for (const prop in settingsObj) {
+ this[prop] = settingsObj[prop];
+ }
+ }
+}
+
+/**
+ * Check if host application can handle DELETE requests.
+ *
+ * @since 3.8.0
+ *
+ * @param {object} settingsObj Settings object
+ * @returns
+ */
+Settings.prototype.canDelete = function (settingsObj) {
+ return settingsObj.hasOwnProperty("caps") && settingsObj.caps.delete == true;
+}
+
+/**
+ * Check if host application can handle SAVE requests.
+ *
+ * @since 3.8.0
+ *
+ * @param {object} settingsObj Settings object
+ * @returns
+ */
+Settings.prototype.canSave = function (settingsObj) {
+ return settingsObj.hasOwnProperty("caps") && settingsObj.caps.save == true;
+}
+
+/**
+ * Check if host application can handle TREE requests.
+ *
+ * @since 3.8.0
+ *
+ * @param {object} settingsObj Settings object
+ * @returns
+ */
+Settings.prototype.canTree = function (settingsObj) {
+ return settingsObj.hasOwnProperty("caps") && settingsObj.caps.tree == true;
+}
+
+/**
+ * Retrieves Browserpass settings or throws an error.
+ * Will also cache the first successful response.
+ *
+ * @since 3.8.0
+ *
+ * @throws {error} Any error response from the host or browser will be thrown
+ * @returns {object} settings
+ */
+Settings.prototype.get = async function () {
+ if (Settings.prototype.isSettings(this.settings)) {
+ return this.settings
+ }
+
+ var response = await chrome.runtime.sendMessage({ action: "getSettings" });
+ if (response.status != "ok") {
+ throw new Error(response.message);
+ }
+
+ // save response to tmp settings variable, sets
+ let sets = response.settings;
+
+ if (sets.hasOwnProperty("hostError")) {
+ throw new Error(sets.hostError.params.message);
+ }
+
+ if (typeof sets.origin === "undefined") {
+ throw new Error("Unable to retrieve current tab information");
+ }
+
+ // cache response.settings for future requests
+ this.settings = new Settings(sets);
+ return this.settings;
+}
+
+/**
+ * Retreive store object. Can optionally return just the sub path value.
+ *
+ * @since 3.8.0
+ *
+ * @param {object} settingsObj Settings object
+ * @param {string} property (optional) store sub property path value to return
+ * @returns {object} store object or path value
+ */
+Settings.prototype.getStore = function (settingsObj, property = "") {
+ let
+ store = (settingsObj.hasOwnProperty("store")) ? settingsObj.store : {},
+ value = null
+ ;
+
+ switch (property) {
+ case "color":
+ case "bgColor":
+ if (store.hasOwnProperty(property)) {
+ value = store[property];
+ }
+ break;
+
+ default:
+ if (property != "" && store.hasOwnProperty(property)) {
+ value = store[property];
+ } else {
+ value = store;
+ }
+ break;
+ }
+
+ return value;
+}
+
+/**
+ * Validation, determine if object passed is Settings.
+ *
+ * @since 3.8.0
+ *
+ * @param {object} settingsObj
+ * @returns
+ */
+Settings.prototype.isSettings = function (settingsObj) {
+ if (typeof settingsObj == 'undefined') {
+ return false;
+ }
+
+ return Settings.prototype.isPrototypeOf(settingsObj);
+}
+
+module.exports = Settings
diff --git a/src/popup/models/Tree.js b/src/popup/models/Tree.js
new file mode 100644
index 00000000..50fa81d9
--- /dev/null
+++ b/src/popup/models/Tree.js
@@ -0,0 +1,143 @@
+"use strict";
+
+/**
+ * Tree Constructor()
+ *
+ * @since 3.8.0
+ *
+ * @param {string} storeId
+ * @param {array} paths string array of directories relative to
+ * password store root directory
+ */
+function Tree(storeId = "", paths = []) {
+ this.id = storeId;
+ this.tree = new Map();
+ paths.forEach((path) => {
+ let dirs = path.split("/");
+ insert(this.tree, dirs);
+ });
+}
+
+/**
+ * Recurssively inserts directories into the Tree
+ *
+ * @since 3.8.0
+ *
+ * @param {map} parentNode current map instance representing a directory
+ * in the password store fs Tree.
+ * @param {array} dirs array of strings for remaining directories
+ * to be inserted in the Tree.
+ */
+function insert(parentNode, dirs = []) {
+ let dir = dirs.shift();
+ // done, no more dirs to add
+ if (dir == undefined) {
+ return;
+ }
+
+ // exclude hidden directories
+ if (dir[0] == ".") {
+ return
+ }
+
+ let node = parentNode.get(dir);
+
+ if (node == undefined) {
+ // doesn't exist, add it
+ node = new Map();
+ parentNode.set(dir, node);
+ }
+
+ insert(node, dirs);
+}
+
+/**
+ * Recurssively loop over entire tree and return sum of nodes
+ *
+ * @since 3.8.0
+ *
+ * @param {map} parentNode current map instance, current directory
+ * @returns {int} sum of all children nodes
+ */
+function size(parentNode) {
+ let sum = 0;
+ parentNode.forEach((node) => {
+ sum = sum + size(node);
+ })
+ return sum + parentNode.size;
+}
+
+/**
+ * Sends a 'tree' request to the host application
+ * @since 3.8.0
+ *
+ * @throws {error} host response errors
+ *
+ * @param {object} settings Settings object
+ * @returns {object} object of Trees with storeId as keys
+ */
+Tree.prototype.getAll = async function(settings) {
+ // get list of directories
+ let response = await chrome.runtime.sendMessage({ action: "listDirs" });
+ if (response.status != "ok") {
+ throw new Error(response.message);
+ }
+
+ let trees = {};
+ for (const storeId in response.dirs) {
+ trees[storeId] = new Tree(storeId, response.dirs[storeId]);
+ }
+ return trees;
+}
+
+Tree.prototype.search = function(searchPath = "") {
+ let paths = searchPath.split("/");
+ return searchTree(this.tree, paths);
+}
+
+function searchTree(parentNode, paths) {
+ let searchTerm = paths.shift();
+ // empty search, no matches found
+ if (searchTerm == undefined) {
+ return [];
+ }
+
+ let node = parentNode.get(searchTerm);
+
+ // found exact directory match
+ let results = []
+ if (node != undefined) {
+ return searchTree(node, paths);
+ }
+
+ // handle regex symbols
+ let escapedSearch = searchTerm.
+ replaceAll(/[!$()*+,-./:?\[\]^{|}.\\]/gu, c => `\\${c}`);
+
+ try {
+ "".search(escapedSearch)
+ } catch (error) {
+ // still need to handle any errors we
+ // might've missed; catch, log, and stop
+ console.log(error);
+ return results;
+ }
+
+ // no exact match, do fuzzy search
+ parentNode.forEach((_, dir) => {
+ if (dir.search(escapedSearch) > -1) {
+ results.push(dir);
+ }
+ });
+ return results;
+}
+
+Tree.prototype.isTree = function(treeObj) {
+ if (typeof treeObj == 'undefined') {
+ return false;
+ }
+
+ return Tree.prototype.isPrototypeOf(treeObj);
+}
+
+module.exports = Tree
diff --git a/src/popup/notifications.js b/src/popup/notifications.js
new file mode 100644
index 00000000..7c1ef267
--- /dev/null
+++ b/src/popup/notifications.js
@@ -0,0 +1,143 @@
+/**
+ * Original source credit goes to github.com/tabula-rasa
+ * https://gist.github.com/tabula-rasa/61d2ab25aac779fdf9899f4e87ab8306
+ * with some changes.
+ */
+
+const m = require("mithril");
+const uuidPrefix = RegExp(/^([a-z0-9]){8}-/);
+
+/**
+ * Generate a globally unique id
+ *
+ * @since 3.8.0
+ *
+ * @returns {string}
+ */
+function guid() {
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
+ var r = (Math.random() * 16) | 0,
+ v = c == "x" ? r : (r & 0x3) | 0x8;
+ return v.toString(16);
+ });
+}
+
+let state = {
+ list: [],
+
+ /**
+ * Remove notification vnode from current state
+ *
+ * @since 3.8.0
+ *
+ * @param {object,string} msg vnode object or uuid string of message to remove
+ * @returns null
+ */
+ destroy(msg) {
+ let messageId = "";
+ if (typeof msg == "string" && msg.search(uuidPrefix) == 0) {
+ messageId = msg;
+ } else if (msg.hasOwnProperty("id") && msg.id.search(uuidPrefix) == 0) {
+ messageId = msg.id;
+ } else {
+ return;
+ }
+
+ // remove message if index of notification state object is found
+ let index = state.list.findIndex((x) => x.id === messageId);
+ if (index > -1) {
+ state.list.splice(index, 1);
+ }
+ },
+};
+
+/**
+ * Creates new notification message and adds it to current
+ * notificatino state.
+ *
+ * @since 3.8.0
+ *
+ * @param {string} text message to display
+ * @param {number} timeout milliseconds timeout until message automatically removed
+ * @param {string} type notification message type
+ * @returns {string} uuid of new message element
+ */
+function addMessage(text, timeout, type = "info") {
+ const id = guid();
+ state.list.push({ id: id, type: type, text, timeout });
+ return id;
+}
+
+function addSuccess(text, timeout = 3500) {
+ return addMessage(text, timeout, "success");
+}
+
+function addInfo(text, timeout = 3500) {
+ return addMessage(text, timeout, "info");
+}
+
+function addWarning(text, timeout = 4000) {
+ return addMessage(text, timeout, "warning");
+}
+
+function addError(text, timeout = 5000) {
+ return addMessage(text, timeout, "error");
+}
+
+let Notifications = {
+ view(vnode) {
+ let ui = vnode.state;
+ return state.list
+ ? m(
+ ".m-notifications",
+ state.list.map((msg) => {
+ return m("div", { key: msg.id }, m(Notification, msg)); //wrap in div with key for proper dom updates
+ })
+ )
+ : null;
+ },
+ // provide caller method to remove message early
+ removeMsg(uuid) {
+ state.destroy(uuid);
+ m.redraw();
+ },
+ errorMsg: addError,
+ infoMsg: addInfo,
+ successMsg: addSuccess,
+ warningMsg: addWarning,
+};
+
+let Notification = {
+ oninit(vnode) {
+ if (vnode.attrs.timeout > 0) {
+ setTimeout(() => {
+ Notification.destroy(vnode);
+ }, vnode.attrs.timeout);
+ }
+ },
+ notificationClass(type) {
+ const types = ["info", "warning", "success", "error"];
+ if (types.indexOf(type) > -1) return type;
+ return "info";
+ },
+ destroy(vnode) {
+ state.destroy(vnode.attrs);
+ m.redraw();
+ },
+ view(vnode) {
+ let ui = vnode.state;
+ let msg = vnode.attrs;
+ return m(
+ ".m-notification",
+ {
+ class: ui.notificationClass(msg.type),
+ onclick: () => {
+ ui.destroy(vnode);
+ },
+ },
+ msg.text
+ );
+ },
+};
+
+module.exports = Notifications;
diff --git a/src/popup/page-loader-dark.gif b/src/popup/page-loader-dark.gif
new file mode 100644
index 00000000..9590093e
Binary files /dev/null and b/src/popup/page-loader-dark.gif differ
diff --git a/src/popup/page-loader.gif b/src/popup/page-loader.gif
new file mode 100644
index 00000000..1ea791a1
Binary files /dev/null and b/src/popup/page-loader.gif differ
diff --git a/src/popup/popup.js b/src/popup/popup.js
index aad6ccec..823f2afd 100644
--- a/src/popup/popup.js
+++ b/src/popup/popup.js
@@ -2,31 +2,23 @@
"use strict";
require("chrome-extension-async");
-const Interface = require("./interface");
-const DetailsInterface = require("./detailsInterface");
+
+// models
+const Login = require("./models/Login");
+const Settings = require("./models/Settings");
+// utils, libs
const helpers = require("../helpers");
const m = require("mithril");
+// components
+const AddEditInterface = require("./addEditInterface");
+const DetailsInterface = require("./detailsInterface");
+const Interface = require("./interface");
+const layout = require("./layoutInterface");
run();
//----------------------------------- Function definitions ----------------------------------//
-/**
- * Handle an error
- *
- * @since 3.0.0
- *
- * @param Error error Error object
- * @param string type Error type
- */
-function handleError(error, type = "error") {
- if (type == "error") {
- console.log(error);
- }
- var node = { view: () => m(`div.part.${type}`, error.toString()) };
- m.mount(document.body, node);
-}
-
/**
* Run the main popup logic
*
@@ -36,106 +28,44 @@ function handleError(error, type = "error") {
*/
async function run() {
try {
- var response = await chrome.runtime.sendMessage({ action: "getSettings" });
- if (response.status != "ok") {
- throw new Error(response.message);
- }
- var settings = response.settings;
+ /**
+ * Create instance of settings, which will cache
+ * first request of settings which will be re-used
+ * for subsequent requests. Pass this settings
+ * instance pre-cached to each of the views.
+ */
+ let settingsModel = new Settings();
- var root = document.getElementsByTagName("html")[0];
+ // get user settings
+ var logins = [],
+ settings = await settingsModel.get(),
+ root = document.getElementsByTagName("html")[0];
root.classList.remove("colors-dark");
root.classList.add(`colors-${settings.theme}`);
- if (settings.hasOwnProperty("hostError")) {
- throw new Error(settings.hostError.params.message);
- }
-
- if (typeof settings.origin === "undefined") {
- throw new Error("Unable to retrieve current tab information");
- }
-
// get list of logins
- response = await chrome.runtime.sendMessage({ action: "listFiles" });
- if (response.status != "ok") {
- throw new Error(response.message);
- }
+ logins = await Login.prototype.getAll(settings);
+ layout.setSessionSettings(settings);
+ // save list of logins to validate when adding
+ // a new one will not overwrite any existing ones
+ layout.setStoreLogins(logins.raw);
- const logins = helpers.prepareLogins(response.files, settings);
- for (let login of logins) {
- login.doAction = withLogin.bind({ settings: settings, login: login });
- }
-
- var popup = new Interface(settings, logins);
- popup.attach(document.body);
+ const LoginView = new AddEditInterface(settingsModel);
+ m.route(document.body, "/list", {
+ "/list": page(new Interface(settings, logins.processed)),
+ "/details/:storeid/:login": page(new DetailsInterface(settingsModel)),
+ "/edit/:storeid/:login": page(LoginView),
+ "/add": page(LoginView),
+ });
} catch (e) {
- handleError(e);
+ helpers.handleError(e);
}
}
-/**
- * Do a login action
- *
- * @since 3.0.0
- *
- * @param string action Action to take
- * @return void
- */
-async function withLogin(action) {
- try {
- // replace popup with a "please wait" notice
- switch (action) {
- case "fill":
- handleError("Filling login details...", "notice");
- break;
- case "launch":
- handleError("Launching URL...", "notice");
- break;
- case "launchInNewTab":
- handleError("Launching URL in a new tab...", "notice");
- break;
- case "copyPassword":
- handleError("Copying password to clipboard...", "notice");
- break;
- case "copyUsername":
- handleError("Copying username to clipboard...", "notice");
- break;
- case "copyOTP":
- handleError("Copying OTP token to clipboard...", "notice");
- break;
- case "getDetails":
- handleError("Loading entry details...", "notice");
- break;
- default:
- handleError("Please wait...", "notice");
- break;
- }
-
- // Firefox requires data to be serializable,
- // this removes everything offending such as functions
- const login = JSON.parse(JSON.stringify(this.login));
-
- // hand off action to background script
- var response = await chrome.runtime.sendMessage({
- action: action,
- login: login,
- });
- if (response.status != "ok") {
- throw new Error(response.message);
- } else {
- if (response.login && typeof response.login === "object") {
- response.login.doAction = withLogin.bind({
- settings: this.settings,
- login: response.login,
- });
- }
- if (action === "getDetails") {
- var details = new DetailsInterface(this.settings, response.login);
- details.attach(document.body);
- } else {
- window.close();
- }
- }
- } catch (e) {
- handleError(e);
- }
+function page(component) {
+ return {
+ render: function (vnode) {
+ return m(layout.LayoutInterface, m(component, { context: vnode.attrs }));
+ },
+ };
}
diff --git a/src/popup/popup.less b/src/popup/popup.less
index 984023ae..87e033d4 100644
--- a/src/popup/popup.less
+++ b/src/popup/popup.less
@@ -136,17 +136,25 @@ body {
align-items: center;
font-family: Source Code Pro, monospace;
}
- & > .copy {
+ & > .copy,
+ & > .generate {
cursor: pointer;
flex-grow: 0;
padding: 0 24px 0 0;
- background-image: url("/popup/icon-copy.svg");
background-position: top 4px right 4px;
background-repeat: no-repeat;
background-size: 16px;
margin: 2px;
}
+ & > .copy {
+ background-image: url("/popup/icon-copy.svg");
+ }
+
+ & > .generate {
+ background-image: url("/popup/icon-generate.svg");
+ }
+
& > .progress-container {
z-index: 2;
position: absolute;
@@ -270,10 +278,15 @@ body {
background-position: center;
background-repeat: no-repeat;
background-size: 19px;
+ cursor: pointer;
width: 30px;
height: @login-part-height;
padding: @login-part-padding;
+ &.back {
+ background-image: url("/popup/icon-back.svg");
+ }
+
&.copy-password {
background-image: url("/popup/icon-key.svg");
}
@@ -285,6 +298,18 @@ body {
&.details {
background-image: url("/popup/icon-details.svg");
}
+
+ &.edit {
+ background-image: url("/popup/icon-edit.svg");
+ }
+
+ &.save {
+ background-image: url("/popup/icon-save.svg");
+ }
+
+ &.delete {
+ background-image: url("/popup/icon-delete.svg");
+ }
}
}
@@ -292,6 +317,336 @@ body {
font-style: normal;
}
+.part.add {
+ display: flex;
+ cursor: pointer;
+ align-items: center;
+ justify-content: center;
+ height: 30px;
+}
+
+.chars {
+ display: flex;
+ flex-grow: 1;
+ align-items: center;
+ font-family: Source Code Pro, monospace;
+ .char {
+ white-space: pre;
+ }
+}
+
+.addEdit,
+.details {
+ .loading {
+ width: 300px;
+ height: 300px;
+ display: flex;
+ align-items: flex-end;
+ p {
+ margin: 50px auto;
+ font-size: large;
+ }
+ }
+
+ .title {
+ display: flex;
+ box-sizing: border-box;
+ // justify-content: space-between;
+
+ span {
+ // align-self: center;
+ margin: auto;
+ }
+ }
+
+ .btn {
+ cursor: pointer;
+ background-position: center;
+ background-repeat: no-repeat;
+ padding: 6px;
+ background-size: 19px;
+ height: 21px;
+
+ &:hover,
+ &:focus {
+ outline: none;
+ }
+
+ &.back {
+ background-image: url("/popup/icon-back.svg");
+ width: 20px;
+ }
+
+ &.edit {
+ background-image: url("/popup/icon-edit.svg");
+ width: 25px;
+ }
+
+ &.alignment {
+ width: 20px;
+ cursor: default;
+ background: none !important;
+ }
+
+ &.generate {
+ width: 15px;
+ padding-right: 10px;
+ background-image: url("/popup/icon-generate.svg");
+ }
+ }
+
+ .location {
+ margin: 6px;
+
+ .store,
+ .path {
+ display: flex;
+ align-items: center;
+ margin-bottom: 6px;
+ padding-right: 5px;
+ }
+
+ .store {
+ select {
+ border-radius: 4px;
+ }
+ }
+
+ .storePath {
+ margin-left: 10px;
+ font-size: 12px;
+ }
+
+ .path {
+ border: 1px solid;
+
+ input {
+ padding-right: 0;
+ }
+ }
+
+ .suffix {
+ font-size: 13px;
+ font-family: Source Code Pro, monospace;
+ }
+
+ select {
+ border: none;
+ outline: none;
+ padding: 5px;
+ cursor: pointer;
+ }
+
+ input[disabled],
+ select[disabled],
+ .suffix.disabled {
+ color: grey;
+ cursor: default;
+ }
+ }
+
+ .contents {
+ display: flex;
+ flex-direction: column;
+ margin: 0 6px 5px;
+
+ label {
+ display: flex;
+ flex-grow: 0;
+ justify-content: flex-end;
+ font-weight: bold;
+ padding: 6px 6px;
+ margin-right: 8px;
+ border: none;
+ min-width: 55px;
+ }
+
+ .password,
+ .options,
+ .details {
+ display: flex;
+ margin-bottom: 6px;
+ border-radius: 2px;
+ }
+
+ .password,
+ .details {
+ border: 1px solid;
+ }
+
+ .options {
+ align-items: center;
+
+ label {
+ margin-right: 0;
+ }
+
+ input[type="checkbox"] {
+ margin: 3px 6px;
+ height: 25px;
+ width: 20px;
+ }
+
+ input[type="number"] {
+ font-size: 12px;
+ width: 40px;
+ height: 20px;
+ }
+ }
+ }
+
+ .actions {
+ display: flex;
+ margin-bottom: 10px;
+
+ .save,
+ .delete {
+ cursor: pointer;
+ font-weight: bolder;
+ font-size: medium;
+ padding: 0.25rem 0.75rem;
+ border-radius: 0.25rem;
+ }
+
+ .save::after,
+ .delete::after {
+ display: inline-block;
+ width: 15px;
+ margin-left: 5px;
+ }
+
+ .save {
+ margin-left: auto;
+ margin-right: 5px;
+ }
+ .save::after {
+ content: url("/popup/icon-save.svg");
+ }
+
+ .delete {
+ margin-left: 5px;
+ margin-right: auto;
+ }
+ .delete::after {
+ content: url("/popup/icon-delete.svg");
+ }
+ }
+
+ input[type="number"],
+ input[type="text"],
+ textarea {
+ border: none;
+ outline: none;
+ width: 100%;
+ font-family: "Open Sans";
+ padding: 5px 8px;
+ font-family: Source Code Pro, monospace;
+ }
+
+ textarea {
+ resize: none;
+ min-height: 110px;
+ min-width: 340px;
+ }
+}
+
.updates {
padding: @login-part-padding;
}
+
+.m-notifications {
+ position: fixed;
+ top: 20px;
+ left: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ z-index: 10;
+
+ .m-notification {
+ width: auto;
+ margin: 0 10px 3px;
+ cursor: pointer;
+ animation: fade-in 0.3s;
+ white-space: normal;
+ // display errors in notifications nicely
+ overflow-wrap: anywhere;
+
+ &.destroy {
+ animation: fade-out 0.3s;
+ }
+
+ &.info,
+ &.warning,
+ &.error,
+ &.success {
+ padding: 0.75rem 1.25rem;
+ border-radius: 0.25rem;
+ }
+ }
+}
+
+div#tree-dirs {
+ position: absolute;
+ overflow-y: scroll;
+ max-height: 265px;
+ margin-top: -8px;
+
+ div.dropdown {
+ padding: 2px;
+
+ a {
+ display: block;
+ text-align: left;
+ padding: 2px 6px;
+ }
+ }
+}
+
+dialog#browserpass-modal {
+ margin: auto 6px;
+ width: auto;
+ border-radius: 0.25rem;
+
+ .modal-content {
+ margin-bottom: 15px;
+ white-space: pre-wrap;
+ }
+
+ .modal-actions {
+ display: flex;
+
+ button {
+ font-weight: bold;
+ padding: 5px 10px;
+ border-radius: 0.25rem;
+
+ &.cancel {
+ margin-right: auto;
+ }
+ &.confirm {
+ margin-left: auto;
+ }
+ }
+ }
+}
+
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes fade-out {
+ from {
+ opacity: 1;
+ }
+
+ to {
+ opacity: 0;
+ }
+}
diff --git a/src/popup/searchinterface.js b/src/popup/searchinterface.js
index 8d80ca8e..9374fd79 100644
--- a/src/popup/searchinterface.js
+++ b/src/popup/searchinterface.js
@@ -39,7 +39,7 @@ function view(ctl, params) {
case "Tab":
e.preventDefault();
if (e.shiftKey) {
- document.querySelector(".part.login:last-child").focus();
+ document.querySelector(".part.add").focus();
break;
}
// fall through to ArrowDown
@@ -49,6 +49,16 @@ function view(ctl, params) {
document.querySelector("*[tabindex]").focus();
}
break;
+ case "ArrowUp":
+ e.preventDefault();
+ document.querySelector(".part.add").focus();
+ break;
+ case "End":
+ if (!e.shiftKey) {
+ e.preventDefault();
+ document.querySelector(".part.add").focus();
+ }
+ break;
case "Enter":
e.preventDefault();
if (self.popup.results.length) {