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

This commit was merged in pull request #61.
This commit is contained in:
sjg
2026-08-10 23:47:51 +02:00
parent d539ff96e5
commit 3c3fc69542
30 changed files with 1115 additions and 462 deletions
@@ -2,11 +2,12 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
export type AuthRole = "rx" | "control";
export type AuthRole = "user" | "admin";
export interface AuthSession {
authenticated: boolean;
role?: AuthRole;
username?: string;
auth_disabled?: boolean;
}
@@ -18,7 +19,7 @@ function decodeAuthSession(value: unknown): AuthSession {
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (session.role !== undefined && session.role !== "rx" && session.role !== "control") {
if (session.role !== undefined && session.role !== "user" && session.role !== "admin") {
throw new TypeError("The authentication response has an invalid role");
}
if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") {
@@ -26,13 +27,17 @@ function decodeAuthSession(value: unknown): AuthSession {
}
const decoded: AuthSession = { authenticated: session.authenticated };
if (session.role !== undefined) decoded.role = session.role;
if (session.username !== undefined) {
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
decoded.username = session.username;
}
if (session.auth_disabled !== undefined) decoded.auth_disabled = session.auth_disabled;
return decoded;
}
const authDisabledSession: AuthSession = {
authenticated: true,
role: "control",
role: "admin",
auth_disabled: true,
};
@@ -48,11 +53,11 @@ export async function fetchAuthSession(): Promise<AuthSession> {
}
}
export async function login(passphrase: string): Promise<AuthSession> {
export async function login(username: string, password: string): Promise<AuthSession> {
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) {
@@ -62,6 +67,41 @@ export async function login(passphrase: string): Promise<AuthSession> {
return decodeAuthSession(await response.json());
}
export interface ManagedUser { username: string; role: AuthRole }
async function userRequest(path: string, init?: RequestInit): Promise<Response> {
const response = await fetch(path, init);
if (!response.ok) {
const payload = await response.json().catch(() => ({})) as { error?: string };
throw new Error(payload.error || `User operation failed (${response.status})`);
}
return response;
}
export async function listUsers(): Promise<ManagedUser[]> {
const value: unknown = await userRequest("/auth/users").then(response => response.json());
if (!Array.isArray(value) || !value.every((user: unknown) => {
if (typeof user !== "object" || user === null) return false;
const record = user as Record<string, unknown>;
return typeof record.username === "string" && (record.role === "user" || record.role === "admin");
})) {
throw new TypeError("The user list response is malformed");
}
return value as ManagedUser[];
}
export async function createUser(username: string, password: string, role: AuthRole): Promise<void> {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, role }) });
}
export async function updateUser(username: string, changes: { password?: string; role?: AuthRole }): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
}
export async function deleteUser(username: string): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
}
export async function logout(): Promise<void> {
const response = await fetch("/auth/logout", { method: "POST" });
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
@@ -21,6 +21,10 @@ import {
fetchAuthSession,
login,
logout,
listUsers,
createUser,
updateUser,
deleteUser,
} from "./api/auth.js";
import {
formatByteSize as recorderFormatSize,
@@ -407,39 +411,38 @@ void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
// --- Authentication ---
let authRole: AuthRole | null = null;
let authUsername: string | null = null;
let authEnabled = true;
async function checkAuthStatus() {
return fetchAuthSession();
}
async function authLogin(passphrase: string) {
return login(passphrase);
async function authLogin(username: string, password: string) {
return login(username, password);
}
async function authLogout() {
try {
await logout();
authRole = null;
authUsername = null;
// Disconnect and show auth gate without page reload
disconnect();
setDecodeHistoryOverlayVisible(false);
requiredElement("content").style.display = "none";
requiredElement("loading").style.display = "none";
requiredElement<HTMLInputElement>("auth-passphrase").value = "";
requiredElement<HTMLInputElement>("auth-password").value = "";
updateAuthUI();
// Check if guest mode is available after logout
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";
@@ -459,12 +462,6 @@ function showAuthGate(allowGuest = false) {
panel.style.display = "none";
});
// Show guest button if guest mode is available
const guestBtn = document.getElementById("auth-guest-btn");
if (guestBtn) {
guestBtn.style.display = allowGuest ? "block" : "none";
}
document.querySelectorAll<HTMLElement>(".tab-bar .tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tab === "main");
});
@@ -516,7 +513,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 (headerAuthBtn) {
headerAuthBtn.textContent = "Logout";
headerAuthBtn.style.display = "block";
@@ -534,8 +531,8 @@ function updateAuthUI() {
function applyAuthRestrictions() {
if (!authRole) return;
// Disable TX/PTT/frequency/mode/VFO controls for rx role
if (authRole === "rx") {
// Disable TX/PTT/frequency/mode/VFO controls for user role
if (authRole === "user") {
const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null;
const powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null;
const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null;
@@ -883,7 +880,7 @@ function syncTopBarAccess() {
}
if (headerRigSwitchSelect) {
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
headerRigSwitchSelect.disabled = loggedOut || authRole === "user" || lastRigIds.length === 0;
}
}
@@ -1464,7 +1461,7 @@ function applyRigList(activeRigId: string | null, rigIds: string[], 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);
@@ -3352,7 +3349,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link: TuneLink) {
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;
}
@@ -4191,8 +4188,8 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
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)) {
@@ -4875,7 +4872,7 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
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<HTMLElement>(`.tab-bar .tab[data-tab="${name}"]`);
@@ -5008,12 +5005,12 @@ window.addEventListener("resize", () => { scheduleSpectrumLayout(); });
// --- Auth startup sequence ---
async function initializeApp() {
showAuthGate(false);
showAuthGate();
const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled;
if (!authEnabled) {
authRole = "control";
authRole = "admin";
hideAuthGate();
updateAuthUI();
connect();
@@ -5027,6 +5024,7 @@ async function initializeApp() {
if (authStatus.authenticated) {
// User has valid session
authRole = authStatus.role ?? null;
authUsername = authStatus.username ?? null;
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -5036,10 +5034,7 @@ async function initializeApp() {
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} else {
// No valid session - show auth gate
// Guest button is shown if guest mode is available (role granted without auth)
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
showAuthGate();
}
}
@@ -5055,22 +5050,96 @@ 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"] as AuthRole[]) {
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?: AuthRole; password?: string } = { role: role.value as AuthRole };
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: unknown) {
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: () => Promise<void>) {
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<HTMLInputElement>("user-create-username");
const password = requiredElement<HTMLInputElement>("user-create-password");
const role = requiredElement<HTMLSelectElement>("user-create-role");
void runUserOperation(async () => {
await createUser(username.value, password.value, role.value as AuthRole);
username.value = ""; password.value = ""; role.value = "user";
});
});
// Setup auth form
requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (e) => {
e.preventDefault();
const passphraseEl = requiredElement<HTMLInputElement>("auth-passphrase");
const passphrase = passphraseEl.value;
const usernameEl = requiredElement<HTMLInputElement>("auth-username");
const passwordEl = requiredElement<HTMLInputElement>("auth-password");
const btn = requiredElement<HTMLFormElement>("auth-form").querySelector<HTMLButtonElement>("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();
@@ -5080,7 +5149,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} catch (err) {
showAuthError("Invalid passphrase");
showAuthError("Invalid username or password");
console.error("Login error:", err);
} finally {
btn.disabled = false;
@@ -5088,23 +5157,6 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
}
});
// Setup guest button
const guestBtn = document.getElementById("auth-guest-btn") as HTMLButtonElement | null;
if (guestBtn) {
guestBtn.addEventListener("click", () => {
authRole = "rx";
requiredElement<HTMLInputElement>("auth-passphrase").value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
connect();
connectDecode();
initSettingsUI();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
});
}
// Setup header auth button (Login/Logout)
const headerAuthBtn = document.getElementById("header-auth-btn") as HTMLButtonElement | null;
if (headerAuthBtn) {
@@ -5116,7 +5168,7 @@ if (headerAuthBtn) {
}
} else {
// Not logged in - show auth gate
showAuthGate(false);
showAuthGate();
}
});
}
@@ -468,7 +468,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
}
function isControlRole(): boolean {
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
return backgroundDecodeRole === "admin" || hostState.authEnabled === false;
}
function showToast(msg: string, isError: boolean): void {
@@ -100,7 +100,7 @@ function bmEsc(str: unknown): string {
}
function bmCanControl() {
return !hostState.authEnabled || hostState.authRole === "control";
return !hostState.authEnabled || hostState.authRole === "admin";
}
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
@@ -356,7 +356,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig);
const enabled =
schedulerRole === "control" &&
schedulerRole === "admin" &&
!!currentRigId &&
!schedulerStepPending &&
state.activeEntries.length > 1;
@@ -466,7 +466,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!panel) return;
const mode = (currentConfig && currentConfig.mode) || "disabled";
const isControl = schedulerRole === "control";
const isControl = schedulerRole === "admin";
// Mode selector
setSelected("scheduler-mode-select", mode);
@@ -40,7 +40,7 @@ test("background decode loads configuration for the explicitly selected rig", as
const source = await bundleEntry(new URL("../src/plugins/background-decode.ts", import.meta.url));
new vm.Script(source).runInContext(context);
window.trx.modules.backgroundDecode.initialize("rig/a", "control");
window.trx.modules.backgroundDecode.initialize("rig/a", "admin");
await new Promise((resolve) => setTimeout(resolve, 0));
assert.ok(requested.includes("/background-decode/rig%2Fa"));
assert.ok(requested.includes("/bookmarks"));
@@ -32,7 +32,7 @@ function hostFixture(overrides = {}) {
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
const state = {
authEnabled: false,
authRole: "control",
authRole: "admin",
lastActiveRigId: null,
lastRigIds: [],
lastRigDisplayNames: {},
@@ -157,7 +157,7 @@ test("bookmark controls follow the host authentication state", async () => {
if (!elements.has(id)) elements.set(id, new ElementFixture());
return elements.get(id);
};
const { window } = hostFixture({ authEnabled: true, authRole: "rx" });
const { window } = hostFixture({ authEnabled: true, authRole: "user" });
const context = vm.createContext({
window,
document: documentFixture(element),
@@ -171,7 +171,7 @@ test("bookmark controls follow the host authentication state", async () => {
assert.equal(element("bm-add-btn").style.display, "none");
window.trx.state.authRole = "control";
window.trx.state.authRole = "admin";
await window.trx.modules.bookmarks.fetch("");
assert.equal(element("bm-add-btn").style.display, "");
});
@@ -19,7 +19,7 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
serverLat: null,
serverLon: null,
authEnabled: false,
authRole: "control",
authRole: "admin",
lastActiveRigId: null,
lastRigIds: [],
lastRigDisplayNames: {},
@@ -76,7 +76,7 @@ test("scheduler self-initializes for the active rig when a role is already known
return elements.get(id);
};
const window = {
...createHost({ state: { authRole: "control", lastActiveRigId: "sdr" } }),
...createHost({ state: { authRole: "admin", lastActiveRigId: "sdr" } }),
trxUi: { confirm: async () => true },
};
const context = vm.createContext({
@@ -232,7 +232,7 @@ export async function startWebFixture({
};
const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
["/auth/session", { authenticated: true, role: "admin", auth_disabled: true }],
["/decoders", DECODER_REGISTRY],
["/rigs", rigsResponse],
["/status", status],