[feat](trx-frontend-http): separate transmit permission
CI / frontend (pull_request) Successful in 5m21s
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m11s
CI / test (push) Successful in 8m16s
CI / frontend (push) Successful in 4m24s
CI / reuse (push) Successful in 5s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m25s
CI / frontend (pull_request) Successful in 5m21s
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m11s
CI / test (push) Successful in 8m16s
CI / frontend (push) Successful in 4m24s
CI / reuse (push) Successful in 5s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m25s
Assisted-By: Codex (GPT-5) Signed-off-by: Stan Grams <sjg@haxx.space>
This commit was merged in pull request #64.
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs && node tests/account-management.mjs",
|
||||
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs && node tests/account-management.mjs && node tests/transmit-role.mjs",
|
||||
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -6,12 +6,13 @@ import type { AuthRole } from "./generated.js";
|
||||
|
||||
export type { AuthRole };
|
||||
|
||||
export const AUTH_ROLES: readonly AuthRole[] = ["guest", "read", "control", "write", "administrator"];
|
||||
export const AUTH_ROLES: readonly AuthRole[] = ["guest", "read", "control", "transmit", "write", "administrator"];
|
||||
export const AUTH_ADMIN_ROLES: readonly AuthRole[] = AUTH_ROLES.filter(role => role !== "guest");
|
||||
export const AUTH_ROLE_LABELS: Readonly<Record<AuthRole, string>> = {
|
||||
guest: "Guest",
|
||||
read: "Read",
|
||||
control: "Control",
|
||||
transmit: "Transmit",
|
||||
write: "Write",
|
||||
administrator: "Administrator",
|
||||
};
|
||||
@@ -28,7 +29,7 @@ export function hasAuthRole(roles: readonly AuthRole[], required: AuthRole): boo
|
||||
return roles.includes("administrator")
|
||||
|| roles.includes(required)
|
||||
|| required === "read" && roles.includes("guest")
|
||||
|| required === "read" && roles.includes("control");
|
||||
|| required === "read" && (roles.includes("control") || roles.includes("transmit"));
|
||||
}
|
||||
|
||||
export function hasAccountControls(roles: readonly AuthRole[]): boolean {
|
||||
|
||||
@@ -123,7 +123,7 @@ export type RigListResponse = { active_remote: string | null, rigs: Array<RigLis
|
||||
|
||||
export type FrontendMeta = { clients: number, rigctl_clients: number, audio_clients: number, rigctl_addr: string | null, active_remote: string | null, remotes: Array<string>, owner_callsign: string | null, owner_website_url: string | null, owner_website_name: string | null, ais_vessel_url_base: string | null, show_sdr_gain_control: boolean, initial_map_zoom: number, spectrum_coverage_margin_hz: number, spectrum_usable_span_ratio: number, bandplan_enabled: boolean, bandplan_region: string, decode_history_retention_min: bigint, server_connected: boolean, };
|
||||
|
||||
export type AuthRole = "guest" | "read" | "control" | "write" | "administrator";
|
||||
export type AuthRole = "guest" | "read" | "control" | "transmit" | "write" | "administrator";
|
||||
|
||||
export type DecoderActivation = "mode_bound" | "toggle";
|
||||
|
||||
|
||||
@@ -584,35 +584,38 @@ function updateAuthUI() {
|
||||
function applyAuthRestrictions() {
|
||||
if (authRoles.length === 0) return;
|
||||
|
||||
// Disable TX/PTT/frequency/mode/VFO controls for user role
|
||||
if (!hasAuthRole("control")) {
|
||||
if (!hasAuthRole("transmit")) {
|
||||
const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null;
|
||||
const txLimitInput = document.getElementById("tx-limit") as HTMLInputElement | null;
|
||||
const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null;
|
||||
const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
|
||||
const txLimitRow = document.getElementById("tx-limit-row");
|
||||
if (pttBtn) pttBtn.disabled = true;
|
||||
if (txAudioBtn) txAudioBtn.disabled = true;
|
||||
if (txLimitBtn) txLimitBtn.disabled = true;
|
||||
if (txLimitInput) txLimitInput.disabled = true;
|
||||
if (txLimitRow) txLimitRow.style.opacity = "0.5";
|
||||
}
|
||||
|
||||
// General tuning and receive-side controls require Control.
|
||||
if (!hasAuthRole("control")) {
|
||||
const powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null;
|
||||
const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null;
|
||||
const freqInput = document.getElementById("freq") as HTMLInputElement | null;
|
||||
const centerFreqInput = document.getElementById("center-freq") as HTMLInputElement | null;
|
||||
const modeSelect = document.getElementById("mode") as HTMLSelectElement | null;
|
||||
const txLimitInput = document.getElementById("tx-limit") as HTMLInputElement | null;
|
||||
const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null;
|
||||
const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
|
||||
const txLimitRow = document.getElementById("tx-limit-row");
|
||||
const jogUp = document.getElementById("jog-up") as HTMLButtonElement | null;
|
||||
const jogDown = document.getElementById("jog-down") as HTMLButtonElement | null;
|
||||
const jogButtons = document.querySelectorAll<HTMLButtonElement>(".jog-step button");
|
||||
const vfoButtons = document.querySelectorAll<HTMLButtonElement>("#vfo-picker button");
|
||||
|
||||
// Disable TX buttons
|
||||
if (pttBtn) pttBtn.disabled = true;
|
||||
if (powerBtn) powerBtn.disabled = true;
|
||||
if (lockBtn) lockBtn.disabled = true;
|
||||
if (txAudioBtn) txAudioBtn.disabled = true;
|
||||
if (txLimitBtn) txLimitBtn.disabled = true;
|
||||
|
||||
// Disable frequency/mode inputs
|
||||
if (freqInput) freqInput.disabled = true;
|
||||
if (centerFreqInput) centerFreqInput.disabled = true;
|
||||
if (modeSelect) modeSelect.disabled = true;
|
||||
if (txLimitInput) txLimitInput.disabled = true;
|
||||
|
||||
// Disable VFO selector
|
||||
vfoButtons.forEach(btn => btn.disabled = true);
|
||||
@@ -656,9 +659,6 @@ function applyAuthRestrictions() {
|
||||
btn.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Hide TX-specific UI but keep controls visible (disabled)
|
||||
if (txLimitRow) txLimitRow.style.opacity = "0.5";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3080,9 +3080,16 @@ function formatSignal(sUnits: number) {
|
||||
}
|
||||
|
||||
function setDisabled(disabled: boolean) {
|
||||
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
|
||||
if (el) el.disabled = disabled;
|
||||
const controlDisabled = disabled || (authEnabled && !hasAuthRole("control"));
|
||||
const transmitDisabled = disabled || (authEnabled && !hasAuthRole("transmit"));
|
||||
[freqEl, centerFreqEl, modeEl, powerBtn, lockBtn].forEach((el) => {
|
||||
if (el) el.disabled = controlDisabled;
|
||||
});
|
||||
[pttBtn, txLimitInput, txLimitBtn].forEach((el) => {
|
||||
if (el) el.disabled = transmitDisabled;
|
||||
});
|
||||
const transmitAudio = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
|
||||
if (transmitAudio) transmitAudio.disabled = transmitDisabled || !hasWebCodecs;
|
||||
syncModePicker();
|
||||
}
|
||||
|
||||
@@ -6370,6 +6377,10 @@ function stopRxAudio() {
|
||||
|
||||
function startTxAudio() {
|
||||
if (txActive) { void stopTxAudio(); return; }
|
||||
if (authEnabled && !hasAuthRole("transmit")) {
|
||||
audioStatus.textContent = "Transmit role required";
|
||||
return;
|
||||
}
|
||||
if (!hasWebCodecs) {
|
||||
audioStatus.textContent = "Audio requires Chrome/Edge";
|
||||
return;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||
|
||||
/* global document */
|
||||
|
||||
const ALL_ROLES = ["read", "control", "write", "administrator"];
|
||||
const ALL_ROLES = ["read", "control", "transmit", "write", "administrator"];
|
||||
const fixture = await startWebFixture({
|
||||
authSession: {
|
||||
authenticated: true,
|
||||
|
||||
@@ -15,13 +15,15 @@ function loadAuth(fetch) {
|
||||
return context.AuthApi;
|
||||
}
|
||||
|
||||
test("role policy centralizes Guest and implied read access", () => {
|
||||
test("role policy centralizes Guest and separates Control from Transmit", () => {
|
||||
const auth = loadAuth(async () => { throw new Error("unused"); });
|
||||
assert.deepEqual(Array.from(auth.AUTH_ROLES), ["guest", "read", "control", "write", "administrator"]);
|
||||
assert.deepEqual(Array.from(auth.AUTH_ROLES), ["guest", "read", "control", "transmit", "write", "administrator"]);
|
||||
assert.equal(auth.hasAuthRole(["guest"], "read"), true);
|
||||
assert.equal(auth.hasAccountControls(["guest"]), false);
|
||||
assert.equal(auth.hasAccountControls(["read"]), true);
|
||||
assert.equal(auth.hasAuthRole(["control"], "read"), true);
|
||||
assert.equal(auth.hasAuthRole(["transmit"], "read"), true);
|
||||
assert.equal(auth.hasAuthRole(["control"], "transmit"), false);
|
||||
assert.equal(auth.hasAuthRole(["control"], "write"), false);
|
||||
assert.equal(auth.hasAuthRole(["administrator"], "write"), true);
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ function hostFixture(overrides = {}) {
|
||||
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
|
||||
const state = {
|
||||
authEnabled: false,
|
||||
authRoles: ["read", "control", "write", "administrator"],
|
||||
authRoles: ["read", "control", "transmit", "write", "administrator"],
|
||||
lastActiveRigId: null,
|
||||
lastRigIds: [],
|
||||
lastRigDisplayNames: {},
|
||||
|
||||
@@ -19,7 +19,7 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
|
||||
serverLat: null,
|
||||
serverLon: null,
|
||||
authEnabled: false,
|
||||
authRoles: ["read", "control", "write", "administrator"],
|
||||
authRoles: ["read", "control", "transmit", "write", "administrator"],
|
||||
lastActiveRigId: null,
|
||||
lastRigIds: [],
|
||||
lastRigDisplayNames: {},
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright-core";
|
||||
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||
|
||||
async function controlState(page) {
|
||||
return {
|
||||
frequency: await page.locator("#freq").isDisabled(),
|
||||
ptt: await page.locator("#ptt-btn").isDisabled(),
|
||||
txAudio: await page.locator("#tx-audio-btn").isDisabled(),
|
||||
txLimit: await page.locator("#tx-limit-btn").isDisabled(),
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectRole(roles) {
|
||||
const fixture = await startWebFixture({
|
||||
tx: true,
|
||||
authSession: {
|
||||
authenticated: true,
|
||||
username: "operator",
|
||||
roles,
|
||||
auth_disabled: false,
|
||||
},
|
||||
});
|
||||
const session = await startBrowser(chromium);
|
||||
try {
|
||||
await session.page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
|
||||
await session.page.locator("#content").waitFor({ state: "visible" });
|
||||
await session.page.waitForTimeout(1200);
|
||||
assert.deepEqual(session.runtimeErrors, []);
|
||||
return await controlState(session.page);
|
||||
} finally {
|
||||
await session.browser.close();
|
||||
await fixture.close();
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(await inspectRole(["control"]), {
|
||||
frequency: false,
|
||||
ptt: true,
|
||||
txAudio: true,
|
||||
txLimit: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(await inspectRole(["transmit"]), {
|
||||
frequency: true,
|
||||
ptt: false,
|
||||
txAudio: false,
|
||||
txLimit: false,
|
||||
});
|
||||
@@ -145,7 +145,7 @@ export async function startWebFixture({
|
||||
bandplanEnabled = false,
|
||||
bandplanUnauthorizedFirst = false,
|
||||
satPasses = null,
|
||||
authSession = { authenticated: true, roles: ["read", "control", "write", "administrator"], auth_disabled: true },
|
||||
authSession = { authenticated: true, roles: ["read", "control", "transmit", "write", "administrator"], auth_disabled: true },
|
||||
users = [],
|
||||
} = {}) {
|
||||
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
|
||||
|
||||
Reference in New Issue
Block a user