Complete managed account lifecycle
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m6s
CI / frontend (pull_request) Successful in 5m17s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m26s

This commit was merged in pull request #63.
This commit is contained in:
sjg
2026-08-11 07:49:53 +02:00
parent 34507ffa17
commit 5e9dae02c7
33 changed files with 1224 additions and 449 deletions
+6 -3
View File
@@ -925,9 +925,12 @@ main
### HTTP Frontend Auth ### HTTP Frontend Auth
- Optional token or HTTP Basic Auth middleware - Optional Argon2id-backed managed accounts with HttpOnly session cookies
- Configured in `[frontends.http.auth]` - Composable Read, Control, Write, and Administrator roles, with policy shared by middleware and handlers
- Rate limiting supported - Atomic JSON persistence with migration from the legacy single-role schema
- Account enable/disable, administrator CRUD, self-service password changes, and session revocation on security changes
- A database invariant always preserves at least one enabled administrator
- Per-IP login rate limiting; configured in `[frontends.http.auth]`
### Transport Security ### Transport Security
+5 -2
View File
@@ -354,8 +354,11 @@ Routes are classified into three tiers:
### 7.3 User Management ### 7.3 User Management
Only administrators can list, add, update, or remove accounts. The final Every authenticated account gets a Settings > Account tab for changing its own
administrator cannot be removed or demoted. password. Only administrators get Settings > Users, where accounts can be
created, enabled/disabled, assigned multiple roles, given a new password, or
removed. The final enabled administrator cannot be disabled, removed, or
demoted. Account security changes revoke every active session for that account.
--- ---
+7 -4
View File
@@ -626,9 +626,11 @@ credentials in configuration before first startup on an exposed deployment.
- Rate limiting is applied per IP to mitigate brute-force attempts. - Rate limiting is applied per IP to mitigate brute-force attempts.
- User records persist in `users_file`; passwords are stored as salted Argon2id hashes. - User records persist in `users_file`; passwords are stored as salted Argon2id hashes.
- Roles are independent; for example, an account may have Read and Write without Control. - Roles are independent; for example, an account may have Read and Write without Control.
- Administrators can add/remove users and change roles/passwords in Settings > Users. - Every signed-in user can change their own password in Settings > Account. This signs out all of their sessions.
- At least one administrator must always remain and cannot be removed or demoted. - Administrators can add, enable/disable, or remove users and change roles/passwords in Settings > Users.
- Removing an account or changing its password/role revokes its sessions. - At least one enabled administrator must always remain and cannot be disabled, removed, or demoted.
- Disabling/removing an account or changing its password/roles revokes all of its sessions.
- Existing account files migrate automatically: legacy accounts are enabled by default and legacy `user`/`admin` roles become Read/all roles.
### Routes ### Routes
@@ -637,8 +639,9 @@ credentials in configuration before first startup on an exposed deployment.
| `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` | | `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` |
| `/auth/logout` | POST | Clear session | | `/auth/logout` | POST | Clear session |
| `/auth/session` | GET | Check current session/roles | | `/auth/session` | GET | Check current session/roles |
| `/auth/account/password` | PATCH | Change the signed-in user's password after verifying the current password |
| `/auth/users` | GET/POST | List or add users (admin only) | | `/auth/users` | GET/POST | List or add users (admin only) |
| `/auth/users/{username}` | PATCH/DELETE | Change password/roles or remove user (administrator only) | | `/auth/users/{username}` | PATCH/DELETE | Change enabled state/password/roles or remove user (administrator only) |
Read routes require Read. Radio mutations require Control. Logbook access and Read routes require Read. Radio mutations require Control. Logbook access and
bookmark mutations require Write. Administrator grants every permission. bookmark mutations require Write. Administrator grants every permission.
@@ -1,3 +1,18 @@
import {
AUTH_ROLES,
AUTH_ROLE_LABELS,
changeOwnPassword,
createUser,
deleteUser,
fetchAuthSession,
hasAuthRole,
listUsers,
login,
logout,
normalizeAuthRoles,
updateUser
} from "./chunk-BB2X7SND.js";
// src/webgl-renderer.ts // src/webgl-renderer.ts
(function initTrxWebGl(global) { (function initTrxWebGl(global) {
"use strict"; "use strict";
@@ -1314,91 +1329,6 @@ async function loadDecoderRegistry(onLoaded) {
bridge.decoderRegistry = decoderRegistry; bridge.decoderRegistry = decoderRegistry;
bridge.onDecoderRegistryReady = onDecoderRegistryReady; bridge.onDecoderRegistryReady = onDecoderRegistryReady;
// src/api/auth.ts
function decodeAuthSession(value) {
if (typeof value !== "object" || value === null) {
throw new TypeError("The authentication response is malformed");
}
const session = value;
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (!Array.isArray(session.roles) || !session.roles.every((role) => role === "read" || role === "control" || role === "write" || role === "administrator")) {
throw new TypeError("The authentication response has invalid roles");
}
if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag");
}
const decoded = { authenticated: session.authenticated, roles: session.roles };
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,
roles: ["read", "control", "write", "administrator"],
auth_disabled: true
};
async function fetchAuthSession() {
try {
const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false, roles: [] };
return decodeAuthSession(await response.json());
} catch (error) {
console.error("Auth check failed:", error);
return { authenticated: false, roles: [] };
}
}
async function login(username, password) {
const response = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
if (response.status === 404) return authDisabledSession;
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Login failed");
}
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" && Array.isArray(record.roles) && record.roles.every((role) => role === "read" || role === "control" || role === "write" || role === "administrator");
})) {
throw new TypeError("The user list response is malformed");
}
return value;
}
async function createUser(username, password, roles) {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles }) });
}
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");
}
// src/core/format.ts // src/core/format.ts
function formatDuration(milliseconds) { function formatDuration(milliseconds) {
const seconds = Math.floor(milliseconds / 1e3); const seconds = Math.floor(milliseconds / 1e3);
@@ -1893,23 +1823,30 @@ function isVchanRdsEntry(value) {
return isRecord2(value) && typeof value.id === "string" && (value.rds === void 0 || value.rds === null || isRdsData(value.rds)) && (value.signal_db === void 0 || value.signal_db === null || typeof value.signal_db === "number"); return isRecord2(value) && typeof value.id === "string" && (value.rds === void 0 || value.rds === null || isRdsData(value.rds)) && (value.signal_db === void 0 || value.signal_db === null || typeof value.signal_db === "number");
} }
void loadDecoderRegistry(refreshOperatorLayoutCapabilities); void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
var authRole = null;
var authRoles = []; var authRoles = [];
var authUsername = null; var authUsername = null;
var authEnabled = true; var authEnabled = true;
var ALL_AUTH_ROLES = ["read", "control", "write", "administrator"];
var AUTH_ROLE_LABELS = {
read: "Read",
control: "Control",
write: "Write",
administrator: "Administrator"
};
function setAuthRoles(roles) { function setAuthRoles(roles) {
authRoles = [...new Set(roles)]; authRoles = normalizeAuthRoles(roles);
authRole = ["administrator", "control", "write", "read"].find((role) => authRoles.includes(role)) ?? null;
} }
function hasAuthRole(role) { function hasAuthRole2(role) {
return authRoles.includes("administrator") || authRoles.includes(role); return hasAuthRole(authRoles, role);
}
function buildRoleChoices(selected) {
const element = document.createElement("span");
element.className = "auth-role-choices";
const inputs = AUTH_ROLES.map((value) => {
const label = document.createElement("label");
label.className = "auth-role-choice";
const input = document.createElement("input");
input.type = "checkbox";
input.value = value;
input.checked = selected.includes(value);
label.append(input, ` ${AUTH_ROLE_LABELS[value]}`);
element.append(label);
return { input, value };
});
return { element, inputs };
} }
async function checkAuthStatus() { async function checkAuthStatus() {
return fetchAuthSession(); return fetchAuthSession();
@@ -1986,13 +1923,16 @@ function updateAuthUI() {
const badge = document.getElementById("auth-badge"); const badge = document.getElementById("auth-badge");
const badgeRole = document.getElementById("auth-role-badge"); const badgeRole = document.getElementById("auth-role-badge");
const headerAuthBtn2 = document.getElementById("header-auth-btn"); const headerAuthBtn2 = document.getElementById("header-auth-btn");
const accountTab = document.getElementById("settings-account-tab");
if (!authEnabled) { if (!authEnabled) {
if (badge) badge.style.display = "none"; if (badge) badge.style.display = "none";
if (headerAuthBtn2) headerAuthBtn2.style.display = "none"; if (headerAuthBtn2) headerAuthBtn2.style.display = "none";
if (accountTab) accountTab.style.display = "none";
syncTopBarAccess(); syncTopBarAccess();
return; return;
} }
if (authRoles.length > 0) { if (authRoles.length > 0) {
if (accountTab) accountTab.style.display = "";
if (badge) badge.style.display = "block"; if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`; if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`;
if (headerAuthBtn2) { if (headerAuthBtn2) {
@@ -2000,6 +1940,7 @@ function updateAuthUI() {
headerAuthBtn2.style.display = "block"; headerAuthBtn2.style.display = "block";
} }
} else { } else {
if (accountTab) accountTab.style.display = "none";
if (badge) badge.style.display = "none"; if (badge) badge.style.display = "none";
if (headerAuthBtn2) { if (headerAuthBtn2) {
headerAuthBtn2.textContent = "Login"; headerAuthBtn2.textContent = "Login";
@@ -2010,7 +1951,7 @@ function updateAuthUI() {
} }
function applyAuthRestrictions() { function applyAuthRestrictions() {
if (authRoles.length === 0) return; if (authRoles.length === 0) return;
if (!hasAuthRole("control")) { if (!hasAuthRole2("control")) {
const pttBtn2 = document.getElementById("ptt-btn"); const pttBtn2 = document.getElementById("ptt-btn");
const powerBtn2 = document.getElementById("power-btn"); const powerBtn2 = document.getElementById("power-btn");
const lockBtn2 = document.getElementById("lock-btn"); const lockBtn2 = document.getElementById("lock-btn");
@@ -2306,7 +2247,7 @@ function syncTopBarAccess() {
if (tabBar) tabBar.style.display = ""; if (tabBar) tabBar.style.display = "";
document.querySelectorAll(".tab-bar .tab").forEach((btn) => { document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
const isMain = btn.dataset.tab === "main"; const isMain = btn.dataset.tab === "main";
const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole("write"); const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole2("write");
btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none"; btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none";
btn.disabled = false; btn.disabled = false;
}); });
@@ -2314,7 +2255,7 @@ function syncTopBarAccess() {
rigSwitch.style.display = loggedOut ? "none" : ""; rigSwitch.style.display = loggedOut ? "none" : "";
} }
if (headerRigSwitchSelect) { if (headerRigSwitchSelect) {
headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole("control") || lastRigIds.length === 0; headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole2("control") || lastRigIds.length === 0;
} }
} }
var overviewDrawPending = false; var overviewDrawPending = false;
@@ -2950,7 +2891,7 @@ function applyRigList(activeRigId, rigIds, displayNames) {
} }
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || ""); const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
const rigListChanged = prevKey !== nextKey; const rigListChanged = prevKey !== nextKey;
const disableSwitch = lastRigIds.length === 0 || !hasAuthRole("control"); const disableSwitch = lastRigIds.length === 0 || !hasAuthRole2("control");
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId); updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId);
@@ -4553,7 +4494,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link) { async function applyTuneLink(link) {
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null; const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
if (!wanted) return; if (!wanted) return;
if (!hasAuthRole("control")) { if (!hasAuthRole2("control")) {
showHint("Read-only session — link not applied", 2500); showHint("Read-only session — link not applied", 2500);
return; return;
} }
@@ -5296,7 +5237,7 @@ async function switchRigFromSelect(selectEl) {
showHint("No rig selected", 1500); showHint("No rig selected", 1500);
return; return;
} }
if (!hasAuthRole("control")) { if (!hasAuthRole2("control")) {
showHint("Control role required", 1500); showHint("Control role required", 1500);
return; return;
} }
@@ -5914,7 +5855,7 @@ function navigateToTab(name, options = {}) {
showAuthGate(); showAuthGate();
return; return;
} }
if (authEnabled && name === "logbook" && !hasAuthRole("write")) { if (authEnabled && name === "logbook" && !hasAuthRole2("write")) {
showHint("Write role required for logbook access", 2500); showHint("Write role required for logbook access", 2500);
navigateToTab("main", options); navigateToTab("main", options);
return; return;
@@ -6033,7 +5974,7 @@ async function initializeApp() {
const authStatus = await checkAuthStatus(); const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled; authEnabled = !authStatus.auth_disabled;
if (!authEnabled) { if (!authEnabled) {
setAuthRoles(ALL_AUTH_ROLES); setAuthRoles(AUTH_ROLES);
hideAuthGate(); hideAuthGate();
updateAuthUI(); updateAuthUI();
connect(); connect();
@@ -6061,10 +6002,10 @@ async function initializeApp() {
var settingsUiReady = false; var settingsUiReady = false;
function initSettingsUI() { function initSettingsUI() {
settingsUiReady = true; settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole); window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
window.trx.modules.scheduler?.wireEvents(); window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) { if (window.trx.modules.backgroundDecode) {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole); window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
window.trx.modules.backgroundDecode.wireEvents(); window.trx.modules.backgroundDecode.wireEvents();
} }
void refreshUserManagement(); void refreshUserManagement();
@@ -6073,7 +6014,7 @@ async function refreshUserManagement() {
const section = document.getElementById("user-management"); const section = document.getElementById("user-management");
const tab = document.getElementById("settings-users-tab"); const tab = document.getElementById("settings-users-tab");
if (!section || !tab) return; if (!section || !tab) return;
const canManageUsers = authEnabled && hasAuthRole("administrator"); const canManageUsers = authEnabled && hasAuthRole2("administrator");
tab.style.display = canManageUsers ? "" : "none"; tab.style.display = canManageUsers ? "" : "none";
if (!canManageUsers) { if (!canManageUsers) {
const panel = document.getElementById("subtab-settings-users"); const panel = document.getElementById("subtab-settings-users");
@@ -6086,7 +6027,7 @@ async function refreshUserManagement() {
const list = requiredElement("user-list"); const list = requiredElement("user-list");
try { try {
const users = await listUsers(); const users = await listUsers();
const adminCount = users.filter((user) => user.roles.includes("administrator")).length; const enabledAdminCount = users.filter((user) => user.enabled && hasAuthRole(user.roles, "administrator")).length;
list.replaceChildren(...users.map((user) => { list.replaceChildren(...users.map((user) => {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "sch-row"; row.className = "sch-row";
@@ -6094,37 +6035,40 @@ async function refreshUserManagement() {
const name = document.createElement("strong"); const name = document.createElement("strong");
name.textContent = user.username; name.textContent = user.username;
name.style.minWidth = "10rem"; name.style.minWidth = "10rem";
const roleInputs = ALL_AUTH_ROLES.map((value) => { if (!user.enabled) name.textContent += " (disabled)";
const label = document.createElement("label"); const { element: roles, inputs: roleInputs } = buildRoleChoices(user.roles);
label.className = "auth-role-choice"; const enabledLabel = document.createElement("label");
const input = document.createElement("input"); enabledLabel.className = "auth-role-choice";
input.type = "checkbox"; const enabled = document.createElement("input");
input.value = value; enabled.type = "checkbox";
input.checked = user.roles.includes(value); enabled.checked = user.enabled;
label.append(input, ` ${AUTH_ROLE_LABELS[value]}`); enabled.disabled = user.username === authUsername;
return { label, input, value }; if (enabled.disabled) enabled.title = "You cannot disable your current account";
}); enabledLabel.append(enabled, " Enabled");
const roles = document.createElement("span"); const isOnlyAdmin = user.enabled && hasAuthRole(user.roles, "administrator") && enabledAdminCount === 1;
roles.className = "auth-role-choices";
roles.append(...roleInputs.map(({ label }) => label));
const isOnlyAdmin = user.roles.includes("administrator") && adminCount === 1;
const administratorInput = roleInputs.find((item) => item.value === "administrator")?.input; const administratorInput = roleInputs.find((item) => item.value === "administrator")?.input;
if (isOnlyAdmin && administratorInput) { if (isOnlyAdmin && administratorInput) {
administratorInput.disabled = true; administratorInput.disabled = true;
administratorInput.title = "The final administrator cannot be demoted"; administratorInput.title = "The final administrator cannot be demoted";
} }
if (isOnlyAdmin) {
enabled.disabled = true;
enabled.title = "The final enabled administrator cannot be disabled";
}
const password = document.createElement("input"); const password = document.createElement("input");
password.type = "password"; password.type = "password";
password.placeholder = "New password (8+ characters)"; password.placeholder = "New password (8+ characters)";
password.autocomplete = "new-password"; password.autocomplete = "new-password";
password.className = "auth-input"; password.className = "auth-input";
password.minLength = 8; password.minLength = 8;
password.maxLength = 1024;
const save = document.createElement("button"); const save = document.createElement("button");
save.type = "button"; save.type = "button";
save.textContent = "Save"; save.textContent = "Save";
save.addEventListener("click", async () => { save.addEventListener("click", async () => {
const changes = { const changes = {
roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value) roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value),
enabled: enabled.checked
}; };
if (password.value) changes.password = password.value; if (password.value) changes.password = password.value;
await runUserOperation(() => updateUser(user.username, changes)); await runUserOperation(() => updateUser(user.username, changes));
@@ -6140,7 +6084,7 @@ async function refreshUserManagement() {
await runUserOperation(() => deleteUser(user.username)); await runUserOperation(() => deleteUser(user.username));
} }
}); });
row.append(name, roles, password, save, remove); row.append(name, enabledLabel, roles, password, save, remove);
return row; return row;
})); }));
} catch (error) { } catch (error) {
@@ -6163,20 +6107,54 @@ async function runUserOperation(operation) {
showUserManagementError(reason); showUserManagementError(reason);
} }
} }
var createRoleContainer = document.getElementById("user-create-roles");
if (createRoleContainer) {
const { element } = buildRoleChoices(["read"]);
element.id = createRoleContainer.id;
createRoleContainer.replaceWith(element);
}
document.getElementById("user-create-form")?.addEventListener("submit", (event) => { document.getElementById("user-create-form")?.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
const username = requiredElement("user-create-username"); const username = requiredElement("user-create-username");
const password = requiredElement("user-create-password"); const password = requiredElement("user-create-password");
const enabled = requiredElement("user-create-enabled");
const roles = Array.from(document.querySelectorAll("#user-create-roles input[type=checkbox]")); const roles = Array.from(document.querySelectorAll("#user-create-roles input[type=checkbox]"));
void runUserOperation(async () => { void runUserOperation(async () => {
await createUser(username.value, password.value, roles.filter((input) => input.checked).map((input) => input.value)); await createUser(username.value, password.value, roles.filter((input) => input.checked).map((input) => input.value), enabled.checked);
username.value = ""; username.value = "";
password.value = ""; password.value = "";
enabled.checked = true;
roles.forEach((input) => { roles.forEach((input) => {
input.checked = input.value === "read"; input.checked = input.value === "read";
}); });
}); });
}); });
document.getElementById("account-password-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget;
const currentPassword = requiredElement("account-current-password");
const newPassword = requiredElement("account-new-password");
const confirmPassword = requiredElement("account-confirm-password");
const error = requiredElement("account-password-error");
const submit = form.querySelector('button[type="submit"]');
if (newPassword.value !== confirmPassword.value) {
error.textContent = "New passwords do not match";
error.style.display = "block";
return;
}
if (submit) submit.disabled = true;
void changeOwnPassword(currentPassword.value, newPassword.value).then(async () => {
form.reset();
error.style.display = "none";
await authLogout();
showHint("Password changed. Sign in again.", 3e3);
}).catch((reason) => {
error.textContent = reason instanceof Error ? reason.message : String(reason);
error.style.display = "block";
}).finally(() => {
if (submit) submit.disabled = false;
});
});
requiredElement("auth-form").addEventListener("submit", async (e) => { requiredElement("auth-form").addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const usernameEl = requiredElement("auth-username"); const usernameEl = requiredElement("auth-username");
@@ -6252,9 +6230,6 @@ Object.defineProperties(trxState, {
authEnabled: { get() { authEnabled: { get() {
return authEnabled; return authEnabled;
} }, } },
authRole: { get() {
return authRole;
} },
authRoles: { get() { authRoles: { get() {
return authRoles; return authRoles;
} }, } },
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-BB2X7SND.js";
import { import {
hostState hostState
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -13,7 +16,7 @@ var bgdWindow = window;
return d.id; return d.id;
}); });
} }
let backgroundDecodeRole = null; let backgroundDecodeRoles = [];
let currentRigId = null; let currentRigId = null;
let currentConfig = null; let currentConfig = null;
let bookmarkList = []; let bookmarkList = [];
@@ -21,8 +24,8 @@ var bgdWindow = window;
let bgdDirty = false; let bgdDirty = false;
let statusByBookmark = /* @__PURE__ */ new Map(); let statusByBookmark = /* @__PURE__ */ new Map();
let lastStatus = null; let lastStatus = null;
function initBackgroundDecode(rigId, role) { function initBackgroundDecode(rigId, roles) {
backgroundDecodeRole = role; backgroundDecodeRoles = roles;
currentRigId = rigId || hostState.lastActiveRigId || null; currentRigId = rigId || hostState.lastActiveRigId || null;
if (currentRigId) loadBackgroundDecode(); if (currentRigId) loadBackgroundDecode();
startStatusPolling(); startStatusPolling();
@@ -358,7 +361,7 @@ var bgdWindow = window;
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save"; btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
} }
function isControlRole() { function isControlRole() {
return backgroundDecodeRole === "administrator" || backgroundDecodeRole === "control" || hostState.authEnabled === false; return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
} }
function showToast(msg, isError) { function showToast(msg, isError) {
const el = document.getElementById("background-decode-toast"); const el = document.getElementById("background-decode-toast");
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-BB2X7SND.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -42,7 +45,7 @@ function bmEsc(str) {
return d.innerHTML; return d.innerHTML;
} }
function bmCanControl() { function bmCanControl() {
return !hostState.authEnabled || hostState.authRoles.includes("administrator") || hostState.authRoles.includes("write"); return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
} }
function bmSyncAccess() { function bmSyncAccess() {
const canCtrl = bmCanControl(); const canCtrl = bmCanControl();
@@ -0,0 +1,128 @@
// src/api/auth.ts
var AUTH_ROLES = ["read", "control", "write", "administrator"];
var AUTH_ROLE_LABELS = {
read: "Read",
control: "Control",
write: "Write",
administrator: "Administrator"
};
function isAuthRole(value) {
return typeof value === "string" && AUTH_ROLES.includes(value);
}
function normalizeAuthRoles(roles) {
return AUTH_ROLES.filter((role) => roles.includes(role));
}
function hasAuthRole(roles, required) {
return roles.includes("administrator") || roles.includes(required) || required === "read" && roles.includes("control");
}
function decodeRoles(value, context) {
if (!Array.isArray(value) || !value.every(isAuthRole)) {
throw new TypeError(`${context} has invalid roles`);
}
return normalizeAuthRoles(value);
}
function decodeAuthSession(value) {
if (typeof value !== "object" || value === null) {
throw new TypeError("The authentication response is malformed");
}
const session = value;
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag");
}
const decoded = {
authenticated: session.authenticated,
roles: decodeRoles(session.roles, "The authentication response")
};
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,
roles: ["read", "control", "write", "administrator"],
auth_disabled: true
};
async function fetchAuthSession() {
try {
const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false, roles: [] };
return decodeAuthSession(await response.json());
} catch (error) {
console.error("Auth check failed:", error);
return { authenticated: false, roles: [] };
}
}
async function login(username, password) {
const response = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
if (response.status === 404) return authDisabledSession;
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Login failed");
}
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" && typeof record.enabled === "boolean" && Array.isArray(record.roles) && record.roles.every(isAuthRole);
})) {
throw new TypeError("The user list response is malformed");
}
return value.map((user) => ({ ...user, roles: normalizeAuthRoles(user.roles) }));
}
async function createUser(username, password, roles, enabled = true) {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles, enabled }) });
}
async function updateUser(username, changes) {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
}
async function changeOwnPassword(currentPassword, newPassword) {
await userRequest("/auth/account/password", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
});
}
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");
}
export {
AUTH_ROLES,
AUTH_ROLE_LABELS,
normalizeAuthRoles,
hasAuthRole,
fetchAuthSession,
login,
listUsers,
createUser,
updateUser,
changeOwnPassword,
deleteUser,
logout
};
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-BB2X7SND.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -48,7 +51,7 @@ var entryGrid = null;
var qsos = []; var qsos = [];
var workedRequest = 0; var workedRequest = 0;
function canWriteLogbook() { function canWriteLogbook() {
return !hostState.authEnabled || hostState.authRoles.includes("administrator") || hostState.authRoles.includes("write"); return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
} }
function notify(message, kind) { function notify(message, kind) {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0); if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0);
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-BB2X7SND.js";
import { import {
hostState hostState
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -15,7 +18,7 @@ function schedulerOptionalEl(id) {
} }
(function() { (function() {
"use strict"; "use strict";
let schedulerRole = null; let schedulerRoles = [];
let currentRigId = null; let currentRigId = null;
let currentConfig = null; let currentConfig = null;
let currentSchedulerStatus = null; let currentSchedulerStatus = null;
@@ -25,8 +28,8 @@ function schedulerOptionalEl(id) {
let schedulerStepPending = false; let schedulerStepPending = false;
let schEntryEditIdx = null; let schEntryEditIdx = null;
let schedulerDirty = false; let schedulerDirty = false;
function initScheduler(rigId, role) { function initScheduler(rigId, roles) {
schedulerRole = role; schedulerRoles = roles;
currentRigId = rigId || null; currentRigId = rigId || null;
if (currentRigId) loadScheduler(); if (currentRigId) loadScheduler();
startStatusPolling(); startStatusPolling();
@@ -272,7 +275,7 @@ function schedulerOptionalEl(id) {
const nextBtn = schedulerEl("scheduler-next-btn"); const nextBtn = schedulerEl("scheduler-next-btn");
if (!prevBtn || !nextBtn) return; if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig); const state = schedulerInterleaveState(currentConfig);
const enabled = (schedulerRole === "administrator" || schedulerRole === "control") && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1; const enabled = hasAuthRole(schedulerRoles, "control") && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
prevBtn.disabled = !enabled; prevBtn.disabled = !enabled;
nextBtn.disabled = !enabled; nextBtn.disabled = !enabled;
const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active"; const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active";
@@ -354,7 +357,7 @@ function schedulerOptionalEl(id) {
const panel = schedulerEl("scheduler-panel"); const panel = schedulerEl("scheduler-panel");
if (!panel) return; if (!panel) return;
const mode = currentConfig && currentConfig.mode || "disabled"; const mode = currentConfig && currentConfig.mode || "disabled";
const isControl = schedulerRole === "administrator" || schedulerRole === "control"; const isControl = hasAuthRole(schedulerRoles, "control");
setSelected("scheduler-mode-select", mode); setSelected("scheduler-mode-select", mode);
const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled; const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled;
const controlRow = document.querySelector(".scheduler-control-row"); const controlRow = document.querySelector(".scheduler-control-row");
@@ -1220,8 +1223,8 @@ function schedulerOptionalEl(id) {
markDirty: markSchedulerDirty markDirty: markSchedulerDirty
}; };
schedulerWindow.trx.modules.scheduler = schedulerService; schedulerWindow.trx.modules.scheduler = schedulerService;
if (hostState.authRole != null) { if (!hostState.authEnabled || hostState.authRoles.length > 0) {
initScheduler(hostState.lastActiveRigId, hostState.authRole); initScheduler(hostState.lastActiveRigId, hostState.authRoles);
wireSchedulerEvents(); wireSchedulerEvents();
} }
})(); })();
@@ -127,7 +127,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
<form id="auth-form" class="auth-form"> <form id="auth-form" class="auth-form">
<input type="text" id="auth-username" class="auth-input" placeholder="Username" autocomplete="username" required /> <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 /> <input type="password" id="auth-password" class="auth-input" placeholder="Password" autocomplete="current-password" maxlength="1024" required />
<button type="submit" class="auth-submit">Login</button> <button type="submit" class="auth-submit">Login</button>
</form> </form>
<div id="auth-error" class="auth-error" style="display: none;"></div> <div id="auth-error" class="auth-error" style="display: none;"></div>
@@ -1461,6 +1461,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button> <button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
<button class="sub-tab" data-subtab="settings-bandplan">Bandplan</button> <button class="sub-tab" data-subtab="settings-bandplan">Bandplan</button>
<button class="sub-tab" data-subtab="settings-history">History</button> <button class="sub-tab" data-subtab="settings-history">History</button>
<button id="settings-account-tab" class="sub-tab" data-subtab="settings-account" style="display:none;">Account</button>
<button id="settings-users-tab" class="sub-tab" data-subtab="settings-users" style="display:none;">Users</button> <button id="settings-users-tab" class="sub-tab" data-subtab="settings-users" style="display:none;">Users</button>
</div> </div>
<div id="subtab-settings-scheduler" class="sub-tab-panel"> <div id="subtab-settings-scheduler" class="sub-tab-panel">
@@ -1751,18 +1752,27 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</div> </div>
</div> </div>
<div id="subtab-settings-account" class="sub-tab-panel" style="display:none;">
<div class="settings-card">
<h3>Change password</h3>
<form id="account-password-form" class="sch-row" style="flex-wrap:wrap; gap:.5rem;">
<input id="account-current-password" class="auth-input" type="password" placeholder="Current password" autocomplete="current-password" maxlength="1024" required />
<input id="account-new-password" class="auth-input" type="password" placeholder="New password (8+ characters)" autocomplete="new-password" minlength="8" maxlength="1024" required />
<input id="account-confirm-password" class="auth-input" type="password" placeholder="Confirm new password" autocomplete="new-password" minlength="8" maxlength="1024" required />
<button type="submit" class="auth-submit">Change password</button>
</form>
<div id="account-password-error" class="auth-error" role="alert" aria-live="polite" style="display:none;"></div>
<p class="settings-note">Changing your password signs out every session for this account.</p>
</div>
</div>
<div id="subtab-settings-users" class="sub-tab-panel" style="display:none;"> <div id="subtab-settings-users" class="sub-tab-panel" style="display:none;">
<div id="user-management"> <div id="user-management">
<div class="settings-card"> <div class="settings-card">
<form id="user-create-form" class="sch-row" style="flex-wrap:wrap; gap:.5rem;"> <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-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 /> <input id="user-create-password" class="auth-input" type="password" placeholder="Password (8+ characters)" autocomplete="new-password" minlength="8" maxlength="1024" required />
<span id="user-create-roles" class="auth-role-choices"> <span id="user-create-roles" class="auth-role-choices"></span>
<label class="auth-role-choice"><input type="checkbox" value="read" checked /> Read</label> <label class="auth-role-choice"><input id="user-create-enabled" type="checkbox" checked /> Enabled</label>
<label class="auth-role-choice"><input type="checkbox" value="control" /> Control</label>
<label class="auth-role-choice"><input type="checkbox" value="write" /> Write</label>
<label class="auth-role-choice"><input type="checkbox" value="administrator" /> Administrator</label>
</span>
<button type="submit" class="auth-submit">Add user</button> <button type="submit" class="auth-submit">Add user</button>
</form> </form>
<div id="user-management-error" class="auth-error" style="display:none;"></div> <div id="user-management-error" class="auth-error" style="display:none;"></div>
@@ -218,6 +218,7 @@ body {
#user-management .auth-submit { width: auto; } #user-management .auth-submit { width: auto; }
#user-management .auth-role-choices { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; } #user-management .auth-role-choices { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
#user-management .auth-role-choice { display: inline-flex; align-items: center; gap: .2rem; white-space: nowrap; } #user-management .auth-role-choice { display: inline-flex; align-items: center; gap: .2rem; white-space: nowrap; }
.settings-note { color: var(--text-muted); font-size: .85rem; margin: .75rem 0 0; }
#user-management button { padding: 0.55rem 0.75rem; } #user-management button { padding: 0.55rem 0.75rem; }
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; } .label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
@@ -15,6 +15,7 @@ use trx_core::rig::{
use trx_core::{DecoderConfig, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel}; use trx_core::{DecoderConfig, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel};
use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse}; use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse};
use trx_frontend_http::server::api::FrontendMeta; use trx_frontend_http::server::api::FrontendMeta;
use trx_frontend_http::server::auth::AuthRole;
use trx_protocol::{DecoderActivation, DecoderDescriptor}; use trx_protocol::{DecoderActivation, DecoderDescriptor};
use ts_rs::{Config, TS}; use ts_rs::{Config, TS};
@@ -56,6 +57,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
export!(RigListItem); export!(RigListItem);
export!(RigListResponse); export!(RigListResponse);
export!(FrontendMeta); export!(FrontendMeta);
export!(AuthRole);
export!(DecoderActivation); export!(DecoderActivation);
export!(DecoderDescriptor); export!(DecoderDescriptor);
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json", "typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern", "lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs", "test": "node --test tests/*.test.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs", "test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs && node tests/account-management.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts" "verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
}, },
"devDependencies": { "devDependencies": {
@@ -2,7 +2,38 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
export type AuthRole = "read" | "control" | "write" | "administrator"; import type { AuthRole } from "./generated.js";
export type { AuthRole };
export const AUTH_ROLES: readonly AuthRole[] = ["read", "control", "write", "administrator"];
export const AUTH_ROLE_LABELS: Readonly<Record<AuthRole, string>> = {
read: "Read",
control: "Control",
write: "Write",
administrator: "Administrator",
};
export function isAuthRole(value: unknown): value is AuthRole {
return typeof value === "string" && (AUTH_ROLES as readonly string[]).includes(value);
}
export function normalizeAuthRoles(roles: readonly AuthRole[]): AuthRole[] {
return AUTH_ROLES.filter(role => roles.includes(role));
}
export function hasAuthRole(roles: readonly AuthRole[], required: AuthRole): boolean {
return roles.includes("administrator")
|| roles.includes(required)
|| required === "read" && roles.includes("control");
}
function decodeRoles(value: unknown, context: string): AuthRole[] {
if (!Array.isArray(value) || !value.every(isAuthRole)) {
throw new TypeError(`${context} has invalid roles`);
}
return normalizeAuthRoles(value);
}
export interface AuthSession { export interface AuthSession {
authenticated: boolean; authenticated: boolean;
@@ -19,14 +50,13 @@ function decodeAuthSession(value: unknown): AuthSession {
if (typeof session.authenticated !== "boolean") { if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag"); throw new TypeError("The authentication response has no authenticated flag");
} }
if (!Array.isArray(session.roles) || !session.roles.every(role =>
role === "read" || role === "control" || role === "write" || role === "administrator")) {
throw new TypeError("The authentication response has invalid roles");
}
if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") { if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag"); throw new TypeError("The authentication response has an invalid auth_disabled flag");
} }
const decoded: AuthSession = { authenticated: session.authenticated, roles: session.roles as AuthRole[] }; const decoded: AuthSession = {
authenticated: session.authenticated,
roles: decodeRoles(session.roles, "The authentication response"),
};
if (session.username !== undefined) { if (session.username !== undefined) {
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username"); if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
decoded.username = session.username; decoded.username = session.username;
@@ -67,7 +97,7 @@ export async function login(username: string, password: string): Promise<AuthSes
return decodeAuthSession(await response.json()); return decodeAuthSession(await response.json());
} }
export interface ManagedUser { username: string; roles: AuthRole[] } export interface ManagedUser { username: string; roles: AuthRole[]; enabled: boolean }
async function userRequest(path: string, init?: RequestInit): Promise<Response> { async function userRequest(path: string, init?: RequestInit): Promise<Response> {
const response = await fetch(path, init); const response = await fetch(path, init);
@@ -83,22 +113,32 @@ export async function listUsers(): Promise<ManagedUser[]> {
if (!Array.isArray(value) || !value.every((user: unknown) => { if (!Array.isArray(value) || !value.every((user: unknown) => {
if (typeof user !== "object" || user === null) return false; if (typeof user !== "object" || user === null) return false;
const record = user as Record<string, unknown>; const record = user as Record<string, unknown>;
return typeof record.username === "string" && Array.isArray(record.roles) return typeof record.username === "string"
&& record.roles.every(role => role === "read" || role === "control" || role === "write" || role === "administrator"); && typeof record.enabled === "boolean"
&& Array.isArray(record.roles)
&& record.roles.every(isAuthRole);
})) { })) {
throw new TypeError("The user list response is malformed"); throw new TypeError("The user list response is malformed");
} }
return value as ManagedUser[]; return (value as ManagedUser[]).map(user => ({ ...user, roles: normalizeAuthRoles(user.roles) }));
} }
export async function createUser(username: string, password: string, roles: AuthRole[]): Promise<void> { export async function createUser(username: string, password: string, roles: AuthRole[], enabled = true): Promise<void> {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles }) }); await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles, enabled }) });
} }
export async function updateUser(username: string, changes: { password?: string; roles?: AuthRole[] }): Promise<void> { export async function updateUser(username: string, changes: { password?: string; roles?: AuthRole[]; enabled?: boolean }): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) }); await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
} }
export async function changeOwnPassword(currentPassword: string, newPassword: string): Promise<void> {
await userRequest("/auth/account/password", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
}
export async function deleteUser(username: string): Promise<void> { export async function deleteUser(username: string): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" }); await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
} }
@@ -123,6 +123,8 @@ export type RigListResponse = { active_remote: string | null, rigs: Array<RigLis
export type FrontendMeta = { clients: number, rigctl_clients: number, audio_clients: number, rigctl_addr: string | null, active_remote: string | null, remotes: Array<string>, owner_callsign: string | null, owner_website_url: string | null, owner_website_name: string | null, ais_vessel_url_base: string | null, show_sdr_gain_control: boolean, initial_map_zoom: number, spectrum_coverage_margin_hz: number, spectrum_usable_span_ratio: number, bandplan_enabled: boolean, bandplan_region: string, decode_history_retention_min: bigint, server_connected: boolean, }; export type FrontendMeta = { clients: number, rigctl_clients: number, audio_clients: number, rigctl_addr: string | null, active_remote: string | null, remotes: Array<string>, owner_callsign: string | null, owner_website_url: string | null, owner_website_name: string | null, ais_vessel_url_base: string | null, show_sdr_gain_control: boolean, initial_map_zoom: number, spectrum_coverage_margin_hz: number, spectrum_usable_span_ratio: number, bandplan_enabled: boolean, bandplan_region: string, decode_history_retention_min: bigint, server_connected: boolean, };
export type AuthRole = "read" | "control" | "write" | "administrator";
export type DecoderActivation = "mode_bound" | "toggle"; export type DecoderActivation = "mode_bound" | "toggle";
export type DecoderDescriptor = { export type DecoderDescriptor = {
@@ -25,6 +25,11 @@ import {
createUser, createUser,
updateUser, updateUser,
deleteUser, deleteUser,
changeOwnPassword,
AUTH_ROLES,
AUTH_ROLE_LABELS,
hasAuthRole as rolesInclude,
normalizeAuthRoles,
} from "./api/auth.js"; } from "./api/auth.js";
import { import {
formatByteSize as recorderFormatSize, formatByteSize as recorderFormatSize,
@@ -193,8 +198,8 @@ interface TrxModules {
reverseGeocodeLocation(lat: number, lon: number, grid: string): void; reverseGeocodeLocation(lat: number, lon: number, grid: string): void;
bandForHz(frequencyHz: number): unknown; bandForHz(frequencyHz: number): unknown;
}; };
scheduler?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void }; scheduler?: { initialize(rigId: string | null, roles: readonly AuthRole[]): void; setRig(rigId: string | null): void; wireEvents(): void };
backgroundDecode?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void }; backgroundDecode?: { initialize(rigId: string | null, roles: readonly AuthRole[]): void; setRig(rigId: string | null): void; wireEvents(): void };
bookmarks?: { bookmarks?: {
readonly overlayList: readonly Bookmark[]; readonly overlayList: readonly Bookmark[];
readonly overlayRevision: number; readonly overlayRevision: number;
@@ -230,7 +235,6 @@ interface TrxState {
readonly initialMapZoom: number; readonly initialMapZoom: number;
readonly decodeHistoryRetentionMin: number; readonly decodeHistoryRetentionMin: number;
readonly authEnabled: boolean; readonly authEnabled: boolean;
readonly authRole: AuthRole | null;
readonly authRoles: readonly AuthRole[]; readonly authRoles: readonly AuthRole[];
readonly decoderRegistry: typeof decoderRegistry; readonly decoderRegistry: typeof decoderRegistry;
readonly sseSessionId: string | null; readonly sseSessionId: string | null;
@@ -411,27 +415,33 @@ declare global {
void loadDecoderRegistry(refreshOperatorLayoutCapabilities); void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
// --- Authentication --- // --- Authentication ---
let authRole: AuthRole | null = null;
let authRoles: AuthRole[] = []; let authRoles: AuthRole[] = [];
let authUsername: string | null = null; let authUsername: string | null = null;
let authEnabled = true; let authEnabled = true;
const ALL_AUTH_ROLES: readonly AuthRole[] = ["read", "control", "write", "administrator"];
const AUTH_ROLE_LABELS: Record<AuthRole, string> = {
read: "Read",
control: "Control",
write: "Write",
administrator: "Administrator",
};
function setAuthRoles(roles: readonly AuthRole[]) { function setAuthRoles(roles: readonly AuthRole[]) {
authRoles = [...new Set(roles)]; authRoles = normalizeAuthRoles(roles);
authRole = (["administrator", "control", "write", "read"] as AuthRole[])
.find(role => authRoles.includes(role)) ?? null;
} }
function hasAuthRole(role: AuthRole) { function hasAuthRole(role: AuthRole) {
return authRoles.includes("administrator") || authRoles.includes(role); return rolesInclude(authRoles, role);
}
function buildRoleChoices(selected: readonly AuthRole[]) {
const element = document.createElement("span");
element.className = "auth-role-choices";
const inputs = AUTH_ROLES.map((value) => {
const label = document.createElement("label");
label.className = "auth-role-choice";
const input = document.createElement("input");
input.type = "checkbox";
input.value = value;
input.checked = selected.includes(value);
label.append(input, ` ${AUTH_ROLE_LABELS[value]}`);
element.append(label);
return { input, value };
});
return { element, inputs };
} }
async function checkAuthStatus() { async function checkAuthStatus() {
@@ -523,15 +533,18 @@ function updateAuthUI() {
const badge = document.getElementById("auth-badge"); const badge = document.getElementById("auth-badge");
const badgeRole = document.getElementById("auth-role-badge"); const badgeRole = document.getElementById("auth-role-badge");
const headerAuthBtn = document.getElementById("header-auth-btn"); const headerAuthBtn = document.getElementById("header-auth-btn");
const accountTab = document.getElementById("settings-account-tab");
if (!authEnabled) { if (!authEnabled) {
if (badge) badge.style.display = "none"; if (badge) badge.style.display = "none";
if (headerAuthBtn) headerAuthBtn.style.display = "none"; if (headerAuthBtn) headerAuthBtn.style.display = "none";
if (accountTab) accountTab.style.display = "none";
syncTopBarAccess(); syncTopBarAccess();
return; return;
} }
if (authRoles.length > 0) { if (authRoles.length > 0) {
if (accountTab) accountTab.style.display = "";
if (badge) badge.style.display = "block"; if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`; if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`;
if (headerAuthBtn) { if (headerAuthBtn) {
@@ -539,6 +552,7 @@ function updateAuthUI() {
headerAuthBtn.style.display = "block"; headerAuthBtn.style.display = "block";
} }
} else { } else {
if (accountTab) accountTab.style.display = "none";
if (badge) badge.style.display = "none"; if (badge) badge.style.display = "none";
if (headerAuthBtn) { if (headerAuthBtn) {
headerAuthBtn.textContent = "Login"; headerAuthBtn.textContent = "Login";
@@ -5036,7 +5050,7 @@ async function initializeApp() {
authEnabled = !authStatus.auth_disabled; authEnabled = !authStatus.auth_disabled;
if (!authEnabled) { if (!authEnabled) {
setAuthRoles(ALL_AUTH_ROLES); setAuthRoles(AUTH_ROLES);
hideAuthGate(); hideAuthGate();
updateAuthUI(); updateAuthUI();
connect(); connect();
@@ -5070,10 +5084,10 @@ let settingsUiReady = false;
function initSettingsUI() { function initSettingsUI() {
settingsUiReady = true; settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole); window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
window.trx.modules.scheduler?.wireEvents(); window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) { if (window.trx.modules.backgroundDecode) {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole); window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
window.trx.modules.backgroundDecode.wireEvents(); window.trx.modules.backgroundDecode.wireEvents();
} }
void refreshUserManagement(); void refreshUserManagement();
@@ -5096,7 +5110,7 @@ async function refreshUserManagement() {
const list = requiredElement("user-list"); const list = requiredElement("user-list");
try { try {
const users = await listUsers(); const users = await listUsers();
const adminCount = users.filter(user => user.roles.includes("administrator")).length; const enabledAdminCount = users.filter(user => user.enabled && rolesInclude(user.roles, "administrator")).length;
list.replaceChildren(...users.map((user) => { list.replaceChildren(...users.map((user) => {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "sch-row"; row.className = "sch-row";
@@ -5104,31 +5118,35 @@ async function refreshUserManagement() {
const name = document.createElement("strong"); const name = document.createElement("strong");
name.textContent = user.username; name.textContent = user.username;
name.style.minWidth = "10rem"; name.style.minWidth = "10rem";
const roleInputs = ALL_AUTH_ROLES.map((value) => { if (!user.enabled) name.textContent += " (disabled)";
const label = document.createElement("label"); const { element: roles, inputs: roleInputs } = buildRoleChoices(user.roles);
label.className = "auth-role-choice"; const enabledLabel = document.createElement("label");
const input = document.createElement("input"); enabledLabel.className = "auth-role-choice";
input.type = "checkbox"; const enabled = document.createElement("input");
input.value = value; enabled.type = "checkbox";
input.checked = user.roles.includes(value); enabled.checked = user.enabled;
label.append(input, ` ${AUTH_ROLE_LABELS[value]}`); enabled.disabled = user.username === authUsername;
return { label, input, value }; if (enabled.disabled) enabled.title = "You cannot disable your current account";
}); enabledLabel.append(enabled, " Enabled");
const roles = document.createElement("span"); const isOnlyAdmin = user.enabled
roles.className = "auth-role-choices"; && rolesInclude(user.roles, "administrator")
roles.append(...roleInputs.map(({ label }) => label)); && enabledAdminCount === 1;
const isOnlyAdmin = user.roles.includes("administrator") && adminCount === 1;
const administratorInput = roleInputs.find(item => item.value === "administrator")?.input; const administratorInput = roleInputs.find(item => item.value === "administrator")?.input;
if (isOnlyAdmin && administratorInput) { if (isOnlyAdmin && administratorInput) {
administratorInput.disabled = true; administratorInput.disabled = true;
administratorInput.title = "The final administrator cannot be demoted"; administratorInput.title = "The final administrator cannot be demoted";
} }
if (isOnlyAdmin) {
enabled.disabled = true;
enabled.title = "The final enabled administrator cannot be disabled";
}
const password = document.createElement("input"); const password = document.createElement("input");
password.type = "password"; password.placeholder = "New password (8+ characters)"; password.autocomplete = "new-password"; password.className = "auth-input"; password.minLength = 8; password.type = "password"; password.placeholder = "New password (8+ characters)"; password.autocomplete = "new-password"; password.className = "auth-input"; password.minLength = 8; password.maxLength = 1024;
const save = document.createElement("button"); save.type = "button"; save.textContent = "Save"; const save = document.createElement("button"); save.type = "button"; save.textContent = "Save";
save.addEventListener("click", async () => { save.addEventListener("click", async () => {
const changes: { roles?: AuthRole[]; password?: string } = { const changes: { roles?: AuthRole[]; password?: string; enabled?: boolean } = {
roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value), roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value),
enabled: enabled.checked,
}; };
if (password.value) changes.password = password.value; if (password.value) changes.password = password.value;
await runUserOperation(() => updateUser(user.username, changes)); await runUserOperation(() => updateUser(user.username, changes));
@@ -5141,7 +5159,7 @@ async function refreshUserManagement() {
await runUserOperation(() => deleteUser(user.username)); await runUserOperation(() => deleteUser(user.username));
} }
}); });
row.append(name, roles, password, save, remove); row.append(name, enabledLabel, roles, password, save, remove);
return row; return row;
})); }));
} catch (error) { } catch (error) {
@@ -5167,18 +5185,57 @@ async function runUserOperation(operation: () => Promise<void>) {
} }
} }
const createRoleContainer = document.getElementById("user-create-roles");
if (createRoleContainer) {
const { element } = buildRoleChoices(["read"]);
element.id = createRoleContainer.id;
createRoleContainer.replaceWith(element);
}
document.getElementById("user-create-form")?.addEventListener("submit", (event) => { document.getElementById("user-create-form")?.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
const username = requiredElement<HTMLInputElement>("user-create-username"); const username = requiredElement<HTMLInputElement>("user-create-username");
const password = requiredElement<HTMLInputElement>("user-create-password"); const password = requiredElement<HTMLInputElement>("user-create-password");
const enabled = requiredElement<HTMLInputElement>("user-create-enabled");
const roles = Array.from(document.querySelectorAll<HTMLInputElement>("#user-create-roles input[type=checkbox]")); const roles = Array.from(document.querySelectorAll<HTMLInputElement>("#user-create-roles input[type=checkbox]"));
void runUserOperation(async () => { void runUserOperation(async () => {
await createUser(username.value, password.value, roles.filter(input => input.checked).map(input => input.value as AuthRole)); await createUser(username.value, password.value, roles.filter(input => input.checked).map(input => input.value as AuthRole), enabled.checked);
username.value = ""; password.value = ""; username.value = ""; password.value = "";
enabled.checked = true;
roles.forEach(input => { input.checked = input.value === "read"; }); roles.forEach(input => { input.checked = input.value === "read"; });
}); });
}); });
document.getElementById("account-password-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const currentPassword = requiredElement<HTMLInputElement>("account-current-password");
const newPassword = requiredElement<HTMLInputElement>("account-new-password");
const confirmPassword = requiredElement<HTMLInputElement>("account-confirm-password");
const error = requiredElement("account-password-error");
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (newPassword.value !== confirmPassword.value) {
error.textContent = "New passwords do not match";
error.style.display = "block";
return;
}
if (submit) submit.disabled = true;
void changeOwnPassword(currentPassword.value, newPassword.value)
.then(async () => {
form.reset();
error.style.display = "none";
await authLogout();
showHint("Password changed. Sign in again.", 3000);
})
.catch((reason: unknown) => {
error.textContent = reason instanceof Error ? reason.message : String(reason);
error.style.display = "block";
})
.finally(() => {
if (submit) submit.disabled = false;
});
});
// Setup auth form // Setup auth form
requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (e) => { requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
@@ -5244,7 +5301,6 @@ Object.defineProperties(trxState, {
initialMapZoom: { get() { return initialMapZoom; } }, initialMapZoom: { get() { return initialMapZoom; } },
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } }, decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
authEnabled: { get() { return authEnabled; } }, authEnabled: { get() { return authEnabled; } },
authRole: { get() { return authRole; } },
authRoles: { get() { return authRoles; } }, authRoles: { get() { return authRoles; } },
decoderRegistry: { get() { return decoderRegistry; } }, decoderRegistry: { get() { return decoderRegistry; } },
sseSessionId: { get() { return sseSessionId; } }, sseSessionId: { get() { return sseSessionId; } },
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js"; import { hostState } from "./host.js";
import { hasAuthRole, type AuthRole } from "../api/auth.js";
export {}; export {};
@@ -44,7 +45,7 @@ interface BackgroundBridge {
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } }; trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
} }
interface BackgroundDecodeService { interface BackgroundDecodeService {
initialize(rigId: string | null, role: string | null): void; initialize(rigId: string | null, roles: readonly AuthRole[]): void;
wireEvents(): void; wireEvents(): void;
setRig(rigId: string | null): void; setRig(rigId: string | null): void;
} }
@@ -60,7 +61,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
.map(function (d) { return d.id; }); .map(function (d) { return d.id; });
} }
let backgroundDecodeRole: string | null = null; let backgroundDecodeRoles: readonly AuthRole[] = [];
let currentRigId: string | null = null; let currentRigId: string | null = null;
let currentConfig: BackgroundDecodeConfig | null = null; let currentConfig: BackgroundDecodeConfig | null = null;
let bookmarkList: Bookmark[] = []; let bookmarkList: Bookmark[] = [];
@@ -70,8 +71,8 @@ const bgdWindow = window as unknown as BackgroundBridge;
let statusByBookmark = new Map<string, BackgroundStatusEntry>(); let statusByBookmark = new Map<string, BackgroundStatusEntry>();
let lastStatus: BackgroundDecodeStatus | null = null; let lastStatus: BackgroundDecodeStatus | null = null;
function initBackgroundDecode(rigId: string | null, role: string | null): void { function initBackgroundDecode(rigId: string | null, roles: readonly AuthRole[]): void {
backgroundDecodeRole = role; backgroundDecodeRoles = roles;
// The panel used to take whatever rig it was handed at load and wait to be // The panel used to take whatever rig it was handed at load and wait to be
// told again. Loading before the rig list arrives handed it null, and the // told again. Loading before the rig list arrives handed it null, and the
// next telling only came when the operator switched rigs, so the panel sat // next telling only came when the operator switched rigs, so the panel sat
@@ -468,9 +469,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
} }
function isControlRole(): boolean { function isControlRole(): boolean {
return backgroundDecodeRole === "administrator" return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
|| backgroundDecodeRole === "control"
|| hostState.authEnabled === false;
} }
function showToast(msg: string, isError: boolean): void { function showToast(msg: string, isError: boolean): void {
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js"; import { hostCore, hostState } from "./host.js";
import { hasAuthRole } from "../api/auth.js";
export {}; export {};
@@ -101,8 +102,7 @@ function bmEsc(str: unknown): string {
function bmCanControl() { function bmCanControl() {
return !hostState.authEnabled return !hostState.authEnabled
|| hostState.authRoles.includes("administrator") || hasAuthRole(hostState.authRoles, "write");
|| hostState.authRoles.includes("write");
} }
// Show/hide the Add Bookmark / Select All buttons based on the current auth role. // Show/hide the Add Bookmark / Select All buttons based on the current auth role.
@@ -11,6 +11,8 @@
// feature bundles from re-deriving it — and from drifting back to bare `window` // feature bundles from re-deriving it — and from drifting back to bare `window`
// properties, which the module graph no longer publishes. // properties, which the module graph no longer publishes.
import type { AuthRole } from "../api/auth.js";
export interface HostDecoderDescriptor { export interface HostDecoderDescriptor {
id: string; id: string;
label: string; label: string;
@@ -25,8 +27,7 @@ export interface HostState {
/** The callsign this station is on the air with, from the client config. */ /** The callsign this station is on the air with, from the client config. */
readonly ownerCallsign: string | null; readonly ownerCallsign: string | null;
readonly authEnabled: boolean; readonly authEnabled: boolean;
readonly authRole: string | null; readonly authRoles: readonly AuthRole[];
readonly authRoles: readonly string[];
readonly lastActiveRigId: string | null; readonly lastActiveRigId: string | null;
readonly lastRigIds: string[]; readonly lastRigIds: string[];
readonly lastRigDisplayNames: Record<string, string>; readonly lastRigDisplayNames: Record<string, string>;
@@ -10,6 +10,7 @@
// keeping if the times in it are the radio's. // keeping if the times in it are the radio's.
import { hostCore, hostState } from "./host.js"; import { hostCore, hostState } from "./host.js";
import { hasAuthRole } from "../api/auth.js";
export {}; export {};
@@ -122,8 +123,7 @@ let workedRequest = 0;
function canWriteLogbook(): boolean { function canWriteLogbook(): boolean {
return !hostState.authEnabled return !hostState.authEnabled
|| hostState.authRoles.includes("administrator") || hasAuthRole(hostState.authRoles, "write");
|| hostState.authRoles.includes("write");
} }
function notify(message: string, kind?: string): void { function notify(message: string, kind?: string): void {
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import type { SatelliteScheduleConfig, SatelliteSchedulerApi } from "./satellite-types.js"; import type { SatelliteScheduleConfig, SatelliteSchedulerApi } from "./satellite-types.js";
import type { AuthRole } from "../api/auth.js";
export type SchedulerMode = "disabled" | "grayline" | "time_span"; export type SchedulerMode = "disabled" | "grayline" | "time_span";
@@ -60,7 +61,7 @@ export interface SchedulerStatus {
} }
export interface SchedulerService { export interface SchedulerService {
initialize(rigId: string | null, role: string | null): void; initialize(rigId: string | null, roles: readonly AuthRole[]): void;
destroy(): void; destroy(): void;
setRig(rigId: string | null): void; setRig(rigId: string | null): void;
wireEvents(): void; wireEvents(): void;
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js"; import { hostState } from "./host.js";
import { hasAuthRole, type AuthRole } from "../api/auth.js";
import type { import type {
ScheduleEntry, ScheduleEntry,
@@ -43,7 +44,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// State // State
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
let schedulerRole: string | null = null; let schedulerRoles: readonly AuthRole[] = [];
let currentRigId: string | null = null; let currentRigId: string | null = null;
let currentConfig: SchedulerConfig | null = null; let currentConfig: SchedulerConfig | null = null;
let currentSchedulerStatus: SchedulerStatus | null = null; let currentSchedulerStatus: SchedulerStatus | null = null;
@@ -58,8 +59,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Init // Init
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
function initScheduler(rigId: string | null, role: string | null): void { function initScheduler(rigId: string | null, roles: readonly AuthRole[]): void {
schedulerRole = role; schedulerRoles = roles;
currentRigId = rigId || null; currentRigId = rigId || null;
if (currentRigId) loadScheduler(); if (currentRigId) loadScheduler();
startStatusPolling(); startStatusPolling();
@@ -356,7 +357,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!prevBtn || !nextBtn) return; if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig); const state = schedulerInterleaveState(currentConfig);
const enabled = const enabled =
(schedulerRole === "administrator" || schedulerRole === "control") && hasAuthRole(schedulerRoles, "control") &&
!!currentRigId && !!currentRigId &&
!schedulerStepPending && !schedulerStepPending &&
state.activeEntries.length > 1; state.activeEntries.length > 1;
@@ -466,7 +467,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!panel) return; if (!panel) return;
const mode = (currentConfig && currentConfig.mode) || "disabled"; const mode = (currentConfig && currentConfig.mode) || "disabled";
const isControl = schedulerRole === "administrator" || schedulerRole === "control"; const isControl = hasAuthRole(schedulerRoles, "control");
// Mode selector // Mode selector
setSelected("scheduler-mode-select", mode); setSelected("scheduler-mode-select", mode);
@@ -1574,8 +1575,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// When loaded eagerly, initSettingsUI() in app.js calls initScheduler(); // When loaded eagerly, initSettingsUI() in app.js calls initScheduler();
// when loaded lazily (e.g. settings tab click after boot), the app has // when loaded lazily (e.g. settings tab click after boot), the app has
// already passed that point, so we must self-initialize here. // already passed that point, so we must self-initialize here.
if (hostState.authRole != null) { if (!hostState.authEnabled || hostState.authRoles.length > 0) {
initScheduler(hostState.lastActiveRigId, hostState.authRole); initScheduler(hostState.lastActiveRigId, hostState.authRoles);
wireSchedulerEvents(); wireSchedulerEvents();
} }
})(); })();
@@ -0,0 +1,65 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document */
const ALL_ROLES = ["read", "control", "write", "administrator"];
const fixture = await startWebFixture({
authSession: {
authenticated: true,
username: "admin",
roles: ALL_ROLES,
auth_disabled: false,
},
users: [
{ username: "admin", roles: ALL_ROLES, enabled: true },
{ username: "listener", roles: ["read"], enabled: false },
],
});
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.goto(`${fixture.origin}/settings`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-settings").waitFor({ state: "visible" });
await page.locator("#settings-account-tab").waitFor({ state: "visible" });
await page.locator("#settings-users-tab").waitFor({ state: "visible" });
await page.locator("#settings-account-tab").click();
assert.equal(await page.locator("#account-password-form").isVisible(), true);
assert.equal(await page.locator("#subtab-settings-users").isVisible(), false);
await page.locator("#settings-users-tab").click();
await page.locator("#user-list").getByText("listener (disabled)").waitFor();
assert.equal(await page.locator("#user-create-form").isVisible(), true);
const state = await page.evaluate(() => {
const rows = [...document.querySelectorAll("#user-list > .sch-row")];
const rowFor = (username) => rows.find((row) => row.querySelector("strong")?.textContent.startsWith(username));
const admin = rowFor("admin");
const listener = rowFor("listener");
const role = (row, value) => row?.querySelector(`input[value="${value}"]`);
return {
createRoles: [...document.querySelectorAll("#user-create-roles input")].map((input) => input.value),
adminEnabledLocked: admin?.querySelector('input[type="checkbox"]')?.disabled,
adminRoleLocked: role(admin, "administrator")?.disabled,
adminRemoveLocked: admin?.querySelector("button.danger")?.disabled,
listenerEnabled: listener?.querySelector('input[type="checkbox"]')?.checked,
listenerRead: role(listener, "read")?.checked,
};
});
assert.deepEqual(state.createRoles, ALL_ROLES);
assert.equal(state.adminEnabledLocked, true);
assert.equal(state.adminRoleLocked, true);
assert.equal(state.adminRemoveLocked, true);
assert.equal(state.listenerEnabled, false);
assert.equal(state.listenerRead, true);
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
const source = await bundleEntry(new URL("../src/api/auth.ts", import.meta.url), "AuthApi");
function loadAuth(fetch) {
const context = vm.createContext({ fetch, console });
new vm.Script(source).runInContext(context);
return context.AuthApi;
}
test("role policy is centralized and preserves the Control-to-Read implication", () => {
const auth = loadAuth(async () => { throw new Error("unused"); });
assert.deepEqual(Array.from(auth.AUTH_ROLES), ["read", "control", "write", "administrator"]);
assert.equal(auth.hasAuthRole(["control"], "read"), true);
assert.equal(auth.hasAuthRole(["control"], "write"), false);
assert.equal(auth.hasAuthRole(["administrator"], "write"), true);
});
test("auth responses normalize roles and require the managed-account lifecycle state", async () => {
const replies = new Map([
["/auth/session", { authenticated: true, roles: ["write", "read", "write"], username: "alice" }],
["/auth/users", [{ username: "alice", roles: ["write", "read"], enabled: false }]],
]);
const auth = loadAuth(async (url) => ({
ok: true,
status: 200,
json: async () => replies.get(String(url)),
}));
assert.deepEqual(Array.from((await auth.fetchAuthSession()).roles), ["read", "write"]);
assert.deepEqual(Array.from((await auth.listUsers())[0].roles), ["read", "write"]);
assert.equal((await auth.listUsers())[0].enabled, false);
});
test("changing a password sends current and replacement credentials", async () => {
let request;
const auth = loadAuth(async (url, init) => {
request = { url, init };
return { ok: true, status: 200, json: async () => ({}) };
});
await auth.changeOwnPassword("old-password", "new-password");
assert.equal(request.url, "/auth/account/password");
assert.equal(request.init.method, "PATCH");
assert.deepEqual(JSON.parse(request.init.body), {
current_password: "old-password",
new_password: "new-password",
});
});
@@ -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)); const source = await bundleEntry(new URL("../src/plugins/background-decode.ts", import.meta.url));
new vm.Script(source).runInContext(context); new vm.Script(source).runInContext(context);
window.trx.modules.backgroundDecode.initialize("rig/a", "administrator"); window.trx.modules.backgroundDecode.initialize("rig/a", ["administrator"]);
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
assert.ok(requested.includes("/background-decode/rig%2Fa")); assert.ok(requested.includes("/background-decode/rig%2Fa"));
assert.ok(requested.includes("/bookmarks")); assert.ok(requested.includes("/bookmarks"));
@@ -32,7 +32,6 @@ function hostFixture(overrides = {}) {
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 }; const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
const state = { const state = {
authEnabled: false, authEnabled: false,
authRole: "administrator",
authRoles: ["read", "control", "write", "administrator"], authRoles: ["read", "control", "write", "administrator"],
lastActiveRigId: null, lastActiveRigId: null,
lastRigIds: [], lastRigIds: [],
@@ -158,7 +157,7 @@ test("bookmark controls follow the host authentication state", async () => {
if (!elements.has(id)) elements.set(id, new ElementFixture()); if (!elements.has(id)) elements.set(id, new ElementFixture());
return elements.get(id); return elements.get(id);
}; };
const { window } = hostFixture({ authEnabled: true, authRole: "read", authRoles: ["read"] }); const { window } = hostFixture({ authEnabled: true, authRoles: ["read"] });
const context = vm.createContext({ const context = vm.createContext({
window, window,
document: documentFixture(element), document: documentFixture(element),
@@ -172,7 +171,6 @@ test("bookmark controls follow the host authentication state", async () => {
assert.equal(element("bm-add-btn").style.display, "none"); assert.equal(element("bm-add-btn").style.display, "none");
window.trx.state.authRole = "write";
window.trx.state.authRoles = ["read", "write"]; window.trx.state.authRoles = ["read", "write"];
await window.trx.modules.bookmarks.fetch(""); await window.trx.modules.bookmarks.fetch("");
assert.equal(element("bm-add-btn").style.display, ""); assert.equal(element("bm-add-btn").style.display, "");
@@ -19,7 +19,6 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
serverLat: null, serverLat: null,
serverLon: null, serverLon: null,
authEnabled: false, authEnabled: false,
authRole: "administrator",
authRoles: ["read", "control", "write", "administrator"], authRoles: ["read", "control", "write", "administrator"],
lastActiveRigId: null, lastActiveRigId: null,
lastRigIds: [], lastRigIds: [],
@@ -11,7 +11,7 @@ import { bundleEntry } from "./bundle-entry.mjs";
test("scheduler registers a typed module service without lifecycle globals", async () => { test("scheduler registers a typed module service without lifecycle globals", async () => {
// No role known yet: the entry registers its service and waits for the // No role known yet: the entry registers its service and waits for the
// application to drive initialization. // application to drive initialization.
const window = { ...createHost({ state: { authRole: null } }), trxUi: { confirm: async () => true } }; const window = { ...createHost({ state: { authEnabled: true, authRoles: [] } }), trxUi: { confirm: async () => true } };
const context = vm.createContext({ const context = vm.createContext({
window, window,
document: { document: {
@@ -76,7 +76,7 @@ test("scheduler self-initializes for the active rig when a role is already known
return elements.get(id); return elements.get(id);
}; };
const window = { const window = {
...createHost({ state: { authRole: "administrator", lastActiveRigId: "sdr" } }), ...createHost({ state: { authRoles: ["administrator"], lastActiveRigId: "sdr" } }),
trxUi: { confirm: async () => true }, trxUi: { confirm: async () => true },
}; };
const context = vm.createContext({ const context = vm.createContext({
@@ -42,6 +42,18 @@ test("administrator user management is a dedicated Settings sub-tab", async () =
assert.match(app, /settings-users-tab/); assert.match(app, /settings-users-tab/);
}); });
test("account lifecycle controls include self-service passwords and enable state", async () => {
const [html, app] = await Promise.all([
readFile(indexPath, "utf8"),
readFile(appPath, "utf8"),
]);
assert.match(html, /data-subtab="settings-account"/);
assert.match(html, /id="account-password-form"/);
assert.match(html, /id="user-create-enabled"/);
assert.match(app, /changeOwnPassword/);
assert.match(app, /enabledAdminCount/);
});
test("lazy frontend features use modules and local map symbols", async () => { test("lazy frontend features use modules and local map symbols", async () => {
const [loader, map] = await Promise.all([ const [loader, map] = await Promise.all([
readFile(pluginLoaderPath, "utf8"), readFile(pluginLoaderPath, "utf8"),
@@ -145,6 +145,8 @@ export async function startWebFixture({
bandplanEnabled = false, bandplanEnabled = false,
bandplanUnauthorizedFirst = false, bandplanUnauthorizedFirst = false,
satPasses = null, satPasses = null,
authSession = { authenticated: true, roles: ["read", "control", "write", "administrator"], auth_disabled: true },
users = [],
} = {}) { } = {}) {
const rigItems = ["rig-a", "rig-b"].map((remote) => ({ const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote, remote,
@@ -232,7 +234,8 @@ export async function startWebFixture({
}; };
const jsonRoutes = new Map([ const jsonRoutes = new Map([
["/auth/session", { authenticated: true, roles: ["read", "control", "write", "administrator"], auth_disabled: true }], ["/auth/session", authSession],
["/auth/users", users],
["/decoders", DECODER_REGISTRY], ["/decoders", DECODER_REGISTRY],
["/rigs", rigsResponse], ["/rigs", rigsResponse],
["/status", status], ["/status", status],
@@ -713,6 +713,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(crate::server::auth::login) .service(crate::server::auth::login)
.service(crate::server::auth::logout) .service(crate::server::auth::logout)
.service(crate::server::auth::session_status) .service(crate::server::auth::session_status)
.service(crate::server::auth::change_own_password)
.service(crate::server::auth::list_users) .service(crate::server::auth::list_users)
.service(crate::server::auth::create_user) .service(crate::server::auth::create_user)
.service(crate::server::auth::update_user) .service(crate::server::auth::update_user)
@@ -964,9 +965,6 @@ mod tests {
std::path::PathBuf::from("unused-users.json"), std::path::PathBuf::from("unused-users.json"),
None, None,
None, None,
false,
"guest".to_string(),
None,
std::time::Duration::from_secs(3600), std::time::Duration::from_secs(3600),
false, false,
crate::server::auth::SameSite::Lax, crate::server::auth::SameSite::Lax,
@@ -980,10 +978,10 @@ mod tests {
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new( crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
true, true,
directory.path().join("users.json"), directory.path().join("users.json"),
Some("admin".to_string()), Some(crate::server::auth::BootstrapAccount::new(
Some("password123".to_string()), "admin".to_string(),
false, "password123".to_string(),
"guest".to_string(), )),
None, None,
std::time::Duration::from_secs(3600), std::time::Duration::from_secs(3600),
false, false,
File diff suppressed because it is too large Load Diff
@@ -252,14 +252,31 @@ fn build_server(
"None" => SameSite::None, "None" => SameSite::None,
_ => SameSite::Lax, // default _ => SameSite::Lax, // default
}; };
let bootstrap_admin = auth::BootstrapAccount::from_parts(
context.http_auth.bootstrap_admin_username.clone(),
context.http_auth.bootstrap_admin_password.clone(),
);
let bootstrap_read = context
.http_auth
.bootstrap_read_enabled
.then(|| {
context
.http_auth
.bootstrap_read_password
.clone()
.map(|password| {
auth::BootstrapAccount::new(
context.http_auth.bootstrap_read_username.clone(),
password,
)
})
})
.flatten();
let auth_config = AuthConfig::new( let auth_config = AuthConfig::new(
context.http_auth.enabled, context.http_auth.enabled,
context.http_auth.users_file.clone().into(), context.http_auth.users_file.clone().into(),
context.http_auth.bootstrap_admin_username.clone(), bootstrap_admin,
context.http_auth.bootstrap_admin_password.clone(), bootstrap_read,
context.http_auth.bootstrap_read_enabled,
context.http_auth.bootstrap_read_username.clone(),
context.http_auth.bootstrap_read_password.clone(),
Duration::from_secs(context.http_auth.session_ttl_secs), Duration::from_secs(context.http_auth.session_ttl_secs),
context.http_auth.cookie_secure, context.http_auth.cookie_secure,
same_site, same_site,