From 0b908f8ef932b100331e50587379c9ef4319b6eb Mon Sep 17 00:00:00 2001 From: Maxim Baz Date: Mon, 11 Mar 2019 12:33:24 +0100 Subject: [PATCH] Implement options page (#38) --- Makefile | 19 ++-- src/Makefile | 12 ++- src/background.js | 43 ++++++--- src/manifest.json | 18 ++-- src/options/interface.js | 193 +++++++++++++++++++++++++++++++++++++++ src/options/options.html | 10 ++ src/options/options.js | 66 +++++++++++++ src/options/options.less | 104 +++++++++++++++++++++ src/popup/popup.js | 4 + 9 files changed, 435 insertions(+), 34 deletions(-) create mode 100644 src/options/interface.js create mode 100644 src/options/options.html create mode 100644 src/options/options.js create mode 100644 src/options/options.less diff --git a/Makefile b/Makefile index 86138fb..9752795 100644 --- a/Makefile +++ b/Makefile @@ -8,15 +8,18 @@ extension: $(MAKE) -C src CHROME_FILES := manifest.json \ - *.css \ - *.png \ - popup/*.html \ - popup/*.svg + *.css \ + *.png \ + popup/*.html \ + popup/*.svg \ + options/*.html CHROME_FILES := $(wildcard $(addprefix src/,$(CHROME_FILES))) \ - src/css/popup.dist.css \ - src/js/background.dist.js \ - src/js/popup.dist.js \ - src/js/inject.dist.js + src/css/popup.dist.css \ + src/css/options.dist.css \ + src/js/background.dist.js \ + src/js/popup.dist.js \ + src/js/options.dist.js \ + src/js/inject.dist.js CHROME_FILES := $(patsubst src/%,chrome/%,$(CHROME_FILES)) .PHONY: chrome diff --git a/src/Makefile b/src/Makefile index b4465b1..14b7e5a 100644 --- a/src/Makefile +++ b/src/Makefile @@ -3,10 +3,10 @@ PRETTIER := node_modules/.bin/prettier LESSC := node_modules/.bin/lessc CLEAN_FILES := js -PRETTIER_FILES := $(wildcard *.js popup/*.js *.less popup/*.less) +PRETTIER_FILES := $(wildcard *.json *.js popup/*.js options/*.js *.less popup/*.less options/*.less *.html popup/*.html options/*.html) .PHONY: all -all: deps prettier css/popup.dist.css js/background.dist.js js/popup.dist.js js/inject.dist.js +all: deps prettier css/popup.dist.css css/options.dist.css js/background.dist.js js/popup.dist.js js/options.dist.js js/inject.dist.js .PHONY: deps deps: @@ -20,6 +20,10 @@ css/popup.dist.css: $(LESSC) popup/popup.less [ -d css ] || mkdir -p css $(LESSC) popup/popup.less css/popup.dist.css +css/options.dist.css: $(LESSC) options/options.less + [ -d css ] || mkdir -p css + $(LESSC) options/options.less css/options.dist.css + js/background.dist.js: $(BROWSERIFY) background.js [ -d js ] || mkdir -p js $(BROWSERIFY) -o js/background.dist.js background.js @@ -28,6 +32,10 @@ js/popup.dist.js: $(BROWSERIFY) popup/*.js [ -d js ] || mkdir -p js $(BROWSERIFY) -o js/popup.dist.js popup/popup.js +js/options.dist.js: $(BROWSERIFY) options/*.js + [ -d js ] || mkdir -p js + $(BROWSERIFY) -o js/options.dist.js options/options.js + js/inject.dist.js: $(BROWSERIFY) inject.js [ -d js ] || mkdir -p js $(BROWSERIFY) -o js/inject.dist.js inject.js diff --git a/src/background.js b/src/background.js index 724116f..e83ddce 100644 --- a/src/background.js +++ b/src/background.js @@ -125,18 +125,18 @@ async function dispatchFill( // if user answered a foreign-origin confirmation, // store the answers in the settings - var needSaveSettings = false; + var foreignFillsChanged = false; for (var frame of perFrameFillResults) { if (typeof frame.foreignFill !== "undefined") { if (typeof settings.foreignFills[settings.host] === "undefined") { settings.foreignFills[settings.host] = {}; } settings.foreignFills[settings.host][frame.foreignOrigin] = frame.foreignFill; - needSaveSettings = true; + foreignFillsChanged = true; } } - if (needSaveSettings) { - saveSettings(settings); + if (foreignFillsChanged) { + await saveSettings(settings); } return filledFields; @@ -328,12 +328,22 @@ async function handleMessage(settings, message, sendResponse) { }); break; case "saveSettings": - saveSettings(message.settings); - sendResponse({ status: "ok" }); + try { + await saveSettings(message.settings); + sendResponse({ status: "ok" }); + } catch (e) { + sendResponse({ + status: "error", + message: "Unable to save settings" + e.toString() + }); + } break; case "listFiles": try { var response = await hostAction(settings, "list"); + if (response.status != "ok") { + throw new Error(JSON.stringify(response)); // TODO handle host error + } sendResponse({ status: "ok", files: response.data.files }); } catch (e) { sendResponse({ @@ -537,10 +547,10 @@ async function receiveMessage(message, sender, sendResponse) { var configureSettings = Object.assign(deepCopy(settings), { defaultStore: {} }); - var response = await chrome.runtime.sendNativeMessage(appID, { - settings: configureSettings, - action: "configure" - }); + var response = await hostAction(configureSettings, "configure"); + if (response.status != "ok") { + throw new Error(JSON.stringify(response)); // TODO handle host error + } settings.version = response.version; if (Object.keys(settings.stores).length > 0) { // there are user-configured stores present @@ -588,9 +598,7 @@ async function receiveMessage(message, sender, sendResponse) { try { settings.tab = (await chrome.tabs.query({ active: true, currentWindow: true }))[0]; settings.host = new URL(settings.tab.url).hostname; - } catch (e) { - throw new Error("Unable to retrieve current tab information"); - } + } catch (e) {} handleMessage(settings, message, sendResponse); } catch (e) { @@ -601,17 +609,22 @@ async function receiveMessage(message, sender, sendResponse) { } /** - * Save settings + * Save settings if they are valid * * @since 3.0.0 * * @param object Final settings object * @return void */ -function saveSettings(settings) { +async function saveSettings(settings) { // 'default' is our reserved name for the default store delete settings.stores.default; + var response = await hostAction(settings, "configure"); + if (response.status != "ok") { + throw new Error(JSON.stringify(response)); // TODO handle host error + } + for (var key in defaultSettings) { if (settings.hasOwnProperty(key)) { localStorage.setItem(key, JSON.stringify(settings[key])); diff --git a/src/manifest.json b/src/manifest.json index f534b7d..1c6f1ad 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,19 +1,14 @@ { "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvlVUvevBvdeIFpvK5Xjcbd/cV8AsMNLg0Y7BmUetSTagjts949Tp12mNmWmIEEaE9Zwmfjl1ownWiclGhsoPSf6x7nP/i0j8yROv6TYibXLhZet9y4vnUMgtCIkb3O5RnuOl0Y+V3XUADwxotmgT1laPUThymJoYnWPv+lwDkYiEopX2Aq2amzRj8aMogNBUbAIkCMxfa9WK3Vm0QTAUdV4ii9WqzbgjHVruQpiFVq99W2U9ddsWNZjOG/36sFREuHw+reulQgblp9FZdaN1Q9X5cGcT5bncQIRB6K3wZYa805gFENc93Wslmzu6aUSEKqqPymlI5ikedaPlXPmlqwIDAQAB", "manifest_version": 2, - "name": "Browserpass CE", - "description": "Browser extension for zx2c4's pass (password manager) - Community Edition.", + "name": "Browserpass", + "description": "Browser extension for zx2c4's pass (password manager)", "version": "3.0.0", - "author": [ - "Maxim Baz ", - "Steve Gilberd " - ], + "author": ["Maxim Baz ", "Steve Gilberd "], "homepage_url": "https://github.com/browserpass/browserpass-extension", "background": { "persistent": true, - "scripts": [ - "js/background.dist.js" - ] + "scripts": ["js/background.dist.js"] }, "icons": { "128": "icon-lock.png" @@ -22,6 +17,11 @@ "default_icon": "icon-lock.png", "default_popup": "popup/popup.html" }, + "options_ui": { + "page": "options/options.html", + "chrome_style": true, + "open_in_tab": false + }, "permissions": [ "activeTab", "nativeMessaging", diff --git a/src/options/interface.js b/src/options/interface.js new file mode 100644 index 0000000..3170e27 --- /dev/null +++ b/src/options/interface.js @@ -0,0 +1,193 @@ +module.exports = Interface; + +var m = require("mithril"); + +/** + * Options main interface + * + * @since 3.0.0 + * + * @param object settings Settings object + * @param function saveSettings Function to save settings + * @return void + */ +function Interface(settings, saveSettings) { + // public methods + this.attach = attach; + this.view = view; + + // fields + this.settings = settings; + this.saveSettings = saveSettings; + this.saveEnabled = false; + + // 'default' store must not be displayed or later attempted to be saved + delete this.settings.stores.default; +} + +/** + * Attach the interface on the given element + * + * @since 3.0.0 + * + * @param DOMElement element Target element + * @return void + */ +function attach(element) { + m.mount(element, this); +} + +/** + * Generates vnodes for render + * + * @since 3.0.0 + * + * @param function ctl Controller + * @param object params Runtime params + * @return []Vnode + */ +function view(ctl, params) { + var nodes = []; + nodes.push(m("h3", "Basic settings")); + nodes.push(createCheckbox.call(this, "autoSubmit", "Automatically submit forms after filling")); + + nodes.push(m("h3", "Custom store locations")); + nodes.push( + m("div", { class: "notice" }, "(this overrides default store and $PASSWORD_STORE_DIR)") + ); + for (var storeId in this.settings.stores) { + nodes.push(createCustomStore.call(this, storeId)); + } + nodes.push( + m( + "a.add-store", + { + onclick: () => { + addEmptyStore(this.settings.stores); + this.saveEnabled = true; + } + }, + "Add store" + ) + ); + + if (typeof this.error !== "undefined") { + nodes.push(m("div.error", this.error.message)); + } + + nodes.push( + m( + "button.save", + { + disabled: !this.saveEnabled, + onclick: async () => { + try { + await this.saveSettings(this.settings); + this.error = undefined; + } catch (e) { + this.error = e; + } + this.saveEnabled = false; + m.redraw(); + } + }, + "Save" + ) + ); + return nodes; +} + +/** + * Generates vnode for a checkbox setting + * + * @since 3.0.0 + * + * @param string key Settings key + * @param string title Label for the checkbox + * @return Vnode + */ +function createCheckbox(key, title) { + return m("div.option", { class: key }, [ + m("label", [ + m("input[type=checkbox]", { + title: title, + checked: this.settings[key], + onchange: e => { + this.settings[key] = e.target.checked; + this.saveEnabled = true; + } + }), + title + ]) + ]); +} + +/** + * Generates vnode for a custom store configuration + * + * @since 3.0.0 + * + * @param string storeId Store ID + * @return Vnode + */ +function createCustomStore(storeId) { + let store = this.settings.stores[storeId]; + + return m("div.option.custom-store", { class: "store-" + store.name }, [ + m("input[type=text].name", { + title: "The name for this password store", + value: store.name, + placeholder: "name", + onchange: e => { + store.name = e.target.value; + this.saveEnabled = true; + } + }), + m("input[type=text].path", { + title: "The full path to this password store", + value: store.path, + placeholder: "/path/to/store", + onchange: e => { + store.path = e.target.value; + this.saveEnabled = true; + } + }), + m( + "a.remove", + { + title: "Remove this password store", + onclick: () => { + delete this.settings.stores[storeId]; + this.saveEnabled = true; + } + }, + "[X]" + ) + ]); +} + +/** + * Generates new store ID + * + * @since 3.0.0 + * + * @return string new store ID + */ +function newId() { + return Math.random() + .toString(36) + .substr(2, 9); +} + +/** + * Generates a new empty store + * + * @since 3.0.0 + * + * @param []object stores List of stores to add a new store to + * @return void + */ +function addEmptyStore(stores) { + let store = { id: newId(), name: "", path: "" }; + stores[store.id] = store; +} diff --git a/src/options/options.html b/src/options/options.html new file mode 100644 index 0000000..f2f2e8f --- /dev/null +++ b/src/options/options.html @@ -0,0 +1,10 @@ + + + + + + + +
Loading options...
+ + diff --git a/src/options/options.js b/src/options/options.js new file mode 100644 index 0000000..6414482 --- /dev/null +++ b/src/options/options.js @@ -0,0 +1,66 @@ +//------------------------------------- Initialisation --------------------------------------// +"use strict"; + +require("chrome-extension-async"); +var Interface = require("./interface"); + +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 errorNode = document.createElement("div"); + errorNode.setAttribute("class", "part " + type); + errorNode.textContent = error.toString(); + document.body.innerHTML = ""; + document.body.appendChild(errorNode); +} + +/** + * Save settings + * + * @since 3.0.0 + * + * @param object settings Settings object + */ +async function saveSettings(settings) { + var response = await chrome.runtime.sendMessage({ + action: "saveSettings", + settings: settings + }); + if (response.status != "ok") { + throw new Error(response.message); + } +} + +/** + * Run the main options logic + * + * @since 3.0.0 + * + * @return void + */ +async function run() { + try { + var response = await chrome.runtime.sendMessage({ action: "getSettings" }); + if (response.status != "ok") { + throw new Error(response.message); + } + + var options = new Interface(response.settings, saveSettings); + options.attach(document.body); + } catch (e) { + handleError(e); + } +} diff --git a/src/options/options.less b/src/options/options.less new file mode 100644 index 0000000..bd24057 --- /dev/null +++ b/src/options/options.less @@ -0,0 +1,104 @@ +html, +body { + box-sizing: border-box; + overflow: hidden; + margin: 0; +} + +body { + margin: 10px 20px 20px; +} + +h3 { + margin-top: 20px; +} + +h3:first-child { + margin-top: 0; +} + +.notice { + margin-top: -10px; + color: gray; + font-size: 10px; +} + +.option { + display: flex; + height: 16px; + line-height: 16px; + margin-bottom: 10px; + margin-top: 10px; +} + +.option input[type="checkbox"] { + height: 12px; + margin: 2px 6px 2px 0; + padding: 0; +} + +.option.custom-store input[type="text"] { + background-color: white; + color: black; + border: none; + border-bottom: 1px solid #aaa; + height: 16px; + line-height: 16px; + margin: -4px 0 0 0; + overflow: hidden; + padding: 0; + width: 25%; +} + +.option.custom-store input[type="text"].path { + margin-left: 6px; + width: calc(100% - 25% - 48px); +} + +.option.custom-store a.remove { + color: #f00; + display: block; + height: 16px; + line-height: 16px; + margin: 0 0 0 6px; + padding: 0; + text-decoration: none; + width: 16px; + cursor: pointer; +} + +.add-store { + cursor: pointer; + display: block; + margin-top: 8px; + margin-bottom: 30px; +} + +.error { + margin-bottom: 30px; + color: red; +} + +.save { + cursor: pointer; + display: block; +} + +@-moz-document url-prefix() { + body { + background: #fff; + border: 1px solid #000; + font-family: sans; + margin: 2px; + padding: 12px; + } + + .option.custom-store input[type="text"] { + background: #fff; + margin: 2px; + } + + .option.custom-store a.remove { + font-size: 12px; + } +} diff --git a/src/popup/popup.js b/src/popup/popup.js index 5e7bfef..ebfb6aa 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -67,6 +67,10 @@ async function run() { } var settings = response.settings; + if (typeof settings.host === "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") {