diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..b2c0e02 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,3 @@ +{ + "include": ["./src/**/*"] +} diff --git a/src/common.scss b/src/common.scss index 74d6c3a..a87d3f2 100644 --- a/src/common.scss +++ b/src/common.scss @@ -38,6 +38,37 @@ form { } } + button, + [type='submit'] { + display: inline-block; + padding: 0.4em 1em; + border-radius: 1em; + border: 1px solid rgba(0, 0, 0, 0.2); + color: #000; + cursor: pointer; + + &:hover { + background-color: rgba(40, 235, 70, 0.5); + border-color: rgba(40, 235, 70, 0.5); + } + + &:active { + background-color: rgba(40, 235, 70, 0.7); + border-color: rgba(40, 235, 70, 0.7); + } + + &:disabled { + background-color: rgba(200, 200, 200, 0.5); + border-color: rgba(200, 200, 200, 0.5); + color: rgba(100, 100, 100, 0.5); + cursor: not-allowed; + } + } + + [type='submit'] { + background-color: rgba(40, 235, 70, 0.3); + } + .buttons { margin-top: 0.5em; padding-top: 0.5em; @@ -69,6 +100,40 @@ form { } } +.help { + font-size: 0.9em; + color: #666; + margin-top: 0.5em; + margin-bottom: 1em; + + a { + color: #888; + text-decoration: none; + + &:hover { + color: #555; + } + } +} + +.errors { + margin-top: 0.5em; + margin-bottom: 1em; + + ul { + list-style-type: none; + padding: 0.5em; + + li { + background: rgba(255, 200, 200, 0.3); + border: 1px solid rgba(255, 100, 100, 0.5); + border-radius: 1em; + margin-bottom: 0.5em; + padding: 0.5em; + } + } +} + .autocomplete__box { border: 0 !important; padding: 0 !important; diff --git a/src/listeners/connect.js b/src/listeners/connect.js index 4fe0183..dd10771 100644 --- a/src/listeners/connect.js +++ b/src/listeners/connect.js @@ -69,7 +69,6 @@ const Service = (() => { switch (message.type) { case 'get': const commands = await browser.commands.getAll(); - console.log('Available commands', commands); port.postMessage(commands); break; } diff --git a/src/options/App.vue b/src/options/App.vue index 6037f68..81de8c8 100644 --- a/src/options/App.vue +++ b/src/options/App.vue @@ -3,11 +3,11 @@ <Menu :hosts="hosts" :selectedTab="selectedTab" :selectedHost="selectedHost" :selectedHostOption="selectedHostOption" @select="select" /> <div class="body"> - <NewHost @add="addHost" v-if="selectedTab === 'add'" /> + <HostForm @add="addHost" v-if="selectedTab === 'add'" /> <Config v-else-if="selectedTab === 'config'" @reload="reload" /> - <LocalCommands :host="selectedHost" v-else-if="selectedHost && selectedHostOption === 'localProc'" :bus="bus" /> + <LocalCommands :host="selectedHost" v-else-if="selectedHost && selectedHostOption === 'actions'" :bus="bus" /> <Run :host="hosts[selectedHost]" v-else-if="selectedHost && selectedHostOption === 'run'" :selectedAction="selectedAction" :selectedScript="selectedScript" /> - <EditHost :host="hosts[selectedHost]" @save="editHost" @remove="removeHost" v-else-if="selectedHost" /> + <HostForm :host="hosts[selectedHost]" @edit="editHost" @remove="removeHost" v-else-if="selectedHost" /> <div class="none" v-else>Select an option from the menu</div> </div> </div> @@ -17,8 +17,7 @@ import Vue from 'vue'; import mixins from '../utils'; import Menu from './Menu'; -import NewHost from './NewHost'; -import EditHost from './EditHost'; +import HostForm from './HostForm'; import LocalCommands from './LocalCommands'; import Config from './Config'; import Run from './Run'; @@ -28,8 +27,7 @@ export default { mixins: [mixins], components: { Menu, - NewHost, - EditHost, + HostForm, LocalCommands, Config, Run, @@ -58,14 +56,20 @@ export default { async reload() { this.hosts = await this.getHosts(); + this.selectedHost = this.getSelectedHost(); + this.selectedTab = this.getSelectedTab(); + + switch (this.selectedTab) { + case 'run': + this.selectedHostOption = 'run'; + break; + case 'actions': + this.selectedHostOption = 'actions'; + break; + } }, async addHost(form) { - if (!this.isHostFormValid(form)) { - this.notify('Invalid device parameter values', 'Device configuration error'); - return; - } - this.loading = true; try { @@ -86,11 +90,6 @@ export default { }, async editHost(form) { - if (!this.isHostFormValid(form)) { - this.notify('Invalid device parameter values', 'Device configuration error'); - return; - } - this.loading = true; try { @@ -139,6 +138,18 @@ export default { }, }, + watch: { + selectedTab(newTab) { + this.setSelectedTab(newTab); + }, + + selectedHostOption(newOption) { + if (newOption?.length) { + this.setSelectedTab(newOption); + } + }, + }, + created() { this.reload(); this.initListeners(); diff --git a/src/options/Config.vue b/src/options/Config.vue index 5d44703..7e70b9a 100644 --- a/src/options/Config.vue +++ b/src/options/Config.vue @@ -89,6 +89,9 @@ export default { }, async reload() { + this.clearUrlArgs(); + this.setSelectedTab('config'); + const config = await this.loadConfig(); this.hosts = config.hosts || []; this.config = JSON.stringify(config, null, ' '); diff --git a/src/options/EditHost.vue b/src/options/EditHost.vue deleted file mode 100644 index 2c96c4f..0000000 --- a/src/options/EditHost.vue +++ /dev/null @@ -1,46 +0,0 @@ -<template> - <div class="page edit"> - <h2>Edit device {{ host.name }}</h2> - <form class="host-form" ref="form" @submit.prevent="$emit('save', $event.target)"> - <input type="text" name="name" placeholder="Name" :value="host.name" autocomplete="off" :disabled="loading" /> - <input type="text" name="address" placeholder="IP or hostname" :value="host.address" autocomplete="off" :disabled="loading" /> - <input type="text" name="port" placeholder="HTTP port" autocomplete="off" :value="host.port" :disabled="loading" @keyup="onPortChange($refs.form)" /> - <input type="text" name="websocketPort" :value="host.websocketPort" placeholder="Websocket port" autocomplete="off" :disabled="loading" /> - <input type="text" name="token" placeholder="Access token" :value="host.token" autocomplete="off" :disabled="loading" /> - <div class="row ssl"> - <input type="checkbox" name="ssl" v-model="host.ssl" :disabled="loading" /> - <label for="ssl">Use SSL</label> - </div> - - <div class="buttons"> - <input type="submit" value="Edit" :disabled="loading" /> - <button type="button" @click="$emit('remove')" :disabled="loading">Remove</button> - </div> - </form> - </div> -</template> - -<script> -import mixins from '../utils'; - -export default { - name: 'EditHost', - mixins: [mixins], - props: { - host: Object, - }, -}; -</script> - -<style lang="scss" scoped> -form { - input[type='text'] { - .row.ssl { - display: flex; - align-items: center; - } - } -} -</style> - -<!-- vim:sw=2:ts=2:et: --> diff --git a/src/options/HostForm.vue b/src/options/HostForm.vue new file mode 100644 index 0000000..ca6a4d9 --- /dev/null +++ b/src/options/HostForm.vue @@ -0,0 +1,297 @@ +<template> + <div class="page" :class="{ action }"> + <h2 v-if="action === 'add'">Add a Platypush device</h2> + <h2 v-else>Edit device {{ host.name || host.url }}</h2> + + <p class="help"> + You can bind a new Platypush device to the extension by specifying its name and base URL.<br /><br /> + The Platypush service needs to have <code>backend.http</code> enabled and be reachable from the browser.<br /><br /> + + <b>Note:</b> If you want to connect to an HTTP-only Platypush service, you may need to disable HTTPS-only mode in your browser - or add an exception for the path of this + extension.<br /> + </p> + + <form class="host-form" ref="form" @submit.prevent="submit"> + <label for="url"> + <b>Service URL</b> + + <p class="help">The base URL of the Platypush service, e.g. <code>http://localhost:8008</code>.<br /></p> + + <input type="text" id="url" name="url" ref="url" placeholder="Base URL" autocomplete="off" :disabled="loading" @keyup="onUrlInput" @blur="onUrlInput" /> + </label> + + <label for="name"> + <i>Device name</i> + + <p class="help"> + A human-readable name for the device, e.g. <code>My Platypush device</code>.<br /> + If left empty, the device name will be retrieved from the Platypush service configuration.<br /> + </p> + <input type="text" id="name" name="name" placeholder="Name" autocomplete="off" :disabled="loading" /> + </label> + + <label for="token"> + <i>Access token</i> + + <p class="help"> + An optional access token to authenticate with the Platypush service.<br /> + You can generate a token from the Platypush web interface, under <code>Settings > Access tokens</code>.<br /> + If left empty, the service will use the credentials of the currently logged-in user.<br /> + </p> + <input type="text" id="token" name="token" placeholder="Access token" autocomplete="off" :disabled="loading" /> + </label> + + <div class="buttons" v-if="action === 'add'"> + <input type="submit" value="Add" :disabled="loading || !formValid" /> + </div> + + <div class="buttons" v-else-if="action === 'edit'"> + <input type="submit" value="Edit" :disabled="loading || !formValid" /> + <button type="button" @click="$emit('remove')" :disabled="loading">Remove</button> + </div> + </form> + + <div class="errors" v-if="errors.length"> + <h3>Errors</h3> + <ul> + <li class="error" v-for="(error, index) in errors" :key="index">{{ error }}</li> + </ul> + </div> + </div> +</template> + +<script> +import axios from 'axios'; +import mixins from '../utils'; + +export default { + mixins: [mixins], + emits: ['add', 'edit', 'remove'], + props: { + host: { + type: Object, + }, + }, + + data() { + return { + errors: [], + formValid: false, + }; + }, + + computed: { + action() { + return Object.keys(this.host || {}).length ? 'edit' : 'add'; + }, + + values() { + return { + url: this.$refs.form?.url?.value?.trim() || '', + name: this.$refs.form?.name?.value?.trim() || '', + token: this.$refs.form?.token?.value?.trim() || '', + }; + }, + }, + + methods: { + async submit(event) { + this.loading = true; + this.errors = []; + const form = event.target; + + try { + this.errors = await this.validateForm(form); + if (this.errors.length > 0) { + return; + } + + this.$emit(this.action, form); + } catch (error) { + this.errors.push('An error occurred while adding the device: ' + error.message); + } finally { + this.loading = false; + } + }, + + async validateForm(form) { + const errors = []; + let url = null; + + try { + url = new URL(form.url.value); + } catch (e) { + errors.push('Invalid URL format'); + } + + if (url?.protocol !== 'http:' && url?.protocol !== 'https:') { + errors.push('URL must start with http:// or https://'); + } else if (url?.origin) { + form.url.value = url.origin; + } else { + return errors; + } + + try { + const config = await this.getConfig(url.origin); + if (!form.name.value?.length) { + form.name.value = config?.device_id || url.hostname; + } + } catch (e) { + errors.push('Failed to fetch device configuration: ' + e.message); + } + + return errors; + }, + + async getConfig(url) { + let response = {}; + + try { + response = await axios({ + method: 'post', + url: url + '/execute', + timeout: 5000, + data: { + type: 'request', + action: 'config.get', + }, + headers: { + 'Content-Type': 'application/json', + ...(this.values.token.length && { Authorization: `Bearer ${this.values.token}` }), + }, + }); + + this.validateResponse(response); + return response.data.response.output; + } catch (e) { + if (e.response?.status === 401) { + // If the access token is set, then it's likely invalid - prompt the user to re-enter it + if (this.values.token.length) { + throw new Error('Invalid access token. Please check the token and try again.'); + } + + // Otherwise, the user is not authenticated to the service on this browser - redirect to the login page + window.open(`${url}/login?redirect=${encodeURIComponent(window.location.href)}`, '_blank'); + throw new Error('You are not authenticated to the Platypush service on this host. Please log in and then try again.'); + } + + throw new Error(`Failed to fetch device configuration: ${e.message}`); + } + }, + + validateResponse(response) { + const errors = response.data.response.errors; + if (errors && errors.length) { + this.errors.push(...errors); + throw errors[0]; + } + }, + + onUrlInput(event) { + const url = (event?.target?.value || this.$refs.form?.url?.value || '').trim(); + if (!url.length) { + this.formValid = false; + return; + } + + try { + new URL(url); + this.formValid = true; + } catch (e) { + this.formValid = false; + } + }, + + parseUrlArgs() { + const args = this.getUrlArgs(); + if (args.url) { + this.$refs.form.url.value = args.url; + } + + if (args.name) { + this.$refs.form.name.value = args.name; + } + + if (args.token) { + this.$refs.form.token.value = args.token; + } + }, + + onHostChange() { + const action = this.host?.name ? 'edit' : 'add'; + this.parseUrlArgs(); + this.clearUrlArgs(); + this.setSelectedTab(action); + this.setSelectedHost(this.host?.name); + + if (!this.$refs.form?.url) { + return; + } + + if (this.host?.url) { + this.$refs.form.url.value = this.host.url; + } else if (this.host?.name) { + this.$refs.form.url.value = (this.host.ssl ? 'https://' : 'http://') + this.host.address + (this.host.port || this.host.port?.length ? `:${this.host.port}` : ''); + } else { + this.$refs.form.url.value = ''; + } + + if (this.host) { + this.$refs.form.name.value = this.host.name || ''; + this.$refs.form.token.value = this.host.token || ''; + } + + this.formValid = true; + }, + }, + + mounted() { + const action = this.getSelectedTab(); + this.parseUrlArgs(); + this.clearUrlArgs(); + this.setSelectedTab(action); + this.setUrlArgs({ + url: this.values.url, + name: this.values.name, + token: this.values.token, + }); + + if (action === 'edit') { + this.$nextTick(() => { + this.onHostChange(); + }); + } else if (this.values.url.length) { + this.onUrlInput(); + this.$refs.form.requestSubmit(); + } else { + this.formValid = false; + } + + this.$nextTick(() => { + this.$refs?.form?.url?.focus(); + }); + }, + + watch: { + errors: { + handler(errors, oldErrors) { + errors + .filter(error => !oldErrors.includes(error)) + ?.forEach(error => { + console.error(error); + this.notify(error, 'Device configuration error'); + }); + }, + }, + + host: { + handler() { + this.onHostChange(); + }, + }, + }, +}; +</script> + +<!-- vim:sw=2:ts=2:et: --> diff --git a/src/options/Menu.vue b/src/options/Menu.vue index 1a409bd..41f9608 100644 --- a/src/options/Menu.vue +++ b/src/options/Menu.vue @@ -54,7 +54,7 @@ export default { computed: { hostOptions() { return { - localProc: { + actions: { displayName: 'Stored Actions', iconClass: 'fas fa-puzzle-piece', }, diff --git a/src/options/NewHost.vue b/src/options/NewHost.vue deleted file mode 100644 index 8e44277..0000000 --- a/src/options/NewHost.vue +++ /dev/null @@ -1,42 +0,0 @@ -<template> - <div class="page add"> - <h2>Add a new device</h2> - <form class="host-form" ref="form" @submit.prevent="$emit('add', $event.target)"> - <input type="text" name="name" placeholder="Name" autocomplete="off" :disabled="loading" /> - <input type="text" name="address" placeholder="IP or hostname" @keyup="onAddrChange($refs.form)" autocomplete="off" :disabled="loading" /> - <input type="text" name="port" value="8008" placeholder="HTTP port" @keyup="onPortChange($refs.form)" autocomplete="off" :disabled="loading" /> - <input type="text" name="websocketPort" value="8009" placeholder="Websocket port" autocomplete="off" :disabled="loading" /> - <input type="text" name="token" placeholder="Access token" autocomplete="off" :disabled="loading" /> - <div class="row ssl"> - <input type="checkbox" name="ssl" :disabled="loading" /> - <label for="ssl">Use SSL</label> - </div> - - <div class="buttons"> - <input type="submit" value="Add" :disabled="loading" /> - </div> - </form> - </div> -</template> - -<script> -import mixins from '../utils'; - -export default { - name: 'NewHost', - mixins: [mixins], -}; -</script> - -<style lang="scss" scoped> -form { - input[type='text'] { - .row.ssl { - display: flex; - align-items: center; - } - } -} -</style> - -<!-- vim:sw=2:ts=2:et: --> diff --git a/src/popup/App.vue b/src/popup/App.vue index 680e37c..d6562e1 100644 --- a/src/popup/App.vue +++ b/src/popup/App.vue @@ -2,7 +2,7 @@ <div class="container"> <div class="no-hosts" v-if="!(hosts && Object.keys(hosts).length)"> No devices found. Click - <a href="/options/options.html" target="_blank">here</a> to configure the extension. + <a href="/options/options.html#view=add" target="_blank">here</a> to configure the extension. </div> <div class="main" v-else> diff --git a/src/utils.js b/src/utils.js index b6f1696..0ab6a46 100644 --- a/src/utils.js +++ b/src/utils.js @@ -482,28 +482,21 @@ export default { }, formToHost(form) { - return { - name: form.name.value, - address: form.address.value, - port: parseInt(form.port.value), - websocketPort: parseInt(form.websocketPort.value), - ssl: form.ssl.checked, - token: form.token.value, - }; - }, - - onAddrChange(form) { - if (form.name.value.length && !form.address.value.startsWith(form.name.value)) { - return; + const url = new URL(form.url.value.trim()); + const ssl = url.protocol === 'https:'; + let port = parseInt(url.port); + if (!this.isPortValid(port)) { + port = ssl ? 443 : 80; } - form.name.value = form.address.value; - }, - - onPortChange(form) { - const port = form.port.value; - if (!this.isPortValid(port)) return; - form.websocketPort.value = '' + (parseInt(port) + 1); + return { + name: form.name.value, + address: url.hostname, + port: port, + websocketPort: port, + ssl: ssl, + token: form.token.value, + }; }, isPortValid(port) { @@ -511,8 +504,55 @@ export default { return !isNaN(port) && port > 0 && port < 65536; }, - isHostFormValid(form) { - return form.name.value.length && form.address.value.length && this.isPortValid(form.port.value) && this.isPortValid(form.websocketPort.value); + getUrlArgs() { + const hash = window.location.hash.slice(1); + const args = {}; + if (!hash) { + return args; + } + + const parts = hash.split('&'); + parts.forEach(part => { + const [key, value] = part.split('='); + if (key && value) { + args[key] = decodeURIComponent(value); + } + }); + + return args; + }, + + setUrlArgs(args) { + const hash = Object.entries({ ...this.getUrlArgs(), ...args }) + .filter(([key, value]) => key && value != null && (typeof value !== 'string' || value.length)) + .map(([key, value]) => `${key}=${encodeURIComponent(value.toString())}`) + .join('&'); + + window.location.hash = hash; + }, + + clearUrlArgs() { + window.location.hash = ''; + }, + + getSelectedTab() { + return this.getUrlArgs().view; + }, + + getSelectedHost() { + return this.getUrlArgs().host; + }, + + setSelectedHost(host) { + const args = this.getUrlArgs(); + args.host = host; + this.setUrlArgs(args); + }, + + setSelectedTab(tab) { + const args = this.getUrlArgs(); + args.view = tab; + this.setUrlArgs(args); }, }, };