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
+