Complete managed account lifecycle
CI / test (push) Successful in 8m12s
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 9m6s
CI / frontend (pull_request) Successful in 5m17s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m26s

This commit was merged in pull request #63.
This commit is contained in:
sjg
2026-08-11 07:49:53 +02:00
parent 34507ffa17
commit 5e9dae02c7
33 changed files with 1224 additions and 449 deletions
@@ -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",
"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",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
},
"devDependencies": {
@@ -2,7 +2,38 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
export type AuthRole = "read" | "control" | "write" | "administrator";
import type { AuthRole } from "./generated.js";
export type { AuthRole };
export const AUTH_ROLES: readonly AuthRole[] = ["read", "control", "write", "administrator"];
export const AUTH_ROLE_LABELS: Readonly<Record<AuthRole, string>> = {
read: "Read",
control: "Control",
write: "Write",
administrator: "Administrator",
};
export function isAuthRole(value: unknown): value is AuthRole {
return typeof value === "string" && (AUTH_ROLES as readonly string[]).includes(value);
}
export function normalizeAuthRoles(roles: readonly AuthRole[]): AuthRole[] {
return AUTH_ROLES.filter(role => roles.includes(role));
}
export function hasAuthRole(roles: readonly AuthRole[], required: AuthRole): boolean {
return roles.includes("administrator")
|| roles.includes(required)
|| required === "read" && roles.includes("control");
}
function decodeRoles(value: unknown, context: string): AuthRole[] {
if (!Array.isArray(value) || !value.every(isAuthRole)) {
throw new TypeError(`${context} has invalid roles`);
}
return normalizeAuthRoles(value);
}
export interface AuthSession {
authenticated: boolean;
@@ -19,14 +50,13 @@ function decodeAuthSession(value: unknown): AuthSession {
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
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, roles: session.roles as AuthRole[] };
const decoded: AuthSession = {
authenticated: session.authenticated,
roles: decodeRoles(session.roles, "The authentication response"),
};
if (session.username !== undefined) {
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
decoded.username = session.username;
@@ -67,7 +97,7 @@ export async function login(username: string, password: string): Promise<AuthSes
return decodeAuthSession(await response.json());
}
export interface ManagedUser { username: string; roles: AuthRole[] }
export interface ManagedUser { username: string; roles: AuthRole[]; enabled: boolean }
async function userRequest(path: string, init?: RequestInit): Promise<Response> {
const response = await fetch(path, init);
@@ -83,22 +113,32 @@ 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" && Array.isArray(record.roles)
&& record.roles.every(role => role === "read" || role === "control" || role === "write" || role === "administrator");
return typeof record.username === "string"
&& typeof record.enabled === "boolean"
&& Array.isArray(record.roles)
&& record.roles.every(isAuthRole);
})) {
throw new TypeError("The user list response is malformed");
}
return value as ManagedUser[];
return (value as ManagedUser[]).map(user => ({ ...user, roles: normalizeAuthRoles(user.roles) }));
}
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 createUser(username: string, password: string, roles: AuthRole[], enabled = true): Promise<void> {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles, enabled }) });
}
export async function updateUser(username: string, changes: { password?: string; roles?: AuthRole[] }): Promise<void> {
export async function updateUser(username: string, changes: { password?: string; roles?: AuthRole[]; enabled?: boolean }): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
}
export async function changeOwnPassword(currentPassword: string, newPassword: string): Promise<void> {
await userRequest("/auth/account/password", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
}
export async function deleteUser(username: string): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
}
@@ -123,6 +123,8 @@ 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 = "read" | "control" | "write" | "administrator";
export type DecoderActivation = "mode_bound" | "toggle";
export type DecoderDescriptor = {
@@ -25,6 +25,11 @@ import {
createUser,
updateUser,
deleteUser,
changeOwnPassword,
AUTH_ROLES,
AUTH_ROLE_LABELS,
hasAuthRole as rolesInclude,
normalizeAuthRoles,
} from "./api/auth.js";
import {
formatByteSize as recorderFormatSize,
@@ -193,8 +198,8 @@ interface TrxModules {
reverseGeocodeLocation(lat: number, lon: number, grid: string): void;
bandForHz(frequencyHz: number): unknown;
};
scheduler?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void };
backgroundDecode?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void };
scheduler?: { initialize(rigId: string | null, roles: readonly AuthRole[]): void; setRig(rigId: string | null): void; wireEvents(): void };
backgroundDecode?: { initialize(rigId: string | null, roles: readonly AuthRole[]): void; setRig(rigId: string | null): void; wireEvents(): void };
bookmarks?: {
readonly overlayList: readonly Bookmark[];
readonly overlayRevision: number;
@@ -230,7 +235,6 @@ interface TrxState {
readonly initialMapZoom: number;
readonly decodeHistoryRetentionMin: number;
readonly authEnabled: boolean;
readonly authRole: AuthRole | null;
readonly authRoles: readonly AuthRole[];
readonly decoderRegistry: typeof decoderRegistry;
readonly sseSessionId: string | null;
@@ -411,27 +415,33 @@ declare global {
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;
authRoles = normalizeAuthRoles(roles);
}
function hasAuthRole(role: AuthRole) {
return authRoles.includes("administrator") || authRoles.includes(role);
return rolesInclude(authRoles, role);
}
function buildRoleChoices(selected: readonly AuthRole[]) {
const element = document.createElement("span");
element.className = "auth-role-choices";
const inputs = 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 = selected.includes(value);
label.append(input, ` ${AUTH_ROLE_LABELS[value]}`);
element.append(label);
return { input, value };
});
return { element, inputs };
}
async function checkAuthStatus() {
@@ -523,15 +533,18 @@ function updateAuthUI() {
const badge = document.getElementById("auth-badge");
const badgeRole = document.getElementById("auth-role-badge");
const headerAuthBtn = document.getElementById("header-auth-btn");
const accountTab = document.getElementById("settings-account-tab");
if (!authEnabled) {
if (badge) badge.style.display = "none";
if (headerAuthBtn) headerAuthBtn.style.display = "none";
if (accountTab) accountTab.style.display = "none";
syncTopBarAccess();
return;
}
if (authRoles.length > 0) {
if (accountTab) accountTab.style.display = "";
if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`;
if (headerAuthBtn) {
@@ -539,6 +552,7 @@ function updateAuthUI() {
headerAuthBtn.style.display = "block";
}
} else {
if (accountTab) accountTab.style.display = "none";
if (badge) badge.style.display = "none";
if (headerAuthBtn) {
headerAuthBtn.textContent = "Login";
@@ -5036,7 +5050,7 @@ async function initializeApp() {
authEnabled = !authStatus.auth_disabled;
if (!authEnabled) {
setAuthRoles(ALL_AUTH_ROLES);
setAuthRoles(AUTH_ROLES);
hideAuthGate();
updateAuthUI();
connect();
@@ -5070,10 +5084,10 @@ let settingsUiReady = false;
function initSettingsUI() {
settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
window.trx.modules.backgroundDecode.wireEvents();
}
void refreshUserManagement();
@@ -5096,7 +5110,7 @@ async function refreshUserManagement() {
const list = requiredElement("user-list");
try {
const users = await listUsers();
const adminCount = users.filter(user => user.roles.includes("administrator")).length;
const enabledAdminCount = users.filter(user => user.enabled && rolesInclude(user.roles, "administrator")).length;
list.replaceChildren(...users.map((user) => {
const row = document.createElement("div");
row.className = "sch-row";
@@ -5104,31 +5118,35 @@ async function refreshUserManagement() {
const name = document.createElement("strong");
name.textContent = user.username;
name.style.minWidth = "10rem";
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;
if (!user.enabled) name.textContent += " (disabled)";
const { element: roles, inputs: roleInputs } = buildRoleChoices(user.roles);
const enabledLabel = document.createElement("label");
enabledLabel.className = "auth-role-choice";
const enabled = document.createElement("input");
enabled.type = "checkbox";
enabled.checked = user.enabled;
enabled.disabled = user.username === authUsername;
if (enabled.disabled) enabled.title = "You cannot disable your current account";
enabledLabel.append(enabled, " Enabled");
const isOnlyAdmin = user.enabled
&& rolesInclude(user.roles, "administrator")
&& enabledAdminCount === 1;
const administratorInput = roleInputs.find(item => item.value === "administrator")?.input;
if (isOnlyAdmin && administratorInput) {
administratorInput.disabled = true;
administratorInput.title = "The final administrator cannot be demoted";
}
if (isOnlyAdmin) {
enabled.disabled = true;
enabled.title = "The final enabled administrator cannot be disabled";
}
const password = document.createElement("input");
password.type = "password"; password.placeholder = "New password (8+ characters)"; 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; password.maxLength = 1024;
const save = document.createElement("button"); save.type = "button"; save.textContent = "Save";
save.addEventListener("click", async () => {
const changes: { roles?: AuthRole[]; password?: string } = {
const changes: { roles?: AuthRole[]; password?: string; enabled?: boolean } = {
roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value),
enabled: enabled.checked,
};
if (password.value) changes.password = password.value;
await runUserOperation(() => updateUser(user.username, changes));
@@ -5141,7 +5159,7 @@ async function refreshUserManagement() {
await runUserOperation(() => deleteUser(user.username));
}
});
row.append(name, roles, password, save, remove);
row.append(name, enabledLabel, roles, password, save, remove);
return row;
}));
} catch (error) {
@@ -5167,18 +5185,57 @@ async function runUserOperation(operation: () => Promise<void>) {
}
}
const createRoleContainer = document.getElementById("user-create-roles");
if (createRoleContainer) {
const { element } = buildRoleChoices(["read"]);
element.id = createRoleContainer.id;
createRoleContainer.replaceWith(element);
}
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 enabled = requiredElement<HTMLInputElement>("user-create-enabled");
const roles = Array.from(document.querySelectorAll<HTMLInputElement>("#user-create-roles input[type=checkbox]"));
void runUserOperation(async () => {
await createUser(username.value, password.value, roles.filter(input => input.checked).map(input => input.value as AuthRole));
await createUser(username.value, password.value, roles.filter(input => input.checked).map(input => input.value as AuthRole), enabled.checked);
username.value = ""; password.value = "";
enabled.checked = true;
roles.forEach(input => { input.checked = input.value === "read"; });
});
});
document.getElementById("account-password-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const currentPassword = requiredElement<HTMLInputElement>("account-current-password");
const newPassword = requiredElement<HTMLInputElement>("account-new-password");
const confirmPassword = requiredElement<HTMLInputElement>("account-confirm-password");
const error = requiredElement("account-password-error");
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (newPassword.value !== confirmPassword.value) {
error.textContent = "New passwords do not match";
error.style.display = "block";
return;
}
if (submit) submit.disabled = true;
void changeOwnPassword(currentPassword.value, newPassword.value)
.then(async () => {
form.reset();
error.style.display = "none";
await authLogout();
showHint("Password changed. Sign in again.", 3000);
})
.catch((reason: unknown) => {
error.textContent = reason instanceof Error ? reason.message : String(reason);
error.style.display = "block";
})
.finally(() => {
if (submit) submit.disabled = false;
});
});
// Setup auth form
requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (e) => {
e.preventDefault();
@@ -5244,7 +5301,6 @@ Object.defineProperties(trxState, {
initialMapZoom: { get() { return initialMapZoom; } },
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
authEnabled: { get() { return authEnabled; } },
authRole: { get() { return authRole; } },
authRoles: { get() { return authRoles; } },
decoderRegistry: { get() { return decoderRegistry; } },
sseSessionId: { get() { return sseSessionId; } },
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
import { hasAuthRole, type AuthRole } from "../api/auth.js";
export {};
@@ -44,7 +45,7 @@ interface BackgroundBridge {
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
}
interface BackgroundDecodeService {
initialize(rigId: string | null, role: string | null): void;
initialize(rigId: string | null, roles: readonly AuthRole[]): void;
wireEvents(): void;
setRig(rigId: string | null): void;
}
@@ -60,7 +61,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
.map(function (d) { return d.id; });
}
let backgroundDecodeRole: string | null = null;
let backgroundDecodeRoles: readonly AuthRole[] = [];
let currentRigId: string | null = null;
let currentConfig: BackgroundDecodeConfig | null = null;
let bookmarkList: Bookmark[] = [];
@@ -70,8 +71,8 @@ const bgdWindow = window as unknown as BackgroundBridge;
let statusByBookmark = new Map<string, BackgroundStatusEntry>();
let lastStatus: BackgroundDecodeStatus | null = null;
function initBackgroundDecode(rigId: string | null, role: string | null): void {
backgroundDecodeRole = role;
function initBackgroundDecode(rigId: string | null, roles: readonly AuthRole[]): void {
backgroundDecodeRoles = roles;
// The panel used to take whatever rig it was handed at load and wait to be
// told again. Loading before the rig list arrives handed it null, and the
// next telling only came when the operator switched rigs, so the panel sat
@@ -468,9 +469,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
}
function isControlRole(): boolean {
return backgroundDecodeRole === "administrator"
|| backgroundDecodeRole === "control"
|| hostState.authEnabled === false;
return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
}
function showToast(msg: string, isError: boolean): void {
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
import { hasAuthRole } from "../api/auth.js";
export {};
@@ -101,8 +102,7 @@ function bmEsc(str: unknown): string {
function bmCanControl() {
return !hostState.authEnabled
|| hostState.authRoles.includes("administrator")
|| hostState.authRoles.includes("write");
|| hasAuthRole(hostState.authRoles, "write");
}
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
@@ -11,6 +11,8 @@
// feature bundles from re-deriving it — and from drifting back to bare `window`
// properties, which the module graph no longer publishes.
import type { AuthRole } from "../api/auth.js";
export interface HostDecoderDescriptor {
id: string;
label: string;
@@ -25,8 +27,7 @@ export interface HostState {
/** The callsign this station is on the air with, from the client config. */
readonly ownerCallsign: string | null;
readonly authEnabled: boolean;
readonly authRole: string | null;
readonly authRoles: readonly string[];
readonly authRoles: readonly AuthRole[];
readonly lastActiveRigId: string | null;
readonly lastRigIds: string[];
readonly lastRigDisplayNames: Record<string, string>;
@@ -10,6 +10,7 @@
// keeping if the times in it are the radio's.
import { hostCore, hostState } from "./host.js";
import { hasAuthRole } from "../api/auth.js";
export {};
@@ -122,8 +123,7 @@ let workedRequest = 0;
function canWriteLogbook(): boolean {
return !hostState.authEnabled
|| hostState.authRoles.includes("administrator")
|| hostState.authRoles.includes("write");
|| hasAuthRole(hostState.authRoles, "write");
}
function notify(message: string, kind?: string): void {
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import type { SatelliteScheduleConfig, SatelliteSchedulerApi } from "./satellite-types.js";
import type { AuthRole } from "../api/auth.js";
export type SchedulerMode = "disabled" | "grayline" | "time_span";
@@ -60,7 +61,7 @@ export interface SchedulerStatus {
}
export interface SchedulerService {
initialize(rigId: string | null, role: string | null): void;
initialize(rigId: string | null, roles: readonly AuthRole[]): void;
destroy(): void;
setRig(rigId: string | null): void;
wireEvents(): void;
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
import { hasAuthRole, type AuthRole } from "../api/auth.js";
import type {
ScheduleEntry,
@@ -43,7 +44,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// -------------------------------------------------------------------------
// State
// -------------------------------------------------------------------------
let schedulerRole: string | null = null;
let schedulerRoles: readonly AuthRole[] = [];
let currentRigId: string | null = null;
let currentConfig: SchedulerConfig | null = null;
let currentSchedulerStatus: SchedulerStatus | null = null;
@@ -58,8 +59,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
function initScheduler(rigId: string | null, role: string | null): void {
schedulerRole = role;
function initScheduler(rigId: string | null, roles: readonly AuthRole[]): void {
schedulerRoles = roles;
currentRigId = rigId || null;
if (currentRigId) loadScheduler();
startStatusPolling();
@@ -356,7 +357,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig);
const enabled =
(schedulerRole === "administrator" || schedulerRole === "control") &&
hasAuthRole(schedulerRoles, "control") &&
!!currentRigId &&
!schedulerStepPending &&
state.activeEntries.length > 1;
@@ -466,7 +467,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!panel) return;
const mode = (currentConfig && currentConfig.mode) || "disabled";
const isControl = schedulerRole === "administrator" || schedulerRole === "control";
const isControl = hasAuthRole(schedulerRoles, "control");
// Mode selector
setSelected("scheduler-mode-select", mode);
@@ -1574,8 +1575,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// When loaded eagerly, initSettingsUI() in app.js calls initScheduler();
// when loaded lazily (e.g. settings tab click after boot), the app has
// already passed that point, so we must self-initialize here.
if (hostState.authRole != null) {
initScheduler(hostState.lastActiveRigId, hostState.authRole);
if (!hostState.authEnabled || hostState.authRoles.length > 0) {
initScheduler(hostState.lastActiveRigId, hostState.authRoles);
wireSchedulerEvents();
}
})();
@@ -0,0 +1,65 @@
// 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";
/* global document */
const ALL_ROLES = ["read", "control", "write", "administrator"];
const fixture = await startWebFixture({
authSession: {
authenticated: true,
username: "admin",
roles: ALL_ROLES,
auth_disabled: false,
},
users: [
{ username: "admin", roles: ALL_ROLES, enabled: true },
{ username: "listener", roles: ["read"], enabled: false },
],
});
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.goto(`${fixture.origin}/settings`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-settings").waitFor({ state: "visible" });
await page.locator("#settings-account-tab").waitFor({ state: "visible" });
await page.locator("#settings-users-tab").waitFor({ state: "visible" });
await page.locator("#settings-account-tab").click();
assert.equal(await page.locator("#account-password-form").isVisible(), true);
assert.equal(await page.locator("#subtab-settings-users").isVisible(), false);
await page.locator("#settings-users-tab").click();
await page.locator("#user-list").getByText("listener (disabled)").waitFor();
assert.equal(await page.locator("#user-create-form").isVisible(), true);
const state = await page.evaluate(() => {
const rows = [...document.querySelectorAll("#user-list > .sch-row")];
const rowFor = (username) => rows.find((row) => row.querySelector("strong")?.textContent.startsWith(username));
const admin = rowFor("admin");
const listener = rowFor("listener");
const role = (row, value) => row?.querySelector(`input[value="${value}"]`);
return {
createRoles: [...document.querySelectorAll("#user-create-roles input")].map((input) => input.value),
adminEnabledLocked: admin?.querySelector('input[type="checkbox"]')?.disabled,
adminRoleLocked: role(admin, "administrator")?.disabled,
adminRemoveLocked: admin?.querySelector("button.danger")?.disabled,
listenerEnabled: listener?.querySelector('input[type="checkbox"]')?.checked,
listenerRead: role(listener, "read")?.checked,
};
});
assert.deepEqual(state.createRoles, ALL_ROLES);
assert.equal(state.adminEnabledLocked, true);
assert.equal(state.adminRoleLocked, true);
assert.equal(state.adminRemoveLocked, true);
assert.equal(state.listenerEnabled, false);
assert.equal(state.listenerRead, true);
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
const source = await bundleEntry(new URL("../src/api/auth.ts", import.meta.url), "AuthApi");
function loadAuth(fetch) {
const context = vm.createContext({ fetch, console });
new vm.Script(source).runInContext(context);
return context.AuthApi;
}
test("role policy is centralized and preserves the Control-to-Read implication", () => {
const auth = loadAuth(async () => { throw new Error("unused"); });
assert.deepEqual(Array.from(auth.AUTH_ROLES), ["read", "control", "write", "administrator"]);
assert.equal(auth.hasAuthRole(["control"], "read"), true);
assert.equal(auth.hasAuthRole(["control"], "write"), false);
assert.equal(auth.hasAuthRole(["administrator"], "write"), true);
});
test("auth responses normalize roles and require the managed-account lifecycle state", async () => {
const replies = new Map([
["/auth/session", { authenticated: true, roles: ["write", "read", "write"], username: "alice" }],
["/auth/users", [{ username: "alice", roles: ["write", "read"], enabled: false }]],
]);
const auth = loadAuth(async (url) => ({
ok: true,
status: 200,
json: async () => replies.get(String(url)),
}));
assert.deepEqual(Array.from((await auth.fetchAuthSession()).roles), ["read", "write"]);
assert.deepEqual(Array.from((await auth.listUsers())[0].roles), ["read", "write"]);
assert.equal((await auth.listUsers())[0].enabled, false);
});
test("changing a password sends current and replacement credentials", async () => {
let request;
const auth = loadAuth(async (url, init) => {
request = { url, init };
return { ok: true, status: 200, json: async () => ({}) };
});
await auth.changeOwnPassword("old-password", "new-password");
assert.equal(request.url, "/auth/account/password");
assert.equal(request.init.method, "PATCH");
assert.deepEqual(JSON.parse(request.init.body), {
current_password: "old-password",
new_password: "new-password",
});
});
@@ -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", "administrator");
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,6 @@ function hostFixture(overrides = {}) {
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
const state = {
authEnabled: false,
authRole: "administrator",
authRoles: ["read", "control", "write", "administrator"],
lastActiveRigId: null,
lastRigIds: [],
@@ -158,7 +157,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: "read", authRoles: ["read"] });
const { window } = hostFixture({ authEnabled: true, authRoles: ["read"] });
const context = vm.createContext({
window,
document: documentFixture(element),
@@ -172,7 +171,6 @@ test("bookmark controls follow the host authentication state", async () => {
assert.equal(element("bm-add-btn").style.display, "none");
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,6 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
serverLat: null,
serverLon: null,
authEnabled: false,
authRole: "administrator",
authRoles: ["read", "control", "write", "administrator"],
lastActiveRigId: null,
lastRigIds: [],
@@ -11,7 +11,7 @@ import { bundleEntry } from "./bundle-entry.mjs";
test("scheduler registers a typed module service without lifecycle globals", async () => {
// No role known yet: the entry registers its service and waits for the
// application to drive initialization.
const window = { ...createHost({ state: { authRole: null } }), trxUi: { confirm: async () => true } };
const window = { ...createHost({ state: { authEnabled: true, authRoles: [] } }), trxUi: { confirm: async () => true } };
const context = vm.createContext({
window,
document: {
@@ -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: "administrator", lastActiveRigId: "sdr" } }),
...createHost({ state: { authRoles: ["administrator"], lastActiveRigId: "sdr" } }),
trxUi: { confirm: async () => true },
};
const context = vm.createContext({
@@ -42,6 +42,18 @@ test("administrator user management is a dedicated Settings sub-tab", async () =
assert.match(app, /settings-users-tab/);
});
test("account lifecycle controls include self-service passwords and enable state", async () => {
const [html, app] = await Promise.all([
readFile(indexPath, "utf8"),
readFile(appPath, "utf8"),
]);
assert.match(html, /data-subtab="settings-account"/);
assert.match(html, /id="account-password-form"/);
assert.match(html, /id="user-create-enabled"/);
assert.match(app, /changeOwnPassword/);
assert.match(app, /enabledAdminCount/);
});
test("lazy frontend features use modules and local map symbols", async () => {
const [loader, map] = await Promise.all([
readFile(pluginLoaderPath, "utf8"),
@@ -145,6 +145,8 @@ export async function startWebFixture({
bandplanEnabled = false,
bandplanUnauthorizedFirst = false,
satPasses = null,
authSession = { authenticated: true, roles: ["read", "control", "write", "administrator"], auth_disabled: true },
users = [],
} = {}) {
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote,
@@ -232,7 +234,8 @@ export async function startWebFixture({
};
const jsonRoutes = new Map([
["/auth/session", { authenticated: true, roles: ["read", "control", "write", "administrator"], auth_disabled: true }],
["/auth/session", authSession],
["/auth/users", users],
["/decoders", DECODER_REGISTRY],
["/rigs", rigsResponse],
["/status", status],