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