From 858cc821d20df9102b8040b78d79893d4b7af352 Mon Sep 17 00:00:00 2001 From: Patrick Miller <3914379+patgmiller@users.noreply.github.com> Date: Sun, 18 Jun 2023 19:34:09 -0600 Subject: [PATCH] Add Ability to Manage New & Existing Passwords (#290) Add create / edit / remove functionality for password entries Includes significant refactoring of the popup UI. --- .gitignore | 1 + Makefile | 1 + src/background.js | 110 ++++-- src/helpers.js | 260 ++++++++++--- src/popup/addEditInterface.js | 651 +++++++++++++++++++++++++++++++++ src/popup/colors-dark.less | 28 +- src/popup/colors-light.less | 44 ++- src/popup/colors.less | 169 ++++++++- src/popup/detailsInterface.js | 327 ++++++++++------- src/popup/icon-back.svg | 6 + src/popup/icon-delete.svg | 10 + src/popup/icon-edit.svg | 23 ++ src/popup/icon-generate.svg | 8 + src/popup/icon-save.svg | 24 ++ src/popup/interface.js | 189 +++++++--- src/popup/layoutInterface.js | 175 +++++++++ src/popup/modalDialog.js | 105 ++++++ src/popup/models/Login.js | 451 +++++++++++++++++++++++ src/popup/models/Settings.js | 143 ++++++++ src/popup/models/Tree.js | 143 ++++++++ src/popup/notifications.js | 143 ++++++++ src/popup/page-loader-dark.gif | Bin 0 -> 45404 bytes src/popup/page-loader.gif | Bin 0 -> 45974 bytes src/popup/popup.js | 150 ++------ src/popup/popup.less | 359 +++++++++++++++++- src/popup/searchinterface.js | 12 +- 26 files changed, 3163 insertions(+), 369 deletions(-) create mode 100644 src/popup/addEditInterface.js create mode 100644 src/popup/icon-back.svg create mode 100644 src/popup/icon-delete.svg create mode 100644 src/popup/icon-edit.svg create mode 100644 src/popup/icon-generate.svg create mode 100644 src/popup/icon-save.svg create mode 100644 src/popup/layoutInterface.js create mode 100644 src/popup/modalDialog.js create mode 100644 src/popup/models/Login.js create mode 100644 src/popup/models/Settings.js create mode 100644 src/popup/models/Tree.js create mode 100644 src/popup/notifications.js create mode 100644 src/popup/page-loader-dark.gif create mode 100644 src/popup/page-loader.gif 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 0000000000000000000000000000000000000000..9590093e9e6d32e5605452cc6cdffee3df0d0abb GIT binary patch literal 45404 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 literal 0 HcmV?d00001 diff --git a/src/popup/page-loader.gif b/src/popup/page-loader.gif new file mode 100644 index 0000000000000000000000000000000000000000..1ea791a11568cb3a7434c7c1fe87342dfc67be2a GIT binary patch literal 45974 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 Kf(H!H`u_l^i%F#b literal 0 HcmV?d00001 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) {