Better auth flow and state management on URL.

Improved authentication flow and addition of new devices
========================================================

- The `NewHost` and `EditHost` components have been merged and all the
  fields have been extensively documented.

- The legacy authentication flow (with hostname, device name, HTTP port,
  Websocket port, token and SSL flag) has been replaced by a much
  simpler flow that only requires the URL of the instance.

- Error handling has also been properly implemented.

- The device name is now optional - if not provided then the connection
  test will perform a call to `config.get` and retrieve the reported
  `device_id`.

- The token is now optional as well. If not provided, the extension will
  leverage an existing authenticated session on the browser. If the user
  isn't logged in, the extension will automatically redirect to the
  authentication page.

State management on the URL
===========================

URLs within the extension options are now dynamically filled with the
navigation state. This makes it possible to directly point to a form to
add a device in the extension.
This commit is contained in:
Fabio Manganiello 2025-06-01 22:07:10 +02:00
parent 7d70572ca6
commit 04e1287306
11 changed files with 460 additions and 130 deletions

3
jsconfig.json Normal file
View file

@ -0,0 +1,3 @@
{
"include": ["./src/**/*"]
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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();

View file

@ -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, ' ');

View file

@ -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: -->

297
src/options/HostForm.vue Normal file
View file

@ -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 &gt; 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: -->

View file

@ -54,7 +54,7 @@ export default {
computed: {
hostOptions() {
return {
localProc: {
actions: {
displayName: 'Stored Actions',
iconClass: 'fas fa-puzzle-piece',
},

View file

@ -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: -->

View file

@ -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>

View file

@ -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);
},
},
};