Files
LEDMatrix/web_interface/templates/v3/partials/general.html
ChuckandClaude Opus 5.5 2236ff3081 fix(web-ui): MQTT password without TLS, Overview poll that never stopped, brightness slider error, token form left dirty (#745)
* fix(web-ui): let the MQTT bridge form save a password without TLS

PUT /api/v3/integrations/mqtt-bridge/config refuses a stored password
while mqtt_tls is off unless allow_insecure_mqtt is set (the CWE-319
guard in api_v3/misc.py). The Tools tab form neither rendered a control
for that flag nor sent it, so a password-protected broker on a LAN
without TLS could never be saved from the UI, and once such a password
was in bridge_config.json every later save from the form was refused.

The form now shows "Allow without TLS (trusted network)" while "Use
TLS" is unchecked, prefilled from the GET's config.allow_insecure_mqtt,
and mqttBody() sends its state as allow_insecure_mqtt. The box is off
until the user ticks it, so the server's guard still refuses a
cleartext password by default.

Tests: the Tools DOM suite checks the control, its show/hide with the
TLS box, the prefill and the value saved; a Flask test pins that the
GET reports the opt-in (false until saved on).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): stop the Overview reconciliation poll from running forever

The reconciliation banner script in partials/overview.html re-asked
/api/v3/plugins/reconciliation-status every 2 s until the answer said
done, with no limit. The route answers done: false whenever
ledmatrix_reconciliation.json is missing or unreadable, which happens
when _run_startup_reconciliation raises before writing it or when /tmp
is cleaned under a long-running web service (reconciliation runs once
per process). The browser then sent that request every 2 s for as long
as the page stayed open, on every tab, since the poll was never tied to
the Overview being visible.

The poll now gives up after 30 tries (a minute) and runs only while the
Overview is the active, visible tab, registered with LEDVisibility under
its own key like the other partials' pollers. Dismissing the banner
ends it too.

Test: test/js/unit/test_overview_reconciliation_poll.js runs the shipped
script in a vm with fake timers and fetch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): drop the Display tab's lookup of a removed brightness label

The brightness slider's input handler in partials/display.html set the
text of both #brightness-value and #brightness-display. #387
(978a03b42) removed the "LED brightness: N%" line that carried
#brightness-display, so getElementById returned null and every step of
the slider threw "Cannot set properties of null" into the console. The
visible label still updated, because it is written first.

The dead lookup is removed.

Test: test/js/unit/test_display_partial_ids.js checks every literal
getElementById() in the partial's inline scripts against the ids its
markup renders, and runs the shipped script in a vm to move the slider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): a created API token leaves the General tab's form clean

app.js marks a form data-dirty on any input inside it and removes the
mark only after a successful htmx request; its beforeunload handler
asks "Leave site?" while a visible form is still dirty. The API token
form in partials/general.html posts through window.webLogin.createToken
with fetch, so the mark survived the token being created and a reload
of the page with the General tab open prompted about a change that had
already been saved.

createToken now removes data-dirty after a successful create, next to
the form.reset() it already did. A refused request keeps the mark.

Test: test/js/unit/test_general_web_login_token.js runs the shipped
script in a vm with a fake fetch and DOM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(js): match <script> tags the way CodeQL's tag-filter rule expects

The three new suites pull the inline scripts out of their partials with
/<script>([\s\S]*?)<\/script>/g. CodeQL flags that shape as a bad HTML
filtering regexp (js/bad-tag-filter: misses upper case and tags with
attributes or whitespace), four high alerts that blocked the PR. These are
our own templates read by tests, not user input, but the stricter pattern
costs nothing: /<script\b[^>]*>(...)<\/script[^>]*>/gi, as
test_html_escaping.js already uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(js): slice the Display partial's markup around its scripts

CodeQL read the script-stripping replace() as an incomplete HTML sanitizer
(js/incomplete-multi-character-sanitization). The test only reads our own
template, but slicing between the matched blocks gives the same markup
without the pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:30:51 -04:00

