diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ee3d9d..9257449f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -768,6 +768,31 @@ policies are unchanged. the panel went off at 23:00 or at 23:01 depending on when in the minute that check ran. Windows that cross midnight and per-day schedules follow the same rule, and so does the dim schedule. +- The MQTT bridge settings on the Tools tab can save a broker password with + TLS off. The server refuses that unless `allow_insecure_mqtt` is set, and + the form had no way to set it, so a password-protected broker on a home + network without TLS could not be saved from the web UI, and once such a + password was stored every later save failed too. While "Use TLS" is + unchecked the form now shows "Allow without TLS (trusted network)", + prefilled from the saved settings. It is off until ticked, so the server + still refuses a cleartext password by default. +- The Overview's plugin-config warning check stops polling. It asked + `/api/v3/plugins/reconciliation-status` every 2 s until startup + reconciliation reported done, and the route reports not done whenever its + status file is missing: reconciliation raised before writing it, or /tmp + was cleaned under a long-running web service. The page then sent that + request every 2 s for as long as it stayed open, whichever tab was showing. + It now gives up after a minute and only polls while the Overview is on + screen. +- Moving the Brightness slider on the Display tab no longer throws an error + in the browser console on every step. Its handler also updated a "LED + brightness" line that was removed from the page in #387; the lookup is + gone. +- Creating an API token on the General tab no longer leaves the page asking + "Leave site?" on reload. The unsaved-changes guard marks a form when you + type in it and clears the mark only after an htmx save, and the token form + saves with a plain request, so it stayed marked after the token was + created. It is cleared once the token is saved. - An on-demand session that ends during scheduled-off hours, by expiring or being stopped, blanks the panel within about a second. It used to stay on until the next minute, because the once-a-minute schedule check had diff --git a/test/js/README.md b/test/js/README.md index b7921ef1..0e3d6853 100644 --- a/test/js/README.md +++ b/test/js/README.md @@ -58,6 +58,9 @@ server has none. | `unit/test_store_registry_fields.js` | no | The store card's registry fields from `plugins_manager.js`: the commit that introduced the listed version (a hex SHA only, linked to that tree), the "Needs LEDMatrix X+" warning, a card from an older registry without either, and `isStorePluginInstalled` answering to `aliases` | | `unit/test_page_registry.js` | no | The page lifecycle in `js/core/registry.js` (a minimal DOM shim): one `init` per `data-page` root, `destroy` and an aborted `ctx.signal` when htmx swaps it away, a vetoed swap keeps it, lazy page modules, a root removed without htmx swept on the next swap | | `unit/test_core_modules.js` | no | `js/core/api.js` (JSON envelope, HTTP/`status: error`/network errors, abort passthrough, the #683 login redirect, same-server paths only) and `js/core/facade.js` (`window.LEDMatrix`, deprecated aliases) | +| `unit/test_overview_reconciliation_poll.js` | no | The Overview's reconciliation-banner poll from `partials/overview.html`, run in a vm: it gives up after a bounded number of requests when the status never says done, runs only while the Overview is on screen (`LEDVisibility`, its own key), and stops once the banner is shown | +| `unit/test_display_partial_ids.js` | no | `partials/display.html`: every literal `getElementById()` in its inline scripts names an id the partial renders, and moving the brightness slider (the shipped script, in a vm with a fake DOM) updates its label without throwing | +| `unit/test_general_web_login_token.js` | no | `window.webLogin.createToken` from `partials/general.html`, run in a vm: a created API token clears the form's `data-dirty` mark (so a reload does not ask "Leave site?"), a refused one keeps it | | `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/`, and an uninstall is confirmed once | | `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup | | `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry | diff --git a/test/js/dom/test_tools_sections.js b/test/js/dom/test_tools_sections.js index ea8ffd05..91fb617e 100644 --- a/test/js/dom/test_tools_sections.js +++ b/test/js/dom/test_tools_sections.js @@ -98,8 +98,50 @@ const get = p => new Promise((res, rej) => window.saveMqttBridge(); await tick(150); ok('save includes password once typed', sent && sent.mqtt_password === 'typed-secret'); + + // A password with TLS off is refused unless allow_insecure_mqtt is set + // (CWE-319, api_v3/misc.py). The form has to be able to send it, or a + // plain-LAN broker with a password can never be saved from here. + const allowRow = () => $('mqtt-allow-insecure-row'); + const shown = el => !!el && !el.classList.contains('hidden'); + ok('allow-without-TLS control rendered', !!$('mqtt-allow-insecure')); + ok('allow-without-TLS starts as saved', + !!$('mqtt-allow-insecure') && $('mqtt-allow-insecure').checked === !!bridge.data.config.allow_insecure_mqtt); + ok('allow-without-TLS shown only while TLS is off', + shown(allowRow()) === !$('mqtt-tls').checked); + $('mqtt-tls').checked = true; + $('mqtt-tls').dispatchEvent(new window.Event('change', { bubbles: true })); + ok('ticking TLS hides it', !shown(allowRow())); + $('mqtt-tls').checked = false; + $('mqtt-tls').dispatchEvent(new window.Event('change', { bubbles: true })); + ok('unticking TLS shows it again', shown(allowRow())); + + const setAllow = v => { if ($('mqtt-allow-insecure')) $('mqtt-allow-insecure').checked = v; }; + setAllow(false); + window.saveMqttBridge(); + await tick(150); + ok('save sends allow_insecure_mqtt false when unticked', !!sent && sent.allow_insecure_mqtt === false, sent); + setAllow(true); + window.saveMqttBridge(); + await tick(150); + ok('save sends allow_insecure_mqtt true when ticked', !!sent && sent.allow_insecure_mqtt === true, sent); onPut = null; + // Prefilled from the saved settings, and hidden while TLS is saved on. + bridgePayload = JSON.parse(JSON.stringify(bridge)); + bridgePayload.data.config.allow_insecure_mqtt = true; + bridgePayload.data.config.mqtt_tls = false; + window.loadMqttBridge(); + await tick(150); + ok('a saved opt-in is prefilled', !!$('mqtt-allow-insecure') && $('mqtt-allow-insecure').checked === true); + bridgePayload.data.config.mqtt_tls = true; + window.loadMqttBridge(); + await tick(150); + ok('hidden on load when TLS is saved on', !shown(allowRow())); + bridgePayload = bridge; + window.loadMqttBridge(); + await tick(150); + // ── Pixlet editor, idle ──────────────────────────────────────────────── const appIds = (apps.data.apps || []).map(a => a.id); ok('editor lists the apps on disk', diff --git a/test/js/run_all.js b/test/js/run_all.js index 81017a45..76155dff 100755 --- a/test/js/run_all.js +++ b/test/js/run_all.js @@ -29,7 +29,10 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js', 'unit/test_inline_handler_escaping.js', 'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js', 'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js', - 'unit/test_page_registry.js', 'unit/test_core_modules.js']; + 'unit/test_page_registry.js', 'unit/test_core_modules.js', + 'unit/test_overview_reconciliation_poll.js', + 'unit/test_display_partial_ids.js', + 'unit/test_general_web_login_token.js']; const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js', 'dom/test_tools_sections.js', 'dom/test_cache_page.js', 'dom/test_durations_page.js', 'dom/test_operation_history_page.js', diff --git a/test/js/unit/test_display_partial_ids.js b/test/js/unit/test_display_partial_ids.js new file mode 100644 index 00000000..6012c5c0 --- /dev/null +++ b/test/js/unit/test_display_partial_ids.js @@ -0,0 +1,108 @@ +// The Display tab's inline script must only look up elements the partial +// renders. +// +// Its brightness slider handler also wrote to #brightness-display, a "LED +// brightness: N%" line that #387 removed from partials/display.html. The +// lookup returned null, so every movement of the slider threw a TypeError. +// This checks every literal getElementById() in the partial's inline scripts +// against the ids its markup renders, and runs the shipped script in a vm +// with a fake DOM (null for an id the markup lacks, as in a browser) to move +// the slider. +// +// No jsdom and no server needed. + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/display.html'); + +let pass = 0, fail = 0; +const ok = (label, cond, extra) => cond + ? (pass++, console.log(' ok ' + label)) + : (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : ''))); + +const html = fs.readFileSync(PARTIAL, 'utf8'); +const blocks = [...html.matchAll(/]*>([\s\S]*?)<\/script[^>]*>/gi)]; +const scripts = blocks.map(m => m[1]); +// The markup is what lies between the script blocks (sliced around them, not +// a replace(), which CodeQL reads as an incomplete HTML sanitizer). +let markup = ''; +let from = 0; +for (const m of blocks) { + markup += html.slice(from, m.index); + from = m.index + m[0].length; +} +markup += html.slice(from); +const rendered = new Set([...markup.matchAll(/\bid="([^"{}]+)"/g)].map(m => m[1])); + +console.log('\n── Display partial: element lookups ──'); + +// 1. Static: every literal lookup names an id the partial renders. +const lookups = scripts.flatMap(s => [...s.matchAll(/getElementById\('([^']+)'\)/g)].map(m => m[1])); +const missing = [...new Set(lookups.filter(id => !rendered.has(id)))]; +ok('the inline scripts look elements up', lookups.length > 0, lookups.length); +ok('every looked-up id is rendered by the partial', missing.length === 0, missing); + +// 2. Behaviour: moving the brightness slider updates its label and throws nothing. +function fakeElement(id) { + const listeners = {}; + const classes = new Set(); + return { + id, value: '', textContent: '', min: '', max: '', checked: false, + style: {}, dataset: {}, className: '', + classList: { + add: c => classes.add(c), remove: c => classes.delete(c), + toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : (on ? classes.add(c) : classes.delete(c))), + contains: c => classes.has(c), + }, + addEventListener: (type, fn) => { (listeners[type] ||= []).push(fn); }, + dispatchEvent() { return true; }, + appendChild() {}, + listeners, + }; +} + +const main = scripts.find(s => s.includes("getElementById('brightness')")); +ok('found the script that wires the brightness slider', !!main); +if (main) { + const elements = new Map(); + const document = { + readyState: 'complete', + hidden: false, + getElementById: id => { + if (!rendered.has(id)) return null; + if (!elements.has(id)) elements.set(id, fakeElement(id)); + return elements.get(id); + }, + createElement: () => fakeElement(''), + createTextNode: () => ({}), + addEventListener() {}, + }; + const window = { + LEDEscape: { html: v => String(v), attr: v => String(v) }, + LEDVisibility: { onActive() {} }, + }; + const context = { + window, document, console, URLSearchParams, + fetch: () => new Promise(() => {}), + setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {}, + }; + vm.createContext(context); + let loadError = null; + try { vm.runInContext(main, context); } catch (e) { loadError = e; } + ok('the script loads', !loadError, loadError && String(loadError)); + + const slider = elements.get('brightness'); + const handlers = (slider && slider.listeners.input) || []; + ok('the slider has an input handler', handlers.length > 0); + let thrown = null; + slider.value = '42'; + try { handlers.forEach(fn => fn.call(slider, { target: slider })); } catch (e) { thrown = e; } + ok('moving the slider throws nothing', !thrown, thrown && String(thrown)); + ok('...and shows the new value', elements.get('brightness-value').textContent === '42', + elements.get('brightness-value').textContent); +} + +console.log(`\n${pass} passed, ${fail} failed\n`); +process.exit(fail ? 1 : 0); diff --git a/test/js/unit/test_general_web_login_token.js b/test/js/unit/test_general_web_login_token.js new file mode 100644 index 00000000..b4fa2662 --- /dev/null +++ b/test/js/unit/test_general_web_login_token.js @@ -0,0 +1,107 @@ +// Creating an API token on the General tab must leave its form clean. +// +// app.js marks a form data-dirty on any input in it and clears the mark only +// after a successful htmx request; its beforeunload handler then asks "Leave +// site?" while any visible form is still dirty. The token form posts with +// fetch (window.webLogin.createToken in partials/general.html), so after a +// token was created the form stayed dirty and reloading the page while the +// General tab was open prompted about changes that had been saved. +// +// Runs the shipped inline script in a vm with a fake fetch and DOM -- no jsdom +// and no server needed. + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/general.html'); + +let pass = 0, fail = 0; +const ok = (label, cond, extra) => cond + ? (pass++, console.log(' ok ' + label)) + : (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : ''))); + +function webLoginScript() { + const html = fs.readFileSync(PARTIAL, 'utf8'); + const scripts = [...html.matchAll(/]*>([\s\S]*?)<\/script[^>]*>/gi)].map(m => m[1]); + const found = scripts.find(s => s.includes('window.webLogin = {')); + if (!found) throw new Error('webLogin script not found in general.html'); + return found; +} + +function el() { + const classes = new Set(['hidden']); + return { + textContent: '', dataset: {}, style: {}, className: '', + classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) }, + appendChild() {}, addEventListener() {}, querySelector: () => null, + }; +} + +function load(answer) { + const elements = { + 'web-login-tokens': el(), + 'web-login-new-token-value': el(), + 'web-login-new-token': el(), + }; + const notes = []; + const window = { showNotification: (m, t) => notes.push([m, t]), alert() {}, confirm: () => true }; + const context = { + window, console, + document: { + getElementById: id => elements[id] || null, + createElement: () => el(), + querySelectorAll: () => [], + }, + fetch: () => Promise.resolve({ + ok: answer.ok, status: answer.ok ? 200 : 400, + json: () => Promise.resolve(answer.body), + }), + }; + vm.createContext(context); + vm.runInContext(webLoginScript(), context); + return { webLogin: context.window.webLogin, elements, notes }; +} + +function dirtyForm() { + const attrs = new Map([['data-dirty', '']]); + return { + querySelector: sel => (sel === '[name="name"]' ? { value: 'Home Assistant' } : null), + reset() {}, + hasAttribute: name => attrs.has(name), + setAttribute: (name, value) => attrs.set(name, String(value)), + removeAttribute: name => attrs.delete(name), + }; +} + +const flush = async () => { for (let i = 0; i < 10; i++) await new Promise(r => setImmediate(r)); }; + +(async () => { + console.log('\n── General tab: API token form ──'); + + { + const t = load({ ok: true, body: { + status: 'success', message: 'Token created', + data: { token: 'lmx_secret', record: { id: 't1', name: 'Home Assistant', prefix: 'lmx_sec' } }, + } }); + const form = dirtyForm(); + t.webLogin.createToken(form); + await flush(); + ok('the new token is shown', t.elements['web-login-new-token-value'].textContent === 'lmx_secret'); + ok('a created token leaves the form clean (no "Leave site?" on reload)', + !form.hasAttribute('data-dirty')); + } + + { + const t = load({ ok: false, body: { status: 'error', message: 'Name is required' } }); + const form = dirtyForm(); + t.webLogin.createToken(form); + await flush(); + ok('a refused request reports the error', t.notes.some(([m, type]) => type === 'error' && /Name is required/.test(m)), + t.notes); + ok('...and keeps the form dirty: nothing was saved', form.hasAttribute('data-dirty')); + } + + console.log(`\n${pass} passed, ${fail} failed\n`); + process.exit(fail ? 1 : 0); +})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); }); diff --git a/test/js/unit/test_overview_reconciliation_poll.js b/test/js/unit/test_overview_reconciliation_poll.js new file mode 100644 index 00000000..f0c8fb46 --- /dev/null +++ b/test/js/unit/test_overview_reconciliation_poll.js @@ -0,0 +1,137 @@ +// The Overview's "Plugin Config Warning" poll must end. +// +// The banner script in partials/overview.html asks +// /api/v3/plugins/reconciliation-status every 2 s until startup reconciliation +// says it is done. The route answers done: false whenever its status file is +// missing -- reconciliation raised before writing it, or /tmp was cleaned +// under a long-running web service -- so the poll used to run every 2 s for +// as long as the page stayed open, on every tab. It now gives up after a +// bounded number of tries and runs only while the Overview is on screen +// (LEDVisibility, like the other partials' pollers). +// +// Runs the shipped inline script in a vm with fake timers, fetch and DOM -- +// no jsdom and no server needed. + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/overview.html'); + +let pass = 0, fail = 0; +const ok = (label, cond, extra) => cond + ? (pass++, console.log(' ok ' + label)) + : (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : ''))); + +function bannerScript() { + const html = fs.readFileSync(PARTIAL, 'utf8'); + const scripts = [...html.matchAll(/]*>([\s\S]*?)<\/script[^>]*>/gi)].map(m => m[1]); + const found = scripts.find(s => s.includes('ledmatrix-recon-dismissed')); + if (!found) throw new Error('reconciliation banner script not found in overview.html'); + return found; +} + +const flush = async () => { for (let i = 0; i < 10; i++) await new Promise(r => setImmediate(r)); }; + +function load({ payload, visibility = true }) { + const timers = new Map(); + let nextId = 1; + const calls = []; + const banner = { style: { setProperty() {} }, dataset: {} }; + const text = { textContent: '' }; + const registrations = []; + const window = {}; + if (visibility) { + window.LEDVisibility = { + onActive(tab, start, stop, key) { registrations.push({ tab, start, stop, key }); start(); }, + }; + } + const context = { + window, + document: { + getElementById: id => ({ 'reconciliation-banner': banner, 'reconciliation-banner-text': text })[id] || null, + }, + sessionStorage: { getItem: () => null, setItem() {} }, + fetch: (url) => { + calls.push(url); + return Promise.resolve({ json: () => Promise.resolve(payload()) }); + }, + setTimeout: (fn) => { const id = nextId++; timers.set(id, fn); return id; }, + clearTimeout: (id) => { timers.delete(id); }, + }; + vm.createContext(context); + vm.runInContext(bannerScript(), context); + const fireTimers = async () => { + const due = [...timers.entries()]; + timers.clear(); + due.forEach(([, fn]) => fn()); + await flush(); + }; + return { calls, timers, registrations, banner, text, window, fireTimers }; +} + +(async () => { + console.log('\n── Overview reconciliation poll ──'); + + // 1. A status file that never says done: the poll stops on its own. + { + const t = load({ payload: () => ({ status: 'success', data: { done: false, unresolved: [] } }) }); + await flush(); + for (let i = 0; i < 200; i++) await t.fireTimers(); + ok('a status that never turns done stops being polled', t.timers.size === 0, + { pending: t.timers.size, requests: t.calls.length }); + ok('...after a bounded number of requests (at most 30, a minute at 2 s)', + t.calls.length > 1 && t.calls.length <= 30, t.calls.length); + } + + // 2. Runs only while the Overview is on screen. + { + const t = load({ payload: () => ({ status: 'success', data: { done: false, unresolved: [] } }) }); + await flush(); + const reg = t.registrations[0]; + ok('registers with LEDVisibility for the overview tab', !!reg && reg.tab === 'overview', reg && reg.tab); + ok('under its own key, so it does not replace another overview poller', + !!reg && !!reg.key && reg.key !== 'overview', reg && reg.key); + ok('first request goes out at once', t.calls.length === 1, t.calls.length); + if (reg) { + reg.stop(); + ok('leaving the tab cancels the pending retry', t.timers.size === 0, t.timers.size); + for (let i = 0; i < 5; i++) await t.fireTimers(); + ok('no requests while another tab is active', t.calls.length === 1, t.calls.length); + reg.start(); + await flush(); + ok('coming back asks again at once', t.calls.length === 2, t.calls.length); + ok('...and keeps polling', t.timers.size === 1, t.timers.size); + } + } + + // 3. A finished reconciliation with findings shows the banner and stops. + { + let done = false; + const t = load({ payload: () => (done + ? { status: 'success', data: { done: true, unresolved: [{ plugin_id: 'clock', type: 'plugin_missing_on_disk' }] } } + : { status: 'success', data: { done: false, unresolved: [] } }) }); + await flush(); + await t.fireTimers(); + done = true; + await t.fireTimers(); + ok('the banner names the finding once reconciliation is done', + t.text.textContent.includes('clock'), t.text.textContent); + const before = t.calls.length; + for (let i = 0; i < 5; i++) await t.fireTimers(); + ok('no more requests once it is done', t.calls.length === before && t.timers.size === 0, + { before, after: t.calls.length, pending: t.timers.size }); + } + + // 4. Without LEDVisibility (base.html always has it) it still runs, bounded. + { + const t = load({ visibility: false, payload: () => ({ status: 'success', data: { done: false } }) }); + await flush(); + ok('runs without LEDVisibility', t.calls.length === 1, t.calls.length); + for (let i = 0; i < 200; i++) await t.fireTimers(); + ok('...and is still bounded', t.timers.size === 0 && t.calls.length <= 30, t.calls.length); + } + + console.log(`\n${pass} passed, ${fail} failed\n`); + process.exit(fail ? 1 : 0); +})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); }); diff --git a/test/test_mqtt_bridge_config_endpoint.py b/test/test_mqtt_bridge_config_endpoint.py index 595e1c74..3fd2f849 100644 --- a/test/test_mqtt_bridge_config_endpoint.py +++ b/test/test_mqtt_bridge_config_endpoint.py @@ -136,3 +136,22 @@ class TestCleartextCredentialsNeedAnExplicitOptIn: "allow_insecure_mqtt": "false"}) assert r.status_code == 400 + def test_the_settings_read_reports_the_opt_in(self, client, monkeypatch): + """The Tools form prefills its "Allow without TLS" box from the GET. + + Off until someone saves it on, so an untouched form sends false and + the guard above still refuses a cleartext password. + """ + c, _ = client + monkeypatch.setattr(misc, "_mqtt_bridge_service_state", + lambda: {"installed": False, "active": False, "enabled": False}) + + def read(): + return c.get("/api/v3/integrations/mqtt-bridge").get_json()["data"]["config"] + + assert read()["allow_insecure_mqtt"] is False + r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False, + "allow_insecure_mqtt": True}) + assert r.status_code == 200, r.get_json() + assert read()["allow_insecure_mqtt"] is True + diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html index bb3909b6..8cff6911 100644 --- a/web_interface/templates/v3/partials/display.html +++ b/web_interface/templates/v3/partials/display.html @@ -884,7 +884,6 @@ With this off a live game takes over the whole display with the full-screen scor // Update brightness display document.getElementById('brightness').addEventListener('input', function() { document.getElementById('brightness-value').textContent = this.value; - document.getElementById('brightness-display').textContent = this.value; }); diff --git a/web_interface/templates/v3/partials/general.html b/web_interface/templates/v3/partials/general.html index b3776426..221adcbe 100644 --- a/web_interface/templates/v3/partials/general.html +++ b/web_interface/templates/v3/partials/general.html @@ -404,6 +404,10 @@ document.getElementById('web-login-new-token-value').textContent = res.d.data.token; document.getElementById('web-login-new-token').classList.remove('hidden'); form.reset(); + // app.js marks a form dirty on input and clears the mark only + // after an htmx save; this one posts with fetch, so clear it + // here or a reload asks "Leave site?" about a saved token. + form.removeAttribute('data-dirty'); notify(res.d.message || 'Token created', 'success'); }).catch(function(err) { notify('Request failed: ' + err.message, 'error'); }); }, diff --git a/web_interface/templates/v3/partials/overview.html b/web_interface/templates/v3/partials/overview.html index 21110a95..6c81bcb5 100644 --- a/web_interface/templates/v3/partials/overview.html +++ b/web_interface/templates/v3/partials/overview.html @@ -55,19 +55,40 @@