Add admin panel, authentication, and first-run setup wizard for v1.0
Implements credentialed admin panel and multi-step setup wizard so new
users can configure location, receivers, and preferences without editing
JSON files. Adds bcrypt password auth with session cookies, setup lockout
after first run, and expanded config schema with backward compatibility.
New files: admin/{html,css,js}, setup/{html,css,js}
Modified: server.py (auth, setup, admin APIs), index.html (dynamic title),
pixel-view.js (site/display config), requirements.txt (bcrypt),
config.json.example (full schema)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+340
@@ -0,0 +1,340 @@
|
||||
// ADS-Bit Admin Panel
|
||||
(function () {
|
||||
const loginScreen = document.getElementById('login-screen');
|
||||
const adminPanel = document.getElementById('admin-panel');
|
||||
let statusInterval = null;
|
||||
|
||||
// ------- Auth -------
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/check');
|
||||
if (res.ok) {
|
||||
showAdmin();
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* not logged in */ }
|
||||
showLogin();
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
loginScreen.classList.remove('hidden');
|
||||
adminPanel.classList.add('hidden');
|
||||
if (statusInterval) clearInterval(statusInterval);
|
||||
}
|
||||
|
||||
function showAdmin() {
|
||||
loginScreen.classList.add('hidden');
|
||||
adminPanel.classList.remove('hidden');
|
||||
loadConfig();
|
||||
loadThemes();
|
||||
refreshStatus();
|
||||
statusInterval = setInterval(refreshStatus, 5000);
|
||||
}
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const pw = document.getElementById('login-password').value;
|
||||
const errEl = document.getElementById('login-error');
|
||||
errEl.classList.add('hidden');
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: pw })
|
||||
});
|
||||
if (res.ok) {
|
||||
showAdmin();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
errEl.textContent = data.error || 'Login failed';
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
errEl.textContent = 'Connection error';
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('logout-btn').addEventListener('click', async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
showLogin();
|
||||
});
|
||||
|
||||
// ------- Tabs -------
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('hidden'));
|
||||
tab.classList.add('active');
|
||||
document.getElementById('tab-' + tab.dataset.tab).classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// ------- Dashboard -------
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/status');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
document.getElementById('stat-flights').textContent = data.active_flights;
|
||||
document.getElementById('stat-viewers').textContent = data.connected_viewers;
|
||||
document.getElementById('stat-receivers').textContent = data.receiver_count;
|
||||
document.getElementById('stat-uptime').textContent = data.uptime;
|
||||
|
||||
const ipsEl = document.getElementById('receiver-ips');
|
||||
if (data.receivers && data.receivers.length) {
|
||||
ipsEl.textContent = data.receivers.join(', ');
|
||||
} else {
|
||||
ipsEl.textContent = 'None connected';
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ------- Load Config -------
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/config');
|
||||
if (!res.ok) return;
|
||||
const cfg = await res.json();
|
||||
|
||||
// Receivers
|
||||
const mode = (cfg.receivers === 'AUTO') ? 'AUTO' : 'MANUAL';
|
||||
document.getElementById('receiver-mode').value = mode;
|
||||
toggleManualIps(mode);
|
||||
if (mode === 'MANUAL') {
|
||||
const ips = Array.isArray(cfg.receivers) ? cfg.receivers : [cfg.receivers];
|
||||
document.getElementById('receiver-ips-input').value = ips.join('\n');
|
||||
}
|
||||
document.getElementById('receiver-port').value = cfg.receiver_port || 30003;
|
||||
|
||||
// Location
|
||||
document.getElementById('location-name').value = cfg.location?.name || '';
|
||||
document.getElementById('location-lat').value = cfg.location?.lat || 0;
|
||||
document.getElementById('location-lon').value = cfg.location?.lon || 0;
|
||||
|
||||
// Display
|
||||
document.getElementById('site-title').value = cfg.site?.title || 'ADS-Bit';
|
||||
document.getElementById('site-subtitle').value = cfg.site?.subtitle || '';
|
||||
document.getElementById('temp-unit').value = cfg.display?.temperature_unit || 'F';
|
||||
document.getElementById('show-weather').checked = cfg.display?.show_weather !== false;
|
||||
document.getElementById('show-sidebar').checked = cfg.display?.show_sidebar !== false;
|
||||
document.getElementById('default-direction').value = cfg.display?.default_view_direction || 0;
|
||||
|
||||
// Set theme select to current theme
|
||||
const themeSelect = document.getElementById('theme-select');
|
||||
if (themeSelect.options.length > 0) {
|
||||
themeSelect.value = cfg.theme || 'desert';
|
||||
} else {
|
||||
themeSelect.dataset.pending = cfg.theme || 'desert';
|
||||
}
|
||||
|
||||
// Tuning
|
||||
document.getElementById('flight-timeout').value = cfg.tuning?.flight_timeout_seconds || 60;
|
||||
document.getElementById('broadcast-interval').value = cfg.tuning?.broadcast_interval_seconds || 1;
|
||||
document.getElementById('cleanup-interval').value = cfg.tuning?.cleanup_interval_seconds || 10;
|
||||
document.getElementById('reconnect-delay').value = cfg.tuning?.receiver_reconnect_seconds || 5;
|
||||
} catch (e) {
|
||||
console.error('Failed to load config', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ------- Load Themes -------
|
||||
async function loadThemes() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/themes');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const sel = document.getElementById('theme-select');
|
||||
sel.innerHTML = '';
|
||||
(data.themes || []).forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t;
|
||||
opt.textContent = t.charAt(0).toUpperCase() + t.slice(1);
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
// Apply pending value if config loaded first
|
||||
if (sel.dataset.pending) {
|
||||
sel.value = sel.dataset.pending;
|
||||
delete sel.dataset.pending;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ------- Receivers -------
|
||||
function toggleManualIps(mode) {
|
||||
document.getElementById('manual-ips-group').classList.toggle('hidden', mode === 'AUTO');
|
||||
}
|
||||
|
||||
document.getElementById('receiver-mode').addEventListener('change', (e) => {
|
||||
toggleManualIps(e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById('scan-receivers-btn').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('scan-receivers-btn');
|
||||
btn.textContent = 'SCANNING...';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/admin/scan-receivers', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
const box = document.getElementById('scan-results');
|
||||
const list = document.getElementById('scan-results-list');
|
||||
box.classList.remove('hidden');
|
||||
if (data.receivers && data.receivers.length) {
|
||||
list.textContent = data.receivers.join(', ');
|
||||
} else {
|
||||
list.textContent = 'No receivers found on network';
|
||||
}
|
||||
} catch (e) {
|
||||
toast('Scan failed', true);
|
||||
}
|
||||
btn.textContent = 'SCAN NOW';
|
||||
btn.disabled = false;
|
||||
});
|
||||
|
||||
document.getElementById('restart-receivers-btn').addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/restart-receivers', { method: 'POST' });
|
||||
if (res.ok) toast('Receivers restarted');
|
||||
else toast('Failed to restart', true);
|
||||
} catch (e) {
|
||||
toast('Error restarting receivers', true);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('save-receivers-btn').addEventListener('click', async () => {
|
||||
const mode = document.getElementById('receiver-mode').value;
|
||||
const port = parseInt(document.getElementById('receiver-port').value) || 30003;
|
||||
const body = { receiver_port: port };
|
||||
|
||||
if (mode === 'AUTO') {
|
||||
body.receivers = 'AUTO';
|
||||
} else {
|
||||
const ips = document.getElementById('receiver-ips-input').value
|
||||
.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
body.receivers = ips.length ? ips : 'AUTO';
|
||||
}
|
||||
|
||||
await saveConfig(body);
|
||||
});
|
||||
|
||||
// ------- Location -------
|
||||
document.getElementById('browser-location-btn').addEventListener('click', () => {
|
||||
if (!navigator.geolocation) {
|
||||
toast('Geolocation not supported', true);
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
document.getElementById('location-lat').value = pos.coords.latitude.toFixed(4);
|
||||
document.getElementById('location-lon').value = pos.coords.longitude.toFixed(4);
|
||||
toast('Location detected');
|
||||
},
|
||||
() => toast('Location access denied', true)
|
||||
);
|
||||
});
|
||||
|
||||
document.getElementById('save-location-btn').addEventListener('click', async () => {
|
||||
await saveConfig({
|
||||
location: {
|
||||
name: document.getElementById('location-name').value,
|
||||
lat: parseFloat(document.getElementById('location-lat').value) || 0,
|
||||
lon: parseFloat(document.getElementById('location-lon').value) || 0
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ------- Display -------
|
||||
document.getElementById('save-display-btn').addEventListener('click', async () => {
|
||||
await saveConfig({
|
||||
theme: document.getElementById('theme-select').value,
|
||||
site: {
|
||||
title: document.getElementById('site-title').value,
|
||||
subtitle: document.getElementById('site-subtitle').value
|
||||
},
|
||||
display: {
|
||||
temperature_unit: document.getElementById('temp-unit').value,
|
||||
show_weather: document.getElementById('show-weather').checked,
|
||||
show_sidebar: document.getElementById('show-sidebar').checked,
|
||||
default_view_direction: parseInt(document.getElementById('default-direction').value) || 0
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ------- Tuning -------
|
||||
document.getElementById('save-tuning-btn').addEventListener('click', async () => {
|
||||
await saveConfig({
|
||||
tuning: {
|
||||
flight_timeout_seconds: parseInt(document.getElementById('flight-timeout').value) || 60,
|
||||
broadcast_interval_seconds: parseInt(document.getElementById('broadcast-interval').value) || 1,
|
||||
cleanup_interval_seconds: parseInt(document.getElementById('cleanup-interval').value) || 10,
|
||||
receiver_reconnect_seconds: parseInt(document.getElementById('reconnect-delay').value) || 5
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ------- Security -------
|
||||
document.getElementById('change-password-btn').addEventListener('click', async () => {
|
||||
const current = document.getElementById('current-password').value;
|
||||
const newPw = document.getElementById('new-password').value;
|
||||
const confirm = document.getElementById('confirm-password').value;
|
||||
|
||||
if (newPw.length < 4) {
|
||||
toast('Password must be at least 4 characters', true);
|
||||
return;
|
||||
}
|
||||
if (newPw !== confirm) {
|
||||
toast('Passwords do not match', true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_password: current, new_password: newPw })
|
||||
});
|
||||
if (res.ok) {
|
||||
toast('Password changed');
|
||||
document.getElementById('current-password').value = '';
|
||||
document.getElementById('new-password').value = '';
|
||||
document.getElementById('confirm-password').value = '';
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast(data.error || 'Failed to change password', true);
|
||||
}
|
||||
} catch (e) {
|
||||
toast('Connection error', true);
|
||||
}
|
||||
});
|
||||
|
||||
// ------- Helpers -------
|
||||
async function saveConfig(body) {
|
||||
try {
|
||||
const res = await fetch('/api/admin/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (res.ok) {
|
||||
toast('Settings saved');
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast(data.error || 'Save failed', true);
|
||||
}
|
||||
} catch (e) {
|
||||
toast('Connection error', true);
|
||||
}
|
||||
}
|
||||
|
||||
function toast(msg, isError) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden', 'error');
|
||||
if (isError) el.classList.add('error');
|
||||
clearTimeout(el._timer);
|
||||
el._timer = setTimeout(() => el.classList.add('hidden'), 3000);
|
||||
}
|
||||
|
||||
// ------- Init -------
|
||||
checkAuth();
|
||||
})();
|
||||
Reference in New Issue
Block a user