443 lines
26 KiB
HTML

{% import 'v3/partials/_macros.html' as ui %}
<div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">General Settings</h2>
<p class="mt-1 text-sm text-gray-600">Configure general system settings and location information.</p>
</div>
{{ ui.settings_filter() }}
<form hx-post="/api/v3/config/main"
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'
hx-swap="none"
hx-on:htmx:after-request="showSaveResult(event.detail.xhr, 'Settings saved', 'Failed to save settings')"
class="space-y-6">
{# Marks this post as the whole form, so an unchecked box saves as
false. Without it the save endpoint treats absent keys as unchanged
(FORM_SECTION_FIELD in api_v3/config.py). #}
<input type="hidden" name="__form_section" value="general">
<!-- Web Display Autostart -->
<div class="form-group" id="setting-general-web_display_autostart" data-setting-key="web_display_autostart">
<label class="flex items-center">
<input type="checkbox"
name="web_display_autostart"
value="true"
{% if main_config.web_display_autostart %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Web Display Autostart</span>
{{ ui.help_tip('Automatically start the web interface when the device boots.\nDefault: on. Turn off if you launch the web UI manually or run headless.', 'Web Display Autostart') }}
</label>
</div>
<!-- Weekly Automatic Updates -->
<div class="form-group" id="setting-general-auto_update" data-setting-key="auto_update.enabled">
<label class="flex items-center">
<input type="checkbox"
name="auto_update_enabled"
value="true"
{% if (main_config.auto_update or {}).enabled %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Automatically check for and install updates once a week</span>
{{ ui.help_tip('Once a week, update LEDMatrix and every installed plugin that has a newer version.\nSkipped (and reported) if you have local changes, low disk space, or the rollback service is missing. After updating, the services are restarted and checked; if they do not stay healthy, the update is rolled back automatically.\nRuns overnight (2-5 AM in your timezone) when possible. Problems show as a banner on Overview. A stopped display is not started. Default: off.', 'Automatic Updates') }}
</label>
{% if auto_update_status and (main_config.auto_update or {}).enabled and auto_update_status.verifier_installed is sameas false %}
<p class="mt-1 ml-6 text-xs text-amber-700">
{% if auto_update_status.setup_status == 'failed' %}
Setting up the update health check failed: {{ auto_update_status.setup_message }}
LEDMatrix code updates are paused until it is fixed; plugin updates still run.
{% else %}
Setting up the update health check. The display service does this when it starts, so it is
ready shortly after saving (or once the display is started). LEDMatrix code updates begin after that.
{% endif %}
</p>
{% endif %}
{% if auto_update_status and auto_update_status.verifying %}
<p class="mt-1 ml-6 text-xs text-blue-700">An update was just installed and its health check is running.</p>
{% endif %}
{% if auto_update_status and (auto_update_status.last_run or auto_update_status.next_due) %}
<div class="mt-1 ml-6 text-xs text-gray-500 space-y-1">
{% if auto_update_status.last_run %}
<p>Last automatic update: {{ auto_update_status.last_run }}
<span class="{{ 'text-red-600' if auto_update_status.status == 'error' else '' }}">— {{ auto_update_status.summary }}</span></p>
{% endif %}
{% if auto_update_status.next_due %}
<p>Next check: {{ auto_update_status.next_due }} or the first overnight window after it</p>
{% endif %}
</div>
{% endif %}
</div>
<!-- Update channel: which code Update Code and the weekly update move to -->
{% set configured_channel = (main_config.auto_update or {}).channel if (main_config.auto_update or {}).channel in ('stable', 'beta') else 'stable' %}
<div class="form-group" id="setting-general-auto_update_channel" data-setting-key="auto_update.channel">
<label for="auto_update_channel" class="block text-sm font-medium text-gray-700">Update Channel{{ ui.help_tip('Which LEDMatrix version updates install, both Update Code on the Overview tab and the weekly automatic update.\nStable: the newest release. Recommended.\nBeta: the newest code on main, before it is released. Gets fixes sooner, and problems too.\nSwitching to Stable never installs an older version than the one running: a device that is ahead of the newest release keeps following main until a release includes its version, then moves to releases.\nDefault: Stable.', 'Update Channel') }}</label>
<select id="auto_update_channel" name="auto_update_channel" class="form-control mt-1">
<option value="stable" {% if configured_channel == 'stable' %}selected{% endif %}>Stable (releases)</option>
<option value="beta" {% if configured_channel == 'beta' %}selected{% endif %}>Beta (newest code on main)</option>
</select>
{% if update_channel_status %}
<p class="mt-1 text-xs {{ 'text-amber-700' if update_channel_status.waiting and configured_channel == 'stable' else 'text-gray-500' }}">
{{ update_channel_status.message }}
</p>
{% endif %}
<p class="mt-1 text-xs text-gray-500">Takes effect at the next update: use Update Code on the Overview tab to apply it now.</p>
</div>
<!-- Timezone -->
<div class="form-group" id="setting-general-timezone" data-setting-key="timezone">
<label for="timezone" class="block text-sm font-medium text-gray-700">Timezone{{ ui.help_tip('Time zone used for clocks, schedules, and time-based content.\nChoose the zone where the display physically lives so on/off schedules fire at the correct local time.', 'Timezone') }}</label>
<div id="timezone_container" class="mt-1"></div>
</div>
<!-- Legacy scroll frame rate (device-wide). Core scrolling no longer reads it. -->
<div class="form-group" id="setting-general-target-fps" data-setting-key="target_fps">
<label for="target_fps" class="block text-sm font-medium text-gray-700">Scroll Frame Rate (legacy){{ ui.help_tip('Kept for older plugins that read it. It no longer changes scrolling speed or smoothness.\nScrolling runs at the panel refresh rate (Display settings), and each plugin sets its own scroll speed.\nRange 30-200. Default: 100.', 'Scroll Frame Rate (legacy)') }}</label>
<input type="number"
id="target_fps"
name="target_fps"
value="{{ main_config.target_fps or 100 }}"
min="30"
max="200"
class="form-control">
</div>
<script>
(function() {
function initTimezoneWidget() {
if (!window.LEDMatrixWidgets) { setTimeout(initTimezoneWidget, 50); return; }
var widget = window.LEDMatrixWidgets.get('timezone-selector');
if (!widget) { setTimeout(initTimezoneWidget, 50); return; }
var container = document.getElementById('timezone_container');
if (!container) return;
// Only render if container is empty (not already rendered)
if (container.children.length > 0) return;
widget.render(container, {
'x-options': { showOffset: true, placeholder: 'Select your timezone...' }
}, {{ (main_config.timezone or "America/Chicago")|tojson }}, {
fieldId: 'timezone',
name: 'timezone'
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTimezoneWidget);
} else {
setTimeout(initTimezoneWidget, 50);
}
})();
</script>
<!-- Location Information -->
<div class="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3 gap-4">
<div class="form-group" id="setting-general-city" data-setting-key="location.city">
<label for="city" class="block text-sm font-medium text-gray-700">City{{ ui.help_tip('City used for weather, sunrise/sunset, radar, and other location-based content.\nExample: Kansas City.\nUsed as the default for the location_city setting on plugins that have one; a value saved on the plugin itself overrides it.', 'City') }}</label>
<input type="text"
id="city"
name="city"
value="{{ main_config.location.city or 'Dallas' }}"
class="form-control">
</div>
<div class="form-group" id="setting-general-state" data-setting-key="location.state">
<label for="state" class="block text-sm font-medium text-gray-700">State{{ ui.help_tip('State or region for your location.\nExample: Missouri. Improves location-lookup accuracy.\nUsed as the default for the location_state setting on plugins that have one.', 'State') }}</label>
<input type="text"
id="state"
name="state"
value="{{ main_config.location.state or 'Texas' }}"
class="form-control">
</div>
<div class="form-group" id="setting-general-country" data-setting-key="location.country">
<label for="country" class="block text-sm font-medium text-gray-700">Country{{ ui.help_tip('Country code or name for your location.\nExample: US. Used with City and State for weather, radar, and geolocation.\nUsed as the default for the location_country setting on plugins that have one.', 'Country') }}</label>
<input type="text"
id="country"
name="country"
value="{{ main_config.location.country or 'US' }}"
class="form-control">
</div>
</div>
<!-- Plugin System Settings -->
<div class="border-t border-gray-200 pt-6 mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Plugin System Settings</h3>
<p class="text-sm text-gray-600 mb-4">Configure the core plugin system behavior.</p>
<div class="space-y-4">
<!-- plugin_system.auto_discover, auto_load_enabled and
development_mode used to be toggles here. Nothing reads
them: plugins are always discovered and every enabled
plugin is loaded. Stored values are left alone. -->
<!-- Plugins Directory -->
<div class="form-group" id="setting-general-plugins_directory" data-setting-key="plugin_system.plugins_directory">
<label for="plugins_directory" class="block text-sm font-medium text-gray-700">Plugins Directory{{ ui.help_tip('Folder (relative to the project root) where plugins are stored.\nDefault: plugin-repos. Only change this if you keep plugins in a custom location.', 'Plugins Directory') }}</label>
<input type="text"
id="plugins_directory"
name="plugins_directory"
value="{{ main_config.get('plugin_system', {}).get('plugins_directory', 'plugin-repos') }}"
placeholder="plugin-repos"
class="form-control">
</div>
</div>
</div>
<!-- Submit Button -->
<div class="flex justify-end mt-6">
<button type="submit"
class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
<i class="fas fa-save mr-2"></i>
Save General Settings
</button>
</div>
</form>
</div>
{% if web_login %}
<!-- Web login (optional; web_interface/auth.py). Off until a password is set. -->
<div id="web-login-settings" class="bg-white rounded-lg shadow p-6 mt-6">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900"><i class="fas fa-lock mr-2" aria-hidden="true"></i>Security</h2>
<p class="mt-1 text-sm text-gray-600">
{% if web_login.enabled %}
<span class="text-green-700 font-semibold">Login is on.</span>
Browsers on your network need the password; integrations use an API token.
{% else %}
Login is off: anyone on your network can open this page. Set a password to require one.
{% endif %}
</p>
</div>
<div class="space-y-6">
<div class="form-group" id="setting-general-web_login" data-setting-key="web_auth.password">
<h3 class="text-lg font-semibold text-gray-900 mb-2">
{# A <label> so the settings search indexes it; no `for`, so
the password inputs keep their own labels. #}
<label>{% if web_login.enabled %}Change the web interface password{% else %}Web interface password{% endif %}{{ ui.help_tip('Optional. With a password set, every page and API call needs a login or an API token.\nAlways allowed without one: this Pi itself (localhost), and the Wi-Fi setup page while the Pi is in access-point mode.\nForgot it? On the Pi run: sudo python3 ~/LEDMatrix/scripts/reset_web_password.py', 'Web interface password') }}</label>
</h3>
<form class="space-y-4" onsubmit="window.webLogin.setPassword(this); return false;" autocomplete="off">
{% if web_login.enabled %}
<div>
<label for="web-login-current" class="block text-sm font-medium text-gray-700">Current password</label>
<input type="password" id="web-login-current" name="current_password" required
autocomplete="current-password" class="form-control mt-1">
</div>
{% endif %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="web-login-new" class="block text-sm font-medium text-gray-700">New password</label>
<input type="password" id="web-login-new" name="new_password" required
minlength="{{ web_login.min_length }}" autocomplete="new-password" class="form-control mt-1">
</div>
<div>
<label for="web-login-confirm" class="block text-sm font-medium text-gray-700">Type it again</label>
<input type="password" id="web-login-confirm" name="confirm_password" required
minlength="{{ web_login.min_length }}" autocomplete="new-password" class="form-control mt-1">
</div>
</div>
<p class="text-sm text-gray-600">
At least {{ web_login.min_length }} characters.
{% if not web_login.enabled %}Write it down: if you lose it, you need SSH access to the Pi (or a browser on the Pi) to turn login off again.{% endif %}
</p>
<div class="flex justify-end">
<button type="submit" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
<i class="fas fa-key mr-2" aria-hidden="true"></i>{% if web_login.enabled %}Change password{% else %}Set password and turn login on{% endif %}
</button>
</div>
</form>
</div>
{% if web_login.enabled %}
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Turn login off</h3>
<form class="flex flex-wrap items-end gap-2" onsubmit="window.webLogin.disable(this); return false;" autocomplete="off">
<div class="flex-1">
<label for="web-login-disable-current" class="block text-sm font-medium text-gray-700">Current password</label>
<input type="password" id="web-login-disable-current" name="current_password" required
autocomplete="current-password" class="form-control mt-1">
</div>
<button type="submit" class="btn bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-md">
<i class="fas fa-lock-open mr-2" aria-hidden="true"></i>Turn login off
</button>
</form>
</div>
{% endif %}
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-2">API tokens</h3>
<p class="text-sm text-gray-600 mb-4">
For Home Assistant, scripts, or the MQTT bridge on another machine, once login is on.
Send it as <code class="bg-gray-100 font-mono">Authorization: Bearer &lt;token&gt;</code>.
A token is shown once, when you create it.
</p>
<div id="web-login-tokens" class="space-y-2 mb-4">
{% for token in web_login.tokens %}
<div class="flex flex-wrap items-center justify-between gap-2 border border-gray-200 rounded-md px-4 py-2" data-token-id="{{ token.id }}">
<div class="text-sm">
<span class="font-semibold text-gray-900">{{ token.name }}</span>
<span class="font-mono text-gray-600 ml-2">{{ token.prefix }}&hellip;</span>
<span class="text-gray-600 ml-2">created {{ (token.created_at or '')[:10] }}</span>
</div>
<button type="button" class="text-sm text-red-600 hover:underline"
data-token-name="{{ token.name }}"
onclick="window.webLogin.revoke(this.closest('[data-token-id]').dataset.tokenId, this.dataset.tokenName)">
Revoke
</button>
</div>
{% else %}
<p class="text-sm text-gray-600" data-empty>No tokens yet.</p>
{% endfor %}
</div>
<form class="flex flex-wrap items-end gap-2" onsubmit="window.webLogin.createToken(this); return false;" autocomplete="off">
<div class="flex-1">
<label for="web-login-token-name" class="block text-sm font-medium text-gray-700">New token name</label>
<input type="text" id="web-login-token-name" name="name" required maxlength="60"
placeholder="Home Assistant" class="form-control mt-1">
</div>
<button type="submit" class="btn bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-md">
<i class="fas fa-plus mr-2" aria-hidden="true"></i>Create token
</button>
</form>
<div id="web-login-new-token" class="hidden mt-4 border border-amber-300 bg-amber-50 rounded-md p-4" role="status">
<p class="text-sm font-semibold text-gray-900 mb-2">Copy this token now. It is not shown again.</p>
<div class="flex flex-wrap items-center gap-2">
<code id="web-login-new-token-value" class="font-mono break-all bg-white border border-gray-200 rounded-md px-4 py-2 flex-1"></code>
<button type="button" class="btn bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-md"
onclick="window.webLogin.copyToken()">
<i class="fas fa-copy mr-2" aria-hidden="true"></i>Copy
</button>
</div>
</div>
</div>
</div>
</div>
<script>
(function() {
var API = '/api/v3/auth';
function notify(message, type) {
if (typeof window.showNotification === 'function') window.showNotification(message, type);
else window.alert(message);
}
function send(method, url, body) {
return fetch(url, {
method: method,
headers: {'Content-Type': 'application/json'},
body: body === undefined ? undefined : JSON.stringify(body)
}).then(function(r) {
return r.json().catch(function() { return {}; }).then(function(d) { return {ok: r.ok, d: d}; });
});
}
function reloadSection() {
if (window.htmx) {
window.htmx.ajax('GET', '/v3/partials/general', {target: '#general-content', swap: 'innerHTML'});
} else {
window.location.reload();
}
}
function field(form, name) {
var el = form.querySelector('[name="' + name + '"]');
return el ? el.value : '';
}
function tokenRow(record) {
var row = document.createElement('div');
row.className = 'flex flex-wrap items-center justify-between gap-2 border border-gray-200 rounded-md px-4 py-2';
row.dataset.tokenId = record.id;
var text = document.createElement('div');
text.className = 'text-sm';
[['font-semibold text-gray-900', record.name],
['font-mono text-gray-600 ml-2', record.prefix + '…'],
['text-gray-600 ml-2', 'created just now']].forEach(function(part) {
var span = document.createElement('span');
span.className = part[0];
span.textContent = part[1];
text.appendChild(span);
});
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'text-sm text-red-600 hover:underline';
btn.textContent = 'Revoke';
btn.addEventListener('click', function() { window.webLogin.revoke(record.id, record.name); });
row.appendChild(text);
row.appendChild(btn);
return row;
}
window.webLogin = {
setPassword: function(form) {
var next = field(form, 'new_password');
if (next !== field(form, 'confirm_password')) {
notify('The two new passwords do not match.', 'error');
return;
}
var body = {new_password: next};
if (form.querySelector('[name="current_password"]')) body.current_password = field(form, 'current_password');
send('POST', API + '/password', body).then(function(res) {
notify(res.d.message || (res.ok ? 'Saved' : 'Could not save the password'), res.ok ? 'success' : 'error');
if (res.ok) reloadSection();
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
},
disable: function(form) {
if (!window.confirm('Turn login off? Anyone on your network will be able to open this page.')) return;
send('POST', API + '/disable', {current_password: field(form, 'current_password')}).then(function(res) {
notify(res.d.message || (res.ok ? 'Login is off' : 'Could not turn login off'), res.ok ? 'success' : 'error');
if (res.ok) reloadSection();
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
},
createToken: function(form) {
send('POST', API + '/tokens', {name: field(form, 'name')}).then(function(res) {
if (!res.ok) {
notify(res.d.message || 'Could not create the token', 'error');
return;
}
var list = document.getElementById('web-login-tokens');
if (list) {
var empty = list.querySelector('[data-empty]');
if (empty) empty.remove();
list.appendChild(tokenRow(res.d.data.record));
}
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'); });
},
copyToken: function() {
var box = document.getElementById('web-login-new-token-value');
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(box.textContent).then(function() { notify('Token copied', 'success'); });
return;
}
// Plain http on a LAN is not a secure context: select it instead.
var range = document.createRange();
range.selectNodeContents(box);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
notify('Selected: press Ctrl+C (or Cmd+C) to copy.', 'info');
},
revoke: function(id, name) {
if (!window.confirm('Revoke the token "' + name + '"? Anything using it stops working.')) return;
send('DELETE', API + '/tokens/' + encodeURIComponent(id)).then(function(res) {
notify(res.d.message || (res.ok ? 'Token revoked' : 'Could not revoke the token'), res.ok ? 'success' : 'error');
if (!res.ok) return;
var rows = document.querySelectorAll('#web-login-tokens [data-token-id]');
Array.prototype.forEach.call(rows, function(row) {
if (row.dataset.tokenId === id) row.remove();
});
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
}
};
})();
</script>
{% endif %}