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:
root
2026-06-08 08:35:07 -07:00
commit 22a3078af9
32 changed files with 5584 additions and 0 deletions
+336
View File
@@ -0,0 +1,336 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: linear-gradient(180deg, #2c3e50 0%, #1a252f 100%);
color: #fcfcfc;
font-family: 'Courier New', monospace;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.hidden { display: none !important; }
.setup-container {
max-width: 560px;
width: 100%;
}
/* Progress */
.progress-bar {
height: 6px;
background: rgba(92, 148, 252, 0.2);
border-radius: 3px;
margin-bottom: 8px;
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0%;
background: #fcd444;
border-radius: 3px;
transition: width 0.4s ease;
}
.progress-steps {
display: flex;
justify-content: space-between;
margin-bottom: 24px;
padding: 0 4px;
}
.step-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(92, 148, 252, 0.3);
border: 2px solid #5c94fc;
transition: all 0.3s;
}
.step-dot.active {
background: #fcd444;
border-color: #fcd444;
}
.step-dot.done {
background: #54fc54;
border-color: #54fc54;
}
/* Steps */
.step {
display: none;
background: rgba(52, 73, 94, 0.9);
border: 3px solid #5c94fc;
border-radius: 12px;
padding: 32px;
text-align: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
animation: fadeIn 0.3s ease;
}
.step.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.step-icon {
font-size: 48px;
color: #fcd444;
margin-bottom: 12px;
}
.retro-title {
font-family: 'Press Start 2P', monospace;
color: #fcd444;
text-shadow: 2px 2px 0px #000;
font-size: 20px;
margin-bottom: 16px;
}
.step-title {
font-family: 'Press Start 2P', monospace;
color: #fcd444;
text-shadow: 2px 2px 0px #000;
font-size: 14px;
margin-bottom: 12px;
}
.step-desc {
color: #b4d4ec;
font-size: 14px;
line-height: 1.6;
margin-bottom: 16px;
}
.feature-list {
list-style: none;
margin-bottom: 24px;
text-align: left;
display: inline-block;
}
.feature-list li {
padding: 4px 0;
color: #b4d4ec;
font-size: 13px;
}
.feature-list li::before {
content: '\2713 ';
color: #54fc54;
font-weight: bold;
}
/* Forms */
.form-group {
margin-bottom: 16px;
text-align: left;
}
.form-group label {
display: block;
font-family: 'Press Start 2P', monospace;
font-size: 9px;
color: #b4d4ec;
margin-bottom: 6px;
}
.form-row {
display: flex;
gap: 16px;
}
.form-row .form-group {
flex: 1;
}
input[type="password"],
input[type="text"],
input[type="number"],
textarea,
select {
background: rgba(0, 0, 0, 0.5);
border: 2px solid #5c94fc;
border-radius: 6px;
color: #fcfcfc;
font-family: 'Courier New', monospace;
font-size: 14px;
padding: 10px 12px;
width: 100%;
outline: none;
transition: border-color 0.2s;
}
input:focus, textarea:focus, select:focus {
border-color: #fcd444;
}
select option {
background: #2c3e50;
color: #fcfcfc;
}
textarea {
resize: vertical;
}
/* Buttons */
.btn {
font-family: 'Press Start 2P', monospace;
font-size: 11px;
padding: 10px 20px;
border: 2px solid #5c94fc;
border-radius: 6px;
background: rgba(92, 148, 252, 0.2);
color: #fcfcfc;
cursor: pointer;
transition: all 0.15s;
}
.btn:hover {
background: rgba(92, 148, 252, 0.4);
transform: translateY(-1px);
}
.btn:active {
transform: translateY(1px);
}
.btn-primary {
background: rgba(252, 212, 68, 0.3);
border-color: #fcd444;
color: #fcd444;
}
.btn-primary:hover {
background: rgba(252, 212, 68, 0.5);
}
.btn-large {
font-size: 12px;
padding: 14px 32px;
}
.btn-detect {
font-size: 9px;
padding: 8px 14px;
margin-bottom: 12px;
border-color: #54fc54;
color: #54fc54;
}
.btn-detect:hover {
background: rgba(84, 252, 84, 0.2);
}
.step-buttons {
display: flex;
justify-content: space-between;
margin-top: 24px;
gap: 12px;
}
/* Messages */
.error-msg {
color: #fc5454;
font-size: 12px;
margin-top: 8px;
}
.status-msg {
font-size: 12px;
padding: 8px 12px;
border-radius: 6px;
margin-bottom: 12px;
}
.status-msg.success {
color: #54fc54;
background: rgba(84, 252, 84, 0.1);
border: 1px solid rgba(84, 252, 84, 0.3);
}
.status-msg.info {
color: #54d4fc;
background: rgba(84, 212, 252, 0.1);
border: 1px solid rgba(84, 212, 252, 0.3);
}
.status-msg.error {
color: #fc5454;
background: rgba(252, 84, 84, 0.1);
border: 1px solid rgba(252, 84, 84, 0.3);
}
/* Summary */
.summary-box {
background: rgba(0, 0, 0, 0.4);
border: 2px solid #5c94fc;
border-radius: 8px;
padding: 16px;
text-align: left;
margin-bottom: 20px;
}
.summary-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid rgba(92, 148, 252, 0.2);
font-size: 12px;
}
.summary-row:last-child {
border-bottom: none;
}
.summary-label {
color: #b4d4ec;
font-family: 'Press Start 2P', monospace;
font-size: 8px;
}
.summary-value {
color: #fcd444;
}
/* Mobile */
@media (max-width: 480px) {
.step {
padding: 20px;
}
.retro-title {
font-size: 16px;
}
.step-title {
font-size: 12px;
}
.form-row {
flex-direction: column;
gap: 0;
}
.step-buttons {
flex-direction: column;
}
.step-buttons .btn {
width: 100%;
}
}
+154
View File
@@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ADS-Bit Setup</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/setup/setup.css">
</head>
<body>
<div class="setup-container">
<!-- Progress Bar -->
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<div class="progress-steps">
<span class="step-dot active" data-step="0"></span>
<span class="step-dot" data-step="1"></span>
<span class="step-dot" data-step="2"></span>
<span class="step-dot" data-step="3"></span>
<span class="step-dot" data-step="4"></span>
<span class="step-dot" data-step="5"></span>
</div>
<!-- Step 0: Welcome -->
<div class="step active" id="step-0">
<div class="step-icon">&#9733;</div>
<h1 class="retro-title">Welcome to ADS-Bit</h1>
<p class="step-desc">Your retro SNES-style ADS-B flight tracker. Let's get everything configured so you can start watching aircraft.</p>
<p class="step-desc">This wizard will guide you through:</p>
<ul class="feature-list">
<li>Setting an admin password</li>
<li>Configuring your location</li>
<li>Finding ADS-B receivers</li>
<li>Choosing your theme</li>
</ul>
<button class="btn btn-primary btn-large" onclick="wizard.next()">LET'S GET STARTED</button>
</div>
<!-- Step 1: Password -->
<div class="step" id="step-1">
<h2 class="step-title">Set Admin Password</h2>
<p class="step-desc">Protect your admin panel with a password. You'll use this to change settings later.</p>
<div class="form-group">
<label>Password (min 4 characters)</label>
<input type="password" id="setup-password" placeholder="Enter password" autocomplete="new-password">
</div>
<div class="form-group">
<label>Confirm Password</label>
<input type="password" id="setup-password-confirm" placeholder="Confirm password" autocomplete="new-password">
</div>
<div id="password-error" class="error-msg hidden"></div>
<div class="step-buttons">
<button class="btn" onclick="wizard.prev()">BACK</button>
<button class="btn btn-primary" onclick="wizard.savePassword()">NEXT</button>
</div>
</div>
<!-- Step 2: Location -->
<div class="step" id="step-2">
<h2 class="step-title">Your Location</h2>
<p class="step-desc">This is used for weather data, sunrise/sunset times, and positioning aircraft relative to you.</p>
<div class="form-group">
<label>Location Name</label>
<input type="text" id="setup-location-name" placeholder="e.g. Denver, CO">
</div>
<div class="form-row">
<div class="form-group">
<label>Latitude</label>
<input type="number" id="setup-lat" step="0.0001" placeholder="39.7392">
</div>
<div class="form-group">
<label>Longitude</label>
<input type="number" id="setup-lon" step="0.0001" placeholder="-104.9903">
</div>
</div>
<button class="btn btn-detect" id="detect-location-btn" onclick="wizard.detectLocation()">USE BROWSER LOCATION</button>
<div id="location-status" class="status-msg hidden"></div>
<div class="step-buttons">
<button class="btn" onclick="wizard.prev()">BACK</button>
<button class="btn btn-primary" onclick="wizard.saveLocation()">NEXT</button>
</div>
</div>
<!-- Step 3: Receivers -->
<div class="step" id="step-3">
<h2 class="step-title">Find Receivers</h2>
<p class="step-desc">ADS-Bit connects to ADS-B receivers on your network (port 30003 by default). You can auto-scan or enter IPs manually.</p>
<div class="form-group">
<label>Discovery Mode</label>
<select id="setup-receiver-mode" onchange="wizard.toggleReceiverMode()">
<option value="AUTO">AUTO - Scan network automatically</option>
<option value="MANUAL">MANUAL - Enter IP addresses</option>
</select>
</div>
<div id="setup-manual-group" class="form-group hidden">
<label>Receiver IPs (one per line)</label>
<textarea id="setup-receiver-ips" rows="3" placeholder="192.168.1.100"></textarea>
</div>
<div class="form-group">
<label>Receiver Port</label>
<input type="number" id="setup-receiver-port" value="30003" min="1" max="65535">
</div>
<button class="btn btn-detect" id="scan-btn" onclick="wizard.scanReceivers()">SCAN FOR RECEIVERS</button>
<div id="scan-status" class="status-msg hidden"></div>
<div class="step-buttons">
<button class="btn" onclick="wizard.prev()">BACK</button>
<button class="btn btn-primary" onclick="wizard.saveReceivers()">NEXT</button>
</div>
</div>
<!-- Step 4: Theme & Preferences -->
<div class="step" id="step-4">
<h2 class="step-title">Theme & Preferences</h2>
<p class="step-desc">Customize how ADS-Bit looks and feels.</p>
<div class="form-group">
<label>Background Theme</label>
<select id="setup-theme"></select>
</div>
<div class="form-row">
<div class="form-group">
<label>Site Title</label>
<input type="text" id="setup-site-title" placeholder="ADS-Bit" value="ADS-Bit">
</div>
<div class="form-group">
<label>Temperature Unit</label>
<select id="setup-temp-unit">
<option value="F">Fahrenheit (F)</option>
<option value="C">Celsius (C)</option>
</select>
</div>
</div>
<div class="step-buttons">
<button class="btn" onclick="wizard.prev()">BACK</button>
<button class="btn btn-primary" onclick="wizard.saveTheme()">NEXT</button>
</div>
</div>
<!-- Step 5: Complete -->
<div class="step" id="step-5">
<div class="step-icon">&#9733;</div>
<h2 class="step-title">Setup Complete!</h2>
<p class="step-desc">Everything is configured and ready to go. Here's a summary:</p>
<div class="summary-box" id="setup-summary"></div>
<p class="step-desc">You can change any of these settings later in the admin panel.</p>
<button class="btn btn-primary btn-large" onclick="wizard.finish()">LAUNCH ADS-BIT</button>
</div>
</div>
<script src="/setup/setup.js"></script>
</body>
</html>
+265
View File
@@ -0,0 +1,265 @@
// ADS-Bit First-Run Setup Wizard
const wizard = {
currentStep: 0,
totalSteps: 6,
init() {
this.updateProgress();
this.loadThemes();
},
show(step) {
document.querySelectorAll('.step').forEach(s => s.classList.remove('active'));
const el = document.getElementById('step-' + step);
if (el) el.classList.add('active');
this.currentStep = step;
this.updateProgress();
if (step === 5) this.buildSummary();
},
next() {
if (this.currentStep < this.totalSteps - 1) {
this.show(this.currentStep + 1);
}
},
prev() {
if (this.currentStep > 0) {
this.show(this.currentStep - 1);
}
},
updateProgress() {
const pct = (this.currentStep / (this.totalSteps - 1)) * 100;
document.getElementById('progress-fill').style.width = pct + '%';
document.querySelectorAll('.step-dot').forEach(dot => {
const s = parseInt(dot.dataset.step);
dot.classList.remove('active', 'done');
if (s === this.currentStep) dot.classList.add('active');
else if (s < this.currentStep) dot.classList.add('done');
});
},
// Step 1: Password
async savePassword() {
const pw = document.getElementById('setup-password').value;
const confirm = document.getElementById('setup-password-confirm').value;
const errEl = document.getElementById('password-error');
errEl.classList.add('hidden');
if (pw.length < 4) {
errEl.textContent = 'Password must be at least 4 characters';
errEl.classList.remove('hidden');
return;
}
if (pw !== confirm) {
errEl.textContent = 'Passwords do not match';
errEl.classList.remove('hidden');
return;
}
try {
const res = await fetch('/api/setup/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw })
});
if (res.ok) {
this.next();
} else {
const data = await res.json();
errEl.textContent = data.error || 'Failed to set password';
errEl.classList.remove('hidden');
}
} catch (e) {
errEl.textContent = 'Connection error';
errEl.classList.remove('hidden');
}
},
// Step 2: Location
detectLocation() {
const statusEl = document.getElementById('location-status');
if (!navigator.geolocation) {
statusEl.textContent = 'Geolocation not supported by your browser';
statusEl.className = 'status-msg error';
statusEl.classList.remove('hidden');
return;
}
statusEl.textContent = 'Detecting location...';
statusEl.className = 'status-msg info';
statusEl.classList.remove('hidden');
navigator.geolocation.getCurrentPosition(
(pos) => {
document.getElementById('setup-lat').value = pos.coords.latitude.toFixed(4);
document.getElementById('setup-lon').value = pos.coords.longitude.toFixed(4);
statusEl.textContent = 'Location detected! You can edit the values if needed.';
statusEl.className = 'status-msg success';
},
(err) => {
statusEl.textContent = 'Could not detect location. Please enter manually.';
statusEl.className = 'status-msg error';
}
);
},
async saveLocation() {
const name = document.getElementById('setup-location-name').value || 'My Location';
const lat = parseFloat(document.getElementById('setup-lat').value) || 0;
const lon = parseFloat(document.getElementById('setup-lon').value) || 0;
try {
const res = await fetch('/api/setup/location', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, lat, lon })
});
if (res.ok) this.next();
} catch (e) { /* proceed anyway */ this.next(); }
},
// Step 3: Receivers
toggleReceiverMode() {
const mode = document.getElementById('setup-receiver-mode').value;
document.getElementById('setup-manual-group').classList.toggle('hidden', mode === 'AUTO');
},
async scanReceivers() {
const btn = document.getElementById('scan-btn');
const statusEl = document.getElementById('scan-status');
btn.textContent = 'SCANNING...';
btn.disabled = true;
statusEl.textContent = 'Scanning your network for ADS-B receivers...';
statusEl.className = 'status-msg info';
statusEl.classList.remove('hidden');
try {
const res = await fetch('/api/setup/scan');
const data = await res.json();
if (data.receivers && data.receivers.length) {
statusEl.textContent = `Found ${data.receivers.length} receiver(s): ${data.receivers.join(', ')}`;
statusEl.className = 'status-msg success';
} else {
statusEl.textContent = 'No receivers found. You can add them manually or try again later.';
statusEl.className = 'status-msg info';
}
} catch (e) {
statusEl.textContent = 'Scan failed. Check your network connection.';
statusEl.className = 'status-msg error';
}
btn.textContent = 'SCAN FOR RECEIVERS';
btn.disabled = false;
},
async saveReceivers() {
const mode = document.getElementById('setup-receiver-mode').value;
const port = parseInt(document.getElementById('setup-receiver-port').value) || 30003;
const body = { mode, port };
if (mode === 'MANUAL') {
const ips = document.getElementById('setup-receiver-ips').value
.split('\n').map(s => s.trim()).filter(Boolean);
body.ips = ips;
}
try {
await fetch('/api/setup/receivers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
} catch (e) { /* proceed */ }
this.next();
},
// Step 4: Theme
async loadThemes() {
try {
const res = await fetch('/api/admin/themes');
if (!res.ok) {
// Not authenticated yet, try fetching available backgrounds
this.addDefaultThemes();
return;
}
const data = await res.json();
const sel = document.getElementById('setup-theme');
sel.innerHTML = '';
(data.themes || ['desert']).forEach(t => {
const opt = document.createElement('option');
opt.value = t;
opt.textContent = t.charAt(0).toUpperCase() + t.slice(1);
sel.appendChild(opt);
});
} catch (e) {
this.addDefaultThemes();
}
},
addDefaultThemes() {
const sel = document.getElementById('setup-theme');
sel.innerHTML = '';
['desert', 'custom'].forEach(t => {
const opt = document.createElement('option');
opt.value = t;
opt.textContent = t.charAt(0).toUpperCase() + t.slice(1);
sel.appendChild(opt);
});
},
async saveTheme() {
const theme = document.getElementById('setup-theme').value;
const tempUnit = document.getElementById('setup-temp-unit').value;
const siteTitle = document.getElementById('setup-site-title').value || 'ADS-Bit';
try {
await fetch('/api/setup/theme', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
theme,
temperature_unit: tempUnit,
site_title: siteTitle
})
});
} catch (e) { /* proceed */ }
this.next();
},
// Step 5: Summary & Complete
buildSummary() {
const container = document.getElementById('setup-summary');
const rows = [
{ label: 'LOCATION', value: document.getElementById('setup-location-name').value || 'My Location' },
{ label: 'COORDINATES', value: `${document.getElementById('setup-lat').value || '0'}, ${document.getElementById('setup-lon').value || '0'}` },
{ label: 'RECEIVERS', value: document.getElementById('setup-receiver-mode').value },
{ label: 'THEME', value: document.getElementById('setup-theme').value || 'desert' },
{ label: 'TEMP UNIT', value: document.getElementById('setup-temp-unit').value || 'F' },
{ label: 'PASSWORD', value: 'Set' }
];
container.innerHTML = rows.map(r =>
`<div class="summary-row"><span class="summary-label">${r.label}</span><span class="summary-value">${r.value}</span></div>`
).join('');
},
async finish() {
try {
const res = await fetch('/api/setup/complete', { method: 'POST' });
if (res.ok) {
window.location.href = '/';
} else {
const data = await res.json();
alert(data.error || 'Setup could not be completed');
}
} catch (e) {
alert('Connection error');
}
}
};
wizard.init();