diff --git a/docs/Architecture.md b/docs/Architecture.md
index 2fc29f6f..845bf0ed 100644
--- a/docs/Architecture.md
+++ b/docs/Architecture.md
@@ -925,9 +925,12 @@ main
### HTTP Frontend Auth
-- Optional token or HTTP Basic Auth middleware
-- Configured in `[frontends.http.auth]`
-- Rate limiting supported
+- Optional Argon2id-backed managed accounts with HttpOnly session cookies
+- Composable Read, Control, Write, and Administrator roles, with policy shared by middleware and handlers
+- 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
diff --git a/docs/UX_Guidelines.md b/docs/UX_Guidelines.md
index 724b6eee..a4f0f966 100644
--- a/docs/UX_Guidelines.md
+++ b/docs/UX_Guidelines.md
@@ -354,8 +354,11 @@ Routes are classified into three tiers:
### 7.3 User Management
-Only administrators can list, add, update, or remove accounts. The final
-administrator cannot be removed or demoted.
+Every authenticated account gets a Settings > Account tab for changing its own
+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.
---
diff --git a/docs/User-Manual.md b/docs/User-Manual.md
index ffc9a076..b4c65650 100644
--- a/docs/User-Manual.md
+++ b/docs/User-Manual.md
@@ -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.
- 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.
-- Administrators can add/remove users and change roles/passwords in Settings > Users.
-- At least one administrator must always remain and cannot be removed or demoted.
-- Removing an account or changing its password/role revokes its sessions.
+- Every signed-in user can change their own password in Settings > Account. This signs out all of their sessions.
+- Administrators can add, enable/disable, or remove users and change roles/passwords in Settings > Users.
+- 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
@@ -637,8 +639,9 @@ credentials in configuration before first startup on an exposed deployment.
| `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` |
| `/auth/logout` | POST | Clear session |
| `/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/{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
bookmark mutations require Write. Administrator grants every permission.
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js
index 68cc3be9..46e71af0 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js
@@ -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
(function initTrxWebGl(global) {
"use strict";
@@ -1314,91 +1329,6 @@ async function loadDecoderRegistry(onLoaded) {
bridge.decoderRegistry = decoderRegistry;
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
function formatDuration(milliseconds) {
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");
}
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
-var authRole = null;
var authRoles = [];
var authUsername = null;
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) {
- authRoles = [...new Set(roles)];
- authRole = ["administrator", "control", "write", "read"].find((role) => authRoles.includes(role)) ?? null;
+ authRoles = normalizeAuthRoles(roles);
}
-function hasAuthRole(role) {
- return authRoles.includes("administrator") || authRoles.includes(role);
+function hasAuthRole2(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() {
return fetchAuthSession();
@@ -1986,13 +1923,16 @@ function updateAuthUI() {
const badge = document.getElementById("auth-badge");
const badgeRole = document.getElementById("auth-role-badge");
const headerAuthBtn2 = document.getElementById("header-auth-btn");
+ const accountTab = document.getElementById("settings-account-tab");
if (!authEnabled) {
if (badge) badge.style.display = "none";
if (headerAuthBtn2) headerAuthBtn2.style.display = "none";
+ if (accountTab) accountTab.style.display = "none";
syncTopBarAccess();
return;
}
if (authRoles.length > 0) {
+ if (accountTab) accountTab.style.display = "";
if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`;
if (headerAuthBtn2) {
@@ -2000,6 +1940,7 @@ function updateAuthUI() {
headerAuthBtn2.style.display = "block";
}
} else {
+ if (accountTab) accountTab.style.display = "none";
if (badge) badge.style.display = "none";
if (headerAuthBtn2) {
headerAuthBtn2.textContent = "Login";
@@ -2010,7 +1951,7 @@ function updateAuthUI() {
}
function applyAuthRestrictions() {
if (authRoles.length === 0) return;
- if (!hasAuthRole("control")) {
+ if (!hasAuthRole2("control")) {
const pttBtn2 = document.getElementById("ptt-btn");
const powerBtn2 = document.getElementById("power-btn");
const lockBtn2 = document.getElementById("lock-btn");
@@ -2306,7 +2247,7 @@ function syncTopBarAccess() {
if (tabBar) tabBar.style.display = "";
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
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.disabled = false;
});
@@ -2314,7 +2255,7 @@ function syncTopBarAccess() {
rigSwitch.style.display = loggedOut ? "none" : "";
}
if (headerRigSwitchSelect) {
- headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole("control") || lastRigIds.length === 0;
+ headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole2("control") || lastRigIds.length === 0;
}
}
var overviewDrawPending = false;
@@ -2950,7 +2891,7 @@ function applyRigList(activeRigId, rigIds, displayNames) {
}
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
const rigListChanged = prevKey !== nextKey;
- const disableSwitch = lastRigIds.length === 0 || !hasAuthRole("control");
+ const disableSwitch = lastRigIds.length === 0 || !hasAuthRole2("control");
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
@@ -4553,7 +4494,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link) {
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
if (!wanted) return;
- if (!hasAuthRole("control")) {
+ if (!hasAuthRole2("control")) {
showHint("Read-only session — link not applied", 2500);
return;
}
@@ -5296,7 +5237,7 @@ async function switchRigFromSelect(selectEl) {
showHint("No rig selected", 1500);
return;
}
- if (!hasAuthRole("control")) {
+ if (!hasAuthRole2("control")) {
showHint("Control role required", 1500);
return;
}
@@ -5914,7 +5855,7 @@ function navigateToTab(name, options = {}) {
showAuthGate();
return;
}
- if (authEnabled && name === "logbook" && !hasAuthRole("write")) {
+ if (authEnabled && name === "logbook" && !hasAuthRole2("write")) {
showHint("Write role required for logbook access", 2500);
navigateToTab("main", options);
return;
@@ -6033,7 +5974,7 @@ async function initializeApp() {
const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled;
if (!authEnabled) {
- setAuthRoles(ALL_AUTH_ROLES);
+ setAuthRoles(AUTH_ROLES);
hideAuthGate();
updateAuthUI();
connect();
@@ -6061,10 +6002,10 @@ async function initializeApp() {
var settingsUiReady = false;
function initSettingsUI() {
settingsUiReady = true;
- window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
+ window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) {
- window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
+ window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
window.trx.modules.backgroundDecode.wireEvents();
}
void refreshUserManagement();
@@ -6073,7 +6014,7 @@ async function refreshUserManagement() {
const section = document.getElementById("user-management");
const tab = document.getElementById("settings-users-tab");
if (!section || !tab) return;
- const canManageUsers = authEnabled && hasAuthRole("administrator");
+ const canManageUsers = authEnabled && hasAuthRole2("administrator");
tab.style.display = canManageUsers ? "" : "none";
if (!canManageUsers) {
const panel = document.getElementById("subtab-settings-users");
@@ -6086,7 +6027,7 @@ async function refreshUserManagement() {
const list = requiredElement("user-list");
try {
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) => {
const row = document.createElement("div");
row.className = "sch-row";
@@ -6094,37 +6035,40 @@ async function refreshUserManagement() {
const name = document.createElement("strong");
name.textContent = user.username;
name.style.minWidth = "10rem";
- const roleInputs = ALL_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 = user.roles.includes(value);
- label.append(input, ` ${AUTH_ROLE_LABELS[value]}`);
- return { label, input, value };
- });
- const roles = document.createElement("span");
- roles.className = "auth-role-choices";
- roles.append(...roleInputs.map(({ label }) => label));
- const isOnlyAdmin = user.roles.includes("administrator") && adminCount === 1;
+ if (!user.enabled) name.textContent += " (disabled)";
+ const { element: roles, inputs: roleInputs } = buildRoleChoices(user.roles);
+ const enabledLabel = document.createElement("label");
+ enabledLabel.className = "auth-role-choice";
+ const enabled = document.createElement("input");
+ enabled.type = "checkbox";
+ enabled.checked = user.enabled;
+ enabled.disabled = user.username === authUsername;
+ if (enabled.disabled) enabled.title = "You cannot disable your current account";
+ enabledLabel.append(enabled, " Enabled");
+ const isOnlyAdmin = user.enabled && hasAuthRole(user.roles, "administrator") && enabledAdminCount === 1;
const administratorInput = roleInputs.find((item) => item.value === "administrator")?.input;
if (isOnlyAdmin && administratorInput) {
administratorInput.disabled = true;
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");
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";
save.addEventListener("click", async () => {
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;
await runUserOperation(() => updateUser(user.username, changes));
@@ -6140,7 +6084,7 @@ async function refreshUserManagement() {
await runUserOperation(() => deleteUser(user.username));
}
});
- row.append(name, roles, password, save, remove);
+ row.append(name, enabledLabel, roles, password, save, remove);
return row;
}));
} catch (error) {
@@ -6163,20 +6107,54 @@ async function runUserOperation(operation) {
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) => {
event.preventDefault();
const username = requiredElement("user-create-username");
const password = requiredElement("user-create-password");
+ const enabled = requiredElement("user-create-enabled");
const roles = Array.from(document.querySelectorAll("#user-create-roles input[type=checkbox]"));
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 = "";
password.value = "";
+ enabled.checked = true;
roles.forEach((input) => {
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) => {
e.preventDefault();
const usernameEl = requiredElement("auth-username");
@@ -6252,9 +6230,6 @@ Object.defineProperties(trxState, {
authEnabled: { get() {
return authEnabled;
} },
- authRole: { get() {
- return authRole;
- } },
authRoles: { get() {
return authRoles;
} },
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js
index dd44eaa3..c30098cf 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js
@@ -1,3 +1,6 @@
+import {
+ hasAuthRole
+} from "./chunk-BB2X7SND.js";
import {
hostState
} from "./chunk-KL66PICH.js";
@@ -13,7 +16,7 @@ var bgdWindow = window;
return d.id;
});
}
- let backgroundDecodeRole = null;
+ let backgroundDecodeRoles = [];
let currentRigId = null;
let currentConfig = null;
let bookmarkList = [];
@@ -21,8 +24,8 @@ var bgdWindow = window;
let bgdDirty = false;
let statusByBookmark = /* @__PURE__ */ new Map();
let lastStatus = null;
- function initBackgroundDecode(rigId, role) {
- backgroundDecodeRole = role;
+ function initBackgroundDecode(rigId, roles) {
+ backgroundDecodeRoles = roles;
currentRigId = rigId || hostState.lastActiveRigId || null;
if (currentRigId) loadBackgroundDecode();
startStatusPolling();
@@ -358,7 +361,7 @@ var bgdWindow = window;
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
}
function isControlRole() {
- return backgroundDecodeRole === "administrator" || backgroundDecodeRole === "control" || hostState.authEnabled === false;
+ return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
}
function showToast(msg, isError) {
const el = document.getElementById("background-decode-toast");
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js
index 270973ae..173a4eaa 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js
@@ -1,3 +1,6 @@
+import {
+ hasAuthRole
+} from "./chunk-BB2X7SND.js";
import {
hostCore,
hostState
@@ -42,7 +45,7 @@ function bmEsc(str) {
return d.innerHTML;
}
function bmCanControl() {
- return !hostState.authEnabled || hostState.authRoles.includes("administrator") || hostState.authRoles.includes("write");
+ return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
}
function bmSyncAccess() {
const canCtrl = bmCanControl();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-BB2X7SND.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-BB2X7SND.js
new file mode 100644
index 00000000..4de8b509
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-BB2X7SND.js
@@ -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
+};
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js
index 4861c49f..89a1b8f8 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js
@@ -1,3 +1,6 @@
+import {
+ hasAuthRole
+} from "./chunk-BB2X7SND.js";
import {
hostCore,
hostState
@@ -48,7 +51,7 @@ var entryGrid = null;
var qsos = [];
var workedRequest = 0;
function canWriteLogbook() {
- return !hostState.authEnabled || hostState.authRoles.includes("administrator") || hostState.authRoles.includes("write");
+ return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
}
function notify(message, kind) {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0);
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js
index 9bbceb02..255e46b0 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js
@@ -1,3 +1,6 @@
+import {
+ hasAuthRole
+} from "./chunk-BB2X7SND.js";
import {
hostState
} from "./chunk-KL66PICH.js";
@@ -15,7 +18,7 @@ function schedulerOptionalEl(id) {
}
(function() {
"use strict";
- let schedulerRole = null;
+ let schedulerRoles = [];
let currentRigId = null;
let currentConfig = null;
let currentSchedulerStatus = null;
@@ -25,8 +28,8 @@ function schedulerOptionalEl(id) {
let schedulerStepPending = false;
let schEntryEditIdx = null;
let schedulerDirty = false;
- function initScheduler(rigId, role) {
- schedulerRole = role;
+ function initScheduler(rigId, roles) {
+ schedulerRoles = roles;
currentRigId = rigId || null;
if (currentRigId) loadScheduler();
startStatusPolling();
@@ -272,7 +275,7 @@ function schedulerOptionalEl(id) {
const nextBtn = schedulerEl("scheduler-next-btn");
if (!prevBtn || !nextBtn) return;
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;
nextBtn.disabled = !enabled;
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");
if (!panel) return;
const mode = currentConfig && currentConfig.mode || "disabled";
- const isControl = schedulerRole === "administrator" || schedulerRole === "control";
+ const isControl = hasAuthRole(schedulerRoles, "control");
setSelected("scheduler-mode-select", mode);
const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled;
const controlRow = document.querySelector(".scheduler-control-row");
@@ -1220,8 +1223,8 @@ function schedulerOptionalEl(id) {
markDirty: markSchedulerDirty
};
schedulerWindow.trx.modules.scheduler = schedulerService;
- if (hostState.authRole != null) {
- initScheduler(hostState.lastActiveRigId, hostState.authRole);
+ if (!hostState.authEnabled || hostState.authRoles.length > 0) {
+ initScheduler(hostState.lastActiveRigId, hostState.authRoles);
wireSchedulerEvents();
}
})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
index afd03ece..d47094ae 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
@@ -127,7 +127,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
@@ -1461,6 +1461,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
@@ -1751,18 +1752,27 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
+
+
Change password
+
+
+
Changing your password signs out every session for this account.
+
+
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
index a2e3fde7..4d19b651 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
@@ -218,6 +218,7 @@ body {
#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-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; }
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs b/src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs
index e7dc2a48..31ffbbf7 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs
@@ -15,6 +15,7 @@ use trx_core::rig::{
use trx_core::{DecoderConfig, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel};
use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse};
use trx_frontend_http::server::api::FrontendMeta;
+use trx_frontend_http::server::auth::AuthRole;
use trx_protocol::{DecoderActivation, DecoderDescriptor};
use ts_rs::{Config, TS};
@@ -56,6 +57,7 @@ fn main() -> Result<(), Box
> {
export!(RigListItem);
export!(RigListResponse);
export!(FrontendMeta);
+ export!(AuthRole);
export!(DecoderActivation);
export!(DecoderDescriptor);
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json
index 043c1eb8..b008f062 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"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"
},
"devDependencies": {
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/auth.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/auth.ts
index caad0104..8534f8f4 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/auth.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/auth.ts
@@ -2,7 +2,38 @@
//
// 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> = {
+ 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 {
authenticated: boolean;
@@ -19,14 +50,13 @@ function decodeAuthSession(value: unknown): AuthSession {
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 !== undefined && typeof session.auth_disabled !== "boolean") {
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 (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
decoded.username = session.username;
@@ -67,7 +97,7 @@ export async function login(username: string, password: string): Promise {
const response = await fetch(path, init);
@@ -83,22 +113,32 @@ export async function listUsers(): Promise {
if (!Array.isArray(value) || !value.every((user: unknown) => {
if (typeof user !== "object" || user === null) return false;
const record = user as Record;
- return typeof record.username === "string" && Array.isArray(record.roles)
- && record.roles.every(role => role === "read" || role === "control" || role === "write" || role === "administrator");
+ 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 as ManagedUser[];
+ return (value as ManagedUser[]).map(user => ({ ...user, roles: normalizeAuthRoles(user.roles) }));
}
-export async function createUser(username: string, password: string, roles: AuthRole[]): Promise {
- await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles }) });
+export async function createUser(username: string, password: string, roles: AuthRole[], enabled = true): Promise {
+ 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 {
+export async function updateUser(username: string, changes: { password?: string; roles?: AuthRole[]; enabled?: boolean }): Promise {
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 {
+ 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 {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
}
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts
index 79d3cd4d..31191c98 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts
@@ -123,6 +123,8 @@ export type RigListResponse = { active_remote: string | null, rigs: Array, 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 DecoderDescriptor = {
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts
index 9101dee8..0a0d4b53 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts
@@ -25,6 +25,11 @@ import {
createUser,
updateUser,
deleteUser,
+ changeOwnPassword,
+ AUTH_ROLES,
+ AUTH_ROLE_LABELS,
+ hasAuthRole as rolesInclude,
+ normalizeAuthRoles,
} from "./api/auth.js";
import {
formatByteSize as recorderFormatSize,
@@ -193,8 +198,8 @@ interface TrxModules {
reverseGeocodeLocation(lat: number, lon: number, grid: string): void;
bandForHz(frequencyHz: number): unknown;
};
- scheduler?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void };
- backgroundDecode?: { 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, roles: readonly AuthRole[]): void; setRig(rigId: string | null): void; wireEvents(): void };
bookmarks?: {
readonly overlayList: readonly Bookmark[];
readonly overlayRevision: number;
@@ -230,7 +235,6 @@ interface TrxState {
readonly initialMapZoom: number;
readonly decodeHistoryRetentionMin: number;
readonly authEnabled: boolean;
- readonly authRole: AuthRole | null;
readonly authRoles: readonly AuthRole[];
readonly decoderRegistry: typeof decoderRegistry;
readonly sseSessionId: string | null;
@@ -411,27 +415,33 @@ declare global {
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
// --- Authentication ---
-let authRole: AuthRole | null = null;
let authRoles: AuthRole[] = [];
let authUsername: string | null = null;
let authEnabled = true;
-const ALL_AUTH_ROLES: readonly AuthRole[] = ["read", "control", "write", "administrator"];
-const AUTH_ROLE_LABELS: Record = {
- read: "Read",
- control: "Control",
- write: "Write",
- administrator: "Administrator",
-};
-
function setAuthRoles(roles: readonly AuthRole[]) {
- authRoles = [...new Set(roles)];
- authRole = (["administrator", "control", "write", "read"] as AuthRole[])
- .find(role => authRoles.includes(role)) ?? null;
+ authRoles = normalizeAuthRoles(roles);
}
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() {
@@ -523,15 +533,18 @@ function updateAuthUI() {
const badge = document.getElementById("auth-badge");
const badgeRole = document.getElementById("auth-role-badge");
const headerAuthBtn = document.getElementById("header-auth-btn");
+ const accountTab = document.getElementById("settings-account-tab");
if (!authEnabled) {
if (badge) badge.style.display = "none";
if (headerAuthBtn) headerAuthBtn.style.display = "none";
+ if (accountTab) accountTab.style.display = "none";
syncTopBarAccess();
return;
}
if (authRoles.length > 0) {
+ if (accountTab) accountTab.style.display = "";
if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`;
if (headerAuthBtn) {
@@ -539,6 +552,7 @@ function updateAuthUI() {
headerAuthBtn.style.display = "block";
}
} else {
+ if (accountTab) accountTab.style.display = "none";
if (badge) badge.style.display = "none";
if (headerAuthBtn) {
headerAuthBtn.textContent = "Login";
@@ -5036,7 +5050,7 @@ async function initializeApp() {
authEnabled = !authStatus.auth_disabled;
if (!authEnabled) {
- setAuthRoles(ALL_AUTH_ROLES);
+ setAuthRoles(AUTH_ROLES);
hideAuthGate();
updateAuthUI();
connect();
@@ -5070,10 +5084,10 @@ let settingsUiReady = false;
function initSettingsUI() {
settingsUiReady = true;
- window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
+ window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) {
- window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
+ window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
window.trx.modules.backgroundDecode.wireEvents();
}
void refreshUserManagement();
@@ -5096,7 +5110,7 @@ async function refreshUserManagement() {
const list = requiredElement("user-list");
try {
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) => {
const row = document.createElement("div");
row.className = "sch-row";
@@ -5104,31 +5118,35 @@ async function refreshUserManagement() {
const name = document.createElement("strong");
name.textContent = user.username;
name.style.minWidth = "10rem";
- const roleInputs = ALL_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 = user.roles.includes(value);
- label.append(input, ` ${AUTH_ROLE_LABELS[value]}`);
- return { label, input, value };
- });
- const roles = document.createElement("span");
- roles.className = "auth-role-choices";
- roles.append(...roleInputs.map(({ label }) => label));
- const isOnlyAdmin = user.roles.includes("administrator") && adminCount === 1;
+ if (!user.enabled) name.textContent += " (disabled)";
+ const { element: roles, inputs: roleInputs } = buildRoleChoices(user.roles);
+ const enabledLabel = document.createElement("label");
+ enabledLabel.className = "auth-role-choice";
+ const enabled = document.createElement("input");
+ enabled.type = "checkbox";
+ enabled.checked = user.enabled;
+ enabled.disabled = user.username === authUsername;
+ if (enabled.disabled) enabled.title = "You cannot disable your current account";
+ enabledLabel.append(enabled, " Enabled");
+ const isOnlyAdmin = user.enabled
+ && rolesInclude(user.roles, "administrator")
+ && enabledAdminCount === 1;
const administratorInput = roleInputs.find(item => item.value === "administrator")?.input;
if (isOnlyAdmin && administratorInput) {
administratorInput.disabled = true;
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");
- 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";
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),
+ enabled: enabled.checked,
};
if (password.value) changes.password = password.value;
await runUserOperation(() => updateUser(user.username, changes));
@@ -5141,7 +5159,7 @@ async function refreshUserManagement() {
await runUserOperation(() => deleteUser(user.username));
}
});
- row.append(name, roles, password, save, remove);
+ row.append(name, enabledLabel, roles, password, save, remove);
return row;
}));
} catch (error) {
@@ -5167,18 +5185,57 @@ async function runUserOperation(operation: () => Promise) {
}
}
+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) => {
event.preventDefault();
const username = requiredElement("user-create-username");
const password = requiredElement("user-create-password");
+ const enabled = requiredElement("user-create-enabled");
const roles = Array.from(document.querySelectorAll("#user-create-roles input[type=checkbox]"));
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 = "";
+ enabled.checked = true;
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("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.", 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
requiredElement("auth-form").addEventListener("submit", async (e) => {
e.preventDefault();
@@ -5244,7 +5301,6 @@ Object.defineProperties(trxState, {
initialMapZoom: { get() { return initialMapZoom; } },
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
authEnabled: { get() { return authEnabled; } },
- authRole: { get() { return authRole; } },
authRoles: { get() { return authRoles; } },
decoderRegistry: { get() { return decoderRegistry; } },
sseSessionId: { get() { return sseSessionId; } },
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.ts
index 3055a153..230f0833 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.ts
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
+import { hasAuthRole, type AuthRole } from "../api/auth.js";
export {};
@@ -44,7 +45,7 @@ interface BackgroundBridge {
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
}
interface BackgroundDecodeService {
- initialize(rigId: string | null, role: string | null): void;
+ initialize(rigId: string | null, roles: readonly AuthRole[]): void;
wireEvents(): void;
setRig(rigId: string | null): void;
}
@@ -60,7 +61,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
.map(function (d) { return d.id; });
}
- let backgroundDecodeRole: string | null = null;
+ let backgroundDecodeRoles: readonly AuthRole[] = [];
let currentRigId: string | null = null;
let currentConfig: BackgroundDecodeConfig | null = null;
let bookmarkList: Bookmark[] = [];
@@ -70,8 +71,8 @@ const bgdWindow = window as unknown as BackgroundBridge;
let statusByBookmark = new Map();
let lastStatus: BackgroundDecodeStatus | null = null;
- function initBackgroundDecode(rigId: string | null, role: string | null): void {
- backgroundDecodeRole = role;
+ function initBackgroundDecode(rigId: string | null, roles: readonly AuthRole[]): void {
+ backgroundDecodeRoles = roles;
// 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
// 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 {
- return backgroundDecodeRole === "administrator"
- || backgroundDecodeRole === "control"
- || hostState.authEnabled === false;
+ return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
}
function showToast(msg: string, isError: boolean): void {
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts
index 957baead..5c990b00 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
+import { hasAuthRole } from "../api/auth.js";
export {};
@@ -101,8 +102,7 @@ function bmEsc(str: unknown): string {
function bmCanControl() {
return !hostState.authEnabled
- || hostState.authRoles.includes("administrator")
- || hostState.authRoles.includes("write");
+ || hasAuthRole(hostState.authRoles, "write");
}
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/host.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/host.ts
index 9a0f35cb..859ca1ef 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/host.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/host.ts
@@ -11,6 +11,8 @@
// feature bundles from re-deriving it — and from drifting back to bare `window`
// properties, which the module graph no longer publishes.
+import type { AuthRole } from "../api/auth.js";
+
export interface HostDecoderDescriptor {
id: string;
label: string;
@@ -25,8 +27,7 @@ export interface HostState {
/** The callsign this station is on the air with, from the client config. */
readonly ownerCallsign: string | null;
readonly authEnabled: boolean;
- readonly authRole: string | null;
- readonly authRoles: readonly string[];
+ readonly authRoles: readonly AuthRole[];
readonly lastActiveRigId: string | null;
readonly lastRigIds: string[];
readonly lastRigDisplayNames: Record;
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/logbook.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/logbook.ts
index d0af4c70..3669f79d 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/logbook.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/logbook.ts
@@ -10,6 +10,7 @@
// keeping if the times in it are the radio's.
import { hostCore, hostState } from "./host.js";
+import { hasAuthRole } from "../api/auth.js";
export {};
@@ -122,8 +123,7 @@ let workedRequest = 0;
function canWriteLogbook(): boolean {
return !hostState.authEnabled
- || hostState.authRoles.includes("administrator")
- || hostState.authRoles.includes("write");
+ || hasAuthRole(hostState.authRoles, "write");
}
function notify(message: string, kind?: string): void {
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler-types.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler-types.ts
index e4ae35f6..aab46e89 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler-types.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler-types.ts
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import type { SatelliteScheduleConfig, SatelliteSchedulerApi } from "./satellite-types.js";
+import type { AuthRole } from "../api/auth.js";
export type SchedulerMode = "disabled" | "grayline" | "time_span";
@@ -60,7 +61,7 @@ export interface SchedulerStatus {
}
export interface SchedulerService {
- initialize(rigId: string | null, role: string | null): void;
+ initialize(rigId: string | null, roles: readonly AuthRole[]): void;
destroy(): void;
setRig(rigId: string | null): void;
wireEvents(): void;
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.ts
index af3e79a6..c69c4eae 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.ts
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
+import { hasAuthRole, type AuthRole } from "../api/auth.js";
import type {
ScheduleEntry,
@@ -43,7 +44,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// -------------------------------------------------------------------------
// State
// -------------------------------------------------------------------------
- let schedulerRole: string | null = null;
+ let schedulerRoles: readonly AuthRole[] = [];
let currentRigId: string | null = null;
let currentConfig: SchedulerConfig | null = null;
let currentSchedulerStatus: SchedulerStatus | null = null;
@@ -58,8 +59,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
- function initScheduler(rigId: string | null, role: string | null): void {
- schedulerRole = role;
+ function initScheduler(rigId: string | null, roles: readonly AuthRole[]): void {
+ schedulerRoles = roles;
currentRigId = rigId || null;
if (currentRigId) loadScheduler();
startStatusPolling();
@@ -356,7 +357,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig);
const enabled =
- (schedulerRole === "administrator" || schedulerRole === "control") &&
+ hasAuthRole(schedulerRoles, "control") &&
!!currentRigId &&
!schedulerStepPending &&
state.activeEntries.length > 1;
@@ -466,7 +467,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!panel) return;
const mode = (currentConfig && currentConfig.mode) || "disabled";
- const isControl = schedulerRole === "administrator" || schedulerRole === "control";
+ const isControl = hasAuthRole(schedulerRoles, "control");
// Mode selector
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 lazily (e.g. settings tab click after boot), the app has
// already passed that point, so we must self-initialize here.
- if (hostState.authRole != null) {
- initScheduler(hostState.lastActiveRigId, hostState.authRole);
+ if (!hostState.authEnabled || hostState.authRoles.length > 0) {
+ initScheduler(hostState.lastActiveRigId, hostState.authRoles);
wireSchedulerEvents();
}
})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/account-management.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/account-management.mjs
new file mode 100644
index 00000000..7bdb8959
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/account-management.mjs
@@ -0,0 +1,65 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// 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();
+}
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/auth.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/auth.test.mjs
new file mode 100644
index 00000000..f79d36f6
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/auth.test.mjs
@@ -0,0 +1,56 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// 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",
+ });
+});
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/background-decode.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/background-decode.test.mjs
index e96cf383..d370a40f 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/background-decode.test.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/background-decode.test.mjs
@@ -40,7 +40,7 @@ test("background decode loads configuration for the explicitly selected rig", as
const source = await bundleEntry(new URL("../src/plugins/background-decode.ts", import.meta.url));
new vm.Script(source).runInContext(context);
- window.trx.modules.backgroundDecode.initialize("rig/a", "administrator");
+ window.trx.modules.backgroundDecode.initialize("rig/a", ["administrator"]);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.ok(requested.includes("/background-decode/rig%2Fa"));
assert.ok(requested.includes("/bookmarks"));
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs
index 8cd68a12..72145ef3 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs
@@ -32,7 +32,6 @@ function hostFixture(overrides = {}) {
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
const state = {
authEnabled: false,
- authRole: "administrator",
authRoles: ["read", "control", "write", "administrator"],
lastActiveRigId: null,
lastRigIds: [],
@@ -158,7 +157,7 @@ test("bookmark controls follow the host authentication state", async () => {
if (!elements.has(id)) elements.set(id, new ElementFixture());
return elements.get(id);
};
- const { window } = hostFixture({ authEnabled: true, authRole: "read", authRoles: ["read"] });
+ const { window } = hostFixture({ authEnabled: true, authRoles: ["read"] });
const context = vm.createContext({
window,
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");
- window.trx.state.authRole = "write";
window.trx.state.authRoles = ["read", "write"];
await window.trx.modules.bookmarks.fetch("");
assert.equal(element("bm-add-btn").style.display, "");
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/host-fixture.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/host-fixture.mjs
index c7000a25..14778877 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/host-fixture.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/host-fixture.mjs
@@ -19,7 +19,6 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
serverLat: null,
serverLon: null,
authEnabled: false,
- authRole: "administrator",
authRoles: ["read", "control", "write", "administrator"],
lastActiveRigId: null,
lastRigIds: [],
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/scheduler.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/scheduler.test.mjs
index 3908b384..e97aaa9c 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/scheduler.test.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/scheduler.test.mjs
@@ -11,7 +11,7 @@ import { bundleEntry } from "./bundle-entry.mjs";
test("scheduler registers a typed module service without lifecycle globals", async () => {
// No role known yet: the entry registers its service and waits for the
// 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({
window,
document: {
@@ -76,7 +76,7 @@ test("scheduler self-initializes for the active rig when a role is already known
return elements.get(id);
};
const window = {
- ...createHost({ state: { authRole: "administrator", lastActiveRigId: "sdr" } }),
+ ...createHost({ state: { authRoles: ["administrator"], lastActiveRigId: "sdr" } }),
trxUi: { confirm: async () => true },
};
const context = vm.createContext({
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs
index fb08b116..1d2db445 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs
@@ -42,6 +42,18 @@ test("administrator user management is a dedicated Settings sub-tab", async () =
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 () => {
const [loader, map] = await Promise.all([
readFile(pluginLoaderPath, "utf8"),
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs
index 755f49e6..6b7a6edd 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs
@@ -145,6 +145,8 @@ export async function startWebFixture({
bandplanEnabled = false,
bandplanUnauthorizedFirst = false,
satPasses = null,
+ authSession = { authenticated: true, roles: ["read", "control", "write", "administrator"], auth_disabled: true },
+ users = [],
} = {}) {
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote,
@@ -232,7 +234,8 @@ export async function startWebFixture({
};
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],
["/rigs", rigsResponse],
["/status", status],
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs
index ac5b4273..31a5270c 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs
@@ -713,6 +713,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(crate::server::auth::login)
.service(crate::server::auth::logout)
.service(crate::server::auth::session_status)
+ .service(crate::server::auth::change_own_password)
.service(crate::server::auth::list_users)
.service(crate::server::auth::create_user)
.service(crate::server::auth::update_user)
@@ -964,9 +965,6 @@ mod tests {
std::path::PathBuf::from("unused-users.json"),
None,
None,
- false,
- "guest".to_string(),
- None,
std::time::Duration::from_secs(3600),
false,
crate::server::auth::SameSite::Lax,
@@ -980,10 +978,10 @@ mod tests {
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
true,
directory.path().join("users.json"),
- Some("admin".to_string()),
- Some("password123".to_string()),
- false,
- "guest".to_string(),
+ Some(crate::server::auth::BootstrapAccount::new(
+ "admin".to_string(),
+ "password123".to_string(),
+ )),
None,
std::time::Duration::from_secs(3600),
false,
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs
index 710377dc..0b87695c 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs
@@ -30,7 +30,9 @@ use tracing::warn;
pub type SessionId = String;
/// Authentication role
-#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+#[derive(
+ Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ts_rs::TS,
+)]
#[serde(rename_all = "lowercase")]
pub enum AuthRole {
Read,
@@ -54,15 +56,25 @@ impl AuthRole {
.into_iter()
.collect()
}
+
+ fn grants(self, required: Self) -> bool {
+ self == Self::Administrator
+ || self == required
+ || self == Self::Control && required == Self::Read
+ }
}
-fn grants(roles: &BTreeSet, required: AuthRole) -> bool {
- roles.contains(&AuthRole::Administrator)
- || roles.contains(&required)
- || required == AuthRole::Read && roles.contains(&AuthRole::Control)
+pub type AuthRoles = BTreeSet;
+
+fn roles_grant(roles: &AuthRoles, required: AuthRole) -> bool {
+ roles.iter().any(|role| role.grants(required))
}
-fn effective_roles(roles: &BTreeSet) -> Vec {
+fn roles_grant_any(roles: &AuthRoles, required: &[AuthRole]) -> bool {
+ required.iter().any(|role| roles_grant(roles, *role))
+}
+
+fn effective_roles(roles: &AuthRoles) -> Vec {
if roles.contains(&AuthRole::Administrator) {
AuthRole::all().into_iter().collect()
} else {
@@ -74,7 +86,7 @@ fn effective_roles(roles: &BTreeSet) -> Vec {
#[derive(Debug, Clone)]
pub struct SessionRecord {
pub username: String,
- pub roles: BTreeSet,
+ pub roles: AuthRoles,
pub issued_at: SystemTime,
pub expires_at: SystemTime,
pub last_seen: SystemTime,
@@ -104,7 +116,7 @@ impl SessionStore {
}
/// Create a new session with the given role and TTL
- pub fn create(&self, username: String, roles: BTreeSet, ttl: Duration) -> SessionId {
+ pub fn create(&self, username: String, roles: AuthRoles, ttl: Duration) -> SessionId {
let now = SystemTime::now();
let expires_at = now + ttl;
let session_id = Self::generate_session_id();
@@ -199,16 +211,32 @@ impl SameSite {
}
}
+/// Credentials used only when creating a missing user database.
+#[derive(Debug, Clone)]
+pub struct BootstrapAccount {
+ pub username: String,
+ pub password: String,
+}
+
+impl BootstrapAccount {
+ pub fn new(username: String, password: String) -> Self {
+ Self { username, password }
+ }
+
+ pub fn from_parts(username: Option, password: Option) -> Option {
+ username
+ .zip(password)
+ .map(|(username, password)| Self::new(username, password))
+ }
+}
+
/// Runtime authentication configuration
#[derive(Debug, Clone)]
pub struct AuthConfig {
pub enabled: bool,
pub users_file: PathBuf,
- pub bootstrap_admin_username: Option,
- pub bootstrap_admin_password: Option,
- pub bootstrap_read_enabled: bool,
- pub bootstrap_read_username: String,
- pub bootstrap_read_password: Option,
+ pub bootstrap_admin: Option,
+ pub bootstrap_read: Option,
pub session_ttl: Duration,
pub cookie_secure: bool,
pub cookie_same_site: SameSite,
@@ -216,15 +244,11 @@ pub struct AuthConfig {
impl AuthConfig {
/// Create a new auth config with all fields
- #[allow(clippy::too_many_arguments)]
pub fn new(
enabled: bool,
users_file: PathBuf,
- bootstrap_admin_username: Option,
- bootstrap_admin_password: Option,
- bootstrap_read_enabled: bool,
- bootstrap_read_username: String,
- bootstrap_read_password: Option,
+ bootstrap_admin: Option,
+ bootstrap_read: Option,
session_ttl: Duration,
cookie_secure: bool,
cookie_same_site: SameSite,
@@ -232,11 +256,8 @@ impl AuthConfig {
Self {
enabled,
users_file,
- bootstrap_admin_username,
- bootstrap_admin_password,
- bootstrap_read_enabled,
- bootstrap_read_username,
- bootstrap_read_password,
+ bootstrap_admin,
+ bootstrap_read,
session_ttl,
cookie_secure,
cookie_same_site,
@@ -248,7 +269,18 @@ impl AuthConfig {
pub struct UserRecord {
pub username: String,
pub password_hash: String,
- pub roles: BTreeSet,
+ pub roles: AuthRoles,
+ pub enabled: bool,
+}
+
+impl UserRecord {
+ fn is_enabled_administrator(&self) -> bool {
+ self.enabled && self.roles.contains(&AuthRole::Administrator)
+ }
+}
+
+const fn default_true() -> bool {
+ true
}
impl<'de> Deserialize<'de> for UserRecord {
@@ -268,7 +300,9 @@ impl<'de> Deserialize<'de> for UserRecord {
username: String,
password_hash: String,
#[serde(default)]
- roles: Option>,
+ roles: Option,
+ #[serde(default = "default_true")]
+ enabled: bool,
#[serde(default)]
role: Option,
}
@@ -286,6 +320,7 @@ impl<'de> Deserialize<'de> for UserRecord {
username: stored.username,
password_hash: stored.password_hash,
roles,
+ enabled: stored.enabled,
})
}
}
@@ -295,6 +330,35 @@ struct UserDatabase {
users: Vec,
}
+#[derive(Debug)]
+enum UserStoreError {
+ Conflict(String),
+ NotFound,
+ Invalid(String),
+ Storage(String),
+}
+
+impl UserStoreError {
+ fn response(self) -> HttpResponse {
+ let (status, message) = match self {
+ Self::Conflict(message) => (actix_web::http::StatusCode::CONFLICT, message),
+ Self::NotFound => (
+ actix_web::http::StatusCode::NOT_FOUND,
+ "user not found".to_string(),
+ ),
+ Self::Invalid(message) => (actix_web::http::StatusCode::BAD_REQUEST, message),
+ Self::Storage(message) => {
+ warn!(error = %message, "Failed to persist user database");
+ (
+ actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
+ "could not persist user database".to_string(),
+ )
+ }
+ };
+ HttpResponse::build(status).json(serde_json::json!({"error": message}))
+ }
+}
+
/// Persistent user database. Passwords are Argon2id hashes; writes replace the
/// database atomically so an interrupted update cannot truncate it.
pub struct UserStore {
@@ -317,38 +381,32 @@ impl UserStore {
.map_err(|e| format!("parse {}: {e}", config.users_file.display()))?;
db.users
} else {
- let username = config.bootstrap_admin_username.as_deref().ok_or_else(|| {
+ let administrator = config.bootstrap_admin.as_ref().ok_or_else(|| {
format!(
"user database {} does not exist and no bootstrap administrator was configured",
config.users_file.display()
)
})?;
- let password = config
- .bootstrap_admin_password
- .as_deref()
- .ok_or_else(|| "bootstrap administrator password is missing".to_string())?;
let mut records = vec![UserRecord {
- username: validate_username(username)?.to_string(),
- password_hash: hash_password(password)?,
+ username: validate_username(&administrator.username)?.to_string(),
+ password_hash: hash_password(&administrator.password)?,
roles: AuthRole::all(),
+ enabled: true,
}];
- if config.bootstrap_read_enabled {
- if config.bootstrap_read_username == username {
+ if let Some(reader) = &config.bootstrap_read {
+ if reader.username == administrator.username {
return Err(
"bootstrap administrator and read-only usernames must differ".to_string(),
);
}
- let password = config
- .bootstrap_read_password
- .as_deref()
- .ok_or_else(|| "bootstrap read-only account password is missing".to_string())?;
- if password != "guest" {
- validate_password(password)?;
+ if reader.password != "guest" {
+ validate_password(&reader.password)?;
}
records.push(UserRecord {
- username: validate_username(&config.bootstrap_read_username)?.to_string(),
- password_hash: hash_password_value(password)?,
+ username: validate_username(&reader.username)?.to_string(),
+ password_hash: hash_password_value(&reader.password)?,
roles: [AuthRole::Read].into_iter().collect(),
+ enabled: true,
});
}
records
@@ -356,15 +414,17 @@ impl UserStore {
let mut users = HashMap::new();
for record in records {
validate_username(&record.username)?;
+ validate_roles(record.roles.clone())?;
+ PasswordHash::new(&record.password_hash)
+ .map_err(|e| format!("invalid password hash for {}: {e}", record.username))?;
if users.insert(record.username.clone(), record).is_some() {
return Err("user database contains duplicate usernames".to_string());
}
}
- if !users
- .values()
- .any(|u| u.roles.contains(&AuthRole::Administrator))
- {
- return Err("user database must contain at least one administrator".to_string());
+ if !users.values().any(UserRecord::is_enabled_administrator) {
+ return Err(
+ "user database must contain at least one enabled administrator".to_string(),
+ );
}
let store = Self {
path: config.users_file.clone(),
@@ -376,9 +436,12 @@ impl UserStore {
Ok(store)
}
- fn authenticate(&self, username: &str, password: &str) -> Option> {
+ fn authenticate(&self, username: &str, password: &str) -> Option {
let users = self.users.read().unwrap_or_else(|e| e.into_inner());
let record = users.get(username)?;
+ if !record.enabled {
+ return None;
+ }
let parsed = PasswordHash::new(&record.password_hash).ok()?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
@@ -393,28 +456,38 @@ impl UserStore {
.map(|u| ManagedUser {
username: u.username.clone(),
roles: u.roles.clone(),
+ enabled: u.enabled,
})
.collect();
result.sort_by(|a, b| a.username.cmp(&b.username));
result
}
- fn add(&self, username: &str, password: &str, roles: BTreeSet) -> Result<(), String> {
- let username = validate_username(username)?.to_string();
- validate_password(password)?;
+ fn add(
+ &self,
+ username: &str,
+ password: &str,
+ roles: AuthRoles,
+ enabled: bool,
+ ) -> Result<(), UserStoreError> {
+ let username = validate_username(username)
+ .map_err(UserStoreError::Invalid)?
+ .to_string();
+ validate_password(password).map_err(UserStoreError::Invalid)?;
let record = UserRecord {
username: username.clone(),
- password_hash: hash_password(password)?,
- roles: validate_roles(roles)?,
+ password_hash: hash_password(password).map_err(UserStoreError::Invalid)?,
+ roles: validate_roles(roles).map_err(UserStoreError::Invalid)?,
+ enabled,
};
let mut users = self.users.write().unwrap_or_else(|e| e.into_inner());
if users.contains_key(&username) {
- return Err("user already exists".to_string());
+ return Err(UserStoreError::Conflict("user already exists".to_string()));
}
users.insert(username.clone(), record);
if let Err(error) = persist_users(&self.path, &users) {
users.remove(&username);
- return Err(error);
+ return Err(UserStoreError::Storage(error));
}
Ok(())
}
@@ -423,62 +496,66 @@ impl UserStore {
&self,
username: &str,
password: Option<&str>,
- roles: Option>,
- ) -> Result<(), String> {
+ roles: Option,
+ enabled: Option,
+ ) -> Result<(), UserStoreError> {
if let Some(value) = password {
- validate_password(value)?;
+ validate_password(value).map_err(UserStoreError::Invalid)?;
}
- let new_hash = password.map(hash_password).transpose()?;
+ let new_hash = password
+ .map(hash_password)
+ .transpose()
+ .map_err(UserStoreError::Invalid)?;
let mut users = self.users.write().unwrap_or_else(|e| e.into_inner());
let old = users
.get(username)
.cloned()
- .ok_or_else(|| "user not found".to_string())?;
- if old.roles.contains(&AuthRole::Administrator)
+ .ok_or(UserStoreError::NotFound)?;
+ let remains_enabled_administrator = enabled.unwrap_or(old.enabled)
&& roles
.as_ref()
- .is_some_and(|next| !next.contains(&AuthRole::Administrator))
- && users
- .values()
- .filter(|u| u.roles.contains(&AuthRole::Administrator))
- .count()
- == 1
+ .unwrap_or(&old.roles)
+ .contains(&AuthRole::Administrator);
+ if old.is_enabled_administrator()
+ && !remains_enabled_administrator
+ && enabled_administrator_count(&users) == 1
{
- return Err("cannot demote the last administrator".to_string());
+ return Err(UserStoreError::Invalid(
+ "cannot disable or demote the last enabled administrator".to_string(),
+ ));
}
let record = users.get_mut(username).expect("checked above");
if let Some(hash) = new_hash {
record.password_hash = hash;
}
if let Some(value) = roles {
- record.roles = validate_roles(value)?;
+ record.roles = validate_roles(value).map_err(UserStoreError::Invalid)?;
+ }
+ if let Some(value) = enabled {
+ record.enabled = value;
}
if let Err(error) = persist_users(&self.path, &users) {
users.insert(username.to_string(), old);
- return Err(error);
+ return Err(UserStoreError::Storage(error));
}
Ok(())
}
- fn remove(&self, username: &str) -> Result<(), String> {
+ fn remove(&self, username: &str) -> Result<(), UserStoreError> {
let mut users = self.users.write().unwrap_or_else(|e| e.into_inner());
let old = users
.get(username)
.cloned()
- .ok_or_else(|| "user not found".to_string())?;
- if old.roles.contains(&AuthRole::Administrator)
- && users
- .values()
- .filter(|u| u.roles.contains(&AuthRole::Administrator))
- .count()
- == 1
- {
- return Err("cannot remove the last administrator".to_string());
+ .ok_or(UserStoreError::NotFound)?;
+ if old.is_enabled_administrator() && enabled_administrator_count(&users) == 1 {
+ return Err(UserStoreError::Invalid(
+ "cannot remove the last enabled administrator".to_string(),
+ ));
}
users.remove(username);
if let Err(error) = persist_users(&self.path, &users) {
users.insert(username.to_string(), old);
- return Err(error);
+ return Err(UserStoreError::Storage(error));
}
Ok(())
}
@@ -530,16 +607,26 @@ fn validate_password(password: &str) -> Result<(), String> {
if password.len() < 8 {
return Err("password must be at least 8 characters".to_string());
}
+ if password.len() > 1024 {
+ return Err("password must be at most 1024 bytes".to_string());
+ }
Ok(())
}
-fn validate_roles(roles: BTreeSet) -> Result, String> {
+fn validate_roles(roles: AuthRoles) -> Result {
if roles.is_empty() {
return Err("at least one role is required".to_string());
}
Ok(roles)
}
+fn enabled_administrator_count(users: &HashMap) -> usize {
+ users
+ .values()
+ .filter(|user| user.is_enabled_administrator())
+ .count()
+}
+
/// Simple per-IP rate limiter for login attempts.
///
/// Tracks failed attempts per IP and enforces a cooldown window after
@@ -633,7 +720,7 @@ pub struct LoginRequest {
}
/// Session status response
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
pub struct SessionStatus {
pub authenticated: bool,
pub roles: Vec,
@@ -652,20 +739,30 @@ pub struct LoginResponse {
#[derive(Debug, Clone, Serialize)]
pub struct ManagedUser {
pub username: String,
- pub roles: BTreeSet,
+ pub roles: AuthRoles,
+ pub enabled: bool,
}
#[derive(Debug, Deserialize)]
pub struct CreateUserRequest {
pub username: String,
pub password: String,
- pub roles: BTreeSet,
+ pub roles: AuthRoles,
+ #[serde(default = "default_true")]
+ pub enabled: bool,
}
#[derive(Debug, Deserialize)]
pub struct UpdateUserRequest {
pub password: Option,
- pub roles: Option>,
+ pub roles: Option,
+ pub enabled: Option,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct ChangePasswordRequest {
+ pub current_password: String,
+ pub new_password: String,
}
/// Extract session from cookie
@@ -675,30 +772,66 @@ fn extract_session_id(req: &HttpRequest) -> Option {
}
/// Get session from request, return role if valid
-pub fn get_session_roles(req: &HttpRequest, auth_state: &AuthState) -> Option> {
+pub fn get_session_roles(req: &HttpRequest, auth_state: &AuthState) -> Option {
let session_id = extract_session_id(req)?;
let record = auth_state.store.get(&session_id)?;
Some(record.roles)
}
pub fn session_grants(req: &HttpRequest, auth_state: &AuthState, role: AuthRole) -> bool {
- get_session_roles(req, auth_state).is_some_and(|roles| grants(&roles, role))
+ get_session_roles(req, auth_state).is_some_and(|roles| roles_grant(&roles, role))
}
-fn require_admin(req: &HttpRequest, auth_state: &AuthState) -> Result {
- let session = extract_session_id(req)
+fn require_session(
+ req: &HttpRequest,
+ auth_state: &AuthState,
+) -> Result {
+ extract_session_id(req)
.and_then(|id| auth_state.store.get(&id))
.ok_or_else(|| {
HttpResponse::Unauthorized()
.json(serde_json::json!({"error":"authentication required"}))
- })?;
- if !grants(&session.roles, AuthRole::Administrator) {
- return Err(HttpResponse::Forbidden()
- .json(serde_json::json!({"error":"administrator role required"})));
+ })
+}
+
+fn require_role(
+ req: &HttpRequest,
+ auth_state: &AuthState,
+ role: AuthRole,
+) -> Result {
+ let session = require_session(req, auth_state)?;
+ if !roles_grant(&session.roles, role) {
+ return Err(HttpResponse::Forbidden().json(serde_json::json!({
+ "error": format!("{} role required", role.as_str())
+ })));
}
Ok(session)
}
+fn require_admin(req: &HttpRequest, auth_state: &AuthState) -> Result {
+ require_role(req, auth_state, AuthRole::Administrator)
+}
+
+fn session_cookie(value: String, config: &AuthConfig, max_age: Duration) -> Cookie<'static> {
+ let mut cookie = Cookie::new("trx_http_sid", value);
+ cookie.set_path("/");
+ cookie.set_http_only(true);
+ cookie.set_secure(config.cookie_secure);
+ cookie.set_same_site(match config.cookie_same_site {
+ SameSite::Strict => actix_web::cookie::SameSite::Strict,
+ SameSite::Lax => actix_web::cookie::SameSite::Lax,
+ SameSite::None => actix_web::cookie::SameSite::None,
+ });
+ cookie.set_max_age(actix_web::cookie::time::Duration::seconds(
+ max_age.as_secs().min(i64::MAX as u64) as i64,
+ ));
+ cookie
+}
+
+fn expired_session_cookie(config: &AuthConfig) -> Cookie<'static> {
+ session_cookie(String::new(), config, Duration::ZERO)
+}
+
// ============================================================================
// Endpoints
// ============================================================================
@@ -747,21 +880,11 @@ pub async fn login(
auth_state.config.session_ttl,
);
- let mut cookie = Cookie::new("trx_http_sid", session_id);
- cookie.set_path("/");
- cookie.set_http_only(true);
- cookie.set_secure(auth_state.config.cookie_secure);
-
- // Set SameSite attribute
- match auth_state.config.cookie_same_site {
- SameSite::Strict => cookie.set_same_site(actix_web::cookie::SameSite::Strict),
- SameSite::Lax => cookie.set_same_site(actix_web::cookie::SameSite::Lax),
- SameSite::None => cookie.set_same_site(actix_web::cookie::SameSite::None),
- };
-
- // Convert Duration to cookie time::Duration
- let ttl_secs = auth_state.config.session_ttl.as_secs() as i64;
- cookie.set_max_age(actix_web::cookie::time::Duration::seconds(ttl_secs));
+ let cookie = session_cookie(
+ session_id,
+ &auth_state.config,
+ auth_state.config.session_ttl,
+ );
Ok(HttpResponse::Ok().cookie(cookie).json(LoginResponse {
authenticated: true,
@@ -785,11 +908,7 @@ pub async fn logout(
auth_state.store.remove(&session_id);
}
- // Clear cookie by setting max_age to 0
- let mut cookie = Cookie::new("trx_http_sid", "");
- cookie.set_path("/");
- cookie.set_http_only(true);
- cookie.set_max_age(actix_web::cookie::time::Duration::seconds(0));
+ let cookie = expired_session_cookie(&auth_state.config);
Ok(HttpResponse::Ok().cookie(cookie).json(serde_json::json!({
"logged_out": true
@@ -832,6 +951,39 @@ pub async fn session_status(
}))
}
+/// PATCH /auth/account/password
+#[patch("/auth/account/password")]
+pub async fn change_own_password(
+ req: HttpRequest,
+ body: web::Json,
+ auth_state: web::Data,
+) -> impl Responder {
+ let session = match require_session(&req, &auth_state) {
+ Ok(value) => value,
+ Err(response) => return response,
+ };
+ if auth_state
+ .users
+ .authenticate(&session.username, &body.current_password)
+ .is_none()
+ {
+ return HttpResponse::Forbidden()
+ .json(serde_json::json!({"error":"current password is incorrect"}));
+ }
+ match auth_state
+ .users
+ .update(&session.username, Some(&body.new_password), None, None)
+ {
+ Ok(()) => {
+ auth_state.store.remove_user(&session.username);
+ HttpResponse::Ok()
+ .cookie(expired_session_cookie(&auth_state.config))
+ .json(serde_json::json!({"updated":true,"reauthenticate":true}))
+ }
+ Err(error) => error.response(),
+ }
+}
+
/// GET /auth/users
#[get("/auth/users")]
pub async fn list_users(req: HttpRequest, auth_state: web::Data) -> impl Responder {
@@ -851,15 +1003,18 @@ pub async fn create_user(
if let Err(response) = require_admin(&req, &auth_state) {
return response;
}
- match auth_state
- .users
- .add(&body.username, &body.password, body.roles.clone())
- {
+ match auth_state.users.add(
+ &body.username,
+ &body.password,
+ body.roles.clone(),
+ body.enabled,
+ ) {
Ok(()) => HttpResponse::Created().json(ManagedUser {
username: body.username.clone(),
roles: body.roles.clone(),
+ enabled: body.enabled,
}),
- Err(error) => HttpResponse::BadRequest().json(serde_json::json!({"error": error})),
+ Err(error) => error.response(),
}
}
@@ -871,22 +1026,30 @@ pub async fn update_user(
body: web::Json,
auth_state: web::Data,
) -> impl Responder {
- if let Err(response) = require_admin(&req, &auth_state) {
- return response;
- }
- if body.password.is_none() && body.roles.is_none() {
+ let admin = match require_admin(&req, &auth_state) {
+ Ok(value) => value,
+ Err(response) => return response,
+ };
+ if body.password.is_none() && body.roles.is_none() && body.enabled.is_none() {
return HttpResponse::BadRequest()
- .json(serde_json::json!({"error":"password or role is required"}));
+ .json(serde_json::json!({"error":"password, roles, or enabled is required"}));
}
- match auth_state
- .users
- .update(&username, body.password.as_deref(), body.roles.clone())
- {
+ if admin.username == *username && body.enabled == Some(false) {
+ return HttpResponse::BadRequest().json(serde_json::json!({
+ "error":"administrators cannot disable their own account"
+ }));
+ }
+ match auth_state.users.update(
+ &username,
+ body.password.as_deref(),
+ body.roles.clone(),
+ body.enabled,
+ ) {
Ok(()) => {
auth_state.store.remove_user(&username);
HttpResponse::Ok().json(serde_json::json!({"updated": true}))
}
- Err(error) => HttpResponse::BadRequest().json(serde_json::json!({"error": error})),
+ Err(error) => error.response(),
}
}
@@ -910,7 +1073,7 @@ pub async fn delete_user(
auth_state.store.remove_user(&username);
HttpResponse::Ok().json(serde_json::json!({"deleted": true}))
}
- Err(error) => HttpResponse::BadRequest().json(serde_json::json!({"error": error})),
+ Err(error) => error.response(),
}
}
@@ -923,6 +1086,8 @@ pub async fn delete_user(
enum RouteAccess {
/// Publicly accessible (no auth required)
Public,
+ /// Any valid account session.
+ Authenticated,
/// Read-only resources (Read, Control, or Administrator required)
Read,
/// Bookmarks (Read, Control, Write, or Administrator required)
@@ -931,6 +1096,8 @@ enum RouteAccess {
Write,
/// Radio control (Control or Administrator required)
Control,
+ /// Managed-account administration.
+ Administrator,
}
impl RouteAccess {
@@ -950,6 +1117,13 @@ impl RouteAccess {
return Self::Public;
}
+ if path == "/auth/account/password" {
+ return Self::Authenticated;
+ }
+ if path == "/auth/users" || path.starts_with("/auth/users/") {
+ return Self::Administrator;
+ }
+
// Static assets. The band plan is one of them: it is compiled into the
// binary and identical for every user, but ".json" is not an asset
// suffix, so it used to fall through to Control — leaving read-only
@@ -958,8 +1132,8 @@ impl RouteAccess {
if path == "/bandplan.json" {
return Self::Public;
}
- if path.starts_with("/style.css")
- || path.starts_with("/app.js")
+ if path == "/style.css"
+ || path == "/app.js"
|| path.ends_with(".js")
|| path.ends_with(".css")
|| path.ends_with(".png")
@@ -981,14 +1155,6 @@ impl RouteAccess {
|| path == "/spectrum"
|| path == "/meter"
|| path == "/audio"
- || path.starts_with("/status?")
- || path.starts_with("/rigs?")
- || path.starts_with("/events?")
- || path.starts_with("/decode?")
- || path.starts_with("/decode/history?")
- || path.starts_with("/spectrum?")
- || path.starts_with("/meter?")
- || path.starts_with("/audio?")
|| path.starts_with("/scheduler/")
|| path.starts_with("/scheduler-control")
|| path.starts_with("/channels/")
@@ -996,10 +1162,7 @@ impl RouteAccess {
return Self::Read;
}
- if path == "/bookmarks"
- || path.starts_with("/bookmarks?")
- || path.starts_with("/bookmarks/")
- {
+ if path == "/bookmarks" || path.starts_with("/bookmarks/") {
return Self::ReadWrite;
}
@@ -1011,15 +1174,18 @@ impl RouteAccess {
Self::Control
}
- fn allows(&self, roles: Option<&BTreeSet>) -> bool {
+ fn allows(&self, roles: Option<&AuthRoles>) -> bool {
match self {
Self::Public => true,
- Self::Read => roles.is_some_and(|roles| grants(roles, AuthRole::Read)),
- Self::ReadWrite => roles.is_some_and(|roles| {
- grants(roles, AuthRole::Read) || grants(roles, AuthRole::Write)
- }),
- Self::Write => roles.is_some_and(|roles| grants(roles, AuthRole::Write)),
- Self::Control => roles.is_some_and(|roles| grants(roles, AuthRole::Control)),
+ Self::Authenticated => roles.is_some(),
+ Self::Read => roles.is_some_and(|roles| roles_grant(roles, AuthRole::Read)),
+ Self::ReadWrite => roles
+ .is_some_and(|roles| roles_grant_any(roles, &[AuthRole::Read, AuthRole::Write])),
+ Self::Write => roles.is_some_and(|roles| roles_grant(roles, AuthRole::Write)),
+ Self::Control => roles.is_some_and(|roles| roles_grant(roles, AuthRole::Control)),
+ Self::Administrator => {
+ roles.is_some_and(|roles| roles_grant(roles, AuthRole::Administrator))
+ }
}
}
}
@@ -1074,37 +1240,34 @@ where
}
// For protected routes, check auth
- let auth_state = req.app_data::>().cloned();
+ let Some(auth_state) = req.app_data::>().cloned() else {
+ return Box::pin(async move {
+ Err(actix_web::error::ErrorInternalServerError(
+ "Authentication state unavailable",
+ ))
+ });
+ };
+ if !auth_state.config.enabled {
+ let fut = self.service.call(req);
+ return Box::pin(async move {
+ let res = fut.await?;
+ Ok(res)
+ });
+ }
- if let Some(auth_state) = auth_state {
- if !auth_state.config.enabled {
- // Auth disabled - allow all
- let fut = self.service.call(req);
- return Box::pin(async move {
- let res = fut.await?;
- Ok(res)
- });
- }
-
- // Auth enabled - check role
- let roles = get_session_roles(req.request(), &auth_state);
-
- if !access.allows(roles.as_ref()) {
- // Access denied
- return Box::pin(async move {
- if roles.is_some() {
- // Has session but insufficient permissions - 403 Forbidden
- Err(actix_web::error::ErrorForbidden(
- "Insufficient permissions".to_string(),
- ))
- } else {
- // No session and no unrestricted access - 401 Unauthorized
- Err(actix_web::error::ErrorUnauthorized(
- "Authentication required".to_string(),
- ))
- }
- });
- }
+ let roles = get_session_roles(req.request(), &auth_state);
+ if !access.allows(roles.as_ref()) {
+ return Box::pin(async move {
+ if roles.is_some() {
+ Err(actix_web::error::ErrorForbidden(
+ "Insufficient permissions".to_string(),
+ ))
+ } else {
+ Err(actix_web::error::ErrorUnauthorized(
+ "Authentication required".to_string(),
+ ))
+ }
+ });
}
let fut = self.service.call(req);
@@ -1128,11 +1291,14 @@ mod tests {
AuthConfig::new(
true,
path,
- Some("admin".to_string()),
- Some("password123".to_string()),
- true,
- "guest".to_string(),
- Some("guest".to_string()),
+ Some(BootstrapAccount::new(
+ "admin".to_string(),
+ "password123".to_string(),
+ )),
+ Some(BootstrapAccount::new(
+ "guest".to_string(),
+ "guest".to_string(),
+ )),
Duration::from_secs(3600),
false,
SameSite::Lax,
@@ -1151,7 +1317,14 @@ mod tests {
assert_eq!(RouteAccess::from_path("/about"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/auth/login"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/auth/logout"), RouteAccess::Public);
- assert_eq!(RouteAccess::from_path("/auth/users"), RouteAccess::Control);
+ assert_eq!(
+ RouteAccess::from_path("/auth/account/password"),
+ RouteAccess::Authenticated
+ );
+ assert_eq!(
+ RouteAccess::from_path("/auth/users"),
+ RouteAccess::Administrator
+ );
assert_eq!(RouteAccess::from_path("/style.css"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/app.js"), RouteAccess::Public);
// Static reference data, served to every role: ".json" is not in the
@@ -1194,6 +1367,8 @@ mod tests {
assert!(RouteAccess::Public.allows(None));
assert!(RouteAccess::Public.allows(Some(&read)));
assert!(RouteAccess::Public.allows(Some(&administrator)));
+ assert!(!RouteAccess::Authenticated.allows(None));
+ assert!(RouteAccess::Authenticated.allows(Some(&write)));
assert!(!RouteAccess::Read.allows(None));
assert!(RouteAccess::Read.allows(Some(&read)));
@@ -1213,6 +1388,8 @@ mod tests {
assert!(!RouteAccess::Control.allows(Some(&read)));
assert!(RouteAccess::Control.allows(Some(&control)));
assert!(RouteAccess::Control.allows(Some(&administrator)));
+ assert!(!RouteAccess::Administrator.allows(Some(&control)));
+ assert!(RouteAccess::Administrator.allows(Some(&administrator)));
}
#[test]
@@ -1254,14 +1431,14 @@ mod tests {
Some(roles(&[AuthRole::Read]))
);
users
- .add("alice", "password456", roles(&[AuthRole::Read]))
+ .add("alice", "password456", roles(&[AuthRole::Read]), true)
.unwrap();
assert_eq!(
users.authenticate("alice", "password456"),
Some(roles(&[AuthRole::Read]))
);
users
- .update("alice", Some("password789"), Some(AuthRole::all()))
+ .update("alice", Some("password789"), Some(AuthRole::all()), None)
.unwrap();
assert_eq!(
users.authenticate("alice", "password789"),
@@ -1272,8 +1449,7 @@ mod tests {
drop(users);
let mut reopen_config = config.clone();
- reopen_config.bootstrap_admin_username = None;
- reopen_config.bootstrap_admin_password = None;
+ reopen_config.bootstrap_admin = None;
let reopened = UserStore::open(&reopen_config).unwrap();
assert_eq!(
reopened.authenticate("alice", "password789"),
@@ -1291,16 +1467,33 @@ mod tests {
let users = UserStore::open(&config).unwrap();
assert!(users.remove("admin").is_err());
assert!(users
- .update("admin", None, Some(roles(&[AuthRole::Read])))
+ .update("admin", None, Some(roles(&[AuthRole::Read])), None)
.is_err());
+ assert!(users.update("admin", None, None, Some(false)).is_err());
+ }
+
+ #[test]
+ fn disabled_users_cannot_authenticate() {
+ let directory = tempfile::tempdir().unwrap();
+ let config = test_auth_config(directory.path().join("users.json"));
+ let users = UserStore::open(&config).unwrap();
+ users
+ .add("alice", "password456", roles(&[AuthRole::Read]), false)
+ .unwrap();
+
+ assert_eq!(users.authenticate("alice", "password456"), None);
+ users.update("alice", None, None, Some(true)).unwrap();
+ assert_eq!(
+ users.authenticate("alice", "password456"),
+ Some(roles(&[AuthRole::Read]))
+ );
}
#[test]
fn guest_bootstrap_can_be_disabled() {
let directory = tempfile::tempdir().unwrap();
let mut config = test_auth_config(directory.path().join("users.json"));
- config.bootstrap_read_enabled = false;
- config.bootstrap_read_password = None;
+ config.bootstrap_read = None;
let users = UserStore::open(&config).unwrap();
assert_eq!(users.list().len(), 1);
@@ -1323,13 +1516,32 @@ mod tests {
.unwrap();
assert_eq!(read.roles, roles(&[AuthRole::Read]));
+ assert!(read.enabled);
assert_eq!(administrator.roles, AuthRole::all());
+ assert!(administrator.enabled);
assert!(serde_json::to_value(administrator)
.unwrap()
.get("role")
.is_none());
}
+ #[test]
+ fn persisted_accounts_are_validated_on_open() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join("users.json");
+ let hash = hash_password("password123").unwrap();
+ fs::write(
+ &path,
+ serde_json::to_vec(&serde_json::json!({"users":[{
+ "username":"admin","password_hash":hash,"roles":[],"enabled":true
+ }]}))
+ .unwrap(),
+ )
+ .unwrap();
+
+ assert!(UserStore::open(&test_auth_config(path)).is_err());
+ }
+
#[actix_web::test]
async fn admin_endpoints_manage_multiple_users_and_reject_regular_users() {
let directory = tempfile::tempdir().unwrap();
@@ -1339,6 +1551,8 @@ mod tests {
App::new()
.app_data(state)
.service(login)
+ .service(session_status)
+ .service(change_own_password)
.service(list_users)
.service(create_user)
.service(update_user)
@@ -1374,7 +1588,7 @@ mod tests {
let user_cookie = response.response().cookies().next().unwrap().to_string();
let request = aw_test::TestRequest::get()
.uri("/auth/users")
- .insert_header((actix_web::http::header::COOKIE, user_cookie))
+ .insert_header((actix_web::http::header::COOKIE, user_cookie.clone()))
.to_request();
assert_eq!(
aw_test::call_service(&app, request).await.status(),
@@ -1390,6 +1604,15 @@ mod tests {
aw_test::call_service(&app, request).await.status(),
actix_web::http::StatusCode::OK
);
+ let request = aw_test::TestRequest::get()
+ .uri("/auth/session")
+ .insert_header((actix_web::http::header::COOKIE, user_cookie))
+ .to_request();
+ let session: SessionStatus = aw_test::call_and_read_body_json(&app, request).await;
+ assert!(
+ !session.authenticated,
+ "role/password updates must revoke sessions"
+ );
let request = aw_test::TestRequest::delete()
.uri("/auth/users/alice")
.insert_header((actix_web::http::header::COOKIE, admin_cookie.clone()))
@@ -1419,4 +1642,171 @@ mod tests {
actix_web::http::StatusCode::BAD_REQUEST
);
}
+
+ #[actix_web::test]
+ async fn disabled_accounts_are_revoked_and_cannot_log_in() {
+ let directory = tempfile::tempdir().unwrap();
+ let state = web::Data::new(
+ AuthState::new(test_auth_config(directory.path().join("users.json"))).unwrap(),
+ );
+ let app = aw_test::init_service(
+ App::new()
+ .app_data(state)
+ .service(login)
+ .service(session_status)
+ .service(create_user)
+ .service(update_user),
+ )
+ .await;
+ let response = aw_test::call_service(
+ &app,
+ aw_test::TestRequest::post()
+ .uri("/auth/login")
+ .set_json(serde_json::json!({"username":"admin","password":"password123"}))
+ .to_request(),
+ )
+ .await;
+ let admin_cookie = response.response().cookies().next().unwrap().to_string();
+
+ let create = || {
+ aw_test::TestRequest::post()
+ .uri("/auth/users")
+ .insert_header((actix_web::http::header::COOKIE, admin_cookie.clone()))
+ .set_json(serde_json::json!({
+ "username":"alice","password":"password456","roles":["read"]
+ }))
+ .to_request()
+ };
+ assert_eq!(
+ aw_test::call_service(&app, create()).await.status(),
+ actix_web::http::StatusCode::CREATED
+ );
+ assert_eq!(
+ aw_test::call_service(&app, create()).await.status(),
+ actix_web::http::StatusCode::CONFLICT
+ );
+
+ let response = aw_test::call_service(
+ &app,
+ aw_test::TestRequest::post()
+ .uri("/auth/login")
+ .set_json(serde_json::json!({"username":"alice","password":"password456"}))
+ .to_request(),
+ )
+ .await;
+ let alice_cookie = response.response().cookies().next().unwrap().to_string();
+ let response = aw_test::call_service(
+ &app,
+ aw_test::TestRequest::patch()
+ .uri("/auth/users/alice")
+ .insert_header((actix_web::http::header::COOKIE, admin_cookie.clone()))
+ .set_json(serde_json::json!({"enabled":false}))
+ .to_request(),
+ )
+ .await;
+ assert_eq!(response.status(), actix_web::http::StatusCode::OK);
+
+ let status: SessionStatus = aw_test::call_and_read_body_json(
+ &app,
+ aw_test::TestRequest::get()
+ .uri("/auth/session")
+ .insert_header((actix_web::http::header::COOKIE, alice_cookie))
+ .to_request(),
+ )
+ .await;
+ assert!(!status.authenticated);
+ assert_eq!(
+ aw_test::call_service(
+ &app,
+ aw_test::TestRequest::post()
+ .uri("/auth/login")
+ .set_json(serde_json::json!({"username":"alice","password":"password456"}))
+ .to_request(),
+ )
+ .await
+ .status(),
+ actix_web::http::StatusCode::UNAUTHORIZED
+ );
+ assert_eq!(
+ aw_test::call_service(
+ &app,
+ aw_test::TestRequest::patch()
+ .uri("/auth/users/missing")
+ .insert_header((actix_web::http::header::COOKIE, admin_cookie))
+ .set_json(serde_json::json!({"enabled":false}))
+ .to_request(),
+ )
+ .await
+ .status(),
+ actix_web::http::StatusCode::NOT_FOUND
+ );
+ }
+
+ #[actix_web::test]
+ async fn users_can_change_their_own_password() {
+ let directory = tempfile::tempdir().unwrap();
+ let state = web::Data::new(
+ AuthState::new(test_auth_config(directory.path().join("users.json"))).unwrap(),
+ );
+ let app = aw_test::init_service(
+ App::new()
+ .app_data(state)
+ .service(login)
+ .service(session_status)
+ .service(change_own_password),
+ )
+ .await;
+ let response = aw_test::call_service(
+ &app,
+ aw_test::TestRequest::post()
+ .uri("/auth/login")
+ .set_json(serde_json::json!({"username":"guest","password":"guest"}))
+ .to_request(),
+ )
+ .await;
+ let guest_cookie = response.response().cookies().next().unwrap().to_string();
+
+ let change = |current: &str, new: &str| {
+ aw_test::TestRequest::patch()
+ .uri("/auth/account/password")
+ .insert_header((actix_web::http::header::COOKIE, guest_cookie.clone()))
+ .set_json(serde_json::json!({
+ "current_password":current,"new_password":new
+ }))
+ .to_request()
+ };
+ assert_eq!(
+ aw_test::call_service(&app, change("wrong", "new-password"))
+ .await
+ .status(),
+ actix_web::http::StatusCode::FORBIDDEN
+ );
+ assert_eq!(
+ aw_test::call_service(&app, change("guest", "new-password"))
+ .await
+ .status(),
+ actix_web::http::StatusCode::OK
+ );
+ let status: SessionStatus = aw_test::call_and_read_body_json(
+ &app,
+ aw_test::TestRequest::get()
+ .uri("/auth/session")
+ .insert_header((actix_web::http::header::COOKIE, guest_cookie))
+ .to_request(),
+ )
+ .await;
+ assert!(!status.authenticated);
+ assert_eq!(
+ aw_test::call_service(
+ &app,
+ aw_test::TestRequest::post()
+ .uri("/auth/login")
+ .set_json(serde_json::json!({"username":"guest","password":"new-password"}))
+ .to_request(),
+ )
+ .await
+ .status(),
+ actix_web::http::StatusCode::OK
+ );
+ }
}
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs
index cb94ce8f..2cd4808d 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs
@@ -252,14 +252,31 @@ fn build_server(
"None" => SameSite::None,
_ => 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(
context.http_auth.enabled,
context.http_auth.users_file.clone().into(),
- context.http_auth.bootstrap_admin_username.clone(),
- context.http_auth.bootstrap_admin_password.clone(),
- context.http_auth.bootstrap_read_enabled,
- context.http_auth.bootstrap_read_username.clone(),
- context.http_auth.bootstrap_read_password.clone(),
+ bootstrap_admin,
+ bootstrap_read,
Duration::from_secs(context.http_auth.session_ttl_secs),
context.http_auth.cookie_secure,
same_site,