Add composable HTTP access roles
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m24s
CI / frontend (pull_request) Successful in 5m12s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 8m8s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s

This commit was merged in pull request #62.
This commit is contained in:
sjg
2026-08-11 01:06:57 +02:00
parent 36c1e56efa
commit 34507ffa17
32 changed files with 710 additions and 268 deletions
@@ -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();
@@ -6053,7 +6073,7 @@ async function refreshUserManagement() {
const section = document.getElementById("user-management");
const tab = document.getElementById("settings-users-tab");
if (!section || !tab) return;
const canManageUsers = authEnabled && authRole === "admin";
const canManageUsers = authEnabled && hasAuthRole("administrator");
tab.style.display = canManageUsers ? "" : "none";
if (!canManageUsers) {
const panel = document.getElementById("subtab-settings-users");
@@ -6066,7 +6086,7 @@ async function refreshUserManagement() {
const list = requiredElement("user-list");
try {
const users = await listUsers();
const adminCount = users.filter((user) => user.role === "admin").length;
const adminCount = users.filter((user) => user.roles.includes("administrator")).length;
list.replaceChildren(...users.map((user) => {
const row = document.createElement("div");
row.className = "sch-row";
@@ -6074,21 +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 isOnlyAdmin = user.role === "admin" && adminCount === 1;
role.disabled = isOnlyAdmin;
if (isOnlyAdmin) role.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;
@@ -6096,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));
});
@@ -6111,7 +6140,7 @@ async function refreshUserManagement() {
await runUserOperation(() => deleteUser(user.username));
}
});
row.append(name, role, password, save, remove);
row.append(name, roles, password, save, remove);
return row;
}));
} catch (error) {
@@ -6138,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) => {
@@ -6156,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();
@@ -6178,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();
}
@@ -6224,6 +6255,9 @@ Object.defineProperties(trxState, {
authRole: { get() {
return authRole;
} },
authRoles: { get() {
return authRoles;
} },
decoderRegistry: { get() {
return decoderRegistry;
} },
@@ -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");
@@ -1757,7 +1757,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
<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>
@@ -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; }