mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
Optional web login, off by default: a device that sets no password behaves exactly as before. Set under General > Security; then every page and API route needs a session login or an API token (Authorization: Bearer). Loopback, the Wi-Fi setup flow in AP mode, static files, captive-portal probes and a reduced /api/v3/health stay open. Secrets live in the web_auth section of config_secrets.json and no API returns them. scripts/reset_web_password.py turns login off. Stacked on #674. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,27 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Optional web login (web_interface/auth.py): a fetch() answered 401
|
||||
with X-LEDMatrix-Login -- the session ran out, or login was turned on
|
||||
from another browser -- goes to the login page instead of leaving
|
||||
every panel showing an error. HTMX requests follow HX-Redirect on
|
||||
their own. Only a same-origin answer can expose that header. -->
|
||||
<script>
|
||||
(function() {
|
||||
if (typeof window.fetch !== 'function') return;
|
||||
var originalFetch = window.fetch;
|
||||
window.fetch = function() {
|
||||
return originalFetch.apply(this, arguments).then(function(response) {
|
||||
var login = response.status === 401 && response.headers.get('X-LEDMatrix-Login');
|
||||
if (login && login.charAt(0) === '/' && login.charAt(1) !== '/') {
|
||||
window.location.assign(login);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Theme initialization (must run before CSS to prevent flash) -->
|
||||
<script>
|
||||
(function() {
|
||||
@@ -352,6 +373,16 @@
|
||||
class="hidden absolute right-0 mt-1 w-80 max-h-96 overflow-y-auto"></div>
|
||||
</div>
|
||||
|
||||
{% if web_auth_state and web_auth_state.signed_in %}
|
||||
<!-- Log out (only shown when the optional web login is on) -->
|
||||
<form method="post" action="{{ url_for('ledmatrix_auth.logout') }}" class="flex items-center">
|
||||
<button type="submit" class="theme-toggle-btn p-2 rounded-md"
|
||||
title="Log out" aria-label="Log out">
|
||||
<i class="fas fa-sign-out-alt" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<!-- Theme toggle -->
|
||||
<button id="theme-toggle"
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Log in - LED Matrix Control Panel</title>
|
||||
<meta name="theme-color" content="#111827">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="{{ url_for('static', filename='v3/icons/icon-192.png') }}">
|
||||
<script>
|
||||
// Same theme choice as the main interface (base.html), before CSS loads.
|
||||
(function() {
|
||||
var theme = null;
|
||||
try { theme = localStorage.getItem('theme'); } catch (e) { /* private mode */ }
|
||||
if (!theme) {
|
||||
try {
|
||||
theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
} catch (e) { theme = 'light'; }
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/fontawesome/css/all.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}">
|
||||
<style>
|
||||
.login-wrap { max-width: 24rem; margin: 0 auto; padding: 4rem 1rem; }
|
||||
.login-error { border: 1px solid #fecaca; border-radius: 0.375rem; padding: 0.75rem; }
|
||||
.login-help { margin-top: 1.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<main class="login-wrap">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h1 class="text-lg font-semibold text-gray-900 mb-2">
|
||||
<i class="fas fa-tv text-blue-600 mr-2" aria-hidden="true"></i>LED Matrix Control
|
||||
</h1>
|
||||
<p class="text-sm text-gray-600 mb-6">This display's settings are protected by a password.</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="login-error bg-red-50 text-red-600 text-sm mb-4" role="alert">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('ledmatrix_auth.login') }}" class="space-y-4">
|
||||
<input type="hidden" name="next" value="{{ next_path }}">
|
||||
<div class="form-group">
|
||||
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
|
||||
<input type="password" id="password" name="password" required autofocus
|
||||
autocomplete="current-password" class="form-control mt-1 w-full">
|
||||
</div>
|
||||
<button type="submit" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md w-full">
|
||||
<i class="fas fa-sign-in-alt mr-2" aria-hidden="true"></i>Log in
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="login-help text-sm text-gray-600">
|
||||
Forgot it? On the Pi, run
|
||||
<code class="bg-gray-100 font-mono">sudo python3 ~/LEDMatrix/scripts/reset_web_password.py</code>
|
||||
(use the folder LEDMatrix is installed in), or open the interface from the Pi itself.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -177,3 +177,246 @@
|
||||
</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 <token></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 }}…</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();
|
||||
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 %}
|
||||
|
||||
@@ -1059,6 +1059,16 @@
|
||||
${field('mqtt-topic', 'Command topic', c.mqtt_topic)}
|
||||
${field('mqtt-client-id', 'Client ID', c.mqtt_client_id)}
|
||||
${field('mqtt-api-base', 'LEDMatrix API base', c.ledmatrix_api_base)}
|
||||
<label class="block">
|
||||
<span class="text-xs font-medium text-gray-700">LEDMatrix API token</span>
|
||||
<input id="mqtt-api-token" type="password" value="" autocomplete="off"
|
||||
placeholder="${data.api_token_set ? 'unchanged — leave blank to keep' : 'none set'}"
|
||||
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md">
|
||||
<span class="text-xs text-gray-500">
|
||||
Only when web login is on and the bridge runs on another machine (General > Security).
|
||||
${data.api_token_set ? '<button type="button" id="mqtt-clear-api-token" class="text-red-600 hover:underline ml-1">Clear it</button>' : ''}
|
||||
</span>
|
||||
</label>
|
||||
${field('mqtt-timeout', 'Request timeout (s)', c.request_timeout, 'number', 'min="1" max="300"')}
|
||||
${field('mqtt-duration', 'On-demand duration (s, blank = default)', c.on_demand_duration, 'number', 'min="1" max="86400"')}
|
||||
<label class="block">
|
||||
@@ -1113,6 +1123,8 @@
|
||||
|
||||
const clearBtn = document.getElementById('mqtt-clear-password');
|
||||
if (clearBtn) clearBtn.addEventListener('click', () => clearMqttPassword());
|
||||
const clearTokenBtn = document.getElementById('mqtt-clear-api-token');
|
||||
if (clearTokenBtn) clearTokenBtn.addEventListener('click', () => clearMqttApiToken());
|
||||
}
|
||||
|
||||
window.loadMqttBridge = function() {
|
||||
@@ -1145,6 +1157,8 @@
|
||||
// Only send a password when one was typed; blank means "leave it alone".
|
||||
const pw = val('mqtt-password');
|
||||
if (pw) body.mqtt_password = pw;
|
||||
const apiToken = val('mqtt-api-token');
|
||||
if (apiToken) body.ledmatrix_api_token = apiToken;
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -1180,6 +1194,21 @@
|
||||
.catch(err => showResult('result-mqtt-save', false, 'Request failed: ' + err.message));
|
||||
}
|
||||
|
||||
function clearMqttApiToken() {
|
||||
if (!confirm('Remove the stored LEDMatrix API token from the bridge settings?')) return;
|
||||
fetch(MQTT_BRIDGE_URL + '/config', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({clear_api_token: true})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
showResult('result-mqtt-save', d.status === 'success', d.message || 'Token cleared');
|
||||
loadMqttBridge();
|
||||
})
|
||||
.catch(err => showResult('result-mqtt-save', false, 'Request failed: ' + err.message));
|
||||
}
|
||||
|
||||
window.installMqttBridge = function() {
|
||||
// Installing pulls dependencies, so it is slower than the other actions
|
||||
// and worth saying so rather than leaving a spinner unexplained.
|
||||
|
||||
Reference in New Issue
Block a user