[feat](trx-frontend-http): complete HTTP authentication implementation (phases 4-5)

Phase 4: Frontend login gate and role-based UI
- Add auth-gate HTML overlay with passphrase form
- Implement checkAuthStatus, authLogin, authLogout functions
- Auth startup sequence checks /auth/session before connecting
- Apply role-based restrictions: hide PTT/TX controls for rx role
- Handle 401/403 errors in postPath, return to login screen
- Add logout button in About tab with auth role display
- Passphrase form shows generic error messages (no info leakage)

Phase 5: Documentation
- Update trx-client.toml.example with [frontends.http.auth] section
  - All config fields with inline documentation and examples
  - security notes about cookie settings
- Update README.md with HTTP Frontend Authentication section
  - Role model explanation (rx vs control)
  - Configuration example
  - Security considerations for local, LAN, and remote deployments
  - Architecture overview

UI Features:
- Login gate blocks main UI until authenticated
- Role badge shows authenticated status in About tab
- Error messages clear after 5 seconds
- Logout confirmation prevents accidental logouts
- Smooth transition from auth gate to main UI

All code compiles successfully. HTTP frontend build verified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-02-13 08:18:49 +01:00
parent a4b014d66a
commit 65e1073ea0
4 changed files with 250 additions and 7 deletions
@@ -10,6 +10,102 @@ function loadSetting(key, fallback) {
} catch(e) { return fallback; }
}
// --- Authentication ---
let authRole = null; // null (not authenticated), "rx" (read-only), or "control" (full access)
async function checkAuthStatus() {
try {
const resp = await fetch("/auth/session");
if (!resp.ok) return { authenticated: false };
const data = await resp.json();
return data;
} catch (e) {
console.error("Auth check failed:", e);
return { authenticated: false };
}
}
async function authLogin(passphrase) {
try {
const resp = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase }),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(text || "Login failed");
}
const data = await resp.json();
return data;
} catch (e) {
throw e;
}
}
async function authLogout() {
try {
const resp = await fetch("/auth/logout", { method: "POST" });
if (!resp.ok) throw new Error("Logout failed");
authRole = null;
location.reload();
} catch (e) {
console.error("Logout failed:", e);
}
}
function showAuthGate() {
document.getElementById("loading").style.display = "none";
document.getElementById("content").style.display = "none";
document.getElementById("auth-gate").style.display = "block";
}
function hideAuthGate() {
document.getElementById("auth-gate").style.display = "none";
document.getElementById("loading").style.display = "block";
}
function showAuthError(msg) {
const el = document.getElementById("auth-error");
el.textContent = msg;
el.style.display = "block";
setTimeout(() => {
el.style.display = "none";
}, 5000);
}
function updateAuthUI() {
const btn = document.getElementById("logout-btn");
const badge = document.getElementById("auth-badge");
const badgeRole = document.getElementById("auth-role-badge");
if (authRole) {
btn.style.display = "block";
badge.style.display = "block";
badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
} else {
btn.style.display = "none";
badge.style.display = "none";
}
}
function applyAuthRestrictions() {
if (!authRole) return;
// Hide TX/PTT controls for rx role
if (authRole === "rx") {
const pttBtn = document.getElementById("ptt-btn");
const powerBtn = document.getElementById("power-btn");
const txLimitRow = document.getElementById("tx-limit-row");
const txAudioBtn = document.getElementById("tx-audio-btn");
if (pttBtn) pttBtn.style.display = "none";
if (powerBtn) powerBtn.disabled = true;
if (txLimitRow) txLimitRow.style.display = "none";
if (txAudioBtn) txAudioBtn.style.display = "none";
}
}
const freqEl = document.getElementById("freq");
const wavelengthEl = document.getElementById("wavelength");
const modeEl = document.getElementById("mode");
@@ -754,10 +850,13 @@ function connect() {
}
};
es.onerror = () => {
powerHint.textContent = "Disconnected, retrying…";
es.close();
pollFreshSnapshot();
scheduleReconnect(1000);
// Check if this is an auth error by looking at readyState
if (es.readyState === EventSource.CLOSED) {
powerHint.textContent = "Disconnected, retrying…";
es.close();
pollFreshSnapshot();
scheduleReconnect(1000);
}
};
esHeartbeat = setInterval(() => {
@@ -772,6 +871,13 @@ function connect() {
async function postPath(path) {
const resp = await fetch(path, { method: "POST" });
if (resp.status === 401 || resp.status === 403) {
// Auth error - return to login
authRole = null;
if (es) es.close();
showAuthGate();
throw new Error("Authentication required");
}
if (!resp.ok) {
const text = await resp.text();
throw new Error(text || resp.statusText);
@@ -1015,9 +1121,57 @@ document.querySelector(".tab-bar").addEventListener("click", (e) => {
document.getElementById(`tab-${btn.dataset.tab}`).style.display = "";
});
connect();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
// --- Auth startup sequence ---
async function initializeApp() {
const authStatus = await checkAuthStatus();
if (authStatus.authenticated) {
authRole = authStatus.role;
updateAuthUI();
applyAuthRestrictions();
connect();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} else {
showAuthGate();
}
}
// Setup auth form
document.getElementById("auth-form").addEventListener("submit", async (e) => {
e.preventDefault();
const passphrase = document.getElementById("auth-passphrase").value;
const btn = document.querySelector("#auth-form button[type=submit]");
btn.disabled = true;
btn.textContent = "Logging in...";
try {
const result = await authLogin(passphrase);
authRole = result.role;
document.getElementById("auth-passphrase").value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
connect();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} catch (err) {
showAuthError("Invalid passphrase");
console.error("Login error:", err);
} finally {
btn.disabled = false;
btn.textContent = "Login";
}
});
// Setup logout button
document.getElementById("logout-btn").addEventListener("click", async () => {
if (confirm("Are you sure you want to logout?")) {
await authLogout();
}
});
// Start the app
initializeApp();
window.addEventListener("resize", resizeHeaderSignalCanvas);
// --- Leaflet Map (lazy-initialized) ---
@@ -25,6 +25,20 @@
<button id="theme-toggle" class="theme-toggle-btn" type="button" aria-label="Toggle dark or light theme">Light</button>
</div>
</div>
<!-- Auth gate (hidden by default, shown if auth is required) -->
<div id="auth-gate" style="display:none; padding: 3rem 2rem; text-align: center;">
<div style="margin-bottom: 1.5rem;">
<img src="/logo.png?v=1" alt="trx logo" style="max-width: 80px; margin-bottom: 1rem;" onerror="this.style.display='none'" />
<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem;">Access Required</div>
<div style="color: var(--text-muted);">Enter passphrase to continue</div>
</div>
<form id="auth-form" style="margin: 1.5rem 0;">
<input type="password" id="auth-passphrase" placeholder="Passphrase" autocomplete="off" style="width: 100%; padding: 0.5rem; margin-bottom: 1rem; border: 1px solid var(--border); border-radius: 0.25rem; background: var(--bg-secondary); color: var(--text); font-size: 1rem;" />
<button type="submit" style="width: 100%; padding: 0.5rem; background: var(--accent-green); color: var(--bg-primary); border: none; border-radius: 0.25rem; font-weight: 600; cursor: pointer; font-size: 1rem;">Login</button>
</form>
<div id="auth-error" style="color: #ff6b6b; font-size: 0.9rem; margin-top: 1rem; display: none;"></div>
<div id="auth-role" style="margin-top: 1rem; color: var(--text-muted); font-size: 0.85rem; display: none;"></div>
</div>
<div class="tab-bar">
<button class="tab active" data-tab="main">Main</button>
<button class="tab" data-tab="plugins">Plugins</button>
@@ -224,6 +238,10 @@
</div>
</div>
<div id="tab-about" class="tab-panel" style="display:none;">
<div style="margin-bottom: 1rem;">
<button id="logout-btn" type="button" style="display:none; padding: 0.5rem 1rem; background: var(--accent-red, #ff6b6b); color: var(--bg-primary); border: none; border-radius: 0.25rem; font-weight: 600; cursor: pointer;">Logout</button>
<div id="auth-badge" style="display:none; margin-top: 0.5rem; color: var(--text-muted); font-size: 0.85rem;">Authenticated as: <strong id="auth-role-badge">--</strong></div>
</div>
<table class="about-table">
<tr><td>Server version</td><td id="about-server-ver">--</td></tr>
<tr><td>Server address</td><td id="about-server-addr">--</td></tr>