Refactor HTTP account system
CI / frontend (pull_request) Successful in 5m13s
CI / reuse (pull_request) Successful in 29s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
CI / lint (pull_request) Successful in 2m25s
CI / test (pull_request) Successful in 9m24s
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 8m19s
CI / frontend (pull_request) Successful in 5m13s
CI / reuse (pull_request) Successful in 29s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
CI / lint (pull_request) Successful in 2m25s
CI / test (pull_request) Successful in 9m24s
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 8m19s
This commit was merged in pull request #61.
This commit is contained in:
@@ -1323,7 +1323,7 @@ function decodeAuthSession(value) {
|
||||
if (typeof session.authenticated !== "boolean") {
|
||||
throw new TypeError("The authentication response has no authenticated flag");
|
||||
}
|
||||
if (session.role !== void 0 && session.role !== "rx" && session.role !== "control") {
|
||||
if (session.role !== void 0 && session.role !== "user" && session.role !== "admin") {
|
||||
throw new TypeError("The authentication response has an invalid role");
|
||||
}
|
||||
if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") {
|
||||
@@ -1331,12 +1331,16 @@ function decodeAuthSession(value) {
|
||||
}
|
||||
const decoded = { authenticated: session.authenticated };
|
||||
if (session.role !== void 0) decoded.role = session.role;
|
||||
if (session.username !== void 0) {
|
||||
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
|
||||
decoded.username = session.username;
|
||||
}
|
||||
if (session.auth_disabled !== void 0) decoded.auth_disabled = session.auth_disabled;
|
||||
return decoded;
|
||||
}
|
||||
var authDisabledSession = {
|
||||
authenticated: true,
|
||||
role: "control",
|
||||
role: "admin",
|
||||
auth_disabled: true
|
||||
};
|
||||
async function fetchAuthSession() {
|
||||
@@ -1350,11 +1354,11 @@ async function fetchAuthSession() {
|
||||
return { authenticated: false };
|
||||
}
|
||||
}
|
||||
async function login(passphrase) {
|
||||
async function login(username, password) {
|
||||
const response = await fetch("/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ passphrase })
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
if (response.status === 404) return authDisabledSession;
|
||||
if (!response.ok) {
|
||||
@@ -1363,6 +1367,34 @@ async function login(passphrase) {
|
||||
}
|
||||
return decodeAuthSession(await response.json());
|
||||
}
|
||||
async function userRequest(path, init) {
|
||||
const response = await fetch(path, init);
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload.error || `User operation failed (${response.status})`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
async function listUsers() {
|
||||
const value = await userRequest("/auth/users").then((response) => response.json());
|
||||
if (!Array.isArray(value) || !value.every((user) => {
|
||||
if (typeof user !== "object" || user === null) return false;
|
||||
const record = user;
|
||||
return typeof record.username === "string" && (record.role === "user" || record.role === "admin");
|
||||
})) {
|
||||
throw new TypeError("The user list response is malformed");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
async function createUser(username, password, role) {
|
||||
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, role }) });
|
||||
}
|
||||
async function updateUser(username, changes) {
|
||||
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
|
||||
}
|
||||
async function deleteUser(username) {
|
||||
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
|
||||
}
|
||||
async function logout() {
|
||||
const response = await fetch("/auth/logout", { method: "POST" });
|
||||
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
|
||||
@@ -1863,32 +1895,32 @@ function isVchanRdsEntry(value) {
|
||||
}
|
||||
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
|
||||
var authRole = null;
|
||||
var authUsername = null;
|
||||
var authEnabled = true;
|
||||
async function checkAuthStatus() {
|
||||
return fetchAuthSession();
|
||||
}
|
||||
async function authLogin(passphrase) {
|
||||
return login(passphrase);
|
||||
async function authLogin(username, password) {
|
||||
return login(username, password);
|
||||
}
|
||||
async function authLogout() {
|
||||
try {
|
||||
await logout();
|
||||
authRole = null;
|
||||
authUsername = null;
|
||||
disconnect();
|
||||
setDecodeHistoryOverlayVisible(false);
|
||||
requiredElement("content").style.display = "none";
|
||||
requiredElement("loading").style.display = "none";
|
||||
requiredElement("auth-passphrase").value = "";
|
||||
requiredElement("auth-password").value = "";
|
||||
updateAuthUI();
|
||||
const authStatus = await checkAuthStatus();
|
||||
const allowGuest = authStatus.role === "rx";
|
||||
showAuthGate(allowGuest);
|
||||
showAuthGate();
|
||||
} catch (e) {
|
||||
console.error("Logout failed:", e);
|
||||
showAuthError("Logout failed");
|
||||
}
|
||||
}
|
||||
function showAuthGate(allowGuest = false) {
|
||||
function showAuthGate() {
|
||||
if (!authEnabled) return;
|
||||
setDecodeHistoryOverlayVisible(false);
|
||||
requiredElement("loading").style.display = "none";
|
||||
@@ -1905,10 +1937,6 @@ function showAuthGate(allowGuest = false) {
|
||||
document.querySelectorAll(".tab-panel").forEach((panel) => {
|
||||
panel.style.display = "none";
|
||||
});
|
||||
const guestBtn2 = document.getElementById("auth-guest-btn");
|
||||
if (guestBtn2) {
|
||||
guestBtn2.style.display = allowGuest ? "block" : "none";
|
||||
}
|
||||
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.tab === "main");
|
||||
});
|
||||
@@ -1952,7 +1980,7 @@ function updateAuthUI() {
|
||||
}
|
||||
if (authRole) {
|
||||
if (badge) badge.style.display = "block";
|
||||
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
|
||||
if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRole === "admin" ? "Admin" : "User (read-only)"}`;
|
||||
if (headerAuthBtn2) {
|
||||
headerAuthBtn2.textContent = "Logout";
|
||||
headerAuthBtn2.style.display = "block";
|
||||
@@ -1968,7 +1996,7 @@ function updateAuthUI() {
|
||||
}
|
||||
function applyAuthRestrictions() {
|
||||
if (!authRole) return;
|
||||
if (authRole === "rx") {
|
||||
if (authRole === "user") {
|
||||
const pttBtn2 = document.getElementById("ptt-btn");
|
||||
const powerBtn2 = document.getElementById("power-btn");
|
||||
const lockBtn2 = document.getElementById("lock-btn");
|
||||
@@ -2271,7 +2299,7 @@ function syncTopBarAccess() {
|
||||
rigSwitch.style.display = loggedOut ? "none" : "";
|
||||
}
|
||||
if (headerRigSwitchSelect) {
|
||||
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
|
||||
headerRigSwitchSelect.disabled = loggedOut || authRole === "user" || lastRigIds.length === 0;
|
||||
}
|
||||
}
|
||||
var overviewDrawPending = false;
|
||||
@@ -2907,7 +2935,7 @@ function applyRigList(activeRigId, rigIds, displayNames) {
|
||||
}
|
||||
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
|
||||
const rigListChanged = prevKey !== nextKey;
|
||||
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
|
||||
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "user";
|
||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||||
updateRigSubtitle(lastActiveRigId);
|
||||
window.trxUi?.setActiveRig(lastActiveRigId);
|
||||
@@ -4510,7 +4538,7 @@ function scheduleTuneLinkSync() {
|
||||
async function applyTuneLink(link) {
|
||||
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
|
||||
if (!wanted) return;
|
||||
if (authRole === "rx") {
|
||||
if (authRole === "user") {
|
||||
showHint("Read-only session — link not applied", 2500);
|
||||
return;
|
||||
}
|
||||
@@ -5253,8 +5281,8 @@ async function switchRigFromSelect(selectEl) {
|
||||
showHint("No rig selected", 1500);
|
||||
return;
|
||||
}
|
||||
if (authRole === "rx") {
|
||||
showHint("Control role required", 1500);
|
||||
if (authRole === "user") {
|
||||
showHint("Admin role required", 1500);
|
||||
return;
|
||||
}
|
||||
if (!lastRigIds.includes(selectEl.value)) {
|
||||
@@ -5868,7 +5896,7 @@ function navigateToTab(name, options = {}) {
|
||||
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
||||
const { updateHistory = true, replaceHistory = false } = options;
|
||||
if (authEnabled && !authRole && name !== "main") {
|
||||
showAuthGate(false);
|
||||
showAuthGate();
|
||||
return;
|
||||
}
|
||||
const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`);
|
||||
@@ -5981,11 +6009,11 @@ window.addEventListener("resize", () => {
|
||||
scheduleSpectrumLayout();
|
||||
});
|
||||
async function initializeApp() {
|
||||
showAuthGate(false);
|
||||
showAuthGate();
|
||||
const authStatus = await checkAuthStatus();
|
||||
authEnabled = !authStatus.auth_disabled;
|
||||
if (!authEnabled) {
|
||||
authRole = "control";
|
||||
authRole = "admin";
|
||||
hideAuthGate();
|
||||
updateAuthUI();
|
||||
connect();
|
||||
@@ -5997,6 +6025,7 @@ async function initializeApp() {
|
||||
}
|
||||
if (authStatus.authenticated) {
|
||||
authRole = authStatus.role ?? null;
|
||||
authUsername = authStatus.username ?? null;
|
||||
hideAuthGate();
|
||||
updateAuthUI();
|
||||
applyAuthRestrictions();
|
||||
@@ -6006,8 +6035,7 @@ async function initializeApp() {
|
||||
resizeHeaderSignalCanvas();
|
||||
startHeaderSignalSampling();
|
||||
} else {
|
||||
const allowGuest = authStatus.role === "rx";
|
||||
showAuthGate(allowGuest);
|
||||
showAuthGate();
|
||||
}
|
||||
}
|
||||
var settingsUiReady = false;
|
||||
@@ -6019,19 +6047,104 @@ function initSettingsUI() {
|
||||
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
|
||||
window.trx.modules.backgroundDecode.wireEvents();
|
||||
}
|
||||
void refreshUserManagement();
|
||||
}
|
||||
async function refreshUserManagement() {
|
||||
const section = document.getElementById("user-management");
|
||||
if (!section) return;
|
||||
section.style.display = authEnabled && authRole === "admin" ? "block" : "none";
|
||||
if (section.style.display === "none") return;
|
||||
const list = requiredElement("user-list");
|
||||
try {
|
||||
const users = await listUsers();
|
||||
list.replaceChildren(...users.map((user) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sch-row";
|
||||
row.style.cssText = "display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin:.4rem 0";
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = user.username;
|
||||
name.style.minWidth = "10rem";
|
||||
const role = document.createElement("select");
|
||||
role.className = "auth-input";
|
||||
for (const value of ["user", "admin"]) {
|
||||
const option = document.createElement("option");
|
||||
option.value = value;
|
||||
option.textContent = value === "admin" ? "Admin" : "User";
|
||||
option.selected = user.role === value;
|
||||
role.append(option);
|
||||
}
|
||||
const password = document.createElement("input");
|
||||
password.type = "password";
|
||||
password.placeholder = "New password";
|
||||
password.autocomplete = "new-password";
|
||||
password.className = "auth-input";
|
||||
password.minLength = 8;
|
||||
const save = document.createElement("button");
|
||||
save.type = "button";
|
||||
save.textContent = "Save";
|
||||
save.addEventListener("click", async () => {
|
||||
const changes = { role: role.value };
|
||||
if (password.value) changes.password = password.value;
|
||||
await runUserOperation(() => updateUser(user.username, changes));
|
||||
});
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.textContent = "Remove";
|
||||
remove.className = "danger";
|
||||
remove.disabled = user.username === authUsername;
|
||||
remove.addEventListener("click", async () => {
|
||||
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
|
||||
await runUserOperation(() => deleteUser(user.username));
|
||||
}
|
||||
});
|
||||
row.append(name, role, password, save, remove);
|
||||
return row;
|
||||
}));
|
||||
} catch (error) {
|
||||
showUserManagementError(error);
|
||||
}
|
||||
}
|
||||
function showUserManagementError(error) {
|
||||
const element = document.getElementById("user-management-error");
|
||||
if (!element) return;
|
||||
element.textContent = error instanceof Error ? error.message : String(error);
|
||||
element.style.display = "block";
|
||||
}
|
||||
async function runUserOperation(operation) {
|
||||
try {
|
||||
await operation();
|
||||
const error = document.getElementById("user-management-error");
|
||||
if (error) error.style.display = "none";
|
||||
await refreshUserManagement();
|
||||
} catch (reason) {
|
||||
showUserManagementError(reason);
|
||||
}
|
||||
}
|
||||
document.getElementById("user-create-form")?.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const username = requiredElement("user-create-username");
|
||||
const password = requiredElement("user-create-password");
|
||||
const role = requiredElement("user-create-role");
|
||||
void runUserOperation(async () => {
|
||||
await createUser(username.value, password.value, role.value);
|
||||
username.value = "";
|
||||
password.value = "";
|
||||
role.value = "user";
|
||||
});
|
||||
});
|
||||
requiredElement("auth-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const passphraseEl = requiredElement("auth-passphrase");
|
||||
const passphrase = passphraseEl.value;
|
||||
const usernameEl = requiredElement("auth-username");
|
||||
const passwordEl = requiredElement("auth-password");
|
||||
const btn = requiredElement("auth-form").querySelector("button[type=submit]");
|
||||
if (!btn) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Logging in...";
|
||||
try {
|
||||
const result = await authLogin(passphrase);
|
||||
const result = await authLogin(usernameEl.value, passwordEl.value);
|
||||
authRole = result.role ?? null;
|
||||
passphraseEl.value = "";
|
||||
authUsername = result.username ?? usernameEl.value;
|
||||
passwordEl.value = "";
|
||||
hideAuthGate();
|
||||
updateAuthUI();
|
||||
applyAuthRestrictions();
|
||||
@@ -6041,28 +6154,13 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
|
||||
resizeHeaderSignalCanvas();
|
||||
startHeaderSignalSampling();
|
||||
} catch (err) {
|
||||
showAuthError("Invalid passphrase");
|
||||
showAuthError("Invalid username or password");
|
||||
console.error("Login error:", err);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Login";
|
||||
}
|
||||
});
|
||||
var guestBtn = document.getElementById("auth-guest-btn");
|
||||
if (guestBtn) {
|
||||
guestBtn.addEventListener("click", () => {
|
||||
authRole = "rx";
|
||||
requiredElement("auth-passphrase").value = "";
|
||||
hideAuthGate();
|
||||
updateAuthUI();
|
||||
applyAuthRestrictions();
|
||||
connect();
|
||||
connectDecode();
|
||||
initSettingsUI();
|
||||
resizeHeaderSignalCanvas();
|
||||
startHeaderSignalSampling();
|
||||
});
|
||||
}
|
||||
var headerAuthBtn = document.getElementById("header-auth-btn");
|
||||
if (headerAuthBtn) {
|
||||
headerAuthBtn.addEventListener("click", async () => {
|
||||
@@ -6071,7 +6169,7 @@ if (headerAuthBtn) {
|
||||
await authLogout();
|
||||
}
|
||||
} else {
|
||||
showAuthGate(false);
|
||||
showAuthGate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -358,7 +358,7 @@ var bgdWindow = window;
|
||||
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
|
||||
}
|
||||
function isControlRole() {
|
||||
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
||||
return backgroundDecodeRole === "admin" || hostState.authEnabled === false;
|
||||
}
|
||||
function showToast(msg, isError) {
|
||||
const el = document.getElementById("background-decode-toast");
|
||||
|
||||
@@ -42,7 +42,7 @@ function bmEsc(str) {
|
||||
return d.innerHTML;
|
||||
}
|
||||
function bmCanControl() {
|
||||
return !hostState.authEnabled || hostState.authRole === "control";
|
||||
return !hostState.authEnabled || hostState.authRole === "admin";
|
||||
}
|
||||
function bmSyncAccess() {
|
||||
const canCtrl = bmCanControl();
|
||||
|
||||
@@ -272,7 +272,7 @@ function schedulerOptionalEl(id) {
|
||||
const nextBtn = schedulerEl("scheduler-next-btn");
|
||||
if (!prevBtn || !nextBtn) return;
|
||||
const state = schedulerInterleaveState(currentConfig);
|
||||
const enabled = schedulerRole === "control" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
|
||||
const enabled = schedulerRole === "admin" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
|
||||
prevBtn.disabled = !enabled;
|
||||
nextBtn.disabled = !enabled;
|
||||
const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active";
|
||||
@@ -354,7 +354,7 @@ function schedulerOptionalEl(id) {
|
||||
const panel = schedulerEl("scheduler-panel");
|
||||
if (!panel) return;
|
||||
const mode = currentConfig && currentConfig.mode || "disabled";
|
||||
const isControl = schedulerRole === "control";
|
||||
const isControl = schedulerRole === "admin";
|
||||
setSelected("scheduler-mode-select", mode);
|
||||
const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled;
|
||||
const controlRow = document.querySelector(".scheduler-control-row");
|
||||
|
||||
@@ -123,13 +123,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div id="auth-gate" class="auth-gate" style="display:none;">
|
||||
<div class="auth-gate-head">
|
||||
<div class="auth-gate-title">Access Required</div>
|
||||
<div class="auth-gate-sub">Enter passphrase to continue</div>
|
||||
<div class="auth-gate-sub">Sign in to continue</div>
|
||||
</div>
|
||||
<form id="auth-form" class="auth-form">
|
||||
<input type="password" id="auth-passphrase" class="auth-input" placeholder="Passphrase" autocomplete="off" />
|
||||
<input type="text" id="auth-username" class="auth-input" placeholder="Username" autocomplete="username" required />
|
||||
<input type="password" id="auth-password" class="auth-input" placeholder="Password" autocomplete="current-password" required />
|
||||
<button type="submit" class="auth-submit">Login</button>
|
||||
</form>
|
||||
<button id="auth-guest-btn" type="button" class="auth-guest" style="display: none;">Continue as Guest</button>
|
||||
<div id="auth-error" class="auth-error" style="display: none;"></div>
|
||||
<div id="auth-role" class="auth-role" style="display: none;"></div>
|
||||
</div>
|
||||
@@ -1750,6 +1750,19 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section id="user-management" style="display:none; margin-top:1rem;">
|
||||
<h2 class="section-heading">User management</h2>
|
||||
<div class="settings-card">
|
||||
<form id="user-create-form" class="sch-row" style="flex-wrap:wrap; gap:.5rem;">
|
||||
<input id="user-create-username" class="auth-input" placeholder="Username" autocomplete="off" required />
|
||||
<input id="user-create-password" class="auth-input" type="password" placeholder="Password (8+ characters)" autocomplete="new-password" minlength="8" required />
|
||||
<select id="user-create-role" class="auth-input"><option value="user">User</option><option value="admin">Admin</option></select>
|
||||
<button type="submit" class="auth-submit">Add user</button>
|
||||
</form>
|
||||
<div id="user-management-error" class="auth-error" style="display:none;"></div>
|
||||
<div id="user-list" style="margin-top:.75rem;"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div id="tab-about" class="tab-panel" style="display:none;">
|
||||
<h2 class="section-heading">About</h2>
|
||||
|
||||
@@ -196,8 +196,7 @@ body {
|
||||
font-size: var(--fs-base);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.auth-submit,
|
||||
.auth-guest {
|
||||
.auth-submit {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
@@ -213,16 +212,11 @@ body {
|
||||
font-weight: 700;
|
||||
}
|
||||
.auth-submit:hover:not(:disabled) { background: var(--accent-green-hover); }
|
||||
.auth-guest {
|
||||
background: var(--btn-bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-light);
|
||||
font-weight: 600;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.auth-guest:hover:not(:disabled) { background: var(--btn-hover-bg); }
|
||||
.auth-error { color: var(--accent-red); font-size: var(--fs-sm); margin-top: var(--space-4); }
|
||||
.auth-role { margin-top: var(--space-4); color: var(--text-muted); font-size: var(--fs-sm); }
|
||||
#user-management .auth-input { width: auto; min-width: 9rem; flex: 1 1 10rem; margin-bottom: 0; }
|
||||
#user-management .auth-submit { width: auto; }
|
||||
#user-management button { padding: 0.55rem 0.75rem; }
|
||||
|
||||
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
|
||||
#tab-main .label > span {
|
||||
|
||||
Reference in New Issue
Block a user