Compare commits

..
Author SHA1 Message Date
sjg 721ff04908 Add restricted guest role
CI / lint (pull_request) Successful in 2m26s
CI / test (pull_request) Successful in 9m22s
CI / frontend (pull_request) Successful in 5m15s
CI / reuse (pull_request) Successful in 5s
2026-08-11 15:24:51 +02:00
sjg 5e9dae02c7 Complete managed account lifecycle
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m6s
CI / frontend (pull_request) Successful in 5m17s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m26s
2026-08-11 07:49:53 +02:00
sjg 34507ffa17 Add composable HTTP access roles
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m24s
CI / frontend (pull_request) Successful in 5m12s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 8m8s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
2026-08-11 01:06:57 +02:00
sjg 36c1e56efa Protect the final administrator 2026-08-11 00:39:25 +02:00
sjg e4cce9a004 Add Users settings tab 2026-08-11 00:37:59 +02:00
sjg 3c3fc69542 Refactor HTTP account system
CI / frontend (pull_request) Successful in 5m13s
CI / reuse (pull_request) Successful in 29s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
CI / lint (pull_request) Successful in 2m25s
CI / test (pull_request) Successful in 9m24s
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 8m19s
2026-08-10 23:47:51 +02:00
43 changed files with 2784 additions and 678 deletions
Generated
+93 -5
View File
@@ -316,6 +316,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 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]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -345,6 +357,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bindgen" name = "bindgen"
version = "0.66.1" version = "0.66.1"
@@ -395,6 +413,24 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" 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]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.12.0" version = "0.12.0"
@@ -525,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.3.0",
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
@@ -721,6 +757,15 @@ dependencies = [
"windows", "windows",
] ]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.3.0" version = "0.3.0"
@@ -739,6 +784,16 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.2.1" version = "0.2.1"
@@ -799,15 +854,26 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "digest" name = "digest"
version = "0.11.2" version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
dependencies = [ dependencies = [
"block-buffer", "block-buffer 0.12.0",
"const-oid", "const-oid",
"crypto-common", "crypto-common 0.2.1",
] ]
[[package]] [[package]]
@@ -1019,6 +1085,16 @@ dependencies = [
"slab", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -1890,6 +1966,17 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "peeking_take_while" name = "peeking_take_while"
version = "0.1.2" version = "0.1.2"
@@ -2492,8 +2579,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.3.0",
"digest", "digest 0.11.2",
] ]
[[package]] [[package]]
@@ -3215,6 +3302,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"actix-web", "actix-web",
"actix-ws", "actix-ws",
"argon2",
"base64", "base64",
"brotli 7.0.0", "brotli 7.0.0",
"bytes", "bytes",
+8 -4
View File
@@ -925,9 +925,13 @@ main
### HTTP Frontend Auth ### HTTP Frontend Auth
- Optional token or HTTP Basic Auth middleware - Optional Argon2id-backed managed accounts with HttpOnly session cookies
- Configured in `[frontends.http.auth]` - An exclusive Guest role plus composable Read, Control, Write, and Administrator roles, with policy shared by middleware and handlers
- Rate limiting supported - Guest sessions receive read-only station access but no account-control endpoints or panels
- Atomic JSON persistence with migration from the legacy single-role schema
- Account enable/disable, administrator CRUD, self-service password changes, and session revocation on security changes
- A database invariant always preserves at least one enabled administrator
- Per-IP login rate limiting; configured in `[frontends.http.auth]`
### Transport Security ### Transport Security
@@ -1043,7 +1047,7 @@ The `FrontendRuntimeContext` struct in `trx-frontend/src/lib.rs` is decomposed i
|-----------|---------|------------| |-----------|---------|------------|
| `AudioContext` | Audio streaming channels | `rx`, `tx`, `info`, `decode_rx`, `clients` | | `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` | | `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/read accounts, `session_ttl_secs`, `tokens` |
| `HttpUiConfig` | HTTP UI display config | `show_sdr_gain_control`, `initial_map_zoom`, `spectrum_*` | | `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` | | `RigRoutingContext` | Remote rig state & routing | `active_rig_id`, `remote_rigs`, `rig_states`, `server_connected` |
| `OwnerInfo` | Station metadata | `callsign`, `website_url`, `ais_vessel_url_base` | | `OwnerInfo` | Station metadata | `callsign`, `website_url`, `ais_vessel_url_base` |
+3 -3
View File
@@ -477,7 +477,7 @@ first wins.
| `GET` | `/api/logbook/now` | The server's UTC clock, for checking the browser's | | `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 | | `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 ### Frontend
@@ -516,7 +516,7 @@ All five are implemented.
| Phase | Lands | | Phase | Lands |
|-------|-------| |-------|-------|
| 1 | `trx-logbook`: `Qso`, the ADI reader and writer, round-trip tests against files from other loggers | | 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 | | 3 | Logbook tab: entry, table, filters, import, export |
| 4 | Ham layout, pre-filled entry from a decode row or the map, worked-before | | 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 | | 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. 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 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, 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. notion of who is logged in.
**Server clock, and the log says so.** The server is the machine at the radio; the browser may **Server clock, and the log says so.** The server is the machine at the radio; the browser may
+17 -11
View File
@@ -121,13 +121,14 @@ The spectrum panel uses `<canvas>` elements (WebGL renderer optional) and offers
When auth is enabled, an **auth gate** blocks the UI with: When auth is enabled, an **auth gate** blocks the UI with:
- Title: "Access Required" - Title: "Access Required"
- Subtitle: "Enter passphrase to continue" - Subtitle: "Sign in to continue"
- Password input + Login button (green accent, full-width) - Username and password inputs + Login button (green accent, full-width)
- Optional "Continue as Guest" button (shown when RX passphrase is not set)
- Error message area (red `#ff6b6b`) - Error message area (red `#ff6b6b`)
- Role badge display - Role badge display
Two roles: **Rx** (read-only) and **Control** (full access including TX/PTT). **Guest** provides read-only station access and is exclusive. Non-Guest accounts
may combine **Read**, **Control**, **Write**, and **Administrator** roles.
Administrator implies all permissions.
Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes. Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes.
@@ -340,21 +341,26 @@ Routes are classified into three tiers:
| Tier | Examples | Requirement | | Tier | Examples | Requirement |
|---|---|---| |---|---|---|
| **Public** | `/`, `/index.html`, `/map`, `/auth/*`, static assets | None | | **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None |
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Rx or Control role | | **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Guest, Read, Control, or Administrator role |
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Control role only | | **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 ### 7.2 Session Management
- Sessions are 128-bit random hex tokens stored in HttpOnly cookies - Sessions are 128-bit random hex tokens stored in HttpOnly cookies
- Configurable TTL (default from TOML config) - Configurable TTL (default from TOML config)
- Expired sessions auto-pruned on access - 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 Every authenticated non-Guest account gets a Settings > Account tab for changing
for Control-role users, providing an extra safety layer. its own password. Guest sees neither Account nor Users and both account-control
APIs deny Guest sessions. 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.
--- ---
+44 -25
View File
@@ -66,8 +66,7 @@ both:
|------------|----------|----------| |------------|----------|----------|
| `[listen.auth].tokens` | `tokens_file` | one token per line | | `[listen.auth].tokens` | `tokens_file` | one token per line |
| `[[remotes]].auth.token` | `token_file` | the token | | `[[remotes]].auth.token` | `token_file` | the token |
| `[frontends.http.auth].rx_passphrase` | `rx_passphrase_file` | the passphrase | | `[frontends.http.auth].bootstrap_admin_password` | `bootstrap_admin_password_file` | the initial administrator password |
| `[frontends.http.auth].control_passphrase` | `control_passphrase_file` | the passphrase |
| `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line | | `[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 Blank lines and `#` comments are ignored in the list files. A config that holds
@@ -350,17 +349,20 @@ A name in any of those maps that no remote answers to is a config error.
| Field | Type | Default | Description | | Field | Type | Default | Description |
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `enabled` | bool | `false` | Require a passphrase | | `enabled` | bool | `false` | Enable the user/password ACL |
| `rx_passphrase` | string | — | Passphrase granting receive-only access | | `users_file` | string | `"trx-http-users.json"` | Persistent managed user database |
| `rx_passphrase_file` | string | — | Read it from this file instead | | `bootstrap_admin_username` | string | — | First administrator, used only if the database is absent |
| `control_passphrase` | string | — | Passphrase granting full control | | `bootstrap_admin_password` | string | — | First administrator password |
| `control_passphrase_file` | string | — | Read it from this file instead | | `bootstrap_admin_password_file` | string | — | Read the bootstrap password from this file instead |
| `tx_access_control_enabled` | bool | `true` | Hide TX from unauthenticated users | | `bootstrap_read_enabled` | bool | `true` | Create the default Guest account when the database is absent |
| `bootstrap_read_username` | string | `"guest"` | Initial Guest username |
| `bootstrap_read_password` | string | `"guest"` | Initial Guest password |
| `session_ttl_min` | u64 | `480` | Session lifetime | | `session_ttl_min` | u64 | `480` | Session lifetime |
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) | | `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` | | `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]` #### `[frontends.rigctl]`
@@ -578,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 address bar itself is updated as you tune, using `replaceState`, so sweeping
the dial does not fill the browser's history. 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 `Control` role; a `Read`
session opens the page and says the link was not applied. Links describe the 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 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. left as it was, rather than publishing a frequency the rig is not on.
@@ -587,53 +589,70 @@ left as it was, rather than publishing a frequency the rig is not on.
## Authentication ## Authentication
The HTTP frontend supports optional passphrase-based authentication with two The HTTP frontend supports an optional user/password ACL:
roles:
- **rx** — read-only access (monitoring, audio, decode streams) - **Guest** — read-only station access with no Account or Users controls; Guest cannot be combined with another role
- **control** — full access (frequency, mode, PTT, and all settings) - **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 ### Configuration
```toml ```toml
[frontends.http.auth] [frontends.http.auth]
enabled = false enabled = false
rx_passphrase = "rx-only-passphrase" users_file = "trx-http-users.json"
control_passphrase = "full-control-passphrase" bootstrap_admin_username = "admin"
tx_access_control_enabled = true bootstrap_admin_password = "change-this-password"
bootstrap_read_enabled = true
bootstrap_read_username = "guest"
bootstrap_read_password = "guest"
session_ttl_min = 480 session_ttl_min = 480
cookie_secure = false # true if served via HTTPS cookie_secure = false # true if served via HTTPS
cookie_same_site = "Lax" # Strict|Lax|None cookie_same_site = "Lax" # Strict|Lax|None
``` ```
When `enabled = false` (the default), all auth is bypassed and the UI behaves 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 (with every non-Guest role), the default `guest`/`guest` Guest
account, and the Argon2id-hashed user database. Change or disable the guest
credentials in configuration before first startup on an exposed deployment.
### Behaviour ### Behaviour
- On login, the server issues an `HttpOnly` session cookie. - On login, the server issues an `HttpOnly` session cookie.
- Sessions are in-memory; a server restart invalidates all sessions. - Sessions are in-memory; a server restart invalidates all sessions.
- Rate limiting is applied per IP to mitigate brute-force attempts. - Rate limiting is applied per IP to mitigate brute-force attempts.
- When `tx_access_control_enabled = true`, TX/PTT controls are hidden and - User records persist in `users_file`; passwords are stored as salted Argon2id hashes.
rejected for unauthenticated or `rx`-role users. - Non-Guest roles are independent; for example, an account may have Read and Write without Control.
- Guest accounts have no account-control panels and cannot call account-control endpoints.
- Every non-Guest 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 ### Routes
| Endpoint | Method | Description | | Endpoint | Method | Description |
|----------|--------|-------------| |----------|--------|-------------|
| `/auth/login` | POST | Submit `{ "passphrase": "..." }` | | `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` |
| `/auth/logout` | POST | Clear session | | `/auth/logout` | POST | Clear session |
| `/auth/session` | GET | Check current session/role | | `/auth/session` | GET | Check current session/roles |
| `/auth/account/password` | PATCH | Change a non-Guest user's password after verifying the current password |
| `/auth/users` | GET/POST | List or add users (admin only) |
| `/auth/users/{username}` | PATCH/DELETE | Change enabled state/password/roles or remove user (administrator only) |
Protected routes require at least `rx` role. Control routes (set frequency, Read routes accept Guest or require Read. Radio mutations require Control. Logbook access and
mode, PTT, etc.) require `control` role. bookmark mutations require Write. Administrator grants every permission.
### Frontend Flow ### Frontend Flow
1. On load, the UI calls `/auth/session`. 1. On load, the UI calls `/auth/session`.
2. If unauthenticated, a login screen is shown. 2. If unauthenticated, a login screen is shown.
3. On successful login, the normal UI loads. 3. On successful login, the normal UI loads.
4. `rx` users see a read-only interface; `control` users 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. 5. If a session expires mid-use, streams stop and the login screen returns.
### Transport Security ### Transport Security
+11 -5
View File
@@ -252,11 +252,17 @@ async fn async_init() -> DynResult<AppState> {
// Set HTTP frontend authentication config // Set HTTP frontend authentication config
frontend_runtime.http_auth.enabled = cfg.frontends.http.auth.enabled; 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.users_file = cfg.frontends.http.auth.users_file.clone();
frontend_runtime.http_auth.control_passphrase = frontend_runtime.http_auth.bootstrap_admin_username =
cfg.frontends.http.auth.control_passphrase.clone(); cfg.frontends.http.auth.bootstrap_admin_username.clone();
frontend_runtime.http_auth.tx_access_control_enabled = frontend_runtime.http_auth.bootstrap_admin_password =
cfg.frontends.http.auth.tx_access_control_enabled; 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.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_secure = cfg.frontends.http.auth.cookie_secure;
frontend_runtime.http_auth.cookie_same_site = match cfg.frontends.http.auth.cookie_same_site { frontend_runtime.http_auth.cookie_same_site = match cfg.frontends.http.auth.cookie_same_site {
+12 -6
View File
@@ -257,9 +257,12 @@ impl Default for DecodeHistoryContext {
/// HTTP authentication configuration. /// HTTP authentication configuration.
pub struct HttpAuthConfig { pub struct HttpAuthConfig {
pub enabled: bool, pub enabled: bool,
pub rx_passphrase: Option<String>, pub users_file: String,
pub control_passphrase: Option<String>, pub bootstrap_admin_username: Option<String>,
pub tx_access_control_enabled: bool, pub bootstrap_admin_password: Option<String>,
pub bootstrap_read_enabled: bool,
pub bootstrap_read_username: String,
pub bootstrap_read_password: Option<String>,
pub session_ttl_secs: u64, pub session_ttl_secs: u64,
pub cookie_secure: bool, pub cookie_secure: bool,
pub cookie_same_site: String, pub cookie_same_site: String,
@@ -271,9 +274,12 @@ impl Default for HttpAuthConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: false, enabled: false,
rx_passphrase: None, users_file: "trx-http-users.json".to_string(),
control_passphrase: None, bootstrap_admin_username: None,
tx_access_control_enabled: true, 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, session_ttl_secs: 480 * 60,
cookie_secure: false, cookie_secure: false,
cookie_same_site: "Lax".to_string(), cookie_same_site: "Lax".to_string(),
@@ -28,6 +28,7 @@ flate2 = { workspace = true }
brotli = "7" brotli = "7"
rand = "0.8" rand = "0.8"
hex = "0.4" hex = "0.4"
argon2 = "0.5"
pickledb = "0.5" pickledb = "0.5"
dirs = "6" dirs = "6"
uuid = { workspace = true } uuid = { workspace = true }
@@ -1,3 +1,20 @@
import {
AUTH_ADMIN_ROLES,
AUTH_ROLES,
AUTH_ROLE_LABELS,
changeOwnPassword,
createUser,
deleteUser,
fetchAuthSession,
hasAccountControls,
hasAuthRole,
listUsers,
login,
logout,
normalizeAuthRoles,
updateUser
} from "./chunk-FT2RH7BL.js";
// src/webgl-renderer.ts // src/webgl-renderer.ts
(function initTrxWebGl(global) { (function initTrxWebGl(global) {
"use strict"; "use strict";
@@ -1314,60 +1331,6 @@ async function loadDecoderRegistry(onLoaded) {
bridge.decoderRegistry = decoderRegistry; bridge.decoderRegistry = decoderRegistry;
bridge.onDecoderRegistryReady = onDecoderRegistryReady; bridge.onDecoderRegistryReady = onDecoderRegistryReady;
// src/api/auth.ts
function decodeAuthSession(value) {
if (typeof value !== "object" || value === null) {
throw new TypeError("The authentication response is malformed");
}
const session = value;
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (session.role !== void 0 && session.role !== "rx" && session.role !== "control") {
throw new TypeError("The authentication response has an invalid role");
}
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;
if (session.auth_disabled !== void 0) decoded.auth_disabled = session.auth_disabled;
return decoded;
}
var authDisabledSession = {
authenticated: true,
role: "control",
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 };
return decodeAuthSession(await response.json());
} catch (error) {
console.error("Auth check failed:", error);
return { authenticated: false };
}
}
async function login(passphrase) {
const response = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase })
});
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 logout() {
const response = await fetch("/auth/logout", { method: "POST" });
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
}
// src/core/format.ts // src/core/format.ts
function formatDuration(milliseconds) { function formatDuration(milliseconds) {
const seconds = Math.floor(milliseconds / 1e3); const seconds = Math.floor(milliseconds / 1e3);
@@ -1862,33 +1825,68 @@ function isVchanRdsEntry(value) {
return isRecord2(value) && typeof value.id === "string" && (value.rds === void 0 || value.rds === null || isRdsData(value.rds)) && (value.signal_db === void 0 || value.signal_db === null || typeof value.signal_db === "number"); return isRecord2(value) && typeof value.id === "string" && (value.rds === void 0 || value.rds === null || isRdsData(value.rds)) && (value.signal_db === void 0 || value.signal_db === null || typeof value.signal_db === "number");
} }
void loadDecoderRegistry(refreshOperatorLayoutCapabilities); void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
var authRole = null; var authRoles = [];
var authUsername = null;
var authEnabled = true; var authEnabled = true;
function setAuthRoles(roles) {
authRoles = normalizeAuthRoles(roles);
}
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 };
});
inputs.forEach(({ input, value }) => {
input.addEventListener("change", () => {
if (!input.checked) return;
if (value === "guest") {
inputs.forEach((choice) => {
if (choice.value !== "guest") choice.input.checked = false;
});
} else {
const guest = inputs.find((choice) => choice.value === "guest");
if (guest) guest.input.checked = false;
}
});
});
return { element, inputs };
}
async function checkAuthStatus() { async function checkAuthStatus() {
return fetchAuthSession(); return fetchAuthSession();
} }
async function authLogin(passphrase) { async function authLogin(username, password) {
return login(passphrase); return login(username, password);
} }
async function authLogout() { async function authLogout() {
try { try {
await logout(); await logout();
authRole = null; setAuthRoles([]);
authUsername = null;
disconnect(); disconnect();
setDecodeHistoryOverlayVisible(false); setDecodeHistoryOverlayVisible(false);
requiredElement("content").style.display = "none"; requiredElement("content").style.display = "none";
requiredElement("loading").style.display = "none"; requiredElement("loading").style.display = "none";
requiredElement("auth-passphrase").value = ""; requiredElement("auth-password").value = "";
updateAuthUI(); updateAuthUI();
const authStatus = await checkAuthStatus(); showAuthGate();
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
} catch (e) { } catch (e) {
console.error("Logout failed:", e); console.error("Logout failed:", e);
showAuthError("Logout failed"); showAuthError("Logout failed");
} }
} }
function showAuthGate(allowGuest = false) { function showAuthGate() {
if (!authEnabled) return; if (!authEnabled) return;
setDecodeHistoryOverlayVisible(false); setDecodeHistoryOverlayVisible(false);
requiredElement("loading").style.display = "none"; requiredElement("loading").style.display = "none";
@@ -1905,10 +1903,6 @@ function showAuthGate(allowGuest = false) {
document.querySelectorAll(".tab-panel").forEach((panel) => { document.querySelectorAll(".tab-panel").forEach((panel) => {
panel.style.display = "none"; 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) => { document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tab === "main"); btn.classList.toggle("active", btn.dataset.tab === "main");
}); });
@@ -1944,20 +1938,30 @@ function updateAuthUI() {
const badge = document.getElementById("auth-badge"); const badge = document.getElementById("auth-badge");
const badgeRole = document.getElementById("auth-role-badge"); const badgeRole = document.getElementById("auth-role-badge");
const headerAuthBtn2 = document.getElementById("header-auth-btn"); const headerAuthBtn2 = document.getElementById("header-auth-btn");
const accountTab = document.getElementById("settings-account-tab");
if (!authEnabled) { if (!authEnabled) {
if (badge) badge.style.display = "none"; if (badge) badge.style.display = "none";
if (headerAuthBtn2) headerAuthBtn2.style.display = "none"; if (headerAuthBtn2) headerAuthBtn2.style.display = "none";
if (accountTab) accountTab.style.display = "none";
syncTopBarAccess(); syncTopBarAccess();
return; return;
} }
if (authRole) { if (authRoles.length > 0) {
const canManageAccount = hasAccountControls(authRoles);
if (accountTab) accountTab.style.display = canManageAccount ? "" : "none";
if (!canManageAccount && accountTab?.classList.contains("active")) {
const panel = document.getElementById("subtab-settings-account");
if (panel) panel.style.display = "none";
document.querySelector('[data-subtab="settings-scheduler"]')?.click();
}
if (badge) badge.style.display = "block"; if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)"; if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`;
if (headerAuthBtn2) { if (headerAuthBtn2) {
headerAuthBtn2.textContent = "Logout"; headerAuthBtn2.textContent = "Logout";
headerAuthBtn2.style.display = "block"; headerAuthBtn2.style.display = "block";
} }
} else { } else {
if (accountTab) accountTab.style.display = "none";
if (badge) badge.style.display = "none"; if (badge) badge.style.display = "none";
if (headerAuthBtn2) { if (headerAuthBtn2) {
headerAuthBtn2.textContent = "Login"; headerAuthBtn2.textContent = "Login";
@@ -1967,8 +1971,8 @@ function updateAuthUI() {
syncTopBarAccess(); syncTopBarAccess();
} }
function applyAuthRestrictions() { function applyAuthRestrictions() {
if (!authRole) return; if (authRoles.length === 0) return;
if (authRole === "rx") { if (!hasAuthRole2("control")) {
const pttBtn2 = document.getElementById("ptt-btn"); const pttBtn2 = document.getElementById("ptt-btn");
const powerBtn2 = document.getElementById("power-btn"); const powerBtn2 = document.getElementById("power-btn");
const lockBtn2 = document.getElementById("lock-btn"); const lockBtn2 = document.getElementById("lock-btn");
@@ -2258,20 +2262,21 @@ window.applyDecodeHistoryRetention = function() {
} }
}; };
function syncTopBarAccess() { function syncTopBarAccess() {
const loggedOut = authEnabled && !authRole; const loggedOut = authEnabled && authRoles.length === 0;
const tabBar = document.getElementById("tab-bar"); const tabBar = document.getElementById("tab-bar");
const rigSwitch = document.querySelector(".header-rig-switch"); const rigSwitch = document.querySelector(".header-rig-switch");
if (tabBar) tabBar.style.display = ""; if (tabBar) tabBar.style.display = "";
document.querySelectorAll(".tab-bar .tab").forEach((btn) => { document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
const isMain = btn.dataset.tab === "main"; const isMain = btn.dataset.tab === "main";
btn.style.display = !loggedOut || isMain ? "" : "none"; const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole2("write");
btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none";
btn.disabled = false; btn.disabled = false;
}); });
if (rigSwitch) { if (rigSwitch) {
rigSwitch.style.display = loggedOut ? "none" : ""; rigSwitch.style.display = loggedOut ? "none" : "";
} }
if (headerRigSwitchSelect) { if (headerRigSwitchSelect) {
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0; headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole2("control") || lastRigIds.length === 0;
} }
} }
var overviewDrawPending = false; var overviewDrawPending = false;
@@ -2907,7 +2912,7 @@ function applyRigList(activeRigId, rigIds, displayNames) {
} }
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || ""); const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
const rigListChanged = prevKey !== nextKey; const rigListChanged = prevKey !== nextKey;
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx"; const disableSwitch = lastRigIds.length === 0 || !hasAuthRole2("control");
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId); updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId);
@@ -4510,7 +4515,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link) { async function applyTuneLink(link) {
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null; const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
if (!wanted) return; if (!wanted) return;
if (authRole === "rx") { if (!hasAuthRole2("control")) {
showHint("Read-only session — link not applied", 2500); showHint("Read-only session — link not applied", 2500);
return; return;
} }
@@ -5228,7 +5233,7 @@ async function postPath(path, options = {}) {
} }
const resp = await fetch(path, { method: "POST" }); const resp = await fetch(path, { method: "POST" });
if (authEnabled && resp.status === 401) { if (authEnabled && resp.status === 401) {
authRole = null; setAuthRoles([]);
if (es) es.close(); if (es) es.close();
showAuthGate(); showAuthGate();
throw new Error("Authentication required"); throw new Error("Authentication required");
@@ -5253,7 +5258,7 @@ async function switchRigFromSelect(selectEl) {
showHint("No rig selected", 1500); showHint("No rig selected", 1500);
return; return;
} }
if (authRole === "rx") { if (!hasAuthRole2("control")) {
showHint("Control role required", 1500); showHint("Control role required", 1500);
return; return;
} }
@@ -5867,8 +5872,13 @@ function navigateToTab(name, options = {}) {
window.trxUi?.closeMobileOverlays?.(); window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites"; const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options; const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") { if (authEnabled && authRoles.length === 0 && name !== "main") {
showAuthGate(false); showAuthGate();
return;
}
if (authEnabled && name === "logbook" && !hasAuthRole2("write")) {
showHint("Write role required for logbook access", 2500);
navigateToTab("main", options);
return; return;
} }
const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`); const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`);
@@ -5981,11 +5991,11 @@ window.addEventListener("resize", () => {
scheduleSpectrumLayout(); scheduleSpectrumLayout();
}); });
async function initializeApp() { async function initializeApp() {
showAuthGate(false); showAuthGate();
const authStatus = await checkAuthStatus(); const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled; authEnabled = !authStatus.auth_disabled;
if (!authEnabled) { if (!authEnabled) {
authRole = "control"; setAuthRoles(AUTH_ADMIN_ROLES);
hideAuthGate(); hideAuthGate();
updateAuthUI(); updateAuthUI();
connect(); connect();
@@ -5996,7 +6006,8 @@ async function initializeApp() {
return; return;
} }
if (authStatus.authenticated) { if (authStatus.authenticated) {
authRole = authStatus.role ?? null; setAuthRoles(authStatus.roles);
authUsername = authStatus.username ?? null;
hideAuthGate(); hideAuthGate();
updateAuthUI(); updateAuthUI();
applyAuthRestrictions(); applyAuthRestrictions();
@@ -6006,32 +6017,183 @@ async function initializeApp() {
resizeHeaderSignalCanvas(); resizeHeaderSignalCanvas();
startHeaderSignalSampling(); startHeaderSignalSampling();
} else { } else {
const allowGuest = authStatus.role === "rx"; showAuthGate();
showAuthGate(allowGuest);
} }
} }
var settingsUiReady = false; var settingsUiReady = false;
function initSettingsUI() { function initSettingsUI() {
settingsUiReady = true; settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole); window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
window.trx.modules.scheduler?.wireEvents(); window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) { if (window.trx.modules.backgroundDecode) {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole); window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
window.trx.modules.backgroundDecode.wireEvents(); window.trx.modules.backgroundDecode.wireEvents();
} }
void refreshUserManagement();
} }
async function refreshUserManagement() {
const section = document.getElementById("user-management");
const tab = document.getElementById("settings-users-tab");
if (!section || !tab) return;
const canManageUsers = authEnabled && hasAuthRole2("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 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";
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";
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;
const guestInput = roleInputs.find((item) => item.value === "guest")?.input;
if (isOnlyAdmin && administratorInput) {
administratorInput.disabled = true;
administratorInput.title = "The final administrator cannot be demoted";
}
if (isOnlyAdmin && guestInput) {
guestInput.disabled = true;
guestInput.title = "The final administrator cannot become a Guest";
}
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),
enabled: enabled.checked
};
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 || 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, enabledLabel, roles, 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);
}
}
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), 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) => { requiredElement("auth-form").addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const passphraseEl = requiredElement("auth-passphrase"); const usernameEl = requiredElement("auth-username");
const passphrase = passphraseEl.value; const passwordEl = requiredElement("auth-password");
const btn = requiredElement("auth-form").querySelector("button[type=submit]"); const btn = requiredElement("auth-form").querySelector("button[type=submit]");
if (!btn) return; if (!btn) return;
btn.disabled = true; btn.disabled = true;
btn.textContent = "Logging in..."; btn.textContent = "Logging in...";
try { try {
const result = await authLogin(passphrase); const result = await authLogin(usernameEl.value, passwordEl.value);
authRole = result.role ?? null; setAuthRoles(result.roles);
passphraseEl.value = ""; authUsername = result.username ?? usernameEl.value;
passwordEl.value = "";
hideAuthGate(); hideAuthGate();
updateAuthUI(); updateAuthUI();
applyAuthRestrictions(); applyAuthRestrictions();
@@ -6041,37 +6203,22 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
resizeHeaderSignalCanvas(); resizeHeaderSignalCanvas();
startHeaderSignalSampling(); startHeaderSignalSampling();
} catch (err) { } catch (err) {
showAuthError("Invalid passphrase"); showAuthError("Invalid username or password");
console.error("Login error:", err); console.error("Login error:", err);
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = "Login"; 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"); var headerAuthBtn = document.getElementById("header-auth-btn");
if (headerAuthBtn) { if (headerAuthBtn) {
headerAuthBtn.addEventListener("click", async () => { 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 })) { 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(); await authLogout();
} }
} else { } else {
showAuthGate(false); showAuthGate();
} }
}); });
} }
@@ -6109,8 +6256,8 @@ Object.defineProperties(trxState, {
authEnabled: { get() { authEnabled: { get() {
return authEnabled; return authEnabled;
} }, } },
authRole: { get() { authRoles: { get() {
return authRole; return authRoles;
} }, } },
decoderRegistry: { get() { decoderRegistry: { get() {
return decoderRegistry; return decoderRegistry;
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-FT2RH7BL.js";
import { import {
hostState hostState
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -13,7 +16,7 @@ var bgdWindow = window;
return d.id; return d.id;
}); });
} }
let backgroundDecodeRole = null; let backgroundDecodeRoles = [];
let currentRigId = null; let currentRigId = null;
let currentConfig = null; let currentConfig = null;
let bookmarkList = []; let bookmarkList = [];
@@ -21,8 +24,8 @@ var bgdWindow = window;
let bgdDirty = false; let bgdDirty = false;
let statusByBookmark = /* @__PURE__ */ new Map(); let statusByBookmark = /* @__PURE__ */ new Map();
let lastStatus = null; let lastStatus = null;
function initBackgroundDecode(rigId, role) { function initBackgroundDecode(rigId, roles) {
backgroundDecodeRole = role; backgroundDecodeRoles = roles;
currentRigId = rigId || hostState.lastActiveRigId || null; currentRigId = rigId || hostState.lastActiveRigId || null;
if (currentRigId) loadBackgroundDecode(); if (currentRigId) loadBackgroundDecode();
startStatusPolling(); startStatusPolling();
@@ -358,7 +361,7 @@ var bgdWindow = window;
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save"; btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
} }
function isControlRole() { function isControlRole() {
return backgroundDecodeRole === "control" || hostState.authEnabled === false; return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
} }
function showToast(msg, isError) { function showToast(msg, isError) {
const el = document.getElementById("background-decode-toast"); const el = document.getElementById("background-decode-toast");
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-FT2RH7BL.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -42,7 +45,7 @@ function bmEsc(str) {
return d.innerHTML; return d.innerHTML;
} }
function bmCanControl() { function bmCanControl() {
return !hostState.authEnabled || hostState.authRole === "control"; return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
} }
function bmSyncAccess() { function bmSyncAccess() {
const canCtrl = bmCanControl(); const canCtrl = bmCanControl();
@@ -0,0 +1,135 @@
// src/api/auth.ts
var AUTH_ROLES = ["guest", "read", "control", "write", "administrator"];
var AUTH_ADMIN_ROLES = AUTH_ROLES.filter((role) => role !== "guest");
var AUTH_ROLE_LABELS = {
guest: "Guest",
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("guest") || required === "read" && roles.includes("control");
}
function hasAccountControls(roles) {
return roles.length > 0 && !roles.includes("guest");
}
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: [...AUTH_ADMIN_ROLES],
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_ADMIN_ROLES,
AUTH_ROLE_LABELS,
normalizeAuthRoles,
hasAuthRole,
hasAccountControls,
fetchAuthSession,
login,
listUsers,
createUser,
updateUser,
changeOwnPassword,
deleteUser,
logout
};
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-FT2RH7BL.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -47,6 +50,9 @@ var entryRigName = null;
var entryGrid = null; var entryGrid = null;
var qsos = []; var qsos = [];
var workedRequest = 0; var workedRequest = 0;
function canWriteLogbook() {
return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
}
function notify(message, kind) { function notify(message, kind) {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0); if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0);
else hostCore.showHint(message, 2e3); else hostCore.showHint(message, 2e3);
@@ -300,6 +306,11 @@ function renderRows() {
row.appendChild(cell); row.appendChild(cell);
} }
const actions = document.createElement("td"); const actions = document.createElement("td");
if (!canWriteLogbook()) {
row.appendChild(actions);
fragment.appendChild(row);
continue;
}
const confirm = document.createElement("button"); const confirm = document.createElement("button");
confirm.type = "button"; confirm.type = "button";
confirm.className = "log-row-btn"; confirm.className = "log-row-btn";
@@ -420,14 +431,19 @@ importFile?.addEventListener("change", () => {
if (file) void importAdif(file); if (file) void importAdif(file);
importFile.value = ""; importFile.value = "";
}); });
if (canWriteLogbook()) {
bridge.logContact = (seed) => { bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook"); bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus()); void openEntry(seed).then(() => callInput?.focus());
}; };
} else {
if (form) form.style.display = "none";
if (importBtn) importBtn.style.display = "none";
}
renderStation(); renderStation();
if (cabrilloCallsign && !cabrilloCallsign.value) { if (cabrilloCallsign && !cabrilloCallsign.value) {
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? ""; cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
} }
syncCabrilloLink(); syncCabrilloLink();
void openEntry(); if (canWriteLogbook()) void openEntry();
void refreshLog(); void refreshLog();
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-FT2RH7BL.js";
import { import {
hostState hostState
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -15,7 +18,7 @@ function schedulerOptionalEl(id) {
} }
(function() { (function() {
"use strict"; "use strict";
let schedulerRole = null; let schedulerRoles = [];
let currentRigId = null; let currentRigId = null;
let currentConfig = null; let currentConfig = null;
let currentSchedulerStatus = null; let currentSchedulerStatus = null;
@@ -25,8 +28,8 @@ function schedulerOptionalEl(id) {
let schedulerStepPending = false; let schedulerStepPending = false;
let schEntryEditIdx = null; let schEntryEditIdx = null;
let schedulerDirty = false; let schedulerDirty = false;
function initScheduler(rigId, role) { function initScheduler(rigId, roles) {
schedulerRole = role; schedulerRoles = roles;
currentRigId = rigId || null; currentRigId = rigId || null;
if (currentRigId) loadScheduler(); if (currentRigId) loadScheduler();
startStatusPolling(); startStatusPolling();
@@ -272,7 +275,7 @@ function schedulerOptionalEl(id) {
const nextBtn = schedulerEl("scheduler-next-btn"); const nextBtn = schedulerEl("scheduler-next-btn");
if (!prevBtn || !nextBtn) return; if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig); const state = schedulerInterleaveState(currentConfig);
const enabled = schedulerRole === "control" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1; const enabled = hasAuthRole(schedulerRoles, "control") && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
prevBtn.disabled = !enabled; prevBtn.disabled = !enabled;
nextBtn.disabled = !enabled; nextBtn.disabled = !enabled;
const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active"; const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active";
@@ -354,7 +357,7 @@ function schedulerOptionalEl(id) {
const panel = schedulerEl("scheduler-panel"); const panel = schedulerEl("scheduler-panel");
if (!panel) return; if (!panel) return;
const mode = currentConfig && currentConfig.mode || "disabled"; const mode = currentConfig && currentConfig.mode || "disabled";
const isControl = schedulerRole === "control"; const isControl = hasAuthRole(schedulerRoles, "control");
setSelected("scheduler-mode-select", mode); setSelected("scheduler-mode-select", mode);
const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled; const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled;
const controlRow = document.querySelector(".scheduler-control-row"); const controlRow = document.querySelector(".scheduler-control-row");
@@ -1220,8 +1223,8 @@ function schedulerOptionalEl(id) {
markDirty: markSchedulerDirty markDirty: markSchedulerDirty
}; };
schedulerWindow.trx.modules.scheduler = schedulerService; schedulerWindow.trx.modules.scheduler = schedulerService;
if (hostState.authRole != null) { if (!hostState.authEnabled || hostState.authRoles.length > 0) {
initScheduler(hostState.lastActiveRigId, hostState.authRole); initScheduler(hostState.lastActiveRigId, hostState.authRoles);
wireSchedulerEvents(); wireSchedulerEvents();
} }
})(); })();
@@ -123,13 +123,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="auth-gate" class="auth-gate" style="display:none;"> <div id="auth-gate" class="auth-gate" style="display:none;">
<div class="auth-gate-head"> <div class="auth-gate-head">
<div class="auth-gate-title">Access Required</div> <div class="auth-gate-title">Access Required</div>
<div class="auth-gate-sub">Enter passphrase to continue</div> <div class="auth-gate-sub">Sign in to continue</div>
</div> </div>
<form id="auth-form" class="auth-form"> <form id="auth-form" class="auth-form">
<input type="password" id="auth-passphrase" class="auth-input" placeholder="Passphrase" autocomplete="off" /> <input type="text" id="auth-username" class="auth-input" placeholder="Username" autocomplete="username" required />
<input type="password" id="auth-password" class="auth-input" placeholder="Password" autocomplete="current-password" maxlength="1024" required />
<button type="submit" class="auth-submit">Login</button> <button type="submit" class="auth-submit">Login</button>
</form> </form>
<button id="auth-guest-btn" type="button" class="auth-guest" style="display: none;">Continue as Guest</button>
<div id="auth-error" class="auth-error" style="display: none;"></div> <div id="auth-error" class="auth-error" style="display: none;"></div>
<div id="auth-role" class="auth-role" style="display: none;"></div> <div id="auth-role" class="auth-role" style="display: none;"></div>
</div> </div>
@@ -1461,6 +1461,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button> <button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
<button class="sub-tab" data-subtab="settings-bandplan">Bandplan</button> <button class="sub-tab" data-subtab="settings-bandplan">Bandplan</button>
<button class="sub-tab" data-subtab="settings-history">History</button> <button class="sub-tab" data-subtab="settings-history">History</button>
<button id="settings-account-tab" class="sub-tab" data-subtab="settings-account" style="display:none;">Account</button>
<button id="settings-users-tab" class="sub-tab" data-subtab="settings-users" style="display:none;">Users</button>
</div> </div>
<div id="subtab-settings-scheduler" class="sub-tab-panel"> <div id="subtab-settings-scheduler" class="sub-tab-panel">
<div id="scheduler-panel" class="sch-panel"> <div id="scheduler-panel" class="sch-panel">
@@ -1750,6 +1752,34 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</div> </div>
</div> </div>
<div id="subtab-settings-account" class="sub-tab-panel" style="display:none;">
<div class="settings-card">
<h3>Change password</h3>
<form id="account-password-form" class="sch-row" style="flex-wrap:wrap; gap:.5rem;">
<input id="account-current-password" class="auth-input" type="password" placeholder="Current password" autocomplete="current-password" maxlength="1024" required />
<input id="account-new-password" class="auth-input" type="password" placeholder="New password (8+ characters)" autocomplete="new-password" minlength="8" maxlength="1024" required />
<input id="account-confirm-password" class="auth-input" type="password" placeholder="Confirm new password" autocomplete="new-password" minlength="8" maxlength="1024" required />
<button type="submit" class="auth-submit">Change password</button>
</form>
<div id="account-password-error" class="auth-error" role="alert" aria-live="polite" style="display:none;"></div>
<p class="settings-note">Changing your password signs out every session for this account.</p>
</div>
</div>
<div id="subtab-settings-users" class="sub-tab-panel" style="display:none;">
<div id="user-management">
<div class="settings-card">
<form id="user-create-form" class="sch-row" style="flex-wrap:wrap; gap:.5rem;">
<input id="user-create-username" class="auth-input" placeholder="Username" autocomplete="off" required />
<input id="user-create-password" class="auth-input" type="password" placeholder="Password (8+ characters)" autocomplete="new-password" minlength="8" maxlength="1024" required />
<span id="user-create-roles" class="auth-role-choices"></span>
<label class="auth-role-choice"><input id="user-create-enabled" type="checkbox" checked /> Enabled</label>
<button type="submit" class="auth-submit">Add user</button>
</form>
<div id="user-management-error" class="auth-error" style="display:none;"></div>
<div id="user-list" style="margin-top:.75rem;"></div>
</div>
</div>
</div>
</div> </div>
<div id="tab-about" class="tab-panel" style="display:none;"> <div id="tab-about" class="tab-panel" style="display:none;">
<h2 class="section-heading">About</h2> <h2 class="section-heading">About</h2>
@@ -196,8 +196,7 @@ body {
font-size: var(--fs-base); font-size: var(--fs-base);
box-sizing: border-box; box-sizing: border-box;
} }
.auth-submit, .auth-submit {
.auth-guest {
width: 100%; width: 100%;
padding: 0.65rem 0.75rem; padding: 0.65rem 0.75rem;
border-radius: var(--radius-md); border-radius: var(--radius-md);
@@ -213,16 +212,14 @@ body {
font-weight: 700; font-weight: 700;
} }
.auth-submit:hover:not(:disabled) { background: var(--accent-green-hover); } .auth-submit:hover:not(:disabled) { background: var(--accent-green-hover); }
.auth-guest {
background: var(--btn-bg);
color: var(--text);
border: 1px solid var(--border-light);
font-weight: 600;
margin-top: var(--space-4);
}
.auth-guest:hover:not(:disabled) { background: var(--btn-hover-bg); }
.auth-error { color: var(--accent-red); font-size: var(--fs-sm); margin-top: var(--space-4); } .auth-error { color: var(--accent-red); font-size: var(--fs-sm); margin-top: var(--space-4); }
.auth-role { margin-top: var(--space-4); color: var(--text-muted); font-size: var(--fs-sm); } .auth-role { margin-top: var(--space-4); color: var(--text-muted); font-size: var(--fs-sm); }
#user-management .auth-input { width: auto; min-width: 9rem; flex: 1 1 10rem; margin-bottom: 0; }
#user-management .auth-submit { width: auto; }
#user-management .auth-role-choices { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
#user-management .auth-role-choice { display: inline-flex; align-items: center; gap: .2rem; white-space: nowrap; }
.settings-note { color: var(--text-muted); font-size: .85rem; margin: .75rem 0 0; }
#user-management button { padding: 0.55rem 0.75rem; }
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; } .label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
#tab-main .label > span { #tab-main .label > span {
@@ -15,6 +15,7 @@ use trx_core::rig::{
use trx_core::{DecoderConfig, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel}; use trx_core::{DecoderConfig, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel};
use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse}; use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse};
use trx_frontend_http::server::api::FrontendMeta; use trx_frontend_http::server::api::FrontendMeta;
use trx_frontend_http::server::auth::AuthRole;
use trx_protocol::{DecoderActivation, DecoderDescriptor}; use trx_protocol::{DecoderActivation, DecoderDescriptor};
use ts_rs::{Config, TS}; use ts_rs::{Config, TS};
@@ -56,6 +57,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
export!(RigListItem); export!(RigListItem);
export!(RigListResponse); export!(RigListResponse);
export!(FrontendMeta); export!(FrontendMeta);
export!(AuthRole);
export!(DecoderActivation); export!(DecoderActivation);
export!(DecoderDescriptor); export!(DecoderDescriptor);
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json", "typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern", "lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs", "test": "node --test tests/*.test.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs", "test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs && node tests/account-management.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts" "verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
}, },
"devDependencies": { "devDependencies": {
@@ -2,11 +2,50 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
export type AuthRole = "rx" | "control"; import type { AuthRole } from "./generated.js";
export type { AuthRole };
export const AUTH_ROLES: readonly AuthRole[] = ["guest", "read", "control", "write", "administrator"];
export const AUTH_ADMIN_ROLES: readonly AuthRole[] = AUTH_ROLES.filter(role => role !== "guest");
export const AUTH_ROLE_LABELS: Readonly<Record<AuthRole, string>> = {
guest: "Guest",
read: "Read",
control: "Control",
write: "Write",
administrator: "Administrator",
};
export function isAuthRole(value: unknown): value is AuthRole {
return typeof value === "string" && (AUTH_ROLES as readonly string[]).includes(value);
}
export function normalizeAuthRoles(roles: readonly AuthRole[]): AuthRole[] {
return AUTH_ROLES.filter(role => roles.includes(role));
}
export function hasAuthRole(roles: readonly AuthRole[], required: AuthRole): boolean {
return roles.includes("administrator")
|| roles.includes(required)
|| required === "read" && roles.includes("guest")
|| required === "read" && roles.includes("control");
}
export function hasAccountControls(roles: readonly AuthRole[]): boolean {
return roles.length > 0 && !roles.includes("guest");
}
function decodeRoles(value: unknown, context: string): AuthRole[] {
if (!Array.isArray(value) || !value.every(isAuthRole)) {
throw new TypeError(`${context} has invalid roles`);
}
return normalizeAuthRoles(value);
}
export interface AuthSession { export interface AuthSession {
authenticated: boolean; authenticated: boolean;
role?: AuthRole; roles: AuthRole[];
username?: string;
auth_disabled?: boolean; auth_disabled?: boolean;
} }
@@ -18,21 +57,24 @@ function decodeAuthSession(value: unknown): AuthSession {
if (typeof session.authenticated !== "boolean") { if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag"); throw new TypeError("The authentication response has no authenticated flag");
} }
if (session.role !== undefined && session.role !== "rx" && session.role !== "control") {
throw new TypeError("The authentication response has an invalid role");
}
if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") { if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag"); throw new TypeError("The authentication response has an invalid auth_disabled flag");
} }
const decoded: AuthSession = { authenticated: session.authenticated }; const decoded: AuthSession = {
if (session.role !== undefined) decoded.role = session.role; authenticated: session.authenticated,
roles: decodeRoles(session.roles, "The authentication response"),
};
if (session.username !== undefined) {
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
decoded.username = session.username;
}
if (session.auth_disabled !== undefined) decoded.auth_disabled = session.auth_disabled; if (session.auth_disabled !== undefined) decoded.auth_disabled = session.auth_disabled;
return decoded; return decoded;
} }
const authDisabledSession: AuthSession = { const authDisabledSession: AuthSession = {
authenticated: true, authenticated: true,
role: "control", roles: [...AUTH_ADMIN_ROLES],
auth_disabled: true, auth_disabled: true,
}; };
@@ -40,19 +82,19 @@ export async function fetchAuthSession(): Promise<AuthSession> {
try { try {
const response = await fetch("/auth/session"); const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession; if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false }; if (!response.ok) return { authenticated: false, roles: [] };
return decodeAuthSession(await response.json()); return decodeAuthSession(await response.json());
} catch (error: unknown) { } catch (error: unknown) {
console.error("Auth check failed:", error); console.error("Auth check failed:", error);
return { authenticated: false }; return { authenticated: false, roles: [] };
} }
} }
export async function login(passphrase: string): Promise<AuthSession> { export async function login(username: string, password: string): Promise<AuthSession> {
const response = await fetch("/auth/login", { const response = await fetch("/auth/login", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase }), body: JSON.stringify({ username, password }),
}); });
if (response.status === 404) return authDisabledSession; if (response.status === 404) return authDisabledSession;
if (!response.ok) { if (!response.ok) {
@@ -62,6 +104,52 @@ export async function login(passphrase: string): Promise<AuthSession> {
return decodeAuthSession(await response.json()); return decodeAuthSession(await response.json());
} }
export interface ManagedUser { username: string; roles: AuthRole[]; enabled: boolean }
async function userRequest(path: string, init?: RequestInit): Promise<Response> {
const response = await fetch(path, init);
if (!response.ok) {
const payload = await response.json().catch(() => ({})) as { error?: string };
throw new Error(payload.error || `User operation failed (${response.status})`);
}
return response;
}
export async function listUsers(): Promise<ManagedUser[]> {
const value: unknown = await userRequest("/auth/users").then(response => response.json());
if (!Array.isArray(value) || !value.every((user: unknown) => {
if (typeof user !== "object" || user === null) return false;
const record = user as Record<string, unknown>;
return typeof record.username === "string"
&& typeof record.enabled === "boolean"
&& Array.isArray(record.roles)
&& record.roles.every(isAuthRole);
})) {
throw new TypeError("The user list response is malformed");
}
return (value as ManagedUser[]).map(user => ({ ...user, roles: normalizeAuthRoles(user.roles) }));
}
export async function createUser(username: string, password: string, roles: AuthRole[], enabled = true): Promise<void> {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles, enabled }) });
}
export async function updateUser(username: string, changes: { password?: string; roles?: AuthRole[]; enabled?: boolean }): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
}
export async function changeOwnPassword(currentPassword: string, newPassword: string): Promise<void> {
await userRequest("/auth/account/password", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
}
export async function deleteUser(username: string): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
}
export async function logout(): Promise<void> { export async function logout(): Promise<void> {
const response = await fetch("/auth/logout", { method: "POST" }); const response = await fetch("/auth/logout", { method: "POST" });
if (response.status !== 404 && !response.ok) throw new Error("Logout failed"); if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
@@ -123,6 +123,8 @@ export type RigListResponse = { active_remote: string | null, rigs: Array<RigLis
export type FrontendMeta = { clients: number, rigctl_clients: number, audio_clients: number, rigctl_addr: string | null, active_remote: string | null, remotes: Array<string>, owner_callsign: string | null, owner_website_url: string | null, owner_website_name: string | null, ais_vessel_url_base: string | null, show_sdr_gain_control: boolean, initial_map_zoom: number, spectrum_coverage_margin_hz: number, spectrum_usable_span_ratio: number, bandplan_enabled: boolean, bandplan_region: string, decode_history_retention_min: bigint, server_connected: boolean, }; export type FrontendMeta = { clients: number, rigctl_clients: number, audio_clients: number, rigctl_addr: string | null, active_remote: string | null, remotes: Array<string>, owner_callsign: string | null, owner_website_url: string | null, owner_website_name: string | null, ais_vessel_url_base: string | null, show_sdr_gain_control: boolean, initial_map_zoom: number, spectrum_coverage_margin_hz: number, spectrum_usable_span_ratio: number, bandplan_enabled: boolean, bandplan_region: string, decode_history_retention_min: bigint, server_connected: boolean, };
export type AuthRole = "guest" | "read" | "control" | "write" | "administrator";
export type DecoderActivation = "mode_bound" | "toggle"; export type DecoderActivation = "mode_bound" | "toggle";
export type DecoderDescriptor = { export type DecoderDescriptor = {
@@ -21,6 +21,17 @@ import {
fetchAuthSession, fetchAuthSession,
login, login,
logout, logout,
listUsers,
createUser,
updateUser,
deleteUser,
changeOwnPassword,
AUTH_ADMIN_ROLES,
AUTH_ROLES,
AUTH_ROLE_LABELS,
hasAccountControls,
hasAuthRole as rolesInclude,
normalizeAuthRoles,
} from "./api/auth.js"; } from "./api/auth.js";
import { import {
formatByteSize as recorderFormatSize, formatByteSize as recorderFormatSize,
@@ -189,8 +200,8 @@ interface TrxModules {
reverseGeocodeLocation(lat: number, lon: number, grid: string): void; reverseGeocodeLocation(lat: number, lon: number, grid: string): void;
bandForHz(frequencyHz: number): unknown; bandForHz(frequencyHz: number): unknown;
}; };
scheduler?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void }; scheduler?: { initialize(rigId: string | null, roles: readonly AuthRole[]): void; setRig(rigId: string | null): void; wireEvents(): void };
backgroundDecode?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void }; backgroundDecode?: { initialize(rigId: string | null, roles: readonly AuthRole[]): void; setRig(rigId: string | null): void; wireEvents(): void };
bookmarks?: { bookmarks?: {
readonly overlayList: readonly Bookmark[]; readonly overlayList: readonly Bookmark[];
readonly overlayRevision: number; readonly overlayRevision: number;
@@ -226,7 +237,7 @@ interface TrxState {
readonly initialMapZoom: number; readonly initialMapZoom: number;
readonly decodeHistoryRetentionMin: number; readonly decodeHistoryRetentionMin: number;
readonly authEnabled: boolean; readonly authEnabled: boolean;
readonly authRole: AuthRole | null; readonly authRoles: readonly AuthRole[];
readonly decoderRegistry: typeof decoderRegistry; readonly decoderRegistry: typeof decoderRegistry;
readonly sseSessionId: string | null; readonly sseSessionId: string | null;
readonly primaryRds: RdsData | null; readonly primaryRds: RdsData | null;
@@ -406,40 +417,75 @@ declare global {
void loadDecoderRegistry(refreshOperatorLayoutCapabilities); void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
// --- Authentication --- // --- Authentication ---
let authRole: AuthRole | null = null; let authRoles: AuthRole[] = [];
let authUsername: string | null = null;
let authEnabled = true; let authEnabled = true;
function setAuthRoles(roles: readonly AuthRole[]) {
authRoles = normalizeAuthRoles(roles);
}
function hasAuthRole(role: AuthRole) {
return rolesInclude(authRoles, role);
}
function buildRoleChoices(selected: readonly AuthRole[]) {
const element = document.createElement("span");
element.className = "auth-role-choices";
const inputs = AUTH_ROLES.map((value) => {
const label = document.createElement("label");
label.className = "auth-role-choice";
const input = document.createElement("input");
input.type = "checkbox";
input.value = value;
input.checked = selected.includes(value);
label.append(input, ` ${AUTH_ROLE_LABELS[value]}`);
element.append(label);
return { input, value };
});
inputs.forEach(({ input, value }) => {
input.addEventListener("change", () => {
if (!input.checked) return;
if (value === "guest") {
inputs.forEach(choice => { if (choice.value !== "guest") choice.input.checked = false; });
} else {
const guest = inputs.find(choice => choice.value === "guest");
if (guest) guest.input.checked = false;
}
});
});
return { element, inputs };
}
async function checkAuthStatus() { async function checkAuthStatus() {
return fetchAuthSession(); return fetchAuthSession();
} }
async function authLogin(passphrase: string) { async function authLogin(username: string, password: string) {
return login(passphrase); return login(username, password);
} }
async function authLogout() { async function authLogout() {
try { try {
await logout(); await logout();
authRole = null; setAuthRoles([]);
authUsername = null;
// Disconnect and show auth gate without page reload // Disconnect and show auth gate without page reload
disconnect(); disconnect();
setDecodeHistoryOverlayVisible(false); setDecodeHistoryOverlayVisible(false);
requiredElement("content").style.display = "none"; requiredElement("content").style.display = "none";
requiredElement("loading").style.display = "none"; requiredElement("loading").style.display = "none";
requiredElement<HTMLInputElement>("auth-passphrase").value = ""; requiredElement<HTMLInputElement>("auth-password").value = "";
updateAuthUI(); updateAuthUI();
// Check if guest mode is available after logout showAuthGate();
const authStatus = await checkAuthStatus();
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
} catch (e) { } catch (e) {
console.error("Logout failed:", e); console.error("Logout failed:", e);
showAuthError("Logout failed"); showAuthError("Logout failed");
} }
} }
function showAuthGate(allowGuest = false) { function showAuthGate() {
if (!authEnabled) return; if (!authEnabled) return;
setDecodeHistoryOverlayVisible(false); setDecodeHistoryOverlayVisible(false);
requiredElement("loading").style.display = "none"; requiredElement("loading").style.display = "none";
@@ -459,12 +505,6 @@ function showAuthGate(allowGuest = false) {
panel.style.display = "none"; panel.style.display = "none";
}); });
// Show guest button if guest mode is available
const guestBtn = document.getElementById("auth-guest-btn");
if (guestBtn) {
guestBtn.style.display = allowGuest ? "block" : "none";
}
document.querySelectorAll<HTMLElement>(".tab-bar .tab").forEach((btn) => { document.querySelectorAll<HTMLElement>(".tab-bar .tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tab === "main"); btn.classList.toggle("active", btn.dataset.tab === "main");
}); });
@@ -506,22 +546,32 @@ function updateAuthUI() {
const badge = document.getElementById("auth-badge"); const badge = document.getElementById("auth-badge");
const badgeRole = document.getElementById("auth-role-badge"); const badgeRole = document.getElementById("auth-role-badge");
const headerAuthBtn = document.getElementById("header-auth-btn"); const headerAuthBtn = document.getElementById("header-auth-btn");
const accountTab = document.getElementById("settings-account-tab");
if (!authEnabled) { if (!authEnabled) {
if (badge) badge.style.display = "none"; if (badge) badge.style.display = "none";
if (headerAuthBtn) headerAuthBtn.style.display = "none"; if (headerAuthBtn) headerAuthBtn.style.display = "none";
if (accountTab) accountTab.style.display = "none";
syncTopBarAccess(); syncTopBarAccess();
return; return;
} }
if (authRole) { if (authRoles.length > 0) {
const canManageAccount = hasAccountControls(authRoles);
if (accountTab) accountTab.style.display = canManageAccount ? "" : "none";
if (!canManageAccount && accountTab?.classList.contains("active")) {
const panel = document.getElementById("subtab-settings-account");
if (panel) panel.style.display = "none";
document.querySelector<HTMLButtonElement>('[data-subtab="settings-scheduler"]')?.click();
}
if (badge) badge.style.display = "block"; if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)"; if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`;
if (headerAuthBtn) { if (headerAuthBtn) {
headerAuthBtn.textContent = "Logout"; headerAuthBtn.textContent = "Logout";
headerAuthBtn.style.display = "block"; headerAuthBtn.style.display = "block";
} }
} else { } else {
if (accountTab) accountTab.style.display = "none";
if (badge) badge.style.display = "none"; if (badge) badge.style.display = "none";
if (headerAuthBtn) { if (headerAuthBtn) {
headerAuthBtn.textContent = "Login"; headerAuthBtn.textContent = "Login";
@@ -532,10 +582,10 @@ function updateAuthUI() {
} }
function applyAuthRestrictions() { function applyAuthRestrictions() {
if (!authRole) return; if (authRoles.length === 0) return;
// Disable TX/PTT/frequency/mode/VFO controls for rx role // Disable TX/PTT/frequency/mode/VFO controls for user role
if (authRole === "rx") { if (!hasAuthRole("control")) {
const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null; const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null;
const powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null; const powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null;
const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null; const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null;
@@ -867,14 +917,15 @@ window.applyDecodeHistoryRetention = function() {
}; };
function syncTopBarAccess() { function syncTopBarAccess() {
const loggedOut = authEnabled && !authRole; const loggedOut = authEnabled && authRoles.length === 0;
const tabBar = document.getElementById("tab-bar"); const tabBar = document.getElementById("tab-bar");
const rigSwitch = document.querySelector<HTMLElement>(".header-rig-switch"); const rigSwitch = document.querySelector<HTMLElement>(".header-rig-switch");
if (tabBar) tabBar.style.display = ""; if (tabBar) tabBar.style.display = "";
document.querySelectorAll<HTMLButtonElement>(".tab-bar .tab").forEach((btn) => { document.querySelectorAll<HTMLButtonElement>(".tab-bar .tab").forEach((btn) => {
const isMain = btn.dataset.tab === "main"; 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; btn.disabled = false;
}); });
@@ -883,7 +934,7 @@ function syncTopBarAccess() {
} }
if (headerRigSwitchSelect) { if (headerRigSwitchSelect) {
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0; headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole("control") || lastRigIds.length === 0;
} }
} }
@@ -1464,7 +1515,7 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
} }
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || ""); const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
const rigListChanged = prevKey !== nextKey; const rigListChanged = prevKey !== nextKey;
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx"; const disableSwitch = lastRigIds.length === 0 || !hasAuthRole("control");
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId); updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId);
@@ -3352,7 +3403,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link: TuneLink) { async function applyTuneLink(link: TuneLink) {
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null; const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
if (!wanted) return; if (!wanted) return;
if (authRole === "rx") { if (!hasAuthRole("control")) {
showHint("Read-only session — link not applied", 2500); showHint("Read-only session — link not applied", 2500);
return; return;
} }
@@ -4162,7 +4213,7 @@ async function postPath(path: string, options: PostOptions = {}) {
const resp = await fetch(path, { method: "POST" }); const resp = await fetch(path, { method: "POST" });
if (authEnabled && resp.status === 401) { if (authEnabled && resp.status === 401) {
// Not authenticated - return to login // Not authenticated - return to login
authRole = null; setAuthRoles([]);
if (es) es.close(); if (es) es.close();
showAuthGate(); showAuthGate();
throw new Error("Authentication required"); throw new Error("Authentication required");
@@ -4191,7 +4242,7 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
showHint("No rig selected", 1500); showHint("No rig selected", 1500);
return; return;
} }
if (authRole === "rx") { if (!hasAuthRole("control")) {
showHint("Control role required", 1500); showHint("Control role required", 1500);
return; return;
} }
@@ -4874,8 +4925,13 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
window.trxUi?.closeMobileOverlays?.(); window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites"; const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options; const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") { if (authEnabled && authRoles.length === 0 && name !== "main") {
showAuthGate(false); showAuthGate();
return;
}
if (authEnabled && name === "logbook" && !hasAuthRole("write")) {
showHint("Write role required for logbook access", 2500);
navigateToTab("main", options);
return; return;
} }
const btn = document.querySelector<HTMLElement>(`.tab-bar .tab[data-tab="${name}"]`); const btn = document.querySelector<HTMLElement>(`.tab-bar .tab[data-tab="${name}"]`);
@@ -5008,12 +5064,12 @@ window.addEventListener("resize", () => { scheduleSpectrumLayout(); });
// --- Auth startup sequence --- // --- Auth startup sequence ---
async function initializeApp() { async function initializeApp() {
showAuthGate(false); showAuthGate();
const authStatus = await checkAuthStatus(); const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled; authEnabled = !authStatus.auth_disabled;
if (!authEnabled) { if (!authEnabled) {
authRole = "control"; setAuthRoles(AUTH_ADMIN_ROLES);
hideAuthGate(); hideAuthGate();
updateAuthUI(); updateAuthUI();
connect(); connect();
@@ -5026,7 +5082,8 @@ async function initializeApp() {
if (authStatus.authenticated) { if (authStatus.authenticated) {
// User has valid session // User has valid session
authRole = authStatus.role ?? null; setAuthRoles(authStatus.roles);
authUsername = authStatus.username ?? null;
hideAuthGate(); hideAuthGate();
updateAuthUI(); updateAuthUI();
applyAuthRestrictions(); applyAuthRestrictions();
@@ -5036,10 +5093,7 @@ async function initializeApp() {
resizeHeaderSignalCanvas(); resizeHeaderSignalCanvas();
startHeaderSignalSampling(); startHeaderSignalSampling();
} else { } else {
// No valid session - show auth gate showAuthGate();
// Guest button is shown if guest mode is available (role granted without auth)
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
} }
} }
@@ -5049,28 +5103,178 @@ let settingsUiReady = false;
function initSettingsUI() { function initSettingsUI() {
settingsUiReady = true; settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole); window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
window.trx.modules.scheduler?.wireEvents(); window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) { if (window.trx.modules.backgroundDecode) {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole); window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
window.trx.modules.backgroundDecode.wireEvents(); window.trx.modules.backgroundDecode.wireEvents();
} }
void refreshUserManagement();
} }
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");
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<HTMLButtonElement>('[data-subtab="settings-scheduler"]')?.click();
}
return;
}
const list = requiredElement("user-list");
try {
const users = await listUsers();
const enabledAdminCount = users.filter(user => user.enabled && rolesInclude(user.roles, "administrator")).length;
list.replaceChildren(...users.map((user) => {
const row = document.createElement("div");
row.className = "sch-row";
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";
if (!user.enabled) name.textContent += " (disabled)";
const { element: roles, inputs: roleInputs } = buildRoleChoices(user.roles);
const enabledLabel = document.createElement("label");
enabledLabel.className = "auth-role-choice";
const enabled = document.createElement("input");
enabled.type = "checkbox";
enabled.checked = user.enabled;
enabled.disabled = user.username === authUsername;
if (enabled.disabled) enabled.title = "You cannot disable your current account";
enabledLabel.append(enabled, " Enabled");
const isOnlyAdmin = user.enabled
&& rolesInclude(user.roles, "administrator")
&& enabledAdminCount === 1;
const administratorInput = roleInputs.find(item => item.value === "administrator")?.input;
const guestInput = roleInputs.find(item => item.value === "guest")?.input;
if (isOnlyAdmin && administratorInput) {
administratorInput.disabled = true;
administratorInput.title = "The final administrator cannot be demoted";
}
if (isOnlyAdmin && guestInput) {
guestInput.disabled = true;
guestInput.title = "The final administrator cannot become a Guest";
}
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?: AuthRole[]; password?: string; enabled?: boolean } = {
roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value),
enabled: enabled.checked,
};
if (password.value) changes.password = password.value;
await runUserOperation(() => updateUser(user.username, changes));
});
const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "Remove"; remove.className = "danger";
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, enabledLabel, roles, password, save, remove);
return row;
}));
} catch (error) {
showUserManagementError(error);
}
}
function showUserManagementError(error: unknown) {
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: () => Promise<void>) {
try {
await operation();
const error = document.getElementById("user-management-error");
if (error) error.style.display = "none";
await refreshUserManagement();
} catch (reason) {
showUserManagementError(reason);
}
}
const createRoleContainer = document.getElementById("user-create-roles");
if (createRoleContainer) {
const { element } = buildRoleChoices(["read"]);
element.id = createRoleContainer.id;
createRoleContainer.replaceWith(element);
}
document.getElementById("user-create-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const username = requiredElement<HTMLInputElement>("user-create-username");
const password = requiredElement<HTMLInputElement>("user-create-password");
const enabled = requiredElement<HTMLInputElement>("user-create-enabled");
const roles = Array.from(document.querySelectorAll<HTMLInputElement>("#user-create-roles input[type=checkbox]"));
void runUserOperation(async () => {
await createUser(username.value, password.value, roles.filter(input => input.checked).map(input => input.value as AuthRole), enabled.checked);
username.value = ""; password.value = "";
enabled.checked = true;
roles.forEach(input => { input.checked = input.value === "read"; });
});
});
document.getElementById("account-password-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const currentPassword = requiredElement<HTMLInputElement>("account-current-password");
const newPassword = requiredElement<HTMLInputElement>("account-new-password");
const confirmPassword = requiredElement<HTMLInputElement>("account-confirm-password");
const error = requiredElement("account-password-error");
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (newPassword.value !== confirmPassword.value) {
error.textContent = "New passwords do not match";
error.style.display = "block";
return;
}
if (submit) submit.disabled = true;
void changeOwnPassword(currentPassword.value, newPassword.value)
.then(async () => {
form.reset();
error.style.display = "none";
await authLogout();
showHint("Password changed. Sign in again.", 3000);
})
.catch((reason: unknown) => {
error.textContent = reason instanceof Error ? reason.message : String(reason);
error.style.display = "block";
})
.finally(() => {
if (submit) submit.disabled = false;
});
});
// Setup auth form // Setup auth form
requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (e) => { requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const passphraseEl = requiredElement<HTMLInputElement>("auth-passphrase"); const usernameEl = requiredElement<HTMLInputElement>("auth-username");
const passphrase = passphraseEl.value; const passwordEl = requiredElement<HTMLInputElement>("auth-password");
const btn = requiredElement<HTMLFormElement>("auth-form").querySelector<HTMLButtonElement>("button[type=submit]"); const btn = requiredElement<HTMLFormElement>("auth-form").querySelector<HTMLButtonElement>("button[type=submit]");
if (!btn) return; if (!btn) return;
btn.disabled = true; btn.disabled = true;
btn.textContent = "Logging in..."; btn.textContent = "Logging in...";
try { try {
const result = await authLogin(passphrase); const result = await authLogin(usernameEl.value, passwordEl.value);
authRole = result.role ?? null; setAuthRoles(result.roles);
passphraseEl.value = ""; authUsername = result.username ?? usernameEl.value;
passwordEl.value = "";
hideAuthGate(); hideAuthGate();
updateAuthUI(); updateAuthUI();
applyAuthRestrictions(); applyAuthRestrictions();
@@ -5080,7 +5284,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
resizeHeaderSignalCanvas(); resizeHeaderSignalCanvas();
startHeaderSignalSampling(); startHeaderSignalSampling();
} catch (err) { } catch (err) {
showAuthError("Invalid passphrase"); showAuthError("Invalid username or password");
console.error("Login error:", err); console.error("Login error:", err);
} finally { } finally {
btn.disabled = false; btn.disabled = false;
@@ -5088,35 +5292,18 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
} }
}); });
// Setup guest button
const guestBtn = document.getElementById("auth-guest-btn") as HTMLButtonElement | null;
if (guestBtn) {
guestBtn.addEventListener("click", () => {
authRole = "rx";
requiredElement<HTMLInputElement>("auth-passphrase").value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
connect();
connectDecode();
initSettingsUI();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
});
}
// Setup header auth button (Login/Logout) // Setup header auth button (Login/Logout)
const headerAuthBtn = document.getElementById("header-auth-btn") as HTMLButtonElement | null; const headerAuthBtn = document.getElementById("header-auth-btn") as HTMLButtonElement | null;
if (headerAuthBtn) { if (headerAuthBtn) {
headerAuthBtn.addEventListener("click", async () => { headerAuthBtn.addEventListener("click", async () => {
if (authRole) { if (authRoles.length > 0) {
// Logged in - show logout confirmation // Logged in - show logout confirmation
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) { 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(); await authLogout();
} }
} else { } else {
// Not logged in - show auth gate // Not logged in - show auth gate
showAuthGate(false); showAuthGate();
} }
}); });
} }
@@ -5138,7 +5325,7 @@ Object.defineProperties(trxState, {
initialMapZoom: { get() { return initialMapZoom; } }, initialMapZoom: { get() { return initialMapZoom; } },
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } }, decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
authEnabled: { get() { return authEnabled; } }, authEnabled: { get() { return authEnabled; } },
authRole: { get() { return authRole; } }, authRoles: { get() { return authRoles; } },
decoderRegistry: { get() { return decoderRegistry; } }, decoderRegistry: { get() { return decoderRegistry; } },
sseSessionId: { get() { return sseSessionId; } }, sseSessionId: { get() { return sseSessionId; } },
primaryRds: { get() { return primaryRds; } }, primaryRds: { get() { return primaryRds; } },
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js"; import { hostState } from "./host.js";
import { hasAuthRole, type AuthRole } from "../api/auth.js";
export {}; export {};
@@ -44,7 +45,7 @@ interface BackgroundBridge {
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } }; trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
} }
interface BackgroundDecodeService { interface BackgroundDecodeService {
initialize(rigId: string | null, role: string | null): void; initialize(rigId: string | null, roles: readonly AuthRole[]): void;
wireEvents(): void; wireEvents(): void;
setRig(rigId: string | null): void; setRig(rigId: string | null): void;
} }
@@ -60,7 +61,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
.map(function (d) { return d.id; }); .map(function (d) { return d.id; });
} }
let backgroundDecodeRole: string | null = null; let backgroundDecodeRoles: readonly AuthRole[] = [];
let currentRigId: string | null = null; let currentRigId: string | null = null;
let currentConfig: BackgroundDecodeConfig | null = null; let currentConfig: BackgroundDecodeConfig | null = null;
let bookmarkList: Bookmark[] = []; let bookmarkList: Bookmark[] = [];
@@ -70,8 +71,8 @@ const bgdWindow = window as unknown as BackgroundBridge;
let statusByBookmark = new Map<string, BackgroundStatusEntry>(); let statusByBookmark = new Map<string, BackgroundStatusEntry>();
let lastStatus: BackgroundDecodeStatus | null = null; let lastStatus: BackgroundDecodeStatus | null = null;
function initBackgroundDecode(rigId: string | null, role: string | null): void { function initBackgroundDecode(rigId: string | null, roles: readonly AuthRole[]): void {
backgroundDecodeRole = role; backgroundDecodeRoles = roles;
// The panel used to take whatever rig it was handed at load and wait to be // The panel used to take whatever rig it was handed at load and wait to be
// told again. Loading before the rig list arrives handed it null, and the // told again. Loading before the rig list arrives handed it null, and the
// next telling only came when the operator switched rigs, so the panel sat // next telling only came when the operator switched rigs, so the panel sat
@@ -468,7 +469,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
} }
function isControlRole(): boolean { function isControlRole(): boolean {
return backgroundDecodeRole === "control" || hostState.authEnabled === false; return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
} }
function showToast(msg: string, isError: boolean): void { function showToast(msg: string, isError: boolean): void {
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js"; import { hostCore, hostState } from "./host.js";
import { hasAuthRole } from "../api/auth.js";
export {}; export {};
@@ -100,7 +101,8 @@ function bmEsc(str: unknown): string {
} }
function bmCanControl() { function bmCanControl() {
return !hostState.authEnabled || hostState.authRole === "control"; return !hostState.authEnabled
|| hasAuthRole(hostState.authRoles, "write");
} }
// Show/hide the Add Bookmark / Select All buttons based on the current auth role. // Show/hide the Add Bookmark / Select All buttons based on the current auth role.
@@ -11,6 +11,8 @@
// feature bundles from re-deriving it — and from drifting back to bare `window` // feature bundles from re-deriving it — and from drifting back to bare `window`
// properties, which the module graph no longer publishes. // properties, which the module graph no longer publishes.
import type { AuthRole } from "../api/auth.js";
export interface HostDecoderDescriptor { export interface HostDecoderDescriptor {
id: string; id: string;
label: string; label: string;
@@ -25,7 +27,7 @@ export interface HostState {
/** The callsign this station is on the air with, from the client config. */ /** The callsign this station is on the air with, from the client config. */
readonly ownerCallsign: string | null; readonly ownerCallsign: string | null;
readonly authEnabled: boolean; readonly authEnabled: boolean;
readonly authRole: string | null; readonly authRoles: readonly AuthRole[];
readonly lastActiveRigId: string | null; readonly lastActiveRigId: string | null;
readonly lastRigIds: string[]; readonly lastRigIds: string[];
readonly lastRigDisplayNames: Record<string, string>; readonly lastRigDisplayNames: Record<string, string>;
@@ -10,6 +10,7 @@
// keeping if the times in it are the radio's. // keeping if the times in it are the radio's.
import { hostCore, hostState } from "./host.js"; import { hostCore, hostState } from "./host.js";
import { hasAuthRole } from "../api/auth.js";
export {}; export {};
@@ -120,6 +121,11 @@ let entryGrid: string | null = null;
let qsos: Qso[] = []; let qsos: Qso[] = [];
let workedRequest = 0; let workedRequest = 0;
function canWriteLogbook(): boolean {
return !hostState.authEnabled
|| hasAuthRole(hostState.authRoles, "write");
}
function notify(message: string, kind?: string): void { function notify(message: string, kind?: string): void {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : undefined); if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : undefined);
else hostCore.showHint(message, 2000); else hostCore.showHint(message, 2000);
@@ -419,6 +425,11 @@ function renderRows(): void {
row.appendChild(cell); row.appendChild(cell);
} }
const actions = document.createElement("td"); const actions = document.createElement("td");
if (!canWriteLogbook()) {
row.appendChild(actions);
fragment.appendChild(row);
continue;
}
// Confirming is the commonest edit a log gets, so it is a button rather // Confirming is the commonest edit a log gets, so it is a button rather
// than a form: a card arrives, and the contact counts towards an award. // than a form: a card arrives, and the contact counts towards an award.
const confirm = document.createElement("button"); const confirm = document.createElement("button");
@@ -547,15 +558,20 @@ importFile?.addEventListener("change", () => {
}); });
/** Start an entry from a decode, and show the operator where it went. */ /** Start an entry from a decode, and show the operator where it went. */
if (canWriteLogbook()) {
bridge.logContact = (seed) => { bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook"); bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus()); void openEntry(seed).then(() => callInput?.focus());
}; };
} else {
if (form) form.style.display = "none";
if (importBtn) importBtn.style.display = "none";
}
renderStation(); renderStation();
if (cabrilloCallsign && !cabrilloCallsign.value) { if (cabrilloCallsign && !cabrilloCallsign.value) {
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? ""; cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
} }
syncCabrilloLink(); syncCabrilloLink();
void openEntry(); if (canWriteLogbook()) void openEntry();
void refreshLog(); void refreshLog();
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import type { SatelliteScheduleConfig, SatelliteSchedulerApi } from "./satellite-types.js"; import type { SatelliteScheduleConfig, SatelliteSchedulerApi } from "./satellite-types.js";
import type { AuthRole } from "../api/auth.js";
export type SchedulerMode = "disabled" | "grayline" | "time_span"; export type SchedulerMode = "disabled" | "grayline" | "time_span";
@@ -60,7 +61,7 @@ export interface SchedulerStatus {
} }
export interface SchedulerService { export interface SchedulerService {
initialize(rigId: string | null, role: string | null): void; initialize(rigId: string | null, roles: readonly AuthRole[]): void;
destroy(): void; destroy(): void;
setRig(rigId: string | null): void; setRig(rigId: string | null): void;
wireEvents(): void; wireEvents(): void;
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js"; import { hostState } from "./host.js";
import { hasAuthRole, type AuthRole } from "../api/auth.js";
import type { import type {
ScheduleEntry, ScheduleEntry,
@@ -43,7 +44,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// State // State
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
let schedulerRole: string | null = null; let schedulerRoles: readonly AuthRole[] = [];
let currentRigId: string | null = null; let currentRigId: string | null = null;
let currentConfig: SchedulerConfig | null = null; let currentConfig: SchedulerConfig | null = null;
let currentSchedulerStatus: SchedulerStatus | null = null; let currentSchedulerStatus: SchedulerStatus | null = null;
@@ -58,8 +59,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Init // Init
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
function initScheduler(rigId: string | null, role: string | null): void { function initScheduler(rigId: string | null, roles: readonly AuthRole[]): void {
schedulerRole = role; schedulerRoles = roles;
currentRigId = rigId || null; currentRigId = rigId || null;
if (currentRigId) loadScheduler(); if (currentRigId) loadScheduler();
startStatusPolling(); startStatusPolling();
@@ -356,7 +357,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!prevBtn || !nextBtn) return; if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig); const state = schedulerInterleaveState(currentConfig);
const enabled = const enabled =
schedulerRole === "control" && hasAuthRole(schedulerRoles, "control") &&
!!currentRigId && !!currentRigId &&
!schedulerStepPending && !schedulerStepPending &&
state.activeEntries.length > 1; state.activeEntries.length > 1;
@@ -466,7 +467,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!panel) return; if (!panel) return;
const mode = (currentConfig && currentConfig.mode) || "disabled"; const mode = (currentConfig && currentConfig.mode) || "disabled";
const isControl = schedulerRole === "control"; const isControl = hasAuthRole(schedulerRoles, "control");
// Mode selector // Mode selector
setSelected("scheduler-mode-select", mode); setSelected("scheduler-mode-select", mode);
@@ -1574,8 +1575,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// When loaded eagerly, initSettingsUI() in app.js calls initScheduler(); // When loaded eagerly, initSettingsUI() in app.js calls initScheduler();
// when loaded lazily (e.g. settings tab click after boot), the app has // when loaded lazily (e.g. settings tab click after boot), the app has
// already passed that point, so we must self-initialize here. // already passed that point, so we must self-initialize here.
if (hostState.authRole != null) { if (!hostState.authEnabled || hostState.authRoles.length > 0) {
initScheduler(hostState.lastActiveRigId, hostState.authRole); initScheduler(hostState.lastActiveRigId, hostState.authRoles);
wireSchedulerEvents(); wireSchedulerEvents();
} }
})(); })();
@@ -0,0 +1,87 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document */
const ALL_ROLES = ["read", "control", "write", "administrator"];
const fixture = await startWebFixture({
authSession: {
authenticated: true,
username: "admin",
roles: ALL_ROLES,
auth_disabled: false,
},
users: [
{ username: "admin", roles: ALL_ROLES, enabled: true },
{ username: "listener", roles: ["read"], enabled: false },
],
});
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.goto(`${fixture.origin}/settings`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-settings").waitFor({ state: "visible" });
await page.locator("#settings-account-tab").waitFor({ state: "visible" });
await page.locator("#settings-users-tab").waitFor({ state: "visible" });
await page.locator("#settings-account-tab").click();
assert.equal(await page.locator("#account-password-form").isVisible(), true);
assert.equal(await page.locator("#subtab-settings-users").isVisible(), false);
await page.locator("#settings-users-tab").click();
await page.locator("#user-list").getByText("listener (disabled)").waitFor();
assert.equal(await page.locator("#user-create-form").isVisible(), true);
const state = await page.evaluate(() => {
const rows = [...document.querySelectorAll("#user-list > .sch-row")];
const rowFor = (username) => rows.find((row) => row.querySelector("strong")?.textContent.startsWith(username));
const admin = rowFor("admin");
const listener = rowFor("listener");
const role = (row, value) => row?.querySelector(`input[value="${value}"]`);
return {
createRoles: [...document.querySelectorAll("#user-create-roles input")].map((input) => input.value),
adminEnabledLocked: admin?.querySelector('input[type="checkbox"]')?.disabled,
adminRoleLocked: role(admin, "administrator")?.disabled,
adminGuestLocked: role(admin, "guest")?.disabled,
adminRemoveLocked: admin?.querySelector("button.danger")?.disabled,
listenerEnabled: listener?.querySelector('input[type="checkbox"]')?.checked,
listenerRead: role(listener, "read")?.checked,
};
});
assert.deepEqual(state.createRoles, ["guest", ...ALL_ROLES]);
assert.equal(state.adminEnabledLocked, true);
assert.equal(state.adminRoleLocked, true);
assert.equal(state.adminGuestLocked, true);
assert.equal(state.adminRemoveLocked, true);
assert.equal(state.listenerEnabled, false);
assert.equal(state.listenerRead, true);
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
const guestFixture = await startWebFixture({
authSession: {
authenticated: true,
username: "guest",
roles: ["guest"],
auth_disabled: false,
},
});
const guestBrowser = await startBrowser(chromium);
try {
await guestBrowser.page.goto(`${guestFixture.origin}/settings`, { waitUntil: "domcontentloaded" });
await guestBrowser.page.locator("#tab-settings").waitFor({ state: "visible" });
assert.equal(await guestBrowser.page.locator("#settings-account-tab").isVisible(), false);
assert.equal(await guestBrowser.page.locator("#settings-users-tab").isVisible(), false);
assert.deepEqual(guestBrowser.runtimeErrors, []);
} finally {
await guestBrowser.browser.close();
await guestFixture.close();
}
@@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
const source = await bundleEntry(new URL("../src/api/auth.ts", import.meta.url), "AuthApi");
function loadAuth(fetch) {
const context = vm.createContext({ fetch, console });
new vm.Script(source).runInContext(context);
return context.AuthApi;
}
test("role policy centralizes Guest and implied read access", () => {
const auth = loadAuth(async () => { throw new Error("unused"); });
assert.deepEqual(Array.from(auth.AUTH_ROLES), ["guest", "read", "control", "write", "administrator"]);
assert.equal(auth.hasAuthRole(["guest"], "read"), true);
assert.equal(auth.hasAccountControls(["guest"]), false);
assert.equal(auth.hasAccountControls(["read"]), true);
assert.equal(auth.hasAuthRole(["control"], "read"), true);
assert.equal(auth.hasAuthRole(["control"], "write"), false);
assert.equal(auth.hasAuthRole(["administrator"], "write"), true);
});
test("auth responses normalize roles and require the managed-account lifecycle state", async () => {
const replies = new Map([
["/auth/session", { authenticated: true, roles: ["write", "read", "write"], username: "alice" }],
["/auth/users", [{ username: "alice", roles: ["write", "read"], enabled: false }]],
]);
const auth = loadAuth(async (url) => ({
ok: true,
status: 200,
json: async () => replies.get(String(url)),
}));
assert.deepEqual(Array.from((await auth.fetchAuthSession()).roles), ["read", "write"]);
assert.deepEqual(Array.from((await auth.listUsers())[0].roles), ["read", "write"]);
assert.equal((await auth.listUsers())[0].enabled, false);
});
test("changing a password sends current and replacement credentials", async () => {
let request;
const auth = loadAuth(async (url, init) => {
request = { url, init };
return { ok: true, status: 200, json: async () => ({}) };
});
await auth.changeOwnPassword("old-password", "new-password");
assert.equal(request.url, "/auth/account/password");
assert.equal(request.init.method, "PATCH");
assert.deepEqual(JSON.parse(request.init.body), {
current_password: "old-password",
new_password: "new-password",
});
});
@@ -40,7 +40,7 @@ test("background decode loads configuration for the explicitly selected rig", as
const source = await bundleEntry(new URL("../src/plugins/background-decode.ts", import.meta.url)); const source = await bundleEntry(new URL("../src/plugins/background-decode.ts", import.meta.url));
new vm.Script(source).runInContext(context); new vm.Script(source).runInContext(context);
window.trx.modules.backgroundDecode.initialize("rig/a", "control"); window.trx.modules.backgroundDecode.initialize("rig/a", ["administrator"]);
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
assert.ok(requested.includes("/background-decode/rig%2Fa")); assert.ok(requested.includes("/background-decode/rig%2Fa"));
assert.ok(requested.includes("/bookmarks")); assert.ok(requested.includes("/bookmarks"));
@@ -32,7 +32,7 @@ function hostFixture(overrides = {}) {
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 }; const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
const state = { const state = {
authEnabled: false, authEnabled: false,
authRole: "control", authRoles: ["read", "control", "write", "administrator"],
lastActiveRigId: null, lastActiveRigId: null,
lastRigIds: [], lastRigIds: [],
lastRigDisplayNames: {}, lastRigDisplayNames: {},
@@ -157,7 +157,7 @@ test("bookmark controls follow the host authentication state", async () => {
if (!elements.has(id)) elements.set(id, new ElementFixture()); if (!elements.has(id)) elements.set(id, new ElementFixture());
return elements.get(id); return elements.get(id);
}; };
const { window } = hostFixture({ authEnabled: true, authRole: "rx" }); const { window } = hostFixture({ authEnabled: true, authRoles: ["read"] });
const context = vm.createContext({ const context = vm.createContext({
window, window,
document: documentFixture(element), document: documentFixture(element),
@@ -171,7 +171,7 @@ test("bookmark controls follow the host authentication state", async () => {
assert.equal(element("bm-add-btn").style.display, "none"); assert.equal(element("bm-add-btn").style.display, "none");
window.trx.state.authRole = "control"; window.trx.state.authRoles = ["read", "write"];
await window.trx.modules.bookmarks.fetch(""); await window.trx.modules.bookmarks.fetch("");
assert.equal(element("bm-add-btn").style.display, ""); assert.equal(element("bm-add-btn").style.display, "");
}); });
@@ -19,7 +19,7 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
serverLat: null, serverLat: null,
serverLon: null, serverLon: null,
authEnabled: false, authEnabled: false,
authRole: "control", authRoles: ["read", "control", "write", "administrator"],
lastActiveRigId: null, lastActiveRigId: null,
lastRigIds: [], lastRigIds: [],
lastRigDisplayNames: {}, lastRigDisplayNames: {},
@@ -11,7 +11,7 @@ import { bundleEntry } from "./bundle-entry.mjs";
test("scheduler registers a typed module service without lifecycle globals", async () => { test("scheduler registers a typed module service without lifecycle globals", async () => {
// No role known yet: the entry registers its service and waits for the // No role known yet: the entry registers its service and waits for the
// application to drive initialization. // application to drive initialization.
const window = { ...createHost({ state: { authRole: null } }), trxUi: { confirm: async () => true } }; const window = { ...createHost({ state: { authEnabled: true, authRoles: [] } }), trxUi: { confirm: async () => true } };
const context = vm.createContext({ const context = vm.createContext({
window, window,
document: { document: {
@@ -76,7 +76,7 @@ test("scheduler self-initializes for the active rig when a role is already known
return elements.get(id); return elements.get(id);
}; };
const window = { const window = {
...createHost({ state: { authRole: "control", lastActiveRigId: "sdr" } }), ...createHost({ state: { authRoles: ["administrator"], lastActiveRigId: "sdr" } }),
trxUi: { confirm: async () => true }, trxUi: { confirm: async () => true },
}; };
const context = vm.createContext({ const context = vm.createContext({
@@ -9,6 +9,7 @@ import { readFile } from "node:fs/promises";
const indexPath = new URL("../../assets/web/index.html", import.meta.url); const indexPath = new URL("../../assets/web/index.html", import.meta.url);
const pluginLoaderPath = new URL("../src/plugin-loader.ts", import.meta.url); const pluginLoaderPath = new URL("../src/plugin-loader.ts", import.meta.url);
const mapCorePath = new URL("../src/map-core.ts", import.meta.url); const mapCorePath = new URL("../src/map-core.ts", import.meta.url);
const appPath = new URL("../src/app.ts", import.meta.url);
test("index loads one first-party application bootstrap", async () => { test("index loads one first-party application bootstrap", async () => {
const html = await readFile(indexPath, "utf8"); const html = await readFile(indexPath, "utf8");
@@ -30,6 +31,30 @@ test("startup has no remote script or stylesheet dependencies", async () => {
); );
}); });
test("administrator user management is a dedicated Settings sub-tab", async () => {
const [html, app] = await Promise.all([
readFile(indexPath, "utf8"),
readFile(appPath, "utf8"),
]);
assert.match(html, /data-subtab="settings-users"/);
assert.match(html, /id="subtab-settings-users" class="sub-tab-panel"/);
assert.match(app, /authEnabled && hasAuthRole\("administrator"\)/);
assert.match(app, /settings-users-tab/);
});
test("account lifecycle controls include self-service passwords and enable state", async () => {
const [html, app] = await Promise.all([
readFile(indexPath, "utf8"),
readFile(appPath, "utf8"),
]);
assert.match(html, /data-subtab="settings-account"/);
assert.match(html, /id="account-password-form"/);
assert.match(html, /id="user-create-enabled"/);
assert.match(app, /changeOwnPassword/);
assert.match(app, /hasAccountControls\(authRoles\)/);
assert.match(app, /enabledAdminCount/);
});
test("lazy frontend features use modules and local map symbols", async () => { test("lazy frontend features use modules and local map symbols", async () => {
const [loader, map] = await Promise.all([ const [loader, map] = await Promise.all([
readFile(pluginLoaderPath, "utf8"), readFile(pluginLoaderPath, "utf8"),
@@ -145,6 +145,8 @@ export async function startWebFixture({
bandplanEnabled = false, bandplanEnabled = false,
bandplanUnauthorizedFirst = false, bandplanUnauthorizedFirst = false,
satPasses = null, satPasses = null,
authSession = { authenticated: true, roles: ["read", "control", "write", "administrator"], auth_disabled: true },
users = [],
} = {}) { } = {}) {
const rigItems = ["rig-a", "rig-b"].map((remote) => ({ const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote, remote,
@@ -232,7 +234,8 @@ export async function startWebFixture({
}; };
const jsonRoutes = new Map([ const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }], ["/auth/session", authSession],
["/auth/users", users],
["/decoders", DECODER_REGISTRY], ["/decoders", DECODER_REGISTRY],
["/rigs", rigsResponse], ["/rigs", rigsResponse],
["/status", status], ["/status", status],
@@ -9,7 +9,7 @@ use std::sync::Arc;
use actix_web::Error; use actix_web::Error;
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse}; use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse};
use super::{no_cache_response, request_accepts_html, require_control}; use super::{no_cache_response, request_accepts_html, require_write};
use crate::server::status; use crate::server::status;
// ============================================================================ // ============================================================================
@@ -165,7 +165,7 @@ pub async fn create_bookmark(
body: web::Json<BookmarkInput>, body: web::Json<BookmarkInput>,
auth_state: web::Data<crate::server::auth::AuthState>, auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?; require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref()); let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
if store.freq_taken(body.freq_hz, None) { if store.freq_taken(body.freq_hz, None) {
return Err(actix_web::error::ErrorConflict( return Err(actix_web::error::ErrorConflict(
@@ -201,7 +201,7 @@ pub async fn update_bookmark(
body: web::Json<BookmarkInput>, body: web::Json<BookmarkInput>,
auth_state: web::Data<crate::server::auth::AuthState>, auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?; require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref()); let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let id = path.into_inner(); let id = path.into_inner();
if store.freq_taken(body.freq_hz, Some(&id)) { if store.freq_taken(body.freq_hz, Some(&id)) {
@@ -235,7 +235,7 @@ pub async fn delete_bookmark(
query: web::Query<BookmarkScopeQuery>, query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>, auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?; require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref()); let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let id = path.into_inner(); let id = path.into_inner();
if store.remove(&id) { if store.remove(&id) {
@@ -253,7 +253,7 @@ pub async fn batch_delete_bookmarks(
query: web::Query<BookmarkScopeQuery>, query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>, auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?; require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref()); let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let mut deleted = 0usize; let mut deleted = 0usize;
for id in &body.ids { for id in &body.ids {
@@ -272,7 +272,7 @@ pub async fn batch_move_bookmarks(
query: web::Query<BookmarkScopeQuery>, query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>, auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?; require_write(&req, &auth_state)?;
let from_store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref()); let from_store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let to_store = resolve_bookmark_store(Some(body.to.as_str()), store_map.get_ref()); let to_store = resolve_bookmark_store(Some(body.to.as_str()), store_map.get_ref());
let mut moved = 0usize; let mut moved = 0usize;
@@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize};
use trx_logbook::qso::{adif_mode_for_rig_mode, band_for_hz, mode_for_decoder}; use trx_logbook::qso::{adif_mode_for_rig_mode, band_for_hz, mode_for_decoder};
use trx_logbook::{LogQuery, Logbook, Qso}; use trx_logbook::{LogQuery, Logbook, Qso};
use super::{active_rig_id_from_context, require_control}; use super::{active_rig_id_from_context, require_write};
use crate::server::auth::AuthState; use crate::server::auth::AuthState;
/// What a contact looks like on the wire. /// What a contact looks like on the wire.
@@ -225,7 +225,7 @@ pub async fn add_qso(
logbook: web::Data<Arc<Logbook>>, logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>, auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> { ) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?; require_write(&req, auth_state.get_ref())?;
let qso = match input.into_inner().into_qso(None) { let qso = match input.into_inner().into_qso(None) {
Ok(qso) => qso, Ok(qso) => qso,
Err(reason) => { Err(reason) => {
@@ -248,7 +248,7 @@ pub async fn edit_qso(
logbook: web::Data<Arc<Logbook>>, logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>, auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> { ) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?; require_write(&req, auth_state.get_ref())?;
let id = path.into_inner(); let id = path.into_inner();
let Some(existing) = book(&logbook).get(&id) else { let Some(existing) = book(&logbook).get(&id) else {
return Ok(HttpResponse::NotFound().json(serde_json::json!({ "error": "no such contact" }))); return Ok(HttpResponse::NotFound().json(serde_json::json!({ "error": "no such contact" })));
@@ -276,7 +276,7 @@ pub async fn delete_qso(
logbook: web::Data<Arc<Logbook>>, logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>, auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> { ) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?; require_write(&req, auth_state.get_ref())?;
match book(&logbook).delete(&path.into_inner()) { match book(&logbook).delete(&path.into_inner()) {
Ok(true) => Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": true }))), Ok(true) => Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": true }))),
Ok(false) => { Ok(false) => {
@@ -312,7 +312,7 @@ pub async fn import_adi(
logbook: web::Data<Arc<Logbook>>, logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>, auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> { ) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?; require_write(&req, auth_state.get_ref())?;
match book(&logbook).import_adi(&body) { match book(&logbook).import_adi(&body) {
Ok(outcome) => Ok(HttpResponse::Ok().json(outcome)), Ok(outcome) => Ok(HttpResponse::Ok().json(outcome)),
Err(err) => Ok(HttpResponse::InternalServerError() Err(err) => Ok(HttpResponse::InternalServerError()
@@ -391,16 +391,20 @@ fn gz_cache_entry(src: &[u8], name: &str) -> GzCacheEntry {
GzCacheEntry { gz, br, etag } GzCacheEntry { gz, br, etag }
} }
fn require_control( fn require_write(
req: &HttpRequest, req: &HttpRequest,
auth_state: &crate::server::auth::AuthState, auth_state: &crate::server::auth::AuthState,
) -> Result<(), actix_web::Error> { ) -> Result<(), actix_web::Error> {
if !auth_state.config.enabled { if !auth_state.config.enabled
return Ok(()); || crate::server::auth::session_grants(
} req,
match crate::server::auth::get_session_role(req, auth_state) { auth_state,
Some(crate::server::auth::AuthRole::Control) => Ok(()), crate::server::auth::AuthRole::Write,
_ => Err(actix_web::error::ErrorForbidden("control role required")), )
{
Ok(())
} else {
Err(actix_web::error::ErrorForbidden("write role required"))
} }
} }
@@ -709,6 +713,11 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(crate::server::auth::login) .service(crate::server::auth::login)
.service(crate::server::auth::logout) .service(crate::server::auth::logout)
.service(crate::server::auth::session_status) .service(crate::server::auth::session_status)
.service(crate::server::auth::change_own_password)
.service(crate::server::auth::list_users)
.service(crate::server::auth::create_user)
.service(crate::server::auth::update_user)
.service(crate::server::auth::delete_user)
// Logbook // Logbook
.service(logbook::list_qsos) .service(logbook::list_qsos)
.service(logbook::add_qso) .service(logbook::add_qso)
@@ -949,30 +958,36 @@ mod tests {
); );
} }
/// Auth off: every session may write, as a station with no passphrase set. /// Auth off: every session may write without an account.
fn auth_state_disabled() -> crate::server::auth::AuthState { fn auth_state_disabled() -> crate::server::auth::AuthState {
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new( crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
false, false,
std::path::PathBuf::from("unused-users.json"),
None, None,
None, None,
false,
std::time::Duration::from_secs(3600), std::time::Duration::from_secs(3600),
false, false,
crate::server::auth::SameSite::Lax, crate::server::auth::SameSite::Lax,
)) ))
.unwrap()
} }
/// Auth on with no session presented, which is what a listener is. /// Auth on with no session presented, which is what a listener is.
fn auth_state_locked() -> crate::server::auth::AuthState { fn auth_state_locked() -> crate::server::auth::AuthState {
let directory = tempfile::tempdir().unwrap();
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new( crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
true, true,
Some("listen".to_string()), directory.path().join("users.json"),
Some("control".to_string()), Some(crate::server::auth::BootstrapAccount::new(
false, "admin".to_string(),
"password123".to_string(),
)),
None,
std::time::Duration::from_secs(3600), std::time::Duration::from_secs(3600),
false, false,
crate::server::auth::SameSite::Lax, crate::server::auth::SameSite::Lax,
)) ))
.unwrap()
} }
/// A contact written over HTTP comes back out of the log, and out of an /// A contact written over HTTP comes back out of the log, and out of an
@@ -1164,10 +1179,16 @@ mod tests {
let logbook = std::sync::Arc::new( let logbook = std::sync::Arc::new(
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"), trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
); );
let auth_state = auth_state_locked();
let session_id = auth_state.store.create(
"reader".to_string(),
[crate::server::auth::AuthRole::Read].into_iter().collect(),
std::time::Duration::from_secs(3600),
);
let app = actix_test::init_service( let app = actix_test::init_service(
App::new() App::new()
.app_data(web::Data::new(logbook)) .app_data(web::Data::new(logbook))
.app_data(web::Data::new(auth_state_locked())) .app_data(web::Data::new(auth_state))
.service(logbook::add_qso) .service(logbook::add_qso)
.service(logbook::list_qsos), .service(logbook::list_qsos),
) )
@@ -1177,6 +1198,10 @@ mod tests {
&app, &app,
actix_test::TestRequest::post() actix_test::TestRequest::post()
.uri("/api/logbook") .uri("/api/logbook")
.cookie(actix_web::cookie::Cookie::new(
"trx_http_sid",
session_id.clone(),
))
.set_json(serde_json::json!({ .set_json(serde_json::json!({
"call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8", "call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8",
})) }))
@@ -1192,12 +1217,47 @@ mod tests {
&app, &app,
actix_test::TestRequest::get() actix_test::TestRequest::get()
.uri("/api/logbook") .uri("/api/logbook")
.cookie(actix_web::cookie::Cookie::new("trx_http_sid", session_id))
.to_request(), .to_request(),
) )
.await; .await;
assert_eq!(listed["total"], 0); assert_eq!(listed["total"], 0);
} }
#[actix_web::test]
async fn a_write_session_can_write_to_the_log() {
let dir = tempfile::tempdir().expect("tempdir");
let logbook = std::sync::Arc::new(
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
);
let auth_state = auth_state_locked();
let session_id = auth_state.store.create(
"writer".to_string(),
[crate::server::auth::AuthRole::Write].into_iter().collect(),
std::time::Duration::from_secs(3600),
);
let app = actix_test::init_service(
App::new()
.app_data(web::Data::new(logbook))
.app_data(web::Data::new(auth_state))
.service(logbook::add_qso),
)
.await;
let response = actix_test::call_service(
&app,
actix_test::TestRequest::post()
.uri("/api/logbook")
.cookie(actix_web::cookie::Cookie::new("trx_http_sid", session_id))
.set_json(serde_json::json!({
"call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8",
}))
.to_request(),
)
.await;
assert_eq!(response.status(), actix_web::http::StatusCode::OK);
}
/// A contact needs a callsign; the panel is not the only thing that has to /// A contact needs a callsign; the panel is not the only thing that has to
/// insist on it. /// insist on it.
#[actix_web::test] #[actix_web::test]
File diff suppressed because it is too large Load Diff
@@ -252,11 +252,31 @@ fn build_server(
"None" => SameSite::None, "None" => SameSite::None,
_ => SameSite::Lax, // default _ => SameSite::Lax, // default
}; };
let bootstrap_admin = auth::BootstrapAccount::from_parts(
context.http_auth.bootstrap_admin_username.clone(),
context.http_auth.bootstrap_admin_password.clone(),
);
let bootstrap_read = context
.http_auth
.bootstrap_read_enabled
.then(|| {
context
.http_auth
.bootstrap_read_password
.clone()
.map(|password| {
auth::BootstrapAccount::new(
context.http_auth.bootstrap_read_username.clone(),
password,
)
})
})
.flatten();
let auth_config = AuthConfig::new( let auth_config = AuthConfig::new(
context.http_auth.enabled, context.http_auth.enabled,
context.http_auth.rx_passphrase.clone(), context.http_auth.users_file.clone().into(),
context.http_auth.control_passphrase.clone(), bootstrap_admin,
context.http_auth.tx_access_control_enabled, bootstrap_read,
Duration::from_secs(context.http_auth.session_ttl_secs), Duration::from_secs(context.http_auth.session_ttl_secs),
context.http_auth.cookie_secure, context.http_auth.cookie_secure,
same_site, same_site,
@@ -273,7 +293,9 @@ fn build_server(
} }
let context_data = web::Data::new(context); let context_data = web::Data::new(context);
let auth_state = web::Data::new(AuthState::new(auth_config.clone())); let auth_state = web::Data::new(
AuthState::new(auth_config.clone()).map_err(actix_web::error::ErrorInternalServerError)?,
);
// Spawn session cleanup task if auth is enabled // Spawn session cleanup task if auth is enabled
if auth_config.enabled { if auth_config.enabled {
+121 -88
View File
@@ -268,18 +268,21 @@ impl AsRef<str> for CookieSameSite {
pub struct HttpAuthConfig { pub struct HttpAuthConfig {
/// Enable HTTP frontend authentication /// Enable HTTP frontend authentication
pub enabled: bool, pub enabled: bool,
/// Passphrase for read-only access (rx role) /// JSON file containing the managed user database.
pub rx_passphrase: Option<String>, pub users_file: String,
/// Read the rx passphrase from this file instead. /// Username used to create the first administrator when the database is absent.
pub bootstrap_admin_username: Option<String>,
/// Password used to create the first administrator when the database is absent.
pub bootstrap_admin_password: Option<String>,
/// Read the bootstrap administrator password from this file instead.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub rx_passphrase_file: Option<String>, pub bootstrap_admin_password_file: Option<String>,
/// Passphrase for full control access (control role) /// Create a Guest account when bootstrapping a new database.
pub control_passphrase: Option<String>, pub bootstrap_read_enabled: bool,
/// Read the control passphrase from this file instead. /// Username for the Guest bootstrap account.
#[serde(default, skip_serializing_if = "Option::is_none")] pub bootstrap_read_username: String,
pub control_passphrase_file: Option<String>, /// Password for the Guest bootstrap account.
/// Enforce TX/PTT access control (hide from unauthenticated/rx users) pub bootstrap_read_password: Option<String>,
pub tx_access_control_enabled: bool,
/// Session time-to-live in minutes /// Session time-to-live in minutes
pub session_ttl_min: u64, pub session_ttl_min: u64,
/// Set Secure flag on session cookie (required for HTTPS) /// Set Secure flag on session cookie (required for HTTPS)
@@ -292,11 +295,13 @@ impl Default for HttpAuthConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: false, enabled: false,
rx_passphrase: None, users_file: "trx-http-users.json".to_string(),
rx_passphrase_file: None, bootstrap_admin_username: None,
control_passphrase: None, bootstrap_admin_password: None,
control_passphrase_file: None, bootstrap_admin_password_file: None,
tx_access_control_enabled: true, bootstrap_read_enabled: true,
bootstrap_read_username: "guest".to_string(),
bootstrap_read_password: Some("guest".to_string()),
session_ttl_min: 480, session_ttl_min: 480,
cookie_secure: false, cookie_secure: false,
cookie_same_site: CookieSameSite::Lax, cookie_same_site: CookieSameSite::Lax,
@@ -802,14 +807,9 @@ impl ClientConfig {
)?; )?;
} }
resolve_secret( resolve_secret(
&mut self.frontends.http.auth.rx_passphrase, &mut self.frontends.http.auth.bootstrap_admin_password,
&self.frontends.http.auth.rx_passphrase_file, &self.frontends.http.auth.bootstrap_admin_password_file,
"[frontends.http.auth].rx_passphrase", "[frontends.http.auth].bootstrap_admin_password",
)?;
resolve_secret(
&mut self.frontends.http.auth.control_passphrase,
&self.frontends.http.auth.control_passphrase_file,
"[frontends.http.auth].control_passphrase",
)?; )?;
resolve_secret_list( resolve_secret_list(
&mut self.frontends.http_json.auth.tokens, &mut self.frontends.http_json.auth.tokens,
@@ -819,7 +819,7 @@ impl ClientConfig {
if let Some(path) = config_path { if let Some(path) = config_path {
if self.has_inline_secrets() { if self.has_inline_secrets() {
crate::secrets::warn_if_group_readable(path, "tokens/passphrases"); crate::secrets::warn_if_group_readable(path, "tokens/passwords");
} }
} }
Ok(()) Ok(())
@@ -832,10 +832,22 @@ impl ClientConfig {
.remotes .remotes
.iter() .iter()
.any(|r| r.auth.token_file.is_none() && r.auth.token.is_some()) .any(|r| r.auth.token_file.is_none() && r.auth.token.is_some())
|| self.frontends.http.auth.rx_passphrase_file.is_none() || self
&& self.frontends.http.auth.rx_passphrase.is_some() .frontends
|| self.frontends.http.auth.control_passphrase_file.is_none() .http
&& self.frontends.http.auth.control_passphrase.is_some() .auth
.bootstrap_admin_password_file
.is_none()
&& self.frontends.http.auth.bootstrap_admin_password.is_some()
|| self.frontends.http.auth.enabled
&& self.frontends.http.auth.bootstrap_read_enabled
&& self
.frontends
.http
.auth
.bootstrap_read_password
.as_deref()
.is_some_and(|password| password != "guest")
|| self.frontends.http_json.auth.tokens_file.is_none() || self.frontends.http_json.auth.tokens_file.is_none()
&& !self.frontends.http_json.auth.tokens.is_empty() && !self.frontends.http_json.auth.tokens.is_empty()
} }
@@ -905,11 +917,13 @@ impl ClientConfig {
decode_history_retention_min_by_rig: HashMap::new(), decode_history_retention_min_by_rig: HashMap::new(),
auth: HttpAuthConfig { auth: HttpAuthConfig {
enabled: false, enabled: false,
rx_passphrase: Some("rx-passphrase-example".to_string()), users_file: "trx-http-users.json".to_string(),
rx_passphrase_file: None, bootstrap_admin_username: Some("admin".to_string()),
control_passphrase: Some("control-passphrase-example".to_string()), bootstrap_admin_password: Some("change-this-password".to_string()),
control_passphrase_file: None, bootstrap_admin_password_file: None,
tx_access_control_enabled: true, bootstrap_read_enabled: true,
bootstrap_read_username: "guest".to_string(),
bootstrap_read_password: Some("guest".to_string()),
session_ttl_min: 480, session_ttl_min: 480,
cookie_secure: false, cookie_secure: false,
cookie_same_site: CookieSameSite::Lax, cookie_same_site: CookieSameSite::Lax,
@@ -948,27 +962,34 @@ fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> {
return Ok(()); return Ok(());
} }
// If enabled, require at least one passphrase if auth.users_file.trim().is_empty() {
if auth.rx_passphrase.is_none() && auth.control_passphrase.is_none() { return Err("[frontends.http.auth].users_file must not be empty".to_string());
}
if auth.bootstrap_admin_username.is_some() != auth.bootstrap_admin_password.is_some() {
return Err("[frontends.http.auth] bootstrap_admin_username and bootstrap_admin_password must be set together".to_string());
}
if auth
.bootstrap_admin_username
.as_deref()
.is_some_and(|v| v.trim().is_empty())
|| auth
.bootstrap_admin_password
.as_deref()
.is_some_and(|v| v.is_empty())
{
return Err( return Err(
"[frontends.http.auth] enabled=true requires at least one passphrase \ "[frontends.http.auth] bootstrap administrator credentials must not be empty"
(rx_passphrase and/or control_passphrase)"
.to_string(), .to_string(),
); );
} }
if auth.bootstrap_read_enabled
// Validate passphrases are not empty strings && (auth.bootstrap_read_username.trim().is_empty()
if let Some(rx) = &auth.rx_passphrase { || auth
if rx.trim().is_empty() { .bootstrap_read_password
return Err("[frontends.http.auth].rx_passphrase must not be empty if set".to_string()); .as_deref()
} .is_none_or(str::is_empty))
} {
if let Some(ctrl) = &auth.control_passphrase { return Err("[frontends.http.auth] enabled bootstrap Guest account requires a non-empty username and password".to_string());
if ctrl.trim().is_empty() {
return Err(
"[frontends.http.auth].control_passphrase must not be empty if set".to_string(),
);
}
} }
// Session TTL must be > 0 // Session TTL must be > 0
@@ -1238,42 +1259,34 @@ home-hf = "audio://10.0.0.5:4600"
} }
#[test] #[test]
fn test_validate_rejects_http_auth_enabled_without_passphrases() { fn test_validate_accepts_http_auth_with_user_database() {
let mut config = ClientConfig::default(); let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true; config.frontends.http.auth.enabled = true;
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_accepts_bootstrap_admin_pair() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string());
config.frontends.http.auth.bootstrap_admin_password = Some("secret-password".to_string());
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_rejects_incomplete_bootstrap_admin_pair() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string());
assert!(config.validate().is_err()); assert!(config.validate().is_err());
} }
#[test] #[test]
fn test_validate_accepts_http_auth_with_rx_passphrase() { fn test_validate_rejects_empty_users_file() {
let mut config = ClientConfig::default(); let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true; config.frontends.http.auth.enabled = true;
config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string()); config.frontends.http.auth.users_file.clear();
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_accepts_http_auth_with_control_passphrase() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.control_passphrase = Some("control-secret".to_string());
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_accepts_http_auth_with_both_passphrases() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string());
config.frontends.http.auth.control_passphrase = Some("control-secret".to_string());
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_rejects_empty_rx_passphrase() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.rx_passphrase = Some("".to_string());
assert!(config.validate().is_err()); assert!(config.validate().is_err());
} }
@@ -1281,16 +1294,27 @@ home-hf = "audio://10.0.0.5:4600"
fn test_validate_rejects_zero_session_ttl() { fn test_validate_rejects_zero_session_ttl() {
let mut config = ClientConfig::default(); let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true; config.frontends.http.auth.enabled = true;
config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string());
config.frontends.http.auth.session_ttl_min = 0; config.frontends.http.auth.session_ttl_min = 0;
assert!(config.validate().is_err()); assert!(config.validate().is_err());
} }
#[test] #[test]
fn test_validate_auth_disabled_ignores_passphrases() { fn test_validate_allows_disabling_read_bootstrap_credentials() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string());
config.frontends.http.auth.bootstrap_admin_password = Some("secret-password".to_string());
config.frontends.http.auth.bootstrap_read_enabled = false;
config.frontends.http.auth.bootstrap_read_username.clear();
config.frontends.http.auth.bootstrap_read_password = None;
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_auth_disabled_ignores_user_settings() {
let mut config = ClientConfig::default(); let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = false; config.frontends.http.auth.enabled = false;
config.frontends.http.auth.rx_passphrase = Some("".to_string()); config.frontends.http.auth.users_file.clear();
assert!(config.validate().is_ok()); assert!(config.validate().is_ok());
} }
@@ -1298,9 +1322,12 @@ home-hf = "audio://10.0.0.5:4600"
fn test_http_auth_config_default() { fn test_http_auth_config_default() {
let auth = HttpAuthConfig::default(); let auth = HttpAuthConfig::default();
assert!(!auth.enabled); assert!(!auth.enabled);
assert!(auth.rx_passphrase.is_none()); assert_eq!(auth.users_file, "trx-http-users.json");
assert!(auth.control_passphrase.is_none()); assert!(auth.bootstrap_admin_username.is_none());
assert!(auth.tx_access_control_enabled); assert!(auth.bootstrap_admin_password.is_none());
assert!(auth.bootstrap_read_enabled);
assert_eq!(auth.bootstrap_read_username, "guest");
assert_eq!(auth.bootstrap_read_password.as_deref(), Some("guest"));
assert_eq!(auth.session_ttl_min, 480); assert_eq!(auth.session_ttl_min, 480);
assert!(!auth.cookie_secure); assert!(!auth.cookie_secure);
assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax)); assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax));
@@ -1602,15 +1629,21 @@ spectrum_interval_ms = 0
} }
#[test] #[test]
fn test_passphrase_file_fills_passphrase() { fn test_bootstrap_password_file_fills_password() {
let f = secret_file("hunter2\n"); let f = secret_file("hunter2\n");
let mut config = ClientConfig::default(); let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true; config.frontends.http.auth.enabled = true;
config.frontends.http.auth.control_passphrase_file = config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string());
config.frontends.http.auth.bootstrap_admin_password_file =
Some(f.path().to_str().unwrap().to_string()); Some(f.path().to_str().unwrap().to_string());
config.resolve_secrets(None).unwrap(); config.resolve_secrets(None).unwrap();
assert_eq!( assert_eq!(
config.frontends.http.auth.control_passphrase.as_deref(), config
.frontends
.http
.auth
.bootstrap_admin_password
.as_deref(),
Some("hunter2") Some("hunter2")
); );
assert!(config.validate().is_ok()); assert!(config.validate().is_ok());
+2 -2
View File
@@ -111,8 +111,8 @@ const SECTION_COMMENTS: &[(&str, &str)] = &[
), ),
( (
"trx-client.frontends.http.auth", "trx-client.frontends.http.auth",
"Passphrase login for the web UI. rx_passphrase_file and\n\ "Optional user/password ACL for the web UI. Administrators manage\n\
control_passphrase_file keep the secrets out of this file.", accounts stored in users_file.",
), ),
( (
"trx-client.frontends.rigctl", "trx-client.frontends.rigctl",
+8 -5
View File
@@ -203,13 +203,16 @@ decode_history_retention_min = 1440
[trx-client.frontends.http.decode_history_retention_min_by_rig] [trx-client.frontends.http.decode_history_retention_min_by_rig]
# Passphrase login for the web UI. rx_passphrase_file and # Optional user/password ACL for the web UI. Administrators manage
# control_passphrase_file keep the secrets out of this file. # accounts stored in users_file.
[trx-client.frontends.http.auth] [trx-client.frontends.http.auth]
enabled = false enabled = false
rx_passphrase = "rx-passphrase-example" users_file = "trx-http-users.json"
control_passphrase = "control-passphrase-example" bootstrap_admin_username = "admin"
tx_access_control_enabled = true bootstrap_admin_password = "change-this-password"
bootstrap_read_enabled = true
bootstrap_read_username = "guest"
bootstrap_read_password = "guest"
session_ttl_min = 480 session_ttl_min = 480
cookie_secure = false cookie_secure = false
cookie_same_site = "Lax" cookie_same_site = "Lax"