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
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:
@@ -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;
|
||||
} },
|
||||
|
||||
Reference in New Issue
Block a user