diff --git a/docs/Architecture.md b/docs/Architecture.md index 920332c8..2fc29f6f 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -1043,7 +1043,7 @@ The `FrontendRuntimeContext` struct in `trx-frontend/src/lib.rs` is decomposed i |-----------|---------|------------| | `AudioContext` | Audio streaming channels | `rx`, `tx`, `info`, `decode_rx`, `clients` | | `DecodeHistoryContext` | Decode history for all types | `ais`, `vdes`, `aprs`, `hf_aprs`, `cw`, `ft8`, `ft4`, `ft2`, `wspr` | -| `HttpAuthConfig` | HTTP auth settings | `enabled`, `users_file`, bootstrap admin, `session_ttl_secs`, `tokens` | +| `HttpAuthConfig` | HTTP auth settings | `enabled`, `users_file`, bootstrap admin/read accounts, `session_ttl_secs`, `tokens` | | `HttpUiConfig` | HTTP UI display config | `show_sdr_gain_control`, `initial_map_zoom`, `spectrum_*` | | `RigRoutingContext` | Remote rig state & routing | `active_rig_id`, `remote_rigs`, `rig_states`, `server_connected` | | `OwnerInfo` | Station metadata | `callsign`, `website_url`, `ais_vessel_url_base` | diff --git a/docs/UX_Guidelines.md b/docs/UX_Guidelines.md index 5b3c8396..724b6eee 100644 --- a/docs/UX_Guidelines.md +++ b/docs/UX_Guidelines.md @@ -126,7 +126,8 @@ When auth is enabled, an **auth gate** blocks the UI with: - Error message area (red `#ff6b6b`) - Role badge display -Two roles: **User** (read-only) and **Admin** (full access including user management). +Accounts may combine **Read**, **Control**, **Write**, and **Administrator** roles. +Administrator implies all permissions. Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes. @@ -340,8 +341,9 @@ Routes are classified into three tiers: | Tier | Examples | Requirement | |---|---|---| | **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None | -| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | User or Admin role | -| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Admin role only | +| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Read, Control, or Administrator role | +| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, radio-control POST routes | Control or Administrator role | +| **Write** | Logbook access and bookmark mutations | Write or Administrator role | ### 7.2 Session Management diff --git a/docs/User-Manual.md b/docs/User-Manual.md index bc2bd327..ffc9a076 100644 --- a/docs/User-Manual.md +++ b/docs/User-Manual.md @@ -354,6 +354,9 @@ A name in any of those maps that no remote answers to is a config error. | `bootstrap_admin_username` | string | — | First administrator, used only if the database is absent | | `bootstrap_admin_password` | string | — | First administrator password | | `bootstrap_admin_password_file` | string | — | Read the bootstrap password from this file instead | +| `bootstrap_read_enabled` | bool | `true` | Create the default read-only account when the database is absent | +| `bootstrap_read_username` | string | `"guest"` | Initial read-only username | +| `bootstrap_read_password` | string | `"guest"` | Initial read-only password | | `session_ttl_min` | u64 | `480` | Session lifetime | | `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) | | `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` | @@ -577,7 +580,7 @@ The link button in the top bar copies the current link to the clipboard. The address bar itself is updated as you tune, using `replaceState`, so sweeping the dial does not fill the browser's history. -Applying a link changes the radio, so it needs the `admin` role; a `user` +Applying a link changes the radio, so it needs the `Control` role; a `Read` session opens the page and says the link was not applied. Links describe the rig's own dial — while a tab is listening to a virtual channel the address is left as it was, rather than publishing a frequency the rig is not on. @@ -586,11 +589,13 @@ left as it was, rather than publishing a frequency the rig is not on. ## Authentication -The HTTP frontend supports an optional user/password ACL with multiple accounts and two -roles: +The HTTP frontend supports an optional user/password ACL with multiple independent +roles. One account may have any combination: -- **user** — read-only access (monitoring, audio, decode streams) -- **admin** — full radio control, settings, and user management +- **Read** — monitoring, audio, decode streams, and bookmark reads +- **Control** — full radio receive/transmit controls +- **Write** — logbook access and bookmark changes +- **Administrator** — user management and all other permissions ### Configuration @@ -600,6 +605,9 @@ enabled = false users_file = "trx-http-users.json" bootstrap_admin_username = "admin" bootstrap_admin_password = "change-this-password" +bootstrap_read_enabled = true +bootstrap_read_username = "guest" +bootstrap_read_password = "guest" session_ttl_min = 480 cookie_secure = false # true if served via HTTPS cookie_same_site = "Lax" # Strict|Lax|None @@ -607,7 +615,9 @@ cookie_same_site = "Lax" # Strict|Lax|None When `enabled = false` (the default), all auth is bypassed and the UI behaves as before. When enabling it for the first time, bootstrap credentials create -the initial administrator and the Argon2id-hashed user database. +the initial administrator (with every role), the default `guest`/`guest` Read +account, and the Argon2id-hashed user database. Change or disable the guest +credentials in configuration before first startup on an exposed deployment. ### Behaviour @@ -615,8 +625,9 @@ the initial administrator and the Argon2id-hashed user database. - Sessions are in-memory; a server restart invalidates all sessions. - Rate limiting is applied per IP to mitigate brute-force attempts. - User records persist in `users_file`; passwords are stored as salted Argon2id hashes. -- `user` sessions cannot call control routes. There is no guest-access mode. -- Administrators can add/remove users and change roles/passwords in Settings. +- 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. ### Routes @@ -625,19 +636,19 @@ the initial administrator and the Argon2id-hashed user database. |----------|--------|-------------| | `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` | | `/auth/logout` | POST | Clear session | -| `/auth/session` | GET | Check current session/role | +| `/auth/session` | GET | Check current session/roles | | `/auth/users` | GET/POST | List or add users (admin only) | -| `/auth/users/{username}` | PATCH/DELETE | Change password/role or remove user (admin only) | +| `/auth/users/{username}` | PATCH/DELETE | Change password/roles or remove user (administrator only) | -Protected routes require at least `user` role. Control routes (set frequency, -mode, PTT, etc.) require `admin` role. +Read routes require Read. Radio mutations require Control. Logbook access and +bookmark mutations require Write. Administrator grants every permission. ### Frontend Flow 1. On load, the UI calls `/auth/session`. 2. If unauthenticated, a login screen is shown. 3. On successful login, the normal UI loads. -4. `user` accounts see a read-only interface; admins get full controls. +4. The interface enables controls according to the account's roles. 5. If a session expires mid-use, streams stop and the login screen returns. ### Transport Security diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index 98632697..244dad46 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -257,6 +257,12 @@ async fn async_init() -> DynResult { cfg.frontends.http.auth.bootstrap_admin_username.clone(); frontend_runtime.http_auth.bootstrap_admin_password = cfg.frontends.http.auth.bootstrap_admin_password.clone(); + frontend_runtime.http_auth.bootstrap_read_enabled = + cfg.frontends.http.auth.bootstrap_read_enabled; + frontend_runtime.http_auth.bootstrap_read_username = + cfg.frontends.http.auth.bootstrap_read_username.clone(); + frontend_runtime.http_auth.bootstrap_read_password = + cfg.frontends.http.auth.bootstrap_read_password.clone(); frontend_runtime.http_auth.session_ttl_secs = cfg.frontends.http.auth.session_ttl().as_secs(); frontend_runtime.http_auth.cookie_secure = cfg.frontends.http.auth.cookie_secure; frontend_runtime.http_auth.cookie_same_site = match cfg.frontends.http.auth.cookie_same_site { diff --git a/src/trx-client/trx-frontend/src/lib.rs b/src/trx-client/trx-frontend/src/lib.rs index ae5d81a6..99f6bd37 100644 --- a/src/trx-client/trx-frontend/src/lib.rs +++ b/src/trx-client/trx-frontend/src/lib.rs @@ -260,6 +260,9 @@ pub struct HttpAuthConfig { pub users_file: String, 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 session_ttl_secs: u64, pub cookie_secure: bool, pub cookie_same_site: String, @@ -274,6 +277,9 @@ impl Default for HttpAuthConfig { users_file: "trx-http-users.json".to_string(), bootstrap_admin_username: None, bootstrap_admin_password: None, + bootstrap_read_enabled: true, + bootstrap_read_username: "guest".to_string(), + bootstrap_read_password: Some("guest".to_string()), session_ttl_secs: 480 * 60, cookie_secure: false, cookie_same_site: "Lax".to_string(), 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 7d18b688..68cc3be9 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 @@ -1323,14 +1323,13 @@ function decodeAuthSession(value) { if (typeof session.authenticated !== "boolean") { throw new TypeError("The authentication response has no authenticated flag"); } - if (session.role !== void 0 && session.role !== "user" && session.role !== "admin") { - throw new TypeError("The authentication response has an invalid role"); + 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 }; - if (session.role !== void 0) decoded.role = session.role; + 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; @@ -1340,18 +1339,18 @@ function decodeAuthSession(value) { } var authDisabledSession = { authenticated: true, - role: "admin", + 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 }; + if (!response.ok) return { authenticated: false, roles: [] }; return decodeAuthSession(await response.json()); } catch (error) { console.error("Auth check failed:", error); - return { authenticated: false }; + return { authenticated: false, roles: [] }; } } async function login(username, password) { @@ -1380,14 +1379,14 @@ async function listUsers() { if (!Array.isArray(value) || !value.every((user) => { if (typeof user !== "object" || user === null) return false; const record = user; - return typeof record.username === "string" && (record.role === "user" || record.role === "admin"); + 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, role) { - await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, role }) }); +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) }); @@ -1895,8 +1894,23 @@ function isVchanRdsEntry(value) { } 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; +} +function hasAuthRole(role) { + return authRoles.includes("administrator") || authRoles.includes(role); +} async function checkAuthStatus() { return fetchAuthSession(); } @@ -1906,7 +1920,7 @@ async function authLogin(username, password) { async function authLogout() { try { await logout(); - authRole = null; + setAuthRoles([]); authUsername = null; disconnect(); setDecodeHistoryOverlayVisible(false); @@ -1978,9 +1992,9 @@ function updateAuthUI() { syncTopBarAccess(); return; } - if (authRole) { + if (authRoles.length > 0) { if (badge) badge.style.display = "block"; - if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRole === "admin" ? "Admin" : "User (read-only)"}`; + if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`; if (headerAuthBtn2) { headerAuthBtn2.textContent = "Logout"; headerAuthBtn2.style.display = "block"; @@ -1995,8 +2009,8 @@ function updateAuthUI() { syncTopBarAccess(); } function applyAuthRestrictions() { - if (!authRole) return; - if (authRole === "user") { + if (authRoles.length === 0) return; + if (!hasAuthRole("control")) { const pttBtn2 = document.getElementById("ptt-btn"); const powerBtn2 = document.getElementById("power-btn"); const lockBtn2 = document.getElementById("lock-btn"); @@ -2286,20 +2300,21 @@ window.applyDecodeHistoryRetention = function() { } }; function syncTopBarAccess() { - const loggedOut = authEnabled && !authRole; + const loggedOut = authEnabled && authRoles.length === 0; const tabBar = document.getElementById("tab-bar"); const rigSwitch = document.querySelector(".header-rig-switch"); if (tabBar) tabBar.style.display = ""; document.querySelectorAll(".tab-bar .tab").forEach((btn) => { const isMain = btn.dataset.tab === "main"; - btn.style.display = !loggedOut || isMain ? "" : "none"; + const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole("write"); + btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none"; btn.disabled = false; }); if (rigSwitch) { rigSwitch.style.display = loggedOut ? "none" : ""; } if (headerRigSwitchSelect) { - headerRigSwitchSelect.disabled = loggedOut || authRole === "user" || lastRigIds.length === 0; + headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole("control") || lastRigIds.length === 0; } } var overviewDrawPending = false; @@ -2935,7 +2950,7 @@ function applyRigList(activeRigId, rigIds, displayNames) { } const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || ""); const rigListChanged = prevKey !== nextKey; - const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "user"; + const disableSwitch = lastRigIds.length === 0 || !hasAuthRole("control"); populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); updateRigSubtitle(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId); @@ -4538,7 +4553,7 @@ function scheduleTuneLinkSync() { async function applyTuneLink(link) { const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null; if (!wanted) return; - if (authRole === "user") { + if (!hasAuthRole("control")) { showHint("Read-only session — link not applied", 2500); return; } @@ -5256,7 +5271,7 @@ async function postPath(path, options = {}) { } const resp = await fetch(path, { method: "POST" }); if (authEnabled && resp.status === 401) { - authRole = null; + setAuthRoles([]); if (es) es.close(); showAuthGate(); throw new Error("Authentication required"); @@ -5281,8 +5296,8 @@ async function switchRigFromSelect(selectEl) { showHint("No rig selected", 1500); return; } - if (authRole === "user") { - showHint("Admin role required", 1500); + if (!hasAuthRole("control")) { + showHint("Control role required", 1500); return; } if (!lastRigIds.includes(selectEl.value)) { @@ -5895,10 +5910,15 @@ function navigateToTab(name, options = {}) { window.trxUi?.closeMobileOverlays?.(); const leavingSatellites = _activeTab === "satellites" && name !== "satellites"; const { updateHistory = true, replaceHistory = false } = options; - if (authEnabled && !authRole && name !== "main") { + if (authEnabled && authRoles.length === 0 && name !== "main") { showAuthGate(); return; } + if (authEnabled && name === "logbook" && !hasAuthRole("write")) { + showHint("Write role required for logbook access", 2500); + navigateToTab("main", options); + return; + } const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`); if (!btn) return; _activeTab = name; @@ -6013,7 +6033,7 @@ async function initializeApp() { const authStatus = await checkAuthStatus(); authEnabled = !authStatus.auth_disabled; if (!authEnabled) { - authRole = "admin"; + setAuthRoles(ALL_AUTH_ROLES); hideAuthGate(); updateAuthUI(); connect(); @@ -6024,7 +6044,7 @@ async function initializeApp() { return; } if (authStatus.authenticated) { - authRole = authStatus.role ?? null; + setAuthRoles(authStatus.roles); authUsername = authStatus.username ?? null; hideAuthGate(); updateAuthUI(); @@ -6051,12 +6071,22 @@ function initSettingsUI() { } async function refreshUserManagement() { const section = document.getElementById("user-management"); - if (!section) return; - section.style.display = authEnabled && authRole === "admin" ? "block" : "none"; - if (section.style.display === "none") return; + const tab = document.getElementById("settings-users-tab"); + if (!section || !tab) return; + const canManageUsers = authEnabled && hasAuthRole("administrator"); + tab.style.display = canManageUsers ? "" : "none"; + if (!canManageUsers) { + const panel = document.getElementById("subtab-settings-users"); + if (panel) panel.style.display = "none"; + if (tab.classList.contains("active")) { + document.querySelector('[data-subtab="settings-scheduler"]')?.click(); + } + return; + } const list = requiredElement("user-list"); try { const users = await listUsers(); + const adminCount = users.filter((user) => user.roles.includes("administrator")).length; list.replaceChildren(...users.map((user) => { const row = document.createElement("div"); row.className = "sch-row"; @@ -6064,18 +6094,28 @@ async function refreshUserManagement() { const name = document.createElement("strong"); name.textContent = user.username; name.style.minWidth = "10rem"; - const role = document.createElement("select"); - role.className = "auth-input"; - for (const value of ["user", "admin"]) { - const option = document.createElement("option"); - option.value = value; - option.textContent = value === "admin" ? "Admin" : "User"; - option.selected = user.role === value; - role.append(option); + const 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; + const administratorInput = roleInputs.find((item) => item.value === "administrator")?.input; + if (isOnlyAdmin && administratorInput) { + administratorInput.disabled = true; + administratorInput.title = "The final administrator cannot be demoted"; } const password = document.createElement("input"); password.type = "password"; - password.placeholder = "New password"; + password.placeholder = "New password (8+ characters)"; password.autocomplete = "new-password"; password.className = "auth-input"; password.minLength = 8; @@ -6083,7 +6123,9 @@ async function refreshUserManagement() { save.type = "button"; save.textContent = "Save"; save.addEventListener("click", async () => { - const changes = { role: role.value }; + const changes = { + roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value) + }; if (password.value) changes.password = password.value; await runUserOperation(() => updateUser(user.username, changes)); }); @@ -6091,13 +6133,14 @@ async function refreshUserManagement() { remove.type = "button"; remove.textContent = "Remove"; remove.className = "danger"; - remove.disabled = user.username === authUsername; + remove.disabled = user.username === authUsername || isOnlyAdmin; + if (isOnlyAdmin) remove.title = "The final administrator cannot be removed"; remove.addEventListener("click", async () => { if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) { await runUserOperation(() => deleteUser(user.username)); } }); - row.append(name, role, password, save, remove); + row.append(name, roles, password, save, remove); return row; })); } catch (error) { @@ -6124,12 +6167,14 @@ document.getElementById("user-create-form")?.addEventListener("submit", (event) event.preventDefault(); const username = requiredElement("user-create-username"); const password = requiredElement("user-create-password"); - const role = requiredElement("user-create-role"); + const roles = Array.from(document.querySelectorAll("#user-create-roles input[type=checkbox]")); void runUserOperation(async () => { - await createUser(username.value, password.value, role.value); + await createUser(username.value, password.value, roles.filter((input) => input.checked).map((input) => input.value)); username.value = ""; password.value = ""; - role.value = "user"; + roles.forEach((input) => { + input.checked = input.value === "read"; + }); }); }); requiredElement("auth-form").addEventListener("submit", async (e) => { @@ -6142,7 +6187,7 @@ requiredElement("auth-form").addEventListener("submit", async (e) => { btn.textContent = "Logging in..."; try { const result = await authLogin(usernameEl.value, passwordEl.value); - authRole = result.role ?? null; + setAuthRoles(result.roles); authUsername = result.username ?? usernameEl.value; passwordEl.value = ""; hideAuthGate(); @@ -6164,7 +6209,7 @@ requiredElement("auth-form").addEventListener("submit", async (e) => { var headerAuthBtn = document.getElementById("header-auth-btn"); if (headerAuthBtn) { headerAuthBtn.addEventListener("click", async () => { - if (authRole) { + if (authRoles.length > 0) { if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) { await authLogout(); } @@ -6210,6 +6255,9 @@ Object.defineProperties(trxState, { authRole: { get() { return authRole; } }, + authRoles: { get() { + return authRoles; + } }, decoderRegistry: { get() { return decoderRegistry; } }, 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 50b86551..dd44eaa3 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 @@ -358,7 +358,7 @@ var bgdWindow = window; btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save"; } function isControlRole() { - return backgroundDecodeRole === "admin" || hostState.authEnabled === false; + return backgroundDecodeRole === "administrator" || backgroundDecodeRole === "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 8ac64be7..270973ae 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 @@ -42,7 +42,7 @@ function bmEsc(str) { return d.innerHTML; } function bmCanControl() { - return !hostState.authEnabled || hostState.authRole === "admin"; + return !hostState.authEnabled || hostState.authRoles.includes("administrator") || hostState.authRoles.includes("write"); } function bmSyncAccess() { const canCtrl = bmCanControl(); 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 089af5f6..4861c49f 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 @@ -47,6 +47,9 @@ var entryRigName = null; var entryGrid = null; var qsos = []; var workedRequest = 0; +function canWriteLogbook() { + return !hostState.authEnabled || hostState.authRoles.includes("administrator") || hostState.authRoles.includes("write"); +} function notify(message, kind) { if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0); else hostCore.showHint(message, 2e3); @@ -300,6 +303,11 @@ function renderRows() { row.appendChild(cell); } const actions = document.createElement("td"); + if (!canWriteLogbook()) { + row.appendChild(actions); + fragment.appendChild(row); + continue; + } const confirm = document.createElement("button"); confirm.type = "button"; confirm.className = "log-row-btn"; @@ -420,14 +428,19 @@ importFile?.addEventListener("change", () => { if (file) void importAdif(file); importFile.value = ""; }); -bridge.logContact = (seed) => { - bridge.navigateToTab?.("logbook"); - void openEntry(seed).then(() => callInput?.focus()); -}; +if (canWriteLogbook()) { + bridge.logContact = (seed) => { + bridge.navigateToTab?.("logbook"); + void openEntry(seed).then(() => callInput?.focus()); + }; +} else { + if (form) form.style.display = "none"; + if (importBtn) importBtn.style.display = "none"; +} renderStation(); if (cabrilloCallsign && !cabrilloCallsign.value) { cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? ""; } syncCabrilloLink(); -void openEntry(); +if (canWriteLogbook()) void openEntry(); void refreshLog(); 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 e744751b..9bbceb02 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 @@ -272,7 +272,7 @@ function schedulerOptionalEl(id) { const nextBtn = schedulerEl("scheduler-next-btn"); if (!prevBtn || !nextBtn) return; const state = schedulerInterleaveState(currentConfig); - const enabled = schedulerRole === "admin" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1; + const enabled = (schedulerRole === "administrator" || schedulerRole === "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 +354,7 @@ function schedulerOptionalEl(id) { const panel = schedulerEl("scheduler-panel"); if (!panel) return; const mode = currentConfig && currentConfig.mode || "disabled"; - const isControl = schedulerRole === "admin"; + const isControl = schedulerRole === "administrator" || schedulerRole === "control"; setSelected("scheduler-mode-select", mode); const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled; const controlRow = document.querySelector(".scheduler-control-row"); 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 a1cc9fb9..afd03ece 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 @@ -1461,6 +1461,7 @@ SPDX-License-Identifier: GPL-2.0-or-later +
@@ -1750,19 +1751,25 @@ SPDX-License-Identifier: GPL-2.0-or-later
- + +