Add composable HTTP access roles #62
@@ -1043,7 +1043,7 @@ The `FrontendRuntimeContext` struct in `trx-frontend/src/lib.rs` is decomposed i
|
||||
|-----------|---------|------------|
|
||||
| `AudioContext` | Audio streaming channels | `rx`, `tx`, `info`, `decode_rx`, `clients` |
|
||||
| `DecodeHistoryContext` | Decode history for all types | `ais`, `vdes`, `aprs`, `hf_aprs`, `cw`, `ft8`, `ft4`, `ft2`, `wspr` |
|
||||
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `users_file`, bootstrap admin, `session_ttl_secs`, `tokens` |
|
||||
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `users_file`, bootstrap admin/read accounts, `session_ttl_secs`, `tokens` |
|
||||
| `HttpUiConfig` | HTTP UI display config | `show_sdr_gain_control`, `initial_map_zoom`, `spectrum_*` |
|
||||
| `RigRoutingContext` | Remote rig state & routing | `active_rig_id`, `remote_rigs`, `rig_states`, `server_connected` |
|
||||
| `OwnerInfo` | Station metadata | `callsign`, `website_url`, `ais_vessel_url_base` |
|
||||
|
||||
@@ -126,7 +126,8 @@ When auth is enabled, an **auth gate** blocks the UI with:
|
||||
- Error message area (red `#ff6b6b`)
|
||||
- Role badge display
|
||||
|
||||
Two roles: **User** (read-only) and **Admin** (full access including user management).
|
||||
Accounts may combine **Read**, **Control**, **Write**, and **Administrator** roles.
|
||||
Administrator implies all permissions.
|
||||
|
||||
Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes.
|
||||
|
||||
@@ -340,8 +341,9 @@ Routes are classified into three tiers:
|
||||
| Tier | Examples | Requirement |
|
||||
|---|---|---|
|
||||
| **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None |
|
||||
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | User or Admin role |
|
||||
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Admin role only |
|
||||
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Read, Control, or Administrator role |
|
||||
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, radio-control POST routes | Control or Administrator role |
|
||||
| **Write** | Logbook access and bookmark mutations | Write or Administrator role |
|
||||
|
||||
### 7.2 Session Management
|
||||
|
||||
|
||||
+24
-13
@@ -354,6 +354,9 @@ A name in any of those maps that no remote answers to is a config error.
|
||||
| `bootstrap_admin_username` | string | — | First administrator, used only if the database is absent |
|
||||
| `bootstrap_admin_password` | string | — | First administrator password |
|
||||
| `bootstrap_admin_password_file` | string | — | Read the bootstrap password from this file instead |
|
||||
| `bootstrap_read_enabled` | bool | `true` | Create the default read-only account when the database is absent |
|
||||
| `bootstrap_read_username` | string | `"guest"` | Initial read-only username |
|
||||
| `bootstrap_read_password` | string | `"guest"` | Initial read-only password |
|
||||
| `session_ttl_min` | u64 | `480` | Session lifetime |
|
||||
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
|
||||
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` |
|
||||
@@ -577,7 +580,7 @@ The link button in the top bar copies the current link to the clipboard. The
|
||||
address bar itself is updated as you tune, using `replaceState`, so sweeping
|
||||
the dial does not fill the browser's history.
|
||||
|
||||
Applying a link changes the radio, so it needs the `admin` role; a `user`
|
||||
Applying a link changes the radio, so it needs the `Control` role; a `Read`
|
||||
session opens the page and says the link was not applied. Links describe the
|
||||
rig's own dial — while a tab is listening to a virtual channel the address is
|
||||
left as it was, rather than publishing a frequency the rig is not on.
|
||||
@@ -586,11 +589,13 @@ left as it was, rather than publishing a frequency the rig is not on.
|
||||
|
||||
## Authentication
|
||||
|
||||
The HTTP frontend supports an optional user/password ACL with multiple accounts and two
|
||||
roles:
|
||||
The HTTP frontend supports an optional user/password ACL with multiple independent
|
||||
roles. One account may have any combination:
|
||||
|
||||
- **user** — read-only access (monitoring, audio, decode streams)
|
||||
- **admin** — full radio control, settings, and user management
|
||||
- **Read** — monitoring, audio, decode streams, and bookmark reads
|
||||
- **Control** — full radio receive/transmit controls
|
||||
- **Write** — logbook access and bookmark changes
|
||||
- **Administrator** — user management and all other permissions
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -600,6 +605,9 @@ enabled = false
|
||||
users_file = "trx-http-users.json"
|
||||
bootstrap_admin_username = "admin"
|
||||
bootstrap_admin_password = "change-this-password"
|
||||
bootstrap_read_enabled = true
|
||||
bootstrap_read_username = "guest"
|
||||
bootstrap_read_password = "guest"
|
||||
session_ttl_min = 480
|
||||
cookie_secure = false # true if served via HTTPS
|
||||
cookie_same_site = "Lax" # Strict|Lax|None
|
||||
@@ -607,7 +615,9 @@ cookie_same_site = "Lax" # Strict|Lax|None
|
||||
|
||||
When `enabled = false` (the default), all auth is bypassed and the UI behaves
|
||||
as before. When enabling it for the first time, bootstrap credentials create
|
||||
the initial administrator and the Argon2id-hashed user database.
|
||||
the initial administrator (with every role), the default `guest`/`guest` Read
|
||||
account, and the Argon2id-hashed user database. Change or disable the guest
|
||||
credentials in configuration before first startup on an exposed deployment.
|
||||
|
||||
### Behaviour
|
||||
|
||||
@@ -615,8 +625,9 @@ the initial administrator and the Argon2id-hashed user database.
|
||||
- Sessions are in-memory; a server restart invalidates all sessions.
|
||||
- Rate limiting is applied per IP to mitigate brute-force attempts.
|
||||
- User records persist in `users_file`; passwords are stored as salted Argon2id hashes.
|
||||
- `user` sessions cannot call control routes. There is no guest-access mode.
|
||||
- Administrators can add/remove users and change roles/passwords in Settings.
|
||||
- Roles are independent; for example, an account may have Read and Write without Control.
|
||||
- Administrators can add/remove users and change roles/passwords in Settings > Users.
|
||||
- At least one administrator must always remain and cannot be removed or demoted.
|
||||
- Removing an account or changing its password/role revokes its sessions.
|
||||
|
||||
### Routes
|
||||
@@ -625,19 +636,19 @@ the initial administrator and the Argon2id-hashed user database.
|
||||
|----------|--------|-------------|
|
||||
| `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` |
|
||||
| `/auth/logout` | POST | Clear session |
|
||||
| `/auth/session` | GET | Check current session/role |
|
||||
| `/auth/session` | GET | Check current session/roles |
|
||||
| `/auth/users` | GET/POST | List or add users (admin only) |
|
||||
| `/auth/users/{username}` | PATCH/DELETE | Change password/role or remove user (admin only) |
|
||||
| `/auth/users/{username}` | PATCH/DELETE | Change password/roles or remove user (administrator only) |
|
||||
|
||||
Protected routes require at least `user` role. Control routes (set frequency,
|
||||
mode, PTT, etc.) require `admin` role.
|
||||
Read routes require Read. Radio mutations require Control. Logbook access and
|
||||
bookmark mutations require Write. Administrator grants every permission.
|
||||
|
||||
### Frontend Flow
|
||||
|
||||
1. On load, the UI calls `/auth/session`.
|
||||
2. If unauthenticated, a login screen is shown.
|
||||
3. On successful login, the normal UI loads.
|
||||
4. `user` accounts see a read-only interface; admins get full controls.
|
||||
4. The interface enables controls according to the account's roles.
|
||||
5. If a session expires mid-use, streams stop and the login screen returns.
|
||||
|
||||
### Transport Security
|
||||
|
||||
@@ -257,6 +257,12 @@ async fn async_init() -> DynResult<AppState> {
|
||||
cfg.frontends.http.auth.bootstrap_admin_username.clone();
|
||||
frontend_runtime.http_auth.bootstrap_admin_password =
|
||||
cfg.frontends.http.auth.bootstrap_admin_password.clone();
|
||||
frontend_runtime.http_auth.bootstrap_read_enabled =
|
||||
cfg.frontends.http.auth.bootstrap_read_enabled;
|
||||
frontend_runtime.http_auth.bootstrap_read_username =
|
||||
cfg.frontends.http.auth.bootstrap_read_username.clone();
|
||||
frontend_runtime.http_auth.bootstrap_read_password =
|
||||
cfg.frontends.http.auth.bootstrap_read_password.clone();
|
||||
frontend_runtime.http_auth.session_ttl_secs = cfg.frontends.http.auth.session_ttl().as_secs();
|
||||
frontend_runtime.http_auth.cookie_secure = cfg.frontends.http.auth.cookie_secure;
|
||||
frontend_runtime.http_auth.cookie_same_site = match cfg.frontends.http.auth.cookie_same_site {
|
||||
|
||||
@@ -260,6 +260,9 @@ pub struct HttpAuthConfig {
|
||||
pub users_file: String,
|
||||
pub bootstrap_admin_username: Option<String>,
|
||||
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 cookie_secure: bool,
|
||||
pub cookie_same_site: String,
|
||||
@@ -274,6 +277,9 @@ impl Default for HttpAuthConfig {
|
||||
users_file: "trx-http-users.json".to_string(),
|
||||
bootstrap_admin_username: None,
|
||||
bootstrap_admin_password: None,
|
||||
bootstrap_read_enabled: true,
|
||||
bootstrap_read_username: "guest".to_string(),
|
||||
bootstrap_read_password: Some("guest".to_string()),
|
||||
session_ttl_secs: 480 * 60,
|
||||
cookie_secure: false,
|
||||
cookie_same_site: "Lax".to_string(),
|
||||
|
||||
@@ -1323,14 +1323,13 @@ function decodeAuthSession(value) {
|
||||
if (typeof session.authenticated !== "boolean") {
|
||||
throw new TypeError("The authentication response has no authenticated flag");
|
||||
}
|
||||
if (session.role !== void 0 && session.role !== "user" && session.role !== "admin") {
|
||||
throw new TypeError("The authentication response has an invalid role");
|
||||
if (!Array.isArray(session.roles) || !session.roles.every((role) => role === "read" || role === "control" || role === "write" || role === "administrator")) {
|
||||
throw new TypeError("The authentication response has invalid roles");
|
||||
}
|
||||
if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") {
|
||||
throw new TypeError("The authentication response has an invalid auth_disabled flag");
|
||||
}
|
||||
const decoded = { authenticated: session.authenticated };
|
||||
if (session.role !== void 0) decoded.role = session.role;
|
||||
const decoded = { authenticated: session.authenticated, roles: session.roles };
|
||||
if (session.username !== void 0) {
|
||||
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
|
||||
decoded.username = session.username;
|
||||
@@ -1340,18 +1339,18 @@ function decodeAuthSession(value) {
|
||||
}
|
||||
var authDisabledSession = {
|
||||
authenticated: true,
|
||||
role: "admin",
|
||||
roles: ["read", "control", "write", "administrator"],
|
||||
auth_disabled: true
|
||||
};
|
||||
async function fetchAuthSession() {
|
||||
try {
|
||||
const response = await fetch("/auth/session");
|
||||
if (response.status === 404) return authDisabledSession;
|
||||
if (!response.ok) return { authenticated: false };
|
||||
if (!response.ok) return { authenticated: false, roles: [] };
|
||||
return decodeAuthSession(await response.json());
|
||||
} catch (error) {
|
||||
console.error("Auth check failed:", error);
|
||||
return { authenticated: false };
|
||||
return { authenticated: false, roles: [] };
|
||||
}
|
||||
}
|
||||
async function login(username, password) {
|
||||
@@ -1380,14 +1379,14 @@ async function listUsers() {
|
||||
if (!Array.isArray(value) || !value.every((user) => {
|
||||
if (typeof user !== "object" || user === null) return false;
|
||||
const record = user;
|
||||
return typeof record.username === "string" && (record.role === "user" || record.role === "admin");
|
||||
return typeof record.username === "string" && Array.isArray(record.roles) && record.roles.every((role) => role === "read" || role === "control" || role === "write" || role === "administrator");
|
||||
})) {
|
||||
throw new TypeError("The user list response is malformed");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
async function createUser(username, password, role) {
|
||||
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, role }) });
|
||||
async function createUser(username, password, roles) {
|
||||
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles }) });
|
||||
}
|
||||
async function updateUser(username, changes) {
|
||||
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
|
||||
@@ -1895,8 +1894,23 @@ function isVchanRdsEntry(value) {
|
||||
}
|
||||
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
|
||||
var authRole = null;
|
||||
var authRoles = [];
|
||||
var authUsername = null;
|
||||
var authEnabled = true;
|
||||
var ALL_AUTH_ROLES = ["read", "control", "write", "administrator"];
|
||||
var AUTH_ROLE_LABELS = {
|
||||
read: "Read",
|
||||
control: "Control",
|
||||
write: "Write",
|
||||
administrator: "Administrator"
|
||||
};
|
||||
function setAuthRoles(roles) {
|
||||
authRoles = [...new Set(roles)];
|
||||
authRole = ["administrator", "control", "write", "read"].find((role) => authRoles.includes(role)) ?? null;
|
||||
}
|
||||
function hasAuthRole(role) {
|
||||
return authRoles.includes("administrator") || authRoles.includes(role);
|
||||
}
|
||||
async function checkAuthStatus() {
|
||||
return fetchAuthSession();
|
||||
}
|
||||
@@ -1906,7 +1920,7 @@ async function authLogin(username, password) {
|
||||
async function authLogout() {
|
||||
try {
|
||||
await logout();
|
||||
authRole = null;
|
||||
setAuthRoles([]);
|
||||
authUsername = null;
|
||||
disconnect();
|
||||
setDecodeHistoryOverlayVisible(false);
|
||||
@@ -1978,9 +1992,9 @@ function updateAuthUI() {
|
||||
syncTopBarAccess();
|
||||
return;
|
||||
}
|
||||
if (authRole) {
|
||||
if (authRoles.length > 0) {
|
||||
if (badge) badge.style.display = "block";
|
||||
if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRole === "admin" ? "Admin" : "User (read-only)"}`;
|
||||
if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`;
|
||||
if (headerAuthBtn2) {
|
||||
headerAuthBtn2.textContent = "Logout";
|
||||
headerAuthBtn2.style.display = "block";
|
||||
@@ -1995,8 +2009,8 @@ function updateAuthUI() {
|
||||
syncTopBarAccess();
|
||||
}
|
||||
function applyAuthRestrictions() {
|
||||
if (!authRole) return;
|
||||
if (authRole === "user") {
|
||||
if (authRoles.length === 0) return;
|
||||
if (!hasAuthRole("control")) {
|
||||
const pttBtn2 = document.getElementById("ptt-btn");
|
||||
const powerBtn2 = document.getElementById("power-btn");
|
||||
const lockBtn2 = document.getElementById("lock-btn");
|
||||
@@ -2286,20 +2300,21 @@ window.applyDecodeHistoryRetention = function() {
|
||||
}
|
||||
};
|
||||
function syncTopBarAccess() {
|
||||
const loggedOut = authEnabled && !authRole;
|
||||
const loggedOut = authEnabled && authRoles.length === 0;
|
||||
const tabBar = document.getElementById("tab-bar");
|
||||
const rigSwitch = document.querySelector(".header-rig-switch");
|
||||
if (tabBar) tabBar.style.display = "";
|
||||
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
|
||||
const isMain = btn.dataset.tab === "main";
|
||||
btn.style.display = !loggedOut || isMain ? "" : "none";
|
||||
const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole("write");
|
||||
btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none";
|
||||
btn.disabled = false;
|
||||
});
|
||||
if (rigSwitch) {
|
||||
rigSwitch.style.display = loggedOut ? "none" : "";
|
||||
}
|
||||
if (headerRigSwitchSelect) {
|
||||
headerRigSwitchSelect.disabled = loggedOut || authRole === "user" || lastRigIds.length === 0;
|
||||
headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole("control") || lastRigIds.length === 0;
|
||||
}
|
||||
}
|
||||
var overviewDrawPending = false;
|
||||
@@ -2935,7 +2950,7 @@ function applyRigList(activeRigId, rigIds, displayNames) {
|
||||
}
|
||||
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
|
||||
const rigListChanged = prevKey !== nextKey;
|
||||
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "user";
|
||||
const disableSwitch = lastRigIds.length === 0 || !hasAuthRole("control");
|
||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||||
updateRigSubtitle(lastActiveRigId);
|
||||
window.trxUi?.setActiveRig(lastActiveRigId);
|
||||
@@ -4538,7 +4553,7 @@ function scheduleTuneLinkSync() {
|
||||
async function applyTuneLink(link) {
|
||||
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
|
||||
if (!wanted) return;
|
||||
if (authRole === "user") {
|
||||
if (!hasAuthRole("control")) {
|
||||
showHint("Read-only session — link not applied", 2500);
|
||||
return;
|
||||
}
|
||||
@@ -5256,7 +5271,7 @@ async function postPath(path, options = {}) {
|
||||
}
|
||||
const resp = await fetch(path, { method: "POST" });
|
||||
if (authEnabled && resp.status === 401) {
|
||||
authRole = null;
|
||||
setAuthRoles([]);
|
||||
if (es) es.close();
|
||||
showAuthGate();
|
||||
throw new Error("Authentication required");
|
||||
@@ -5281,8 +5296,8 @@ async function switchRigFromSelect(selectEl) {
|
||||
showHint("No rig selected", 1500);
|
||||
return;
|
||||
}
|
||||
if (authRole === "user") {
|
||||
showHint("Admin role required", 1500);
|
||||
if (!hasAuthRole("control")) {
|
||||
showHint("Control role required", 1500);
|
||||
return;
|
||||
}
|
||||
if (!lastRigIds.includes(selectEl.value)) {
|
||||
@@ -5895,10 +5910,15 @@ function navigateToTab(name, options = {}) {
|
||||
window.trxUi?.closeMobileOverlays?.();
|
||||
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
||||
const { updateHistory = true, replaceHistory = false } = options;
|
||||
if (authEnabled && !authRole && name !== "main") {
|
||||
if (authEnabled && authRoles.length === 0 && name !== "main") {
|
||||
showAuthGate();
|
||||
return;
|
||||
}
|
||||
if (authEnabled && name === "logbook" && !hasAuthRole("write")) {
|
||||
showHint("Write role required for logbook access", 2500);
|
||||
navigateToTab("main", options);
|
||||
return;
|
||||
}
|
||||
const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`);
|
||||
if (!btn) return;
|
||||
_activeTab = name;
|
||||
@@ -6013,7 +6033,7 @@ async function initializeApp() {
|
||||
const authStatus = await checkAuthStatus();
|
||||
authEnabled = !authStatus.auth_disabled;
|
||||
if (!authEnabled) {
|
||||
authRole = "admin";
|
||||
setAuthRoles(ALL_AUTH_ROLES);
|
||||
hideAuthGate();
|
||||
updateAuthUI();
|
||||
connect();
|
||||
@@ -6024,7 +6044,7 @@ async function initializeApp() {
|
||||
return;
|
||||
}
|
||||
if (authStatus.authenticated) {
|
||||
authRole = authStatus.role ?? null;
|
||||
setAuthRoles(authStatus.roles);
|
||||
authUsername = authStatus.username ?? null;
|
||||
hideAuthGate();
|
||||
updateAuthUI();
|
||||
@@ -6051,12 +6071,22 @@ function initSettingsUI() {
|
||||
}
|
||||
async function refreshUserManagement() {
|
||||
const section = document.getElementById("user-management");
|
||||
if (!section) return;
|
||||
section.style.display = authEnabled && authRole === "admin" ? "block" : "none";
|
||||
if (section.style.display === "none") return;
|
||||
const tab = document.getElementById("settings-users-tab");
|
||||
if (!section || !tab) return;
|
||||
const canManageUsers = authEnabled && hasAuthRole("administrator");
|
||||
tab.style.display = canManageUsers ? "" : "none";
|
||||
if (!canManageUsers) {
|
||||
const panel = document.getElementById("subtab-settings-users");
|
||||
if (panel) panel.style.display = "none";
|
||||
if (tab.classList.contains("active")) {
|
||||
document.querySelector('[data-subtab="settings-scheduler"]')?.click();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const list = requiredElement("user-list");
|
||||
try {
|
||||
const users = await listUsers();
|
||||
const adminCount = users.filter((user) => user.roles.includes("administrator")).length;
|
||||
list.replaceChildren(...users.map((user) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sch-row";
|
||||
@@ -6064,18 +6094,28 @@ async function refreshUserManagement() {
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = user.username;
|
||||
name.style.minWidth = "10rem";
|
||||
const role = document.createElement("select");
|
||||
role.className = "auth-input";
|
||||
for (const value of ["user", "admin"]) {
|
||||
const option = document.createElement("option");
|
||||
option.value = value;
|
||||
option.textContent = value === "admin" ? "Admin" : "User";
|
||||
option.selected = user.role === value;
|
||||
role.append(option);
|
||||
const roleInputs = ALL_AUTH_ROLES.map((value) => {
|
||||
const label = document.createElement("label");
|
||||
label.className = "auth-role-choice";
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.value = value;
|
||||
input.checked = user.roles.includes(value);
|
||||
label.append(input, ` ${AUTH_ROLE_LABELS[value]}`);
|
||||
return { label, input, value };
|
||||
});
|
||||
const roles = document.createElement("span");
|
||||
roles.className = "auth-role-choices";
|
||||
roles.append(...roleInputs.map(({ label }) => label));
|
||||
const isOnlyAdmin = user.roles.includes("administrator") && adminCount === 1;
|
||||
const administratorInput = roleInputs.find((item) => item.value === "administrator")?.input;
|
||||
if (isOnlyAdmin && administratorInput) {
|
||||
administratorInput.disabled = true;
|
||||
administratorInput.title = "The final administrator cannot be demoted";
|
||||
}
|
||||
const password = document.createElement("input");
|
||||
password.type = "password";
|
||||
password.placeholder = "New password";
|
||||
password.placeholder = "New password (8+ characters)";
|
||||
password.autocomplete = "new-password";
|
||||
password.className = "auth-input";
|
||||
password.minLength = 8;
|
||||
@@ -6083,7 +6123,9 @@ async function refreshUserManagement() {
|
||||
save.type = "button";
|
||||
save.textContent = "Save";
|
||||
save.addEventListener("click", async () => {
|
||||
const changes = { role: role.value };
|
||||
const changes = {
|
||||
roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value)
|
||||
};
|
||||
if (password.value) changes.password = password.value;
|
||||
await runUserOperation(() => updateUser(user.username, changes));
|
||||
});
|
||||
@@ -6091,13 +6133,14 @@ async function refreshUserManagement() {
|
||||
remove.type = "button";
|
||||
remove.textContent = "Remove";
|
||||
remove.className = "danger";
|
||||
remove.disabled = user.username === authUsername;
|
||||
remove.disabled = user.username === authUsername || isOnlyAdmin;
|
||||
if (isOnlyAdmin) remove.title = "The final administrator cannot be removed";
|
||||
remove.addEventListener("click", async () => {
|
||||
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
|
||||
await runUserOperation(() => deleteUser(user.username));
|
||||
}
|
||||
});
|
||||
row.append(name, role, password, save, remove);
|
||||
row.append(name, roles, password, save, remove);
|
||||
return row;
|
||||
}));
|
||||
} catch (error) {
|
||||
@@ -6124,12 +6167,14 @@ document.getElementById("user-create-form")?.addEventListener("submit", (event)
|
||||
event.preventDefault();
|
||||
const username = requiredElement("user-create-username");
|
||||
const password = requiredElement("user-create-password");
|
||||
const role = requiredElement("user-create-role");
|
||||
const roles = Array.from(document.querySelectorAll("#user-create-roles input[type=checkbox]"));
|
||||
void runUserOperation(async () => {
|
||||
await createUser(username.value, password.value, role.value);
|
||||
await createUser(username.value, password.value, roles.filter((input) => input.checked).map((input) => input.value));
|
||||
username.value = "";
|
||||
password.value = "";
|
||||
role.value = "user";
|
||||
roles.forEach((input) => {
|
||||
input.checked = input.value === "read";
|
||||
});
|
||||
});
|
||||
});
|
||||
requiredElement("auth-form").addEventListener("submit", async (e) => {
|
||||
@@ -6142,7 +6187,7 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
|
||||
btn.textContent = "Logging in...";
|
||||
try {
|
||||
const result = await authLogin(usernameEl.value, passwordEl.value);
|
||||
authRole = result.role ?? null;
|
||||
setAuthRoles(result.roles);
|
||||
authUsername = result.username ?? usernameEl.value;
|
||||
passwordEl.value = "";
|
||||
hideAuthGate();
|
||||
@@ -6164,7 +6209,7 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
|
||||
var headerAuthBtn = document.getElementById("header-auth-btn");
|
||||
if (headerAuthBtn) {
|
||||
headerAuthBtn.addEventListener("click", async () => {
|
||||
if (authRole) {
|
||||
if (authRoles.length > 0) {
|
||||
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) {
|
||||
await authLogout();
|
||||
}
|
||||
@@ -6210,6 +6255,9 @@ Object.defineProperties(trxState, {
|
||||
authRole: { get() {
|
||||
return authRole;
|
||||
} },
|
||||
authRoles: { get() {
|
||||
return authRoles;
|
||||
} },
|
||||
decoderRegistry: { get() {
|
||||
return decoderRegistry;
|
||||
} },
|
||||
|
||||
+1
-1
@@ -358,7 +358,7 @@ var bgdWindow = window;
|
||||
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
|
||||
}
|
||||
function isControlRole() {
|
||||
return backgroundDecodeRole === "admin" || hostState.authEnabled === false;
|
||||
return backgroundDecodeRole === "administrator" || backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
||||
}
|
||||
function showToast(msg, isError) {
|
||||
const el = document.getElementById("background-decode-toast");
|
||||
|
||||
@@ -42,7 +42,7 @@ function bmEsc(str) {
|
||||
return d.innerHTML;
|
||||
}
|
||||
function bmCanControl() {
|
||||
return !hostState.authEnabled || hostState.authRole === "admin";
|
||||
return !hostState.authEnabled || hostState.authRoles.includes("administrator") || hostState.authRoles.includes("write");
|
||||
}
|
||||
function bmSyncAccess() {
|
||||
const canCtrl = bmCanControl();
|
||||
|
||||
@@ -47,6 +47,9 @@ var entryRigName = null;
|
||||
var entryGrid = null;
|
||||
var qsos = [];
|
||||
var workedRequest = 0;
|
||||
function canWriteLogbook() {
|
||||
return !hostState.authEnabled || hostState.authRoles.includes("administrator") || hostState.authRoles.includes("write");
|
||||
}
|
||||
function notify(message, kind) {
|
||||
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0);
|
||||
else hostCore.showHint(message, 2e3);
|
||||
@@ -300,6 +303,11 @@ function renderRows() {
|
||||
row.appendChild(cell);
|
||||
}
|
||||
const actions = document.createElement("td");
|
||||
if (!canWriteLogbook()) {
|
||||
row.appendChild(actions);
|
||||
fragment.appendChild(row);
|
||||
continue;
|
||||
}
|
||||
const confirm = document.createElement("button");
|
||||
confirm.type = "button";
|
||||
confirm.className = "log-row-btn";
|
||||
@@ -420,14 +428,19 @@ importFile?.addEventListener("change", () => {
|
||||
if (file) void importAdif(file);
|
||||
importFile.value = "";
|
||||
});
|
||||
bridge.logContact = (seed) => {
|
||||
bridge.navigateToTab?.("logbook");
|
||||
void openEntry(seed).then(() => callInput?.focus());
|
||||
};
|
||||
if (canWriteLogbook()) {
|
||||
bridge.logContact = (seed) => {
|
||||
bridge.navigateToTab?.("logbook");
|
||||
void openEntry(seed).then(() => callInput?.focus());
|
||||
};
|
||||
} else {
|
||||
if (form) form.style.display = "none";
|
||||
if (importBtn) importBtn.style.display = "none";
|
||||
}
|
||||
renderStation();
|
||||
if (cabrilloCallsign && !cabrilloCallsign.value) {
|
||||
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
|
||||
}
|
||||
syncCabrilloLink();
|
||||
void openEntry();
|
||||
if (canWriteLogbook()) void openEntry();
|
||||
void refreshLog();
|
||||
|
||||
@@ -272,7 +272,7 @@ function schedulerOptionalEl(id) {
|
||||
const nextBtn = schedulerEl("scheduler-next-btn");
|
||||
if (!prevBtn || !nextBtn) return;
|
||||
const state = schedulerInterleaveState(currentConfig);
|
||||
const enabled = schedulerRole === "admin" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
|
||||
const enabled = (schedulerRole === "administrator" || schedulerRole === "control") && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
|
||||
prevBtn.disabled = !enabled;
|
||||
nextBtn.disabled = !enabled;
|
||||
const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active";
|
||||
@@ -354,7 +354,7 @@ function schedulerOptionalEl(id) {
|
||||
const panel = schedulerEl("scheduler-panel");
|
||||
if (!panel) return;
|
||||
const mode = currentConfig && currentConfig.mode || "disabled";
|
||||
const isControl = schedulerRole === "admin";
|
||||
const isControl = schedulerRole === "administrator" || schedulerRole === "control";
|
||||
setSelected("scheduler-mode-select", mode);
|
||||
const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled;
|
||||
const controlRow = document.querySelector(".scheduler-control-row");
|
||||
|
||||
@@ -1461,6 +1461,7 @@ 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-bandplan">Bandplan</button>
|
||||
<button class="sub-tab" data-subtab="settings-history">History</button>
|
||||
<button id="settings-users-tab" class="sub-tab" data-subtab="settings-users" style="display:none;">Users</button>
|
||||
</div>
|
||||
<div id="subtab-settings-scheduler" class="sub-tab-panel">
|
||||
<div id="scheduler-panel" class="sch-panel">
|
||||
@@ -1750,19 +1751,25 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section id="user-management" style="display:none; margin-top:1rem;">
|
||||
<h2 class="section-heading">User management</h2>
|
||||
<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" required />
|
||||
<select id="user-create-role" class="auth-input"><option value="user">User</option><option value="admin">Admin</option></select>
|
||||
<span id="user-create-roles" class="auth-role-choices">
|
||||
<label class="auth-role-choice"><input type="checkbox" value="read" checked /> Read</label>
|
||||
<label class="auth-role-choice"><input type="checkbox" value="control" /> Control</label>
|
||||
<label class="auth-role-choice"><input type="checkbox" value="write" /> Write</label>
|
||||
<label class="auth-role-choice"><input type="checkbox" value="administrator" /> Administrator</label>
|
||||
</span>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-about" class="tab-panel" style="display:none;">
|
||||
<h2 class="section-heading">About</h2>
|
||||
|
||||
@@ -216,6 +216,8 @@ body {
|
||||
.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; }
|
||||
#user-management button { padding: 0.55rem 0.75rem; }
|
||||
|
||||
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
export type AuthRole = "user" | "admin";
|
||||
export type AuthRole = "read" | "control" | "write" | "administrator";
|
||||
|
||||
export interface AuthSession {
|
||||
authenticated: boolean;
|
||||
role?: AuthRole;
|
||||
roles: AuthRole[];
|
||||
username?: string;
|
||||
auth_disabled?: boolean;
|
||||
}
|
||||
@@ -19,14 +19,14 @@ function decodeAuthSession(value: unknown): AuthSession {
|
||||
if (typeof session.authenticated !== "boolean") {
|
||||
throw new TypeError("The authentication response has no authenticated flag");
|
||||
}
|
||||
if (session.role !== undefined && session.role !== "user" && session.role !== "admin") {
|
||||
throw new TypeError("The authentication response has an invalid role");
|
||||
if (!Array.isArray(session.roles) || !session.roles.every(role =>
|
||||
role === "read" || role === "control" || role === "write" || role === "administrator")) {
|
||||
throw new TypeError("The authentication response has invalid roles");
|
||||
}
|
||||
if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") {
|
||||
throw new TypeError("The authentication response has an invalid auth_disabled flag");
|
||||
}
|
||||
const decoded: AuthSession = { authenticated: session.authenticated };
|
||||
if (session.role !== undefined) decoded.role = session.role;
|
||||
const decoded: AuthSession = { authenticated: session.authenticated, roles: session.roles as AuthRole[] };
|
||||
if (session.username !== undefined) {
|
||||
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
|
||||
decoded.username = session.username;
|
||||
@@ -37,7 +37,7 @@ function decodeAuthSession(value: unknown): AuthSession {
|
||||
|
||||
const authDisabledSession: AuthSession = {
|
||||
authenticated: true,
|
||||
role: "admin",
|
||||
roles: ["read", "control", "write", "administrator"],
|
||||
auth_disabled: true,
|
||||
};
|
||||
|
||||
@@ -45,11 +45,11 @@ export async function fetchAuthSession(): Promise<AuthSession> {
|
||||
try {
|
||||
const response = await fetch("/auth/session");
|
||||
if (response.status === 404) return authDisabledSession;
|
||||
if (!response.ok) return { authenticated: false };
|
||||
if (!response.ok) return { authenticated: false, roles: [] };
|
||||
return decodeAuthSession(await response.json());
|
||||
} catch (error: unknown) {
|
||||
console.error("Auth check failed:", error);
|
||||
return { authenticated: false };
|
||||
return { authenticated: false, roles: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function login(username: string, password: string): Promise<AuthSes
|
||||
return decodeAuthSession(await response.json());
|
||||
}
|
||||
|
||||
export interface ManagedUser { username: string; role: AuthRole }
|
||||
export interface ManagedUser { username: string; roles: AuthRole[] }
|
||||
|
||||
async function userRequest(path: string, init?: RequestInit): Promise<Response> {
|
||||
const response = await fetch(path, init);
|
||||
@@ -83,18 +83,19 @@ export async function listUsers(): Promise<ManagedUser[]> {
|
||||
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" && (record.role === "user" || record.role === "admin");
|
||||
return typeof record.username === "string" && Array.isArray(record.roles)
|
||||
&& record.roles.every(role => role === "read" || role === "control" || role === "write" || role === "administrator");
|
||||
})) {
|
||||
throw new TypeError("The user list response is malformed");
|
||||
}
|
||||
return value as ManagedUser[];
|
||||
}
|
||||
|
||||
export async function createUser(username: string, password: string, role: AuthRole): Promise<void> {
|
||||
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, role }) });
|
||||
export async function createUser(username: string, password: string, roles: AuthRole[]): Promise<void> {
|
||||
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles }) });
|
||||
}
|
||||
|
||||
export async function updateUser(username: string, changes: { password?: string; role?: AuthRole }): Promise<void> {
|
||||
export async function updateUser(username: string, changes: { password?: string; roles?: AuthRole[] }): Promise<void> {
|
||||
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
|
||||
}
|
||||
|
||||
|
||||
@@ -231,6 +231,7 @@ interface TrxState {
|
||||
readonly decodeHistoryRetentionMin: number;
|
||||
readonly authEnabled: boolean;
|
||||
readonly authRole: AuthRole | null;
|
||||
readonly authRoles: readonly AuthRole[];
|
||||
readonly decoderRegistry: typeof decoderRegistry;
|
||||
readonly sseSessionId: string | null;
|
||||
readonly primaryRds: RdsData | null;
|
||||
@@ -411,9 +412,28 @@ void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
|
||||
|
||||
// --- Authentication ---
|
||||
let authRole: AuthRole | null = null;
|
||||
let authRoles: AuthRole[] = [];
|
||||
let authUsername: string | null = null;
|
||||
let authEnabled = true;
|
||||
|
||||
const ALL_AUTH_ROLES: readonly AuthRole[] = ["read", "control", "write", "administrator"];
|
||||
const AUTH_ROLE_LABELS: Record<AuthRole, string> = {
|
||||
read: "Read",
|
||||
control: "Control",
|
||||
write: "Write",
|
||||
administrator: "Administrator",
|
||||
};
|
||||
|
||||
function setAuthRoles(roles: readonly AuthRole[]) {
|
||||
authRoles = [...new Set(roles)];
|
||||
authRole = (["administrator", "control", "write", "read"] as AuthRole[])
|
||||
.find(role => authRoles.includes(role)) ?? null;
|
||||
}
|
||||
|
||||
function hasAuthRole(role: AuthRole) {
|
||||
return authRoles.includes("administrator") || authRoles.includes(role);
|
||||
}
|
||||
|
||||
async function checkAuthStatus() {
|
||||
return fetchAuthSession();
|
||||
}
|
||||
@@ -425,7 +445,7 @@ async function authLogin(username: string, password: string) {
|
||||
async function authLogout() {
|
||||
try {
|
||||
await logout();
|
||||
authRole = null;
|
||||
setAuthRoles([]);
|
||||
authUsername = null;
|
||||
// Disconnect and show auth gate without page reload
|
||||
disconnect();
|
||||
@@ -511,9 +531,9 @@ function updateAuthUI() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (authRole) {
|
||||
if (authRoles.length > 0) {
|
||||
if (badge) badge.style.display = "block";
|
||||
if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRole === "admin" ? "Admin" : "User (read-only)"}`;
|
||||
if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`;
|
||||
if (headerAuthBtn) {
|
||||
headerAuthBtn.textContent = "Logout";
|
||||
headerAuthBtn.style.display = "block";
|
||||
@@ -529,10 +549,10 @@ function updateAuthUI() {
|
||||
}
|
||||
|
||||
function applyAuthRestrictions() {
|
||||
if (!authRole) return;
|
||||
if (authRoles.length === 0) return;
|
||||
|
||||
// Disable TX/PTT/frequency/mode/VFO controls for user role
|
||||
if (authRole === "user") {
|
||||
if (!hasAuthRole("control")) {
|
||||
const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null;
|
||||
const powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null;
|
||||
const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null;
|
||||
@@ -864,14 +884,15 @@ window.applyDecodeHistoryRetention = function() {
|
||||
};
|
||||
|
||||
function syncTopBarAccess() {
|
||||
const loggedOut = authEnabled && !authRole;
|
||||
const loggedOut = authEnabled && authRoles.length === 0;
|
||||
const tabBar = document.getElementById("tab-bar");
|
||||
const rigSwitch = document.querySelector<HTMLElement>(".header-rig-switch");
|
||||
if (tabBar) tabBar.style.display = "";
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>(".tab-bar .tab").forEach((btn) => {
|
||||
const isMain = btn.dataset.tab === "main";
|
||||
btn.style.display = !loggedOut || isMain ? "" : "none";
|
||||
const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole("write");
|
||||
btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none";
|
||||
btn.disabled = false;
|
||||
});
|
||||
|
||||
@@ -880,7 +901,7 @@ function syncTopBarAccess() {
|
||||
}
|
||||
|
||||
if (headerRigSwitchSelect) {
|
||||
headerRigSwitchSelect.disabled = loggedOut || authRole === "user" || lastRigIds.length === 0;
|
||||
headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole("control") || lastRigIds.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1461,7 +1482,7 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
|
||||
}
|
||||
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
|
||||
const rigListChanged = prevKey !== nextKey;
|
||||
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "user";
|
||||
const disableSwitch = lastRigIds.length === 0 || !hasAuthRole("control");
|
||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||||
updateRigSubtitle(lastActiveRigId);
|
||||
window.trxUi?.setActiveRig(lastActiveRigId);
|
||||
@@ -3349,7 +3370,7 @@ function scheduleTuneLinkSync() {
|
||||
async function applyTuneLink(link: TuneLink) {
|
||||
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
|
||||
if (!wanted) return;
|
||||
if (authRole === "user") {
|
||||
if (!hasAuthRole("control")) {
|
||||
showHint("Read-only session — link not applied", 2500);
|
||||
return;
|
||||
}
|
||||
@@ -4159,7 +4180,7 @@ async function postPath(path: string, options: PostOptions = {}) {
|
||||
const resp = await fetch(path, { method: "POST" });
|
||||
if (authEnabled && resp.status === 401) {
|
||||
// Not authenticated - return to login
|
||||
authRole = null;
|
||||
setAuthRoles([]);
|
||||
if (es) es.close();
|
||||
showAuthGate();
|
||||
throw new Error("Authentication required");
|
||||
@@ -4188,8 +4209,8 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
|
||||
showHint("No rig selected", 1500);
|
||||
return;
|
||||
}
|
||||
if (authRole === "user") {
|
||||
showHint("Admin role required", 1500);
|
||||
if (!hasAuthRole("control")) {
|
||||
showHint("Control role required", 1500);
|
||||
return;
|
||||
}
|
||||
if (!lastRigIds.includes(selectEl.value)) {
|
||||
@@ -4871,10 +4892,15 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
|
||||
window.trxUi?.closeMobileOverlays?.();
|
||||
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
||||
const { updateHistory = true, replaceHistory = false } = options;
|
||||
if (authEnabled && !authRole && name !== "main") {
|
||||
if (authEnabled && authRoles.length === 0 && name !== "main") {
|
||||
showAuthGate();
|
||||
return;
|
||||
}
|
||||
if (authEnabled && name === "logbook" && !hasAuthRole("write")) {
|
||||
showHint("Write role required for logbook access", 2500);
|
||||
navigateToTab("main", options);
|
||||
return;
|
||||
}
|
||||
const btn = document.querySelector<HTMLElement>(`.tab-bar .tab[data-tab="${name}"]`);
|
||||
if (!btn) return;
|
||||
_activeTab = name;
|
||||
@@ -5010,7 +5036,7 @@ async function initializeApp() {
|
||||
authEnabled = !authStatus.auth_disabled;
|
||||
|
||||
if (!authEnabled) {
|
||||
authRole = "admin";
|
||||
setAuthRoles(ALL_AUTH_ROLES);
|
||||
hideAuthGate();
|
||||
updateAuthUI();
|
||||
connect();
|
||||
@@ -5023,7 +5049,7 @@ async function initializeApp() {
|
||||
|
||||
if (authStatus.authenticated) {
|
||||
// User has valid session
|
||||
authRole = authStatus.role ?? null;
|
||||
setAuthRoles(authStatus.roles);
|
||||
authUsername = authStatus.username ?? null;
|
||||
hideAuthGate();
|
||||
updateAuthUI();
|
||||
@@ -5055,12 +5081,22 @@ function initSettingsUI() {
|
||||
|
||||
async function refreshUserManagement() {
|
||||
const section = document.getElementById("user-management");
|
||||
if (!section) return;
|
||||
section.style.display = authEnabled && authRole === "admin" ? "block" : "none";
|
||||
if (section.style.display === "none") return;
|
||||
const tab = document.getElementById("settings-users-tab");
|
||||
if (!section || !tab) return;
|
||||
const canManageUsers = authEnabled && hasAuthRole("administrator");
|
||||
tab.style.display = canManageUsers ? "" : "none";
|
||||
if (!canManageUsers) {
|
||||
const panel = document.getElementById("subtab-settings-users");
|
||||
if (panel) panel.style.display = "none";
|
||||
if (tab.classList.contains("active")) {
|
||||
document.querySelector<HTMLButtonElement>('[data-subtab="settings-scheduler"]')?.click();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const list = requiredElement("user-list");
|
||||
try {
|
||||
const users = await listUsers();
|
||||
const adminCount = users.filter(user => user.roles.includes("administrator")).length;
|
||||
list.replaceChildren(...users.map((user) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sch-row";
|
||||
@@ -5068,27 +5104,44 @@ async function refreshUserManagement() {
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = user.username;
|
||||
name.style.minWidth = "10rem";
|
||||
const role = document.createElement("select");
|
||||
role.className = "auth-input";
|
||||
for (const value of ["user", "admin"] as AuthRole[]) {
|
||||
const option = document.createElement("option"); option.value = value; option.textContent = value === "admin" ? "Admin" : "User"; option.selected = user.role === value; role.append(option);
|
||||
const roleInputs = ALL_AUTH_ROLES.map((value) => {
|
||||
const label = document.createElement("label");
|
||||
label.className = "auth-role-choice";
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.value = value;
|
||||
input.checked = user.roles.includes(value);
|
||||
label.append(input, ` ${AUTH_ROLE_LABELS[value]}`);
|
||||
return { label, input, value };
|
||||
});
|
||||
const roles = document.createElement("span");
|
||||
roles.className = "auth-role-choices";
|
||||
roles.append(...roleInputs.map(({ label }) => label));
|
||||
const isOnlyAdmin = user.roles.includes("administrator") && adminCount === 1;
|
||||
const administratorInput = roleInputs.find(item => item.value === "administrator")?.input;
|
||||
if (isOnlyAdmin && administratorInput) {
|
||||
administratorInput.disabled = true;
|
||||
administratorInput.title = "The final administrator cannot be demoted";
|
||||
}
|
||||
const password = document.createElement("input");
|
||||
password.type = "password"; password.placeholder = "New password"; password.autocomplete = "new-password"; password.className = "auth-input"; password.minLength = 8;
|
||||
password.type = "password"; password.placeholder = "New password (8+ characters)"; password.autocomplete = "new-password"; password.className = "auth-input"; password.minLength = 8;
|
||||
const save = document.createElement("button"); save.type = "button"; save.textContent = "Save";
|
||||
save.addEventListener("click", async () => {
|
||||
const changes: { role?: AuthRole; password?: string } = { role: role.value as AuthRole };
|
||||
const changes: { roles?: AuthRole[]; password?: string } = {
|
||||
roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value),
|
||||
};
|
||||
if (password.value) changes.password = password.value;
|
||||
await runUserOperation(() => updateUser(user.username, changes));
|
||||
});
|
||||
const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "Remove"; remove.className = "danger";
|
||||
remove.disabled = user.username === authUsername;
|
||||
remove.disabled = user.username === authUsername || isOnlyAdmin;
|
||||
if (isOnlyAdmin) remove.title = "The final administrator cannot be removed";
|
||||
remove.addEventListener("click", async () => {
|
||||
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
|
||||
await runUserOperation(() => deleteUser(user.username));
|
||||
}
|
||||
});
|
||||
row.append(name, role, password, save, remove);
|
||||
row.append(name, roles, password, save, remove);
|
||||
return row;
|
||||
}));
|
||||
} catch (error) {
|
||||
@@ -5118,10 +5171,11 @@ 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 role = requiredElement<HTMLSelectElement>("user-create-role");
|
||||
const roles = Array.from(document.querySelectorAll<HTMLInputElement>("#user-create-roles input[type=checkbox]"));
|
||||
void runUserOperation(async () => {
|
||||
await createUser(username.value, password.value, role.value as AuthRole);
|
||||
username.value = ""; password.value = ""; role.value = "user";
|
||||
await createUser(username.value, password.value, roles.filter(input => input.checked).map(input => input.value as AuthRole));
|
||||
username.value = ""; password.value = "";
|
||||
roles.forEach(input => { input.checked = input.value === "read"; });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5137,7 +5191,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
|
||||
|
||||
try {
|
||||
const result = await authLogin(usernameEl.value, passwordEl.value);
|
||||
authRole = result.role ?? null;
|
||||
setAuthRoles(result.roles);
|
||||
authUsername = result.username ?? usernameEl.value;
|
||||
passwordEl.value = "";
|
||||
hideAuthGate();
|
||||
@@ -5161,7 +5215,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
|
||||
const headerAuthBtn = document.getElementById("header-auth-btn") as HTMLButtonElement | null;
|
||||
if (headerAuthBtn) {
|
||||
headerAuthBtn.addEventListener("click", async () => {
|
||||
if (authRole) {
|
||||
if (authRoles.length > 0) {
|
||||
// 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 })) {
|
||||
await authLogout();
|
||||
@@ -5191,6 +5245,7 @@ Object.defineProperties(trxState, {
|
||||
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
|
||||
authEnabled: { get() { return authEnabled; } },
|
||||
authRole: { get() { return authRole; } },
|
||||
authRoles: { get() { return authRoles; } },
|
||||
decoderRegistry: { get() { return decoderRegistry; } },
|
||||
sseSessionId: { get() { return sseSessionId; } },
|
||||
primaryRds: { get() { return primaryRds; } },
|
||||
|
||||
+3
-1
@@ -468,7 +468,9 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
||||
}
|
||||
|
||||
function isControlRole(): boolean {
|
||||
return backgroundDecodeRole === "admin" || hostState.authEnabled === false;
|
||||
return backgroundDecodeRole === "administrator"
|
||||
|| backgroundDecodeRole === "control"
|
||||
|| hostState.authEnabled === false;
|
||||
}
|
||||
|
||||
function showToast(msg: string, isError: boolean): void {
|
||||
|
||||
@@ -100,7 +100,9 @@ function bmEsc(str: unknown): string {
|
||||
}
|
||||
|
||||
function bmCanControl() {
|
||||
return !hostState.authEnabled || hostState.authRole === "admin";
|
||||
return !hostState.authEnabled
|
||||
|| hostState.authRoles.includes("administrator")
|
||||
|| hostState.authRoles.includes("write");
|
||||
}
|
||||
|
||||
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface HostState {
|
||||
readonly ownerCallsign: string | null;
|
||||
readonly authEnabled: boolean;
|
||||
readonly authRole: string | null;
|
||||
readonly authRoles: readonly string[];
|
||||
readonly lastActiveRigId: string | null;
|
||||
readonly lastRigIds: string[];
|
||||
readonly lastRigDisplayNames: Record<string, string>;
|
||||
|
||||
@@ -120,6 +120,12 @@ let entryGrid: string | null = null;
|
||||
let qsos: Qso[] = [];
|
||||
let workedRequest = 0;
|
||||
|
||||
function canWriteLogbook(): boolean {
|
||||
return !hostState.authEnabled
|
||||
|| hostState.authRoles.includes("administrator")
|
||||
|| hostState.authRoles.includes("write");
|
||||
}
|
||||
|
||||
function notify(message: string, kind?: string): void {
|
||||
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : undefined);
|
||||
else hostCore.showHint(message, 2000);
|
||||
@@ -419,6 +425,11 @@ function renderRows(): void {
|
||||
row.appendChild(cell);
|
||||
}
|
||||
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
|
||||
// than a form: a card arrives, and the contact counts towards an award.
|
||||
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. */
|
||||
bridge.logContact = (seed) => {
|
||||
bridge.navigateToTab?.("logbook");
|
||||
void openEntry(seed).then(() => callInput?.focus());
|
||||
};
|
||||
if (canWriteLogbook()) {
|
||||
bridge.logContact = (seed) => {
|
||||
bridge.navigateToTab?.("logbook");
|
||||
void openEntry(seed).then(() => callInput?.focus());
|
||||
};
|
||||
} else {
|
||||
if (form) form.style.display = "none";
|
||||
if (importBtn) importBtn.style.display = "none";
|
||||
}
|
||||
|
||||
renderStation();
|
||||
if (cabrilloCallsign && !cabrilloCallsign.value) {
|
||||
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
|
||||
}
|
||||
syncCabrilloLink();
|
||||
void openEntry();
|
||||
if (canWriteLogbook()) void openEntry();
|
||||
void refreshLog();
|
||||
|
||||
@@ -356,7 +356,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
|
||||
if (!prevBtn || !nextBtn) return;
|
||||
const state = schedulerInterleaveState(currentConfig);
|
||||
const enabled =
|
||||
schedulerRole === "admin" &&
|
||||
(schedulerRole === "administrator" || schedulerRole === "control") &&
|
||||
!!currentRigId &&
|
||||
!schedulerStepPending &&
|
||||
state.activeEntries.length > 1;
|
||||
@@ -466,7 +466,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
|
||||
if (!panel) return;
|
||||
|
||||
const mode = (currentConfig && currentConfig.mode) || "disabled";
|
||||
const isControl = schedulerRole === "admin";
|
||||
const isControl = schedulerRole === "administrator" || schedulerRole === "control";
|
||||
|
||||
// Mode selector
|
||||
setSelected("scheduler-mode-select", mode);
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ test("background decode loads configuration for the explicitly selected rig", as
|
||||
const source = await bundleEntry(new URL("../src/plugins/background-decode.ts", import.meta.url));
|
||||
new vm.Script(source).runInContext(context);
|
||||
|
||||
window.trx.modules.backgroundDecode.initialize("rig/a", "admin");
|
||||
window.trx.modules.backgroundDecode.initialize("rig/a", "administrator");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.ok(requested.includes("/background-decode/rig%2Fa"));
|
||||
assert.ok(requested.includes("/bookmarks"));
|
||||
|
||||
@@ -32,7 +32,8 @@ function hostFixture(overrides = {}) {
|
||||
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
|
||||
const state = {
|
||||
authEnabled: false,
|
||||
authRole: "admin",
|
||||
authRole: "administrator",
|
||||
authRoles: ["read", "control", "write", "administrator"],
|
||||
lastActiveRigId: null,
|
||||
lastRigIds: [],
|
||||
lastRigDisplayNames: {},
|
||||
@@ -157,7 +158,7 @@ test("bookmark controls follow the host authentication state", async () => {
|
||||
if (!elements.has(id)) elements.set(id, new ElementFixture());
|
||||
return elements.get(id);
|
||||
};
|
||||
const { window } = hostFixture({ authEnabled: true, authRole: "user" });
|
||||
const { window } = hostFixture({ authEnabled: true, authRole: "read", authRoles: ["read"] });
|
||||
const context = vm.createContext({
|
||||
window,
|
||||
document: documentFixture(element),
|
||||
@@ -171,7 +172,8 @@ test("bookmark controls follow the host authentication state", async () => {
|
||||
|
||||
assert.equal(element("bm-add-btn").style.display, "none");
|
||||
|
||||
window.trx.state.authRole = "admin";
|
||||
window.trx.state.authRole = "write";
|
||||
window.trx.state.authRoles = ["read", "write"];
|
||||
await window.trx.modules.bookmarks.fetch("");
|
||||
assert.equal(element("bm-add-btn").style.display, "");
|
||||
});
|
||||
|
||||
@@ -19,7 +19,8 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
|
||||
serverLat: null,
|
||||
serverLon: null,
|
||||
authEnabled: false,
|
||||
authRole: "admin",
|
||||
authRole: "administrator",
|
||||
authRoles: ["read", "control", "write", "administrator"],
|
||||
lastActiveRigId: null,
|
||||
lastRigIds: [],
|
||||
lastRigDisplayNames: {},
|
||||
|
||||
@@ -76,7 +76,7 @@ test("scheduler self-initializes for the active rig when a role is already known
|
||||
return elements.get(id);
|
||||
};
|
||||
const window = {
|
||||
...createHost({ state: { authRole: "admin", lastActiveRigId: "sdr" } }),
|
||||
...createHost({ state: { authRole: "administrator", lastActiveRigId: "sdr" } }),
|
||||
trxUi: { confirm: async () => true },
|
||||
};
|
||||
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 pluginLoaderPath = new URL("../src/plugin-loader.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 () => {
|
||||
const html = await readFile(indexPath, "utf8");
|
||||
@@ -30,6 +31,17 @@ 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("lazy frontend features use modules and local map symbols", async () => {
|
||||
const [loader, map] = await Promise.all([
|
||||
readFile(pluginLoaderPath, "utf8"),
|
||||
|
||||
@@ -232,7 +232,7 @@ export async function startWebFixture({
|
||||
};
|
||||
|
||||
const jsonRoutes = new Map([
|
||||
["/auth/session", { authenticated: true, role: "admin", auth_disabled: true }],
|
||||
["/auth/session", { authenticated: true, roles: ["read", "control", "write", "administrator"], auth_disabled: true }],
|
||||
["/decoders", DECODER_REGISTRY],
|
||||
["/rigs", rigsResponse],
|
||||
["/status", status],
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
||||
use actix_web::Error;
|
||||
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;
|
||||
|
||||
// ============================================================================
|
||||
@@ -165,7 +165,7 @@ pub async fn create_bookmark(
|
||||
body: web::Json<BookmarkInput>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> 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());
|
||||
if store.freq_taken(body.freq_hz, None) {
|
||||
return Err(actix_web::error::ErrorConflict(
|
||||
@@ -201,7 +201,7 @@ pub async fn update_bookmark(
|
||||
body: web::Json<BookmarkInput>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> 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 id = path.into_inner();
|
||||
if store.freq_taken(body.freq_hz, Some(&id)) {
|
||||
@@ -235,7 +235,7 @@ pub async fn delete_bookmark(
|
||||
query: web::Query<BookmarkScopeQuery>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> 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 id = path.into_inner();
|
||||
if store.remove(&id) {
|
||||
@@ -253,7 +253,7 @@ pub async fn batch_delete_bookmarks(
|
||||
query: web::Query<BookmarkScopeQuery>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> 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 mut deleted = 0usize;
|
||||
for id in &body.ids {
|
||||
@@ -272,7 +272,7 @@ pub async fn batch_move_bookmarks(
|
||||
query: web::Query<BookmarkScopeQuery>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> 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 to_store = resolve_bookmark_store(Some(body.to.as_str()), store_map.get_ref());
|
||||
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::{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;
|
||||
|
||||
/// What a contact looks like on the wire.
|
||||
@@ -225,7 +225,7 @@ pub async fn add_qso(
|
||||
logbook: web::Data<Arc<Logbook>>,
|
||||
auth_state: web::Data<AuthState>,
|
||||
) -> 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) {
|
||||
Ok(qso) => qso,
|
||||
Err(reason) => {
|
||||
@@ -248,7 +248,7 @@ pub async fn edit_qso(
|
||||
logbook: web::Data<Arc<Logbook>>,
|
||||
auth_state: web::Data<AuthState>,
|
||||
) -> 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 Some(existing) = book(&logbook).get(&id) else {
|
||||
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>>,
|
||||
auth_state: web::Data<AuthState>,
|
||||
) -> 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()) {
|
||||
Ok(true) => Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": true }))),
|
||||
Ok(false) => {
|
||||
@@ -312,7 +312,7 @@ pub async fn import_adi(
|
||||
logbook: web::Data<Arc<Logbook>>,
|
||||
auth_state: web::Data<AuthState>,
|
||||
) -> 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) {
|
||||
Ok(outcome) => Ok(HttpResponse::Ok().json(outcome)),
|
||||
Err(err) => Ok(HttpResponse::InternalServerError()
|
||||
|
||||
@@ -391,16 +391,20 @@ fn gz_cache_entry(src: &[u8], name: &str) -> GzCacheEntry {
|
||||
GzCacheEntry { gz, br, etag }
|
||||
}
|
||||
|
||||
fn require_control(
|
||||
fn require_write(
|
||||
req: &HttpRequest,
|
||||
auth_state: &crate::server::auth::AuthState,
|
||||
) -> Result<(), actix_web::Error> {
|
||||
if !auth_state.config.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
match crate::server::auth::get_session_role(req, auth_state) {
|
||||
Some(crate::server::auth::AuthRole::Admin) => Ok(()),
|
||||
_ => Err(actix_web::error::ErrorForbidden("admin role required")),
|
||||
if !auth_state.config.enabled
|
||||
|| crate::server::auth::session_grants(
|
||||
req,
|
||||
auth_state,
|
||||
crate::server::auth::AuthRole::Write,
|
||||
)
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(actix_web::error::ErrorForbidden("write role required"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -960,6 +964,9 @@ mod tests {
|
||||
std::path::PathBuf::from("unused-users.json"),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
"guest".to_string(),
|
||||
None,
|
||||
std::time::Duration::from_secs(3600),
|
||||
false,
|
||||
crate::server::auth::SameSite::Lax,
|
||||
@@ -975,6 +982,9 @@ mod tests {
|
||||
directory.path().join("users.json"),
|
||||
Some("admin".to_string()),
|
||||
Some("password123".to_string()),
|
||||
false,
|
||||
"guest".to_string(),
|
||||
None,
|
||||
std::time::Duration::from_secs(3600),
|
||||
false,
|
||||
crate::server::auth::SameSite::Lax,
|
||||
@@ -1171,10 +1181,16 @@ mod tests {
|
||||
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(
|
||||
"reader".to_string(),
|
||||
[crate::server::auth::AuthRole::Read].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_locked()))
|
||||
.app_data(web::Data::new(auth_state))
|
||||
.service(logbook::add_qso)
|
||||
.service(logbook::list_qsos),
|
||||
)
|
||||
@@ -1184,6 +1200,10 @@ mod tests {
|
||||
&app,
|
||||
actix_test::TestRequest::post()
|
||||
.uri("/api/logbook")
|
||||
.cookie(actix_web::cookie::Cookie::new(
|
||||
"trx_http_sid",
|
||||
session_id.clone(),
|
||||
))
|
||||
.set_json(serde_json::json!({
|
||||
"call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8",
|
||||
}))
|
||||
@@ -1199,12 +1219,47 @@ mod tests {
|
||||
&app,
|
||||
actix_test::TestRequest::get()
|
||||
.uri("/api/logbook")
|
||||
.cookie(actix_web::cookie::Cookie::new("trx_http_sid", session_id))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
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
|
||||
/// insist on it.
|
||||
#[actix_web::test]
|
||||
|
||||
@@ -19,7 +19,7 @@ use argon2::{
|
||||
};
|
||||
use futures_util::future::LocalBoxFuture;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
@@ -30,29 +30,51 @@ use tracing::warn;
|
||||
pub type SessionId = String;
|
||||
|
||||
/// Authentication role
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthRole {
|
||||
/// Read-only access.
|
||||
User,
|
||||
/// Full control and user-management access.
|
||||
Admin,
|
||||
Read,
|
||||
Control,
|
||||
Write,
|
||||
Administrator,
|
||||
}
|
||||
|
||||
impl AuthRole {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::User => "user",
|
||||
Self::Admin => "admin",
|
||||
Self::Read => "read",
|
||||
Self::Control => "control",
|
||||
Self::Write => "write",
|
||||
Self::Administrator => "administrator",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all() -> BTreeSet<Self> {
|
||||
[Self::Read, Self::Control, Self::Write, Self::Administrator]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn grants(roles: &BTreeSet<AuthRole>, required: AuthRole) -> bool {
|
||||
roles.contains(&AuthRole::Administrator)
|
||||
|| roles.contains(&required)
|
||||
|| required == AuthRole::Read && roles.contains(&AuthRole::Control)
|
||||
}
|
||||
|
||||
fn effective_roles(roles: &BTreeSet<AuthRole>) -> Vec<AuthRole> {
|
||||
if roles.contains(&AuthRole::Administrator) {
|
||||
AuthRole::all().into_iter().collect()
|
||||
} else {
|
||||
roles.iter().copied().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Session record stored in the session store
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionRecord {
|
||||
pub username: String,
|
||||
pub role: AuthRole,
|
||||
pub roles: BTreeSet<AuthRole>,
|
||||
pub issued_at: SystemTime,
|
||||
pub expires_at: SystemTime,
|
||||
pub last_seen: SystemTime,
|
||||
@@ -82,14 +104,14 @@ impl SessionStore {
|
||||
}
|
||||
|
||||
/// Create a new session with the given role and TTL
|
||||
pub fn create(&self, username: String, role: AuthRole, ttl: Duration) -> SessionId {
|
||||
pub fn create(&self, username: String, roles: BTreeSet<AuthRole>, ttl: Duration) -> SessionId {
|
||||
let now = SystemTime::now();
|
||||
let expires_at = now + ttl;
|
||||
let session_id = Self::generate_session_id();
|
||||
|
||||
let record = SessionRecord {
|
||||
username,
|
||||
role,
|
||||
roles,
|
||||
issued_at: now,
|
||||
expires_at,
|
||||
last_seen: now,
|
||||
@@ -184,6 +206,9 @@ pub struct AuthConfig {
|
||||
pub users_file: PathBuf,
|
||||
pub bootstrap_admin_username: Option<String>,
|
||||
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: Duration,
|
||||
pub cookie_secure: bool,
|
||||
pub cookie_same_site: SameSite,
|
||||
@@ -191,11 +216,15 @@ pub struct AuthConfig {
|
||||
|
||||
impl AuthConfig {
|
||||
/// Create a new auth config with all fields
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
enabled: bool,
|
||||
users_file: PathBuf,
|
||||
bootstrap_admin_username: Option<String>,
|
||||
bootstrap_admin_password: Option<String>,
|
||||
bootstrap_read_enabled: bool,
|
||||
bootstrap_read_username: String,
|
||||
bootstrap_read_password: Option<String>,
|
||||
session_ttl: Duration,
|
||||
cookie_secure: bool,
|
||||
cookie_same_site: SameSite,
|
||||
@@ -205,6 +234,9 @@ impl AuthConfig {
|
||||
users_file,
|
||||
bootstrap_admin_username,
|
||||
bootstrap_admin_password,
|
||||
bootstrap_read_enabled,
|
||||
bootstrap_read_username,
|
||||
bootstrap_read_password,
|
||||
session_ttl,
|
||||
cookie_secure,
|
||||
cookie_same_site,
|
||||
@@ -212,11 +244,50 @@ impl AuthConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UserRecord {
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub role: AuthRole,
|
||||
pub roles: BTreeSet<AuthRole>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for UserRecord {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum LegacyRole {
|
||||
User,
|
||||
Admin,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StoredUserRecord {
|
||||
username: String,
|
||||
password_hash: String,
|
||||
#[serde(default)]
|
||||
roles: Option<BTreeSet<AuthRole>>,
|
||||
#[serde(default)]
|
||||
role: Option<LegacyRole>,
|
||||
}
|
||||
|
||||
let stored = StoredUserRecord::deserialize(deserializer)?;
|
||||
let roles = match (stored.roles, stored.role) {
|
||||
(Some(roles), _) => roles,
|
||||
(None, Some(LegacyRole::User)) => [AuthRole::Read].into_iter().collect(),
|
||||
(None, Some(LegacyRole::Admin)) => AuthRole::all(),
|
||||
(None, None) => {
|
||||
return Err(serde::de::Error::missing_field("roles"));
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
username: stored.username,
|
||||
password_hash: stored.password_hash,
|
||||
roles,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
@@ -256,11 +327,31 @@ impl UserStore {
|
||||
.bootstrap_admin_password
|
||||
.as_deref()
|
||||
.ok_or_else(|| "bootstrap administrator password is missing".to_string())?;
|
||||
vec![UserRecord {
|
||||
let mut records = vec![UserRecord {
|
||||
username: validate_username(username)?.to_string(),
|
||||
password_hash: hash_password(password)?,
|
||||
role: AuthRole::Admin,
|
||||
}]
|
||||
roles: AuthRole::all(),
|
||||
}];
|
||||
if config.bootstrap_read_enabled {
|
||||
if config.bootstrap_read_username == username {
|
||||
return Err(
|
||||
"bootstrap administrator and read-only usernames must differ".to_string(),
|
||||
);
|
||||
}
|
||||
let password = config
|
||||
.bootstrap_read_password
|
||||
.as_deref()
|
||||
.ok_or_else(|| "bootstrap read-only account password is missing".to_string())?;
|
||||
if password != "guest" {
|
||||
validate_password(password)?;
|
||||
}
|
||||
records.push(UserRecord {
|
||||
username: validate_username(&config.bootstrap_read_username)?.to_string(),
|
||||
password_hash: hash_password_value(password)?,
|
||||
roles: [AuthRole::Read].into_iter().collect(),
|
||||
});
|
||||
}
|
||||
records
|
||||
};
|
||||
let mut users = HashMap::new();
|
||||
for record in records {
|
||||
@@ -269,7 +360,10 @@ impl UserStore {
|
||||
return Err("user database contains duplicate usernames".to_string());
|
||||
}
|
||||
}
|
||||
if !users.values().any(|u| u.role == AuthRole::Admin) {
|
||||
if !users
|
||||
.values()
|
||||
.any(|u| u.roles.contains(&AuthRole::Administrator))
|
||||
{
|
||||
return Err("user database must contain at least one administrator".to_string());
|
||||
}
|
||||
let store = Self {
|
||||
@@ -282,14 +376,14 @@ impl UserStore {
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
fn authenticate(&self, username: &str, password: &str) -> Option<AuthRole> {
|
||||
fn authenticate(&self, username: &str, password: &str) -> Option<BTreeSet<AuthRole>> {
|
||||
let users = self.users.read().unwrap_or_else(|e| e.into_inner());
|
||||
let record = users.get(username)?;
|
||||
let parsed = PasswordHash::new(&record.password_hash).ok()?;
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.ok()?;
|
||||
Some(record.role)
|
||||
Some(record.roles.clone())
|
||||
}
|
||||
|
||||
fn list(&self) -> Vec<ManagedUser> {
|
||||
@@ -298,20 +392,20 @@ impl UserStore {
|
||||
.values()
|
||||
.map(|u| ManagedUser {
|
||||
username: u.username.clone(),
|
||||
role: u.role,
|
||||
roles: u.roles.clone(),
|
||||
})
|
||||
.collect();
|
||||
result.sort_by(|a, b| a.username.cmp(&b.username));
|
||||
result
|
||||
}
|
||||
|
||||
fn add(&self, username: &str, password: &str, role: AuthRole) -> Result<(), String> {
|
||||
fn add(&self, username: &str, password: &str, roles: BTreeSet<AuthRole>) -> Result<(), String> {
|
||||
let username = validate_username(username)?.to_string();
|
||||
validate_password(password)?;
|
||||
let record = UserRecord {
|
||||
username: username.clone(),
|
||||
password_hash: hash_password(password)?,
|
||||
role,
|
||||
roles: validate_roles(roles)?,
|
||||
};
|
||||
let mut users = self.users.write().unwrap_or_else(|e| e.into_inner());
|
||||
if users.contains_key(&username) {
|
||||
@@ -329,7 +423,7 @@ impl UserStore {
|
||||
&self,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
role: Option<AuthRole>,
|
||||
roles: Option<BTreeSet<AuthRole>>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(value) = password {
|
||||
validate_password(value)?;
|
||||
@@ -340,9 +434,15 @@ impl UserStore {
|
||||
.get(username)
|
||||
.cloned()
|
||||
.ok_or_else(|| "user not found".to_string())?;
|
||||
if old.role == AuthRole::Admin
|
||||
&& role == Some(AuthRole::User)
|
||||
&& users.values().filter(|u| u.role == AuthRole::Admin).count() == 1
|
||||
if old.roles.contains(&AuthRole::Administrator)
|
||||
&& roles
|
||||
.as_ref()
|
||||
.is_some_and(|next| !next.contains(&AuthRole::Administrator))
|
||||
&& users
|
||||
.values()
|
||||
.filter(|u| u.roles.contains(&AuthRole::Administrator))
|
||||
.count()
|
||||
== 1
|
||||
{
|
||||
return Err("cannot demote the last administrator".to_string());
|
||||
}
|
||||
@@ -350,8 +450,8 @@ impl UserStore {
|
||||
if let Some(hash) = new_hash {
|
||||
record.password_hash = hash;
|
||||
}
|
||||
if let Some(value) = role {
|
||||
record.role = value;
|
||||
if let Some(value) = roles {
|
||||
record.roles = validate_roles(value)?;
|
||||
}
|
||||
if let Err(error) = persist_users(&self.path, &users) {
|
||||
users.insert(username.to_string(), old);
|
||||
@@ -366,8 +466,12 @@ impl UserStore {
|
||||
.get(username)
|
||||
.cloned()
|
||||
.ok_or_else(|| "user not found".to_string())?;
|
||||
if old.role == AuthRole::Admin
|
||||
&& users.values().filter(|u| u.role == AuthRole::Admin).count() == 1
|
||||
if old.roles.contains(&AuthRole::Administrator)
|
||||
&& users
|
||||
.values()
|
||||
.filter(|u| u.roles.contains(&AuthRole::Administrator))
|
||||
.count()
|
||||
== 1
|
||||
{
|
||||
return Err("cannot remove the last administrator".to_string());
|
||||
}
|
||||
@@ -400,6 +504,10 @@ fn persist_users(path: &Path, users: &HashMap<String, UserRecord>) -> Result<(),
|
||||
|
||||
fn hash_password(password: &str) -> Result<String, String> {
|
||||
validate_password(password)?;
|
||||
hash_password_value(password)
|
||||
}
|
||||
|
||||
fn hash_password_value(password: &str) -> Result<String, String> {
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
|
||||
.map(|hash| hash.to_string())
|
||||
@@ -420,11 +528,18 @@ fn validate_username(username: &str) -> Result<&str, String> {
|
||||
|
||||
fn validate_password(password: &str) -> Result<(), String> {
|
||||
if password.len() < 8 {
|
||||
return Err("password must contain at least 8 characters".to_string());
|
||||
return Err("password must be at least 8 characters".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_roles(roles: BTreeSet<AuthRole>) -> Result<BTreeSet<AuthRole>, String> {
|
||||
if roles.is_empty() {
|
||||
return Err("at least one role is required".to_string());
|
||||
}
|
||||
Ok(roles)
|
||||
}
|
||||
|
||||
/// Simple per-IP rate limiter for login attempts.
|
||||
///
|
||||
/// Tracks failed attempts per IP and enforces a cooldown window after
|
||||
@@ -521,7 +636,7 @@ pub struct LoginRequest {
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SessionStatus {
|
||||
pub authenticated: bool,
|
||||
pub role: Option<String>,
|
||||
pub roles: Vec<AuthRole>,
|
||||
pub username: Option<String>,
|
||||
pub auth_disabled: bool,
|
||||
}
|
||||
@@ -530,27 +645,27 @@ pub struct SessionStatus {
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub authenticated: bool,
|
||||
pub role: String,
|
||||
pub roles: Vec<AuthRole>,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ManagedUser {
|
||||
pub username: String,
|
||||
pub role: AuthRole,
|
||||
pub roles: BTreeSet<AuthRole>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateUserRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub role: AuthRole,
|
||||
pub roles: BTreeSet<AuthRole>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateUserRequest {
|
||||
pub password: Option<String>,
|
||||
pub role: Option<AuthRole>,
|
||||
pub roles: Option<BTreeSet<AuthRole>>,
|
||||
}
|
||||
|
||||
/// Extract session from cookie
|
||||
@@ -560,10 +675,14 @@ fn extract_session_id(req: &HttpRequest) -> Option<SessionId> {
|
||||
}
|
||||
|
||||
/// Get session from request, return role if valid
|
||||
pub fn get_session_role(req: &HttpRequest, auth_state: &AuthState) -> Option<AuthRole> {
|
||||
pub fn get_session_roles(req: &HttpRequest, auth_state: &AuthState) -> Option<BTreeSet<AuthRole>> {
|
||||
let session_id = extract_session_id(req)?;
|
||||
let record = auth_state.store.get(&session_id)?;
|
||||
Some(record.role)
|
||||
Some(record.roles)
|
||||
}
|
||||
|
||||
pub fn session_grants(req: &HttpRequest, auth_state: &AuthState, role: AuthRole) -> bool {
|
||||
get_session_roles(req, auth_state).is_some_and(|roles| grants(&roles, role))
|
||||
}
|
||||
|
||||
fn require_admin(req: &HttpRequest, auth_state: &AuthState) -> Result<SessionRecord, HttpResponse> {
|
||||
@@ -573,7 +692,7 @@ fn require_admin(req: &HttpRequest, auth_state: &AuthState) -> Result<SessionRec
|
||||
HttpResponse::Unauthorized()
|
||||
.json(serde_json::json!({"error":"authentication required"}))
|
||||
})?;
|
||||
if session.role != AuthRole::Admin {
|
||||
if !grants(&session.roles, AuthRole::Administrator) {
|
||||
return Err(HttpResponse::Forbidden()
|
||||
.json(serde_json::json!({"error":"administrator role required"})));
|
||||
}
|
||||
@@ -606,7 +725,7 @@ pub async fn login(
|
||||
})));
|
||||
}
|
||||
|
||||
let role = match auth_state
|
||||
let roles = match auth_state
|
||||
.users
|
||||
.authenticate(&body.username, &body.password)
|
||||
{
|
||||
@@ -622,10 +741,11 @@ pub async fn login(
|
||||
auth_state.rate_limiter.reset(&peer_ip);
|
||||
|
||||
// Create session
|
||||
let session_id =
|
||||
auth_state
|
||||
.store
|
||||
.create(body.username.clone(), role, auth_state.config.session_ttl);
|
||||
let session_id = auth_state.store.create(
|
||||
body.username.clone(),
|
||||
roles.clone(),
|
||||
auth_state.config.session_ttl,
|
||||
);
|
||||
|
||||
let mut cookie = Cookie::new("trx_http_sid", session_id);
|
||||
cookie.set_path("/");
|
||||
@@ -645,7 +765,7 @@ pub async fn login(
|
||||
|
||||
Ok(HttpResponse::Ok().cookie(cookie).json(LoginResponse {
|
||||
authenticated: true,
|
||||
role: role.as_str().to_string(),
|
||||
roles: effective_roles(&roles),
|
||||
username: body.username.clone(),
|
||||
}))
|
||||
}
|
||||
@@ -686,7 +806,7 @@ pub async fn session_status(
|
||||
if !auth_state.config.enabled {
|
||||
return Ok(HttpResponse::Ok().json(SessionStatus {
|
||||
authenticated: true,
|
||||
role: Some("admin".to_string()),
|
||||
roles: effective_roles(&AuthRole::all()),
|
||||
username: None,
|
||||
auth_disabled: true,
|
||||
}));
|
||||
@@ -697,7 +817,7 @@ pub async fn session_status(
|
||||
// User has valid session
|
||||
return Ok(HttpResponse::Ok().json(SessionStatus {
|
||||
authenticated: true,
|
||||
role: Some(session_record.role.as_str().to_string()),
|
||||
roles: effective_roles(&session_record.roles),
|
||||
username: Some(session_record.username),
|
||||
auth_disabled: false,
|
||||
}));
|
||||
@@ -706,7 +826,7 @@ pub async fn session_status(
|
||||
// Auth required but no valid session
|
||||
Ok(HttpResponse::Ok().json(SessionStatus {
|
||||
authenticated: false,
|
||||
role: None,
|
||||
roles: Vec::new(),
|
||||
username: None,
|
||||
auth_disabled: false,
|
||||
}))
|
||||
@@ -733,11 +853,11 @@ pub async fn create_user(
|
||||
}
|
||||
match auth_state
|
||||
.users
|
||||
.add(&body.username, &body.password, body.role)
|
||||
.add(&body.username, &body.password, body.roles.clone())
|
||||
{
|
||||
Ok(()) => HttpResponse::Created().json(ManagedUser {
|
||||
username: body.username.clone(),
|
||||
role: body.role,
|
||||
roles: body.roles.clone(),
|
||||
}),
|
||||
Err(error) => HttpResponse::BadRequest().json(serde_json::json!({"error": error})),
|
||||
}
|
||||
@@ -754,13 +874,13 @@ pub async fn update_user(
|
||||
if let Err(response) = require_admin(&req, &auth_state) {
|
||||
return response;
|
||||
}
|
||||
if body.password.is_none() && body.role.is_none() {
|
||||
if body.password.is_none() && body.roles.is_none() {
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error":"password or role is required"}));
|
||||
}
|
||||
match auth_state
|
||||
.users
|
||||
.update(&username, body.password.as_deref(), body.role)
|
||||
.update(&username, body.password.as_deref(), body.roles.clone())
|
||||
{
|
||||
Ok(()) => {
|
||||
auth_state.store.remove_user(&username);
|
||||
@@ -803,9 +923,13 @@ pub async fn delete_user(
|
||||
enum RouteAccess {
|
||||
/// Publicly accessible (no auth required)
|
||||
Public,
|
||||
/// Read-only (user or admin role required)
|
||||
/// Read-only resources (Read, Control, or Administrator required)
|
||||
Read,
|
||||
/// Control only (admin role required)
|
||||
/// Bookmarks (Read, Control, Write, or Administrator required)
|
||||
ReadWrite,
|
||||
/// Logbook access (Write or Administrator required)
|
||||
Write,
|
||||
/// Radio control (Control or Administrator required)
|
||||
Control,
|
||||
}
|
||||
|
||||
@@ -857,7 +981,6 @@ impl RouteAccess {
|
||||
|| path == "/spectrum"
|
||||
|| path == "/meter"
|
||||
|| path == "/audio"
|
||||
|| path == "/bookmarks"
|
||||
|| path.starts_with("/status?")
|
||||
|| path.starts_with("/rigs?")
|
||||
|| path.starts_with("/events?")
|
||||
@@ -866,8 +989,6 @@ impl RouteAccess {
|
||||
|| path.starts_with("/spectrum?")
|
||||
|| path.starts_with("/meter?")
|
||||
|| path.starts_with("/audio?")
|
||||
|| path.starts_with("/bookmarks?")
|
||||
|| path.starts_with("/bookmarks/")
|
||||
|| path.starts_with("/scheduler/")
|
||||
|| path.starts_with("/scheduler-control")
|
||||
|| path.starts_with("/channels/")
|
||||
@@ -875,15 +996,30 @@ impl RouteAccess {
|
||||
return Self::Read;
|
||||
}
|
||||
|
||||
if path == "/bookmarks"
|
||||
|| path.starts_with("/bookmarks?")
|
||||
|| path.starts_with("/bookmarks/")
|
||||
{
|
||||
return Self::ReadWrite;
|
||||
}
|
||||
|
||||
if path.starts_with("/api/logbook") {
|
||||
return Self::Write;
|
||||
}
|
||||
|
||||
// All other routes require control
|
||||
Self::Control
|
||||
}
|
||||
|
||||
fn allows(&self, role: Option<AuthRole>) -> bool {
|
||||
fn allows(&self, roles: Option<&BTreeSet<AuthRole>>) -> bool {
|
||||
match self {
|
||||
Self::Public => true,
|
||||
Self::Read => role.is_some(),
|
||||
Self::Control => matches!(role, Some(AuthRole::Admin)),
|
||||
Self::Read => roles.is_some_and(|roles| grants(roles, AuthRole::Read)),
|
||||
Self::ReadWrite => roles.is_some_and(|roles| {
|
||||
grants(roles, AuthRole::Read) || grants(roles, AuthRole::Write)
|
||||
}),
|
||||
Self::Write => roles.is_some_and(|roles| grants(roles, AuthRole::Write)),
|
||||
Self::Control => roles.is_some_and(|roles| grants(roles, AuthRole::Control)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -951,12 +1087,12 @@ where
|
||||
}
|
||||
|
||||
// Auth enabled - check role
|
||||
let role = get_session_role(req.request(), &auth_state);
|
||||
let roles = get_session_roles(req.request(), &auth_state);
|
||||
|
||||
if !access.allows(role) {
|
||||
if !access.allows(roles.as_ref()) {
|
||||
// Access denied
|
||||
return Box::pin(async move {
|
||||
if role.is_some() {
|
||||
if roles.is_some() {
|
||||
// Has session but insufficient permissions - 403 Forbidden
|
||||
Err(actix_web::error::ErrorForbidden(
|
||||
"Insufficient permissions".to_string(),
|
||||
@@ -984,6 +1120,25 @@ mod tests {
|
||||
use super::*;
|
||||
use actix_web::{test as aw_test, App};
|
||||
|
||||
fn roles(values: &[AuthRole]) -> BTreeSet<AuthRole> {
|
||||
values.iter().copied().collect()
|
||||
}
|
||||
|
||||
fn test_auth_config(path: PathBuf) -> AuthConfig {
|
||||
AuthConfig::new(
|
||||
true,
|
||||
path,
|
||||
Some("admin".to_string()),
|
||||
Some("password123".to_string()),
|
||||
true,
|
||||
"guest".to_string(),
|
||||
Some("guest".to_string()),
|
||||
Duration::from_secs(3600),
|
||||
false,
|
||||
SameSite::Lax,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_access_public_paths() {
|
||||
assert_eq!(RouteAccess::from_path("/"), RouteAccess::Public);
|
||||
@@ -1016,6 +1171,8 @@ mod tests {
|
||||
assert_eq!(RouteAccess::from_path("/spectrum"), RouteAccess::Read);
|
||||
assert_eq!(RouteAccess::from_path("/meter"), RouteAccess::Read);
|
||||
assert_eq!(RouteAccess::from_path("/audio"), RouteAccess::Read);
|
||||
assert_eq!(RouteAccess::from_path("/api/logbook"), RouteAccess::Write);
|
||||
assert_eq!(RouteAccess::from_path("/bookmarks"), RouteAccess::ReadWrite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1030,30 +1187,45 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_route_access_allows() {
|
||||
let read = roles(&[AuthRole::Read]);
|
||||
let control = roles(&[AuthRole::Control]);
|
||||
let write = roles(&[AuthRole::Write]);
|
||||
let administrator = roles(&[AuthRole::Administrator]);
|
||||
assert!(RouteAccess::Public.allows(None));
|
||||
assert!(RouteAccess::Public.allows(Some(AuthRole::User)));
|
||||
assert!(RouteAccess::Public.allows(Some(AuthRole::Admin)));
|
||||
assert!(RouteAccess::Public.allows(Some(&read)));
|
||||
assert!(RouteAccess::Public.allows(Some(&administrator)));
|
||||
|
||||
assert!(!RouteAccess::Read.allows(None));
|
||||
assert!(RouteAccess::Read.allows(Some(AuthRole::User)));
|
||||
assert!(RouteAccess::Read.allows(Some(AuthRole::Admin)));
|
||||
assert!(RouteAccess::Read.allows(Some(&read)));
|
||||
assert!(RouteAccess::Read.allows(Some(&control)));
|
||||
assert!(!RouteAccess::Read.allows(Some(&write)));
|
||||
assert!(RouteAccess::Read.allows(Some(&administrator)));
|
||||
|
||||
assert!(RouteAccess::ReadWrite.allows(Some(&read)));
|
||||
assert!(RouteAccess::ReadWrite.allows(Some(&control)));
|
||||
assert!(RouteAccess::ReadWrite.allows(Some(&write)));
|
||||
assert!(!RouteAccess::Write.allows(Some(&read)));
|
||||
assert!(!RouteAccess::Write.allows(Some(&control)));
|
||||
assert!(RouteAccess::Write.allows(Some(&write)));
|
||||
assert!(RouteAccess::Write.allows(Some(&administrator)));
|
||||
|
||||
assert!(!RouteAccess::Control.allows(None));
|
||||
assert!(!RouteAccess::Control.allows(Some(AuthRole::User)));
|
||||
assert!(RouteAccess::Control.allows(Some(AuthRole::Admin)));
|
||||
assert!(!RouteAccess::Control.allows(Some(&read)));
|
||||
assert!(RouteAccess::Control.allows(Some(&control)));
|
||||
assert!(RouteAccess::Control.allows(Some(&administrator)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_store_create_and_get() {
|
||||
let store = SessionStore::new();
|
||||
let ttl = Duration::from_secs(3600);
|
||||
let session_id = store.create("alice".to_string(), AuthRole::User, ttl);
|
||||
let session_id = store.create("alice".to_string(), roles(&[AuthRole::Read]), ttl);
|
||||
|
||||
let record = store.get(&session_id);
|
||||
assert!(record.is_some());
|
||||
let record = record.unwrap();
|
||||
assert_eq!(record.username, "alice");
|
||||
assert_eq!(record.role, AuthRole::User);
|
||||
assert_eq!(record.roles, roles(&[AuthRole::Read]));
|
||||
assert!(!record.is_expired());
|
||||
}
|
||||
|
||||
@@ -1061,7 +1233,7 @@ mod tests {
|
||||
fn test_session_store_remove() {
|
||||
let store = SessionStore::new();
|
||||
let ttl = Duration::from_secs(3600);
|
||||
let session_id = store.create("alice".to_string(), AuthRole::User, ttl);
|
||||
let session_id = store.create("alice".to_string(), roles(&[AuthRole::Read]), ttl);
|
||||
|
||||
store.remove(&session_id);
|
||||
assert!(store.get(&session_id).is_none());
|
||||
@@ -1070,35 +1242,33 @@ mod tests {
|
||||
#[test]
|
||||
fn user_store_bootstraps_and_manages_users() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let config = AuthConfig::new(
|
||||
true,
|
||||
directory.path().join("users.json"),
|
||||
Some("admin".to_string()),
|
||||
Some("password123".to_string()),
|
||||
Duration::from_secs(3600),
|
||||
false,
|
||||
SameSite::Lax,
|
||||
);
|
||||
let config = test_auth_config(directory.path().join("users.json"));
|
||||
let users = UserStore::open(&config).unwrap();
|
||||
assert_eq!(
|
||||
users.authenticate("admin", "password123"),
|
||||
Some(AuthRole::Admin)
|
||||
Some(AuthRole::all())
|
||||
);
|
||||
assert_eq!(users.authenticate("admin", "wrong"), None);
|
||||
users.add("alice", "password456", AuthRole::User).unwrap();
|
||||
assert_eq!(
|
||||
users.authenticate("alice", "password456"),
|
||||
Some(AuthRole::User)
|
||||
users.authenticate("guest", "guest"),
|
||||
Some(roles(&[AuthRole::Read]))
|
||||
);
|
||||
users
|
||||
.update("alice", Some("password789"), Some(AuthRole::Admin))
|
||||
.add("alice", "password456", roles(&[AuthRole::Read]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
users.authenticate("alice", "password456"),
|
||||
Some(roles(&[AuthRole::Read]))
|
||||
);
|
||||
users
|
||||
.update("alice", Some("password789"), Some(AuthRole::all()))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
users.authenticate("alice", "password789"),
|
||||
Some(AuthRole::Admin)
|
||||
Some(AuthRole::all())
|
||||
);
|
||||
users.remove("admin").unwrap();
|
||||
assert_eq!(users.list().len(), 1);
|
||||
assert_eq!(users.list().len(), 2);
|
||||
drop(users);
|
||||
|
||||
let mut reopen_config = config.clone();
|
||||
@@ -1107,7 +1277,7 @@ mod tests {
|
||||
let reopened = UserStore::open(&reopen_config).unwrap();
|
||||
assert_eq!(
|
||||
reopened.authenticate("alice", "password789"),
|
||||
Some(AuthRole::Admin)
|
||||
Some(AuthRole::all())
|
||||
);
|
||||
let database = fs::read_to_string(&reopen_config.users_file).unwrap();
|
||||
assert!(database.contains("$argon2"));
|
||||
@@ -1117,32 +1287,53 @@ mod tests {
|
||||
#[test]
|
||||
fn user_store_protects_last_admin() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let config = AuthConfig::new(
|
||||
true,
|
||||
directory.path().join("users.json"),
|
||||
Some("admin".to_string()),
|
||||
Some("password123".to_string()),
|
||||
Duration::from_secs(3600),
|
||||
false,
|
||||
SameSite::Lax,
|
||||
);
|
||||
let config = test_auth_config(directory.path().join("users.json"));
|
||||
let users = UserStore::open(&config).unwrap();
|
||||
assert!(users.remove("admin").is_err());
|
||||
assert!(users.update("admin", None, Some(AuthRole::User)).is_err());
|
||||
assert!(users
|
||||
.update("admin", None, Some(roles(&[AuthRole::Read])))
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guest_bootstrap_can_be_disabled() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut config = test_auth_config(directory.path().join("users.json"));
|
||||
config.bootstrap_read_enabled = false;
|
||||
config.bootstrap_read_password = None;
|
||||
let users = UserStore::open(&config).unwrap();
|
||||
|
||||
assert_eq!(users.list().len(), 1);
|
||||
assert_eq!(users.authenticate("guest", "guest"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_single_roles_are_migrated() {
|
||||
let read: UserRecord = serde_json::from_value(serde_json::json!({
|
||||
"username": "reader",
|
||||
"password_hash": "unused",
|
||||
"role": "user"
|
||||
}))
|
||||
.unwrap();
|
||||
let administrator: UserRecord = serde_json::from_value(serde_json::json!({
|
||||
"username": "admin",
|
||||
"password_hash": "unused",
|
||||
"role": "admin"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(read.roles, roles(&[AuthRole::Read]));
|
||||
assert_eq!(administrator.roles, AuthRole::all());
|
||||
assert!(serde_json::to_value(administrator)
|
||||
.unwrap()
|
||||
.get("role")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn admin_endpoints_manage_multiple_users_and_reject_regular_users() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let config = AuthConfig::new(
|
||||
true,
|
||||
directory.path().join("users.json"),
|
||||
Some("admin".to_string()),
|
||||
Some("password123".to_string()),
|
||||
Duration::from_secs(3600),
|
||||
false,
|
||||
SameSite::Lax,
|
||||
);
|
||||
let config = test_auth_config(directory.path().join("users.json"));
|
||||
let state = web::Data::new(AuthState::new(config).unwrap());
|
||||
let app = aw_test::init_service(
|
||||
App::new()
|
||||
@@ -1167,7 +1358,7 @@ mod tests {
|
||||
.uri("/auth/users")
|
||||
.insert_header((actix_web::http::header::COOKIE, admin_cookie.clone()))
|
||||
.set_json(
|
||||
serde_json::json!({"username":"alice","password":"password456","role":"user"}),
|
||||
serde_json::json!({"username":"alice","password":"password456","roles":["read"]}),
|
||||
)
|
||||
.to_request();
|
||||
assert_eq!(
|
||||
@@ -1193,7 +1384,7 @@ mod tests {
|
||||
let request = aw_test::TestRequest::patch()
|
||||
.uri("/auth/users/alice")
|
||||
.insert_header((actix_web::http::header::COOKIE, admin_cookie.clone()))
|
||||
.set_json(serde_json::json!({"password":"new-password","role":"admin"}))
|
||||
.set_json(serde_json::json!({"password":"new-password","roles":["read","control","write","administrator"]}))
|
||||
.to_request();
|
||||
assert_eq!(
|
||||
aw_test::call_service(&app, request).await.status(),
|
||||
@@ -1201,11 +1392,31 @@ mod tests {
|
||||
);
|
||||
let request = aw_test::TestRequest::delete()
|
||||
.uri("/auth/users/alice")
|
||||
.insert_header((actix_web::http::header::COOKIE, admin_cookie))
|
||||
.insert_header((actix_web::http::header::COOKIE, admin_cookie.clone()))
|
||||
.to_request();
|
||||
assert_eq!(
|
||||
aw_test::call_service(&app, request).await.status(),
|
||||
actix_web::http::StatusCode::OK
|
||||
);
|
||||
|
||||
// Once only one administrator remains, neither API operation may
|
||||
// leave the database without an administrator.
|
||||
let request = aw_test::TestRequest::patch()
|
||||
.uri("/auth/users/admin")
|
||||
.insert_header((actix_web::http::header::COOKIE, admin_cookie.clone()))
|
||||
.set_json(serde_json::json!({"roles":["read"]}))
|
||||
.to_request();
|
||||
assert_eq!(
|
||||
aw_test::call_service(&app, request).await.status(),
|
||||
actix_web::http::StatusCode::BAD_REQUEST
|
||||
);
|
||||
let request = aw_test::TestRequest::delete()
|
||||
.uri("/auth/users/admin")
|
||||
.insert_header((actix_web::http::header::COOKIE, admin_cookie))
|
||||
.to_request();
|
||||
assert_eq!(
|
||||
aw_test::call_service(&app, request).await.status(),
|
||||
actix_web::http::StatusCode::BAD_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,9 @@ fn build_server(
|
||||
context.http_auth.users_file.clone().into(),
|
||||
context.http_auth.bootstrap_admin_username.clone(),
|
||||
context.http_auth.bootstrap_admin_password.clone(),
|
||||
context.http_auth.bootstrap_read_enabled,
|
||||
context.http_auth.bootstrap_read_username.clone(),
|
||||
context.http_auth.bootstrap_read_password.clone(),
|
||||
Duration::from_secs(context.http_auth.session_ttl_secs),
|
||||
context.http_auth.cookie_secure,
|
||||
same_site,
|
||||
|
||||
@@ -277,6 +277,12 @@ pub struct HttpAuthConfig {
|
||||
/// Read the bootstrap administrator password from this file instead.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bootstrap_admin_password_file: Option<String>,
|
||||
/// Create a read-only account when bootstrapping a new database.
|
||||
pub bootstrap_read_enabled: bool,
|
||||
/// Username for the read-only bootstrap account.
|
||||
pub bootstrap_read_username: String,
|
||||
/// Password for the read-only bootstrap account.
|
||||
pub bootstrap_read_password: Option<String>,
|
||||
/// Session time-to-live in minutes
|
||||
pub session_ttl_min: u64,
|
||||
/// Set Secure flag on session cookie (required for HTTPS)
|
||||
@@ -293,6 +299,9 @@ impl Default for HttpAuthConfig {
|
||||
bootstrap_admin_username: None,
|
||||
bootstrap_admin_password: None,
|
||||
bootstrap_admin_password_file: None,
|
||||
bootstrap_read_enabled: true,
|
||||
bootstrap_read_username: "guest".to_string(),
|
||||
bootstrap_read_password: Some("guest".to_string()),
|
||||
session_ttl_min: 480,
|
||||
cookie_secure: false,
|
||||
cookie_same_site: CookieSameSite::Lax,
|
||||
@@ -830,6 +839,15 @@ impl ClientConfig {
|
||||
.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.is_empty()
|
||||
}
|
||||
@@ -903,6 +921,9 @@ impl ClientConfig {
|
||||
bootstrap_admin_username: Some("admin".to_string()),
|
||||
bootstrap_admin_password: Some("change-this-password".to_string()),
|
||||
bootstrap_admin_password_file: None,
|
||||
bootstrap_read_enabled: true,
|
||||
bootstrap_read_username: "guest".to_string(),
|
||||
bootstrap_read_password: Some("guest".to_string()),
|
||||
session_ttl_min: 480,
|
||||
cookie_secure: false,
|
||||
cookie_same_site: CookieSameSite::Lax,
|
||||
@@ -961,6 +982,15 @@ fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> {
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if auth.bootstrap_read_enabled
|
||||
&& (auth.bootstrap_read_username.trim().is_empty()
|
||||
|| auth
|
||||
.bootstrap_read_password
|
||||
.as_deref()
|
||||
.is_none_or(str::is_empty))
|
||||
{
|
||||
return Err("[frontends.http.auth] enabled bootstrap read account requires a non-empty username and password".to_string());
|
||||
}
|
||||
|
||||
// Session TTL must be > 0
|
||||
if auth.session_ttl_min == 0 {
|
||||
@@ -1268,6 +1298,18 @@ home-hf = "audio://10.0.0.5:4600"
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
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();
|
||||
@@ -1283,6 +1325,9 @@ home-hf = "audio://10.0.0.5:4600"
|
||||
assert_eq!(auth.users_file, "trx-http-users.json");
|
||||
assert!(auth.bootstrap_admin_username.is_none());
|
||||
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!(!auth.cookie_secure);
|
||||
assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax));
|
||||
|
||||
@@ -210,6 +210,9 @@ enabled = false
|
||||
users_file = "trx-http-users.json"
|
||||
bootstrap_admin_username = "admin"
|
||||
bootstrap_admin_password = "change-this-password"
|
||||
bootstrap_read_enabled = true
|
||||
bootstrap_read_username = "guest"
|
||||
bootstrap_read_password = "guest"
|
||||
session_ttl_min = 480
|
||||
cookie_secure = false
|
||||
cookie_same_site = "Lax"
|
||||
|
||||
Reference in New Issue
Block a user