diff --git a/Cargo.lock b/Cargo.lock index a8466f2c..63668937 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -316,6 +316,18 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -345,6 +357,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bindgen" version = "0.66.1" @@ -395,6 +413,24 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.12.0" @@ -525,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -721,6 +757,15 @@ dependencies = [ "windows", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -739,6 +784,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.1" @@ -799,15 +854,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "digest" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ - "block-buffer", + "block-buffer 0.12.0", "const-oid", - "crypto-common", + "crypto-common 0.2.1", ] [[package]] @@ -1019,6 +1085,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1890,6 +1966,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "peeking_take_while" version = "0.1.2" @@ -2492,8 +2579,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -3215,6 +3302,7 @@ version = "0.1.0" dependencies = [ "actix-web", "actix-ws", + "argon2", "base64", "brotli 7.0.0", "bytes", diff --git a/docs/Architecture.md b/docs/Architecture.md index 82d7f0b8..920332c8 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`, `rx_passphrase`, `session_ttl_secs`, `tokens` | +| `HttpAuthConfig` | HTTP auth settings | `enabled`, `users_file`, bootstrap admin, `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/Planned-Features.md b/docs/Planned-Features.md index 54360ff1..a6fc3e6f 100644 --- a/docs/Planned-Features.md +++ b/docs/Planned-Features.md @@ -477,7 +477,7 @@ first wins. | `GET` | `/api/logbook/now` | The server's UTC clock, for checking the browser's | | `GET` | `/api/logbook/prefill` | The six fields an entry opens with | -Writes require the control role, as the rig endpoints do. +Writes require the admin role, as the rig endpoints do. ### Frontend @@ -516,7 +516,7 @@ All five are implemented. | Phase | Lands | |-------|-------| | 1 | `trx-logbook`: `Qso`, the ADI reader and writer, round-trip tests against files from other loggers | -| 2 | Store, dedupe, and the HTTP API behind the control role | +| 2 | Store, dedupe, and the HTTP API behind the admin role | | 3 | Logbook tab: entry, table, filters, import, export | | 4 | Ham layout, pre-filled entry from a decode row or the map, worked-before | | 5 | Contest exchange fields and Cabrillo export; QSL and LoTW/eQSL fields; per-band worked/confirmed statistics | @@ -541,7 +541,7 @@ setting, which is also what LoTW's station locations expect. rotate operators through one station callsign, which is why contest loggers record it per QSO. It is stored per QSO, defaulted from the configured callsign so a single operator never touches it, and changed on the station line at the top of the panel where it sticks for the session. -It cannot be taken from the session's identity: the auth roles are `control` and `rx`, with no +It cannot be taken from the session's identity: the auth roles are `admin` and `user`, with no notion of who is logged in. **Server clock, and the log says so.** The server is the machine at the radio; the browser may diff --git a/docs/UX_Guidelines.md b/docs/UX_Guidelines.md index 3f347640..5b3c8396 100644 --- a/docs/UX_Guidelines.md +++ b/docs/UX_Guidelines.md @@ -121,13 +121,12 @@ The spectrum panel uses `` elements (WebGL renderer optional) and offers When auth is enabled, an **auth gate** blocks the UI with: - Title: "Access Required" -- Subtitle: "Enter passphrase to continue" -- Password input + Login button (green accent, full-width) -- Optional "Continue as Guest" button (shown when RX passphrase is not set) +- Subtitle: "Sign in to continue" +- Username and password inputs + Login button (green accent, full-width) - Error message area (red `#ff6b6b`) - Role badge display -Two roles: **Rx** (read-only) and **Control** (full access including TX/PTT). +Two roles: **User** (read-only) and **Admin** (full access including user management). Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes. @@ -340,21 +339,21 @@ Routes are classified into three tiers: | Tier | Examples | Requirement | |---|---|---| -| **Public** | `/`, `/index.html`, `/map`, `/auth/*`, static assets | None | -| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Rx or Control role | -| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Control role only | +| **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 | ### 7.2 Session Management - Sessions are 128-bit random hex tokens stored in HttpOnly cookies - Configurable TTL (default from TOML config) - Expired sessions auto-pruned on access -- Constant-time passphrase comparison to mitigate timing attacks +- Passwords are verified against salted Argon2id hashes -### 7.3 TX Access Control +### 7.3 User Management -An additional `tx_access_control_enabled` flag can restrict transmit-related actions even -for Control-role users, providing an extra safety layer. +Only administrators can list, add, update, or remove accounts. The final +administrator cannot be removed or demoted. --- diff --git a/docs/User-Manual.md b/docs/User-Manual.md index f37fd0ac..bc2bd327 100644 --- a/docs/User-Manual.md +++ b/docs/User-Manual.md @@ -66,8 +66,7 @@ both: |------------|----------|----------| | `[listen.auth].tokens` | `tokens_file` | one token per line | | `[[remotes]].auth.token` | `token_file` | the token | -| `[frontends.http.auth].rx_passphrase` | `rx_passphrase_file` | the passphrase | -| `[frontends.http.auth].control_passphrase` | `control_passphrase_file` | the passphrase | +| `[frontends.http.auth].bootstrap_admin_password` | `bootstrap_admin_password_file` | the initial administrator password | | `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line | Blank lines and `#` comments are ignored in the list files. A config that holds @@ -350,17 +349,17 @@ A name in any of those maps that no remote answers to is a config error. | Field | Type | Default | Description | |-------|------|---------|-------------| -| `enabled` | bool | `false` | Require a passphrase | -| `rx_passphrase` | string | — | Passphrase granting receive-only access | -| `rx_passphrase_file` | string | — | Read it from this file instead | -| `control_passphrase` | string | — | Passphrase granting full control | -| `control_passphrase_file` | string | — | Read it from this file instead | -| `tx_access_control_enabled` | bool | `true` | Hide TX from unauthenticated users | +| `enabled` | bool | `false` | Enable the user/password ACL | +| `users_file` | string | `"trx-http-users.json"` | Persistent managed user database | +| `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 | | `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` | -With `enabled = true`, at least one passphrase must be set. +When enabling ACL for the first time, configure both bootstrap fields. After +the database exists, remove the bootstrap credentials from configuration. #### `[frontends.rigctl]` @@ -578,7 +577,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 `control` role; an `rx` +Applying a link changes the radio, so it needs the `admin` role; a `user` 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. @@ -587,53 +586,58 @@ left as it was, rather than publishing a frequency the rig is not on. ## Authentication -The HTTP frontend supports optional passphrase-based authentication with two +The HTTP frontend supports an optional user/password ACL with multiple accounts and two roles: -- **rx** — read-only access (monitoring, audio, decode streams) -- **control** — full access (frequency, mode, PTT, and all settings) +- **user** — read-only access (monitoring, audio, decode streams) +- **admin** — full radio control, settings, and user management ### Configuration ```toml [frontends.http.auth] enabled = false -rx_passphrase = "rx-only-passphrase" -control_passphrase = "full-control-passphrase" -tx_access_control_enabled = true +users_file = "trx-http-users.json" +bootstrap_admin_username = "admin" +bootstrap_admin_password = "change-this-password" session_ttl_min = 480 cookie_secure = false # true if served via HTTPS cookie_same_site = "Lax" # Strict|Lax|None ``` When `enabled = false` (the default), all auth is bypassed and the UI behaves -as before. When enabled, at least one passphrase must be set. +as before. When enabling it for the first time, bootstrap credentials create +the initial administrator and the Argon2id-hashed user database. ### Behaviour - On login, the server issues an `HttpOnly` session cookie. - Sessions are in-memory; a server restart invalidates all sessions. - Rate limiting is applied per IP to mitigate brute-force attempts. -- When `tx_access_control_enabled = true`, TX/PTT controls are hidden and - rejected for unauthenticated or `rx`-role users. +- 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. +- Removing an account or changing its password/role revokes its sessions. ### Routes | Endpoint | Method | Description | |----------|--------|-------------| -| `/auth/login` | POST | Submit `{ "passphrase": "..." }` | +| `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` | | `/auth/logout` | POST | Clear session | | `/auth/session` | GET | Check current session/role | +| `/auth/users` | GET/POST | List or add users (admin only) | +| `/auth/users/{username}` | PATCH/DELETE | Change password/role or remove user (admin only) | -Protected routes require at least `rx` role. Control routes (set frequency, -mode, PTT, etc.) require `control` role. +Protected routes require at least `user` role. Control routes (set frequency, +mode, PTT, etc.) require `admin` role. ### 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. `rx` users see a read-only interface; `control` users get full controls. +4. `user` accounts see a read-only interface; admins get full controls. 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 cde1f992..98632697 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -252,11 +252,11 @@ async fn async_init() -> DynResult { // Set HTTP frontend authentication config frontend_runtime.http_auth.enabled = cfg.frontends.http.auth.enabled; - frontend_runtime.http_auth.rx_passphrase = cfg.frontends.http.auth.rx_passphrase.clone(); - frontend_runtime.http_auth.control_passphrase = - cfg.frontends.http.auth.control_passphrase.clone(); - frontend_runtime.http_auth.tx_access_control_enabled = - cfg.frontends.http.auth.tx_access_control_enabled; + frontend_runtime.http_auth.users_file = cfg.frontends.http.auth.users_file.clone(); + frontend_runtime.http_auth.bootstrap_admin_username = + 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.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 d57ba240..ae5d81a6 100644 --- a/src/trx-client/trx-frontend/src/lib.rs +++ b/src/trx-client/trx-frontend/src/lib.rs @@ -257,9 +257,9 @@ impl Default for DecodeHistoryContext { /// HTTP authentication configuration. pub struct HttpAuthConfig { pub enabled: bool, - pub rx_passphrase: Option, - pub control_passphrase: Option, - pub tx_access_control_enabled: bool, + pub users_file: String, + pub bootstrap_admin_username: Option, + pub bootstrap_admin_password: Option, pub session_ttl_secs: u64, pub cookie_secure: bool, pub cookie_same_site: String, @@ -271,9 +271,9 @@ impl Default for HttpAuthConfig { fn default() -> Self { Self { enabled: false, - rx_passphrase: None, - control_passphrase: None, - tx_access_control_enabled: true, + users_file: "trx-http-users.json".to_string(), + bootstrap_admin_username: None, + bootstrap_admin_password: None, 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/Cargo.toml b/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml index 25cfb039..f7795904 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml +++ b/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml @@ -28,6 +28,7 @@ flate2 = { workspace = true } brotli = "7" rand = "0.8" hex = "0.4" +argon2 = "0.5" pickledb = "0.5" dirs = "6" uuid = { workspace = true } 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 3e8a529a..7d18b688 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,7 +1323,7 @@ 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 !== "rx" && session.role !== "control") { + if (session.role !== void 0 && session.role !== "user" && session.role !== "admin") { throw new TypeError("The authentication response has an invalid role"); } if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") { @@ -1331,12 +1331,16 @@ function decodeAuthSession(value) { } const decoded = { authenticated: session.authenticated }; if (session.role !== void 0) decoded.role = session.role; + 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, - role: "control", + role: "admin", auth_disabled: true }; async function fetchAuthSession() { @@ -1350,11 +1354,11 @@ async function fetchAuthSession() { return { authenticated: false }; } } -async function login(passphrase) { +async function login(username, password) { const response = await fetch("/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ passphrase }) + body: JSON.stringify({ username, password }) }); if (response.status === 404) return authDisabledSession; if (!response.ok) { @@ -1363,6 +1367,34 @@ async function login(passphrase) { } 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" && (record.role === "user" || record.role === "admin"); + })) { + 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 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"); @@ -1863,32 +1895,32 @@ function isVchanRdsEntry(value) { } void loadDecoderRegistry(refreshOperatorLayoutCapabilities); var authRole = null; +var authUsername = null; var authEnabled = true; async function checkAuthStatus() { return fetchAuthSession(); } -async function authLogin(passphrase) { - return login(passphrase); +async function authLogin(username, password) { + return login(username, password); } async function authLogout() { try { await logout(); authRole = null; + authUsername = null; disconnect(); setDecodeHistoryOverlayVisible(false); requiredElement("content").style.display = "none"; requiredElement("loading").style.display = "none"; - requiredElement("auth-passphrase").value = ""; + requiredElement("auth-password").value = ""; updateAuthUI(); - const authStatus = await checkAuthStatus(); - const allowGuest = authStatus.role === "rx"; - showAuthGate(allowGuest); + showAuthGate(); } catch (e) { console.error("Logout failed:", e); showAuthError("Logout failed"); } } -function showAuthGate(allowGuest = false) { +function showAuthGate() { if (!authEnabled) return; setDecodeHistoryOverlayVisible(false); requiredElement("loading").style.display = "none"; @@ -1905,10 +1937,6 @@ function showAuthGate(allowGuest = false) { document.querySelectorAll(".tab-panel").forEach((panel) => { panel.style.display = "none"; }); - const guestBtn2 = document.getElementById("auth-guest-btn"); - if (guestBtn2) { - guestBtn2.style.display = allowGuest ? "block" : "none"; - } document.querySelectorAll(".tab-bar .tab").forEach((btn) => { btn.classList.toggle("active", btn.dataset.tab === "main"); }); @@ -1952,7 +1980,7 @@ function updateAuthUI() { } if (authRole) { if (badge) badge.style.display = "block"; - if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)"; + if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRole === "admin" ? "Admin" : "User (read-only)"}`; if (headerAuthBtn2) { headerAuthBtn2.textContent = "Logout"; headerAuthBtn2.style.display = "block"; @@ -1968,7 +1996,7 @@ function updateAuthUI() { } function applyAuthRestrictions() { if (!authRole) return; - if (authRole === "rx") { + if (authRole === "user") { const pttBtn2 = document.getElementById("ptt-btn"); const powerBtn2 = document.getElementById("power-btn"); const lockBtn2 = document.getElementById("lock-btn"); @@ -2271,7 +2299,7 @@ function syncTopBarAccess() { rigSwitch.style.display = loggedOut ? "none" : ""; } if (headerRigSwitchSelect) { - headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0; + headerRigSwitchSelect.disabled = loggedOut || authRole === "user" || lastRigIds.length === 0; } } var overviewDrawPending = false; @@ -2907,7 +2935,7 @@ function applyRigList(activeRigId, rigIds, displayNames) { } const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || ""); const rigListChanged = prevKey !== nextKey; - const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx"; + const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "user"; populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); updateRigSubtitle(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId); @@ -4510,7 +4538,7 @@ function scheduleTuneLinkSync() { async function applyTuneLink(link) { const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null; if (!wanted) return; - if (authRole === "rx") { + if (authRole === "user") { showHint("Read-only session — link not applied", 2500); return; } @@ -5253,8 +5281,8 @@ async function switchRigFromSelect(selectEl) { showHint("No rig selected", 1500); return; } - if (authRole === "rx") { - showHint("Control role required", 1500); + if (authRole === "user") { + showHint("Admin role required", 1500); return; } if (!lastRigIds.includes(selectEl.value)) { @@ -5868,7 +5896,7 @@ function navigateToTab(name, options = {}) { const leavingSatellites = _activeTab === "satellites" && name !== "satellites"; const { updateHistory = true, replaceHistory = false } = options; if (authEnabled && !authRole && name !== "main") { - showAuthGate(false); + showAuthGate(); return; } const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`); @@ -5981,11 +6009,11 @@ window.addEventListener("resize", () => { scheduleSpectrumLayout(); }); async function initializeApp() { - showAuthGate(false); + showAuthGate(); const authStatus = await checkAuthStatus(); authEnabled = !authStatus.auth_disabled; if (!authEnabled) { - authRole = "control"; + authRole = "admin"; hideAuthGate(); updateAuthUI(); connect(); @@ -5997,6 +6025,7 @@ async function initializeApp() { } if (authStatus.authenticated) { authRole = authStatus.role ?? null; + authUsername = authStatus.username ?? null; hideAuthGate(); updateAuthUI(); applyAuthRestrictions(); @@ -6006,8 +6035,7 @@ async function initializeApp() { resizeHeaderSignalCanvas(); startHeaderSignalSampling(); } else { - const allowGuest = authStatus.role === "rx"; - showAuthGate(allowGuest); + showAuthGate(); } } var settingsUiReady = false; @@ -6019,19 +6047,104 @@ function initSettingsUI() { window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole); window.trx.modules.backgroundDecode.wireEvents(); } + void refreshUserManagement(); } +async function refreshUserManagement() { + const section = document.getElementById("user-management"); + if (!section) return; + section.style.display = authEnabled && authRole === "admin" ? "block" : "none"; + if (section.style.display === "none") return; + const list = requiredElement("user-list"); + try { + const users = await listUsers(); + list.replaceChildren(...users.map((user) => { + const row = document.createElement("div"); + row.className = "sch-row"; + row.style.cssText = "display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin:.4rem 0"; + const name = document.createElement("strong"); + name.textContent = user.username; + name.style.minWidth = "10rem"; + const role = document.createElement("select"); + role.className = "auth-input"; + for (const value of ["user", "admin"]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value === "admin" ? "Admin" : "User"; + option.selected = user.role === value; + role.append(option); + } + const password = document.createElement("input"); + password.type = "password"; + password.placeholder = "New password"; + password.autocomplete = "new-password"; + password.className = "auth-input"; + password.minLength = 8; + const save = document.createElement("button"); + save.type = "button"; + save.textContent = "Save"; + save.addEventListener("click", async () => { + const changes = { role: role.value }; + if (password.value) changes.password = password.value; + await runUserOperation(() => updateUser(user.username, changes)); + }); + const remove = document.createElement("button"); + remove.type = "button"; + remove.textContent = "Remove"; + remove.className = "danger"; + remove.disabled = user.username === authUsername; + remove.addEventListener("click", async () => { + if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) { + await runUserOperation(() => deleteUser(user.username)); + } + }); + row.append(name, role, password, save, remove); + return row; + })); + } catch (error) { + showUserManagementError(error); + } +} +function showUserManagementError(error) { + const element = document.getElementById("user-management-error"); + if (!element) return; + element.textContent = error instanceof Error ? error.message : String(error); + element.style.display = "block"; +} +async function runUserOperation(operation) { + try { + await operation(); + const error = document.getElementById("user-management-error"); + if (error) error.style.display = "none"; + await refreshUserManagement(); + } catch (reason) { + showUserManagementError(reason); + } +} +document.getElementById("user-create-form")?.addEventListener("submit", (event) => { + event.preventDefault(); + const username = requiredElement("user-create-username"); + const password = requiredElement("user-create-password"); + const role = requiredElement("user-create-role"); + void runUserOperation(async () => { + await createUser(username.value, password.value, role.value); + username.value = ""; + password.value = ""; + role.value = "user"; + }); +}); requiredElement("auth-form").addEventListener("submit", async (e) => { e.preventDefault(); - const passphraseEl = requiredElement("auth-passphrase"); - const passphrase = passphraseEl.value; + const usernameEl = requiredElement("auth-username"); + const passwordEl = requiredElement("auth-password"); const btn = requiredElement("auth-form").querySelector("button[type=submit]"); if (!btn) return; btn.disabled = true; btn.textContent = "Logging in..."; try { - const result = await authLogin(passphrase); + const result = await authLogin(usernameEl.value, passwordEl.value); authRole = result.role ?? null; - passphraseEl.value = ""; + authUsername = result.username ?? usernameEl.value; + passwordEl.value = ""; hideAuthGate(); updateAuthUI(); applyAuthRestrictions(); @@ -6041,28 +6154,13 @@ requiredElement("auth-form").addEventListener("submit", async (e) => { resizeHeaderSignalCanvas(); startHeaderSignalSampling(); } catch (err) { - showAuthError("Invalid passphrase"); + showAuthError("Invalid username or password"); console.error("Login error:", err); } finally { btn.disabled = false; btn.textContent = "Login"; } }); -var guestBtn = document.getElementById("auth-guest-btn"); -if (guestBtn) { - guestBtn.addEventListener("click", () => { - authRole = "rx"; - requiredElement("auth-passphrase").value = ""; - hideAuthGate(); - updateAuthUI(); - applyAuthRestrictions(); - connect(); - connectDecode(); - initSettingsUI(); - resizeHeaderSignalCanvas(); - startHeaderSignalSampling(); - }); -} var headerAuthBtn = document.getElementById("header-auth-btn"); if (headerAuthBtn) { headerAuthBtn.addEventListener("click", async () => { @@ -6071,7 +6169,7 @@ if (headerAuthBtn) { await authLogout(); } } else { - showAuthGate(false); + showAuthGate(); } }); } 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 a14bc591..50b86551 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 === "control" || hostState.authEnabled === false; + return backgroundDecodeRole === "admin" || 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 224b5fd8..8ac64be7 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 === "control"; + return !hostState.authEnabled || hostState.authRole === "admin"; } function bmSyncAccess() { const canCtrl = bmCanControl(); 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 dfd4f77d..e744751b 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 === "control" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1; + const enabled = schedulerRole === "admin" && !!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 === "control"; + const isControl = schedulerRole === "admin"; 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 fa524ee5..a1cc9fb9 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 @@ -123,13 +123,13 @@ SPDX-License-Identifier: GPL-2.0-or-later @@ -1750,6 +1750,19 @@ SPDX-License-Identifier: GPL-2.0-or-later +