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
@@ -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();
@@ -5057,7 +5083,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");
@@ -5070,7 +5096,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";
@@ -5078,19 +5104,32 @@ 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 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.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));
});
@@ -5102,7 +5141,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) {
@@ -5132,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"; });
});
});
@@ -5151,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();
@@ -5175,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();
@@ -5205,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; } },
@@ -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);
@@ -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({
@@ -38,7 +38,7 @@ test("administrator user management is a dedicated Settings sub-tab", async () =
]);
assert.match(html, /data-subtab="settings-users"/);
assert.match(html, /id="subtab-settings-users" class="sub-tab-panel"/);
assert.match(app, /authEnabled && authRole === "admin"/);
assert.match(app, /authEnabled && hasAuthRole\("administrator"\)/);
assert.match(app, /settings-users-tab/);
});
@@ -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],