[feat](trx-frontend-http): add restricted Guest role
Assisted-By: Codex (GPT-5) Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -6,8 +6,10 @@ import type { AuthRole } from "./generated.js";
|
||||
|
||||
export type { AuthRole };
|
||||
|
||||
export const AUTH_ROLES: readonly AuthRole[] = ["read", "control", "write", "administrator"];
|
||||
export const AUTH_ROLES: readonly AuthRole[] = ["guest", "read", "control", "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",
|
||||
write: "Write",
|
||||
@@ -25,9 +27,14 @@ export function normalizeAuthRoles(roles: readonly AuthRole[]): AuthRole[] {
|
||||
export function hasAuthRole(roles: readonly AuthRole[], required: AuthRole): boolean {
|
||||
return roles.includes("administrator")
|
||||
|| roles.includes(required)
|
||||
|| required === "read" && roles.includes("guest")
|
||||
|| required === "read" && roles.includes("control");
|
||||
}
|
||||
|
||||
export function hasAccountControls(roles: readonly AuthRole[]): boolean {
|
||||
return roles.length > 0 && !roles.includes("guest");
|
||||
}
|
||||
|
||||
function decodeRoles(value: unknown, context: string): AuthRole[] {
|
||||
if (!Array.isArray(value) || !value.every(isAuthRole)) {
|
||||
throw new TypeError(`${context} has invalid roles`);
|
||||
@@ -67,7 +74,7 @@ function decodeAuthSession(value: unknown): AuthSession {
|
||||
|
||||
const authDisabledSession: AuthSession = {
|
||||
authenticated: true,
|
||||
roles: ["read", "control", "write", "administrator"],
|
||||
roles: [...AUTH_ADMIN_ROLES],
|
||||
auth_disabled: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = "read" | "control" | "write" | "administrator";
|
||||
export type AuthRole = "guest" | "read" | "control" | "write" | "administrator";
|
||||
|
||||
export type DecoderActivation = "mode_bound" | "toggle";
|
||||
|
||||
|
||||
@@ -26,8 +26,10 @@ import {
|
||||
updateUser,
|
||||
deleteUser,
|
||||
changeOwnPassword,
|
||||
AUTH_ADMIN_ROLES,
|
||||
AUTH_ROLES,
|
||||
AUTH_ROLE_LABELS,
|
||||
hasAccountControls,
|
||||
hasAuthRole as rolesInclude,
|
||||
normalizeAuthRoles,
|
||||
} from "./api/auth.js";
|
||||
@@ -441,6 +443,17 @@ function buildRoleChoices(selected: readonly AuthRole[]) {
|
||||
element.append(label);
|
||||
return { input, value };
|
||||
});
|
||||
inputs.forEach(({ input, value }) => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
if (value === "guest") {
|
||||
inputs.forEach(choice => { if (choice.value !== "guest") choice.input.checked = false; });
|
||||
} else {
|
||||
const guest = inputs.find(choice => choice.value === "guest");
|
||||
if (guest) guest.input.checked = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
return { element, inputs };
|
||||
}
|
||||
|
||||
@@ -544,7 +557,13 @@ function updateAuthUI() {
|
||||
}
|
||||
|
||||
if (authRoles.length > 0) {
|
||||
if (accountTab) accountTab.style.display = "";
|
||||
const canManageAccount = hasAccountControls(authRoles);
|
||||
if (accountTab) accountTab.style.display = canManageAccount ? "" : "none";
|
||||
if (!canManageAccount && accountTab?.classList.contains("active")) {
|
||||
const panel = document.getElementById("subtab-settings-account");
|
||||
if (panel) panel.style.display = "none";
|
||||
document.querySelector<HTMLButtonElement>('[data-subtab="settings-scheduler"]')?.click();
|
||||
}
|
||||
if (badge) badge.style.display = "block";
|
||||
if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`;
|
||||
if (headerAuthBtn) {
|
||||
@@ -5050,7 +5069,7 @@ async function initializeApp() {
|
||||
authEnabled = !authStatus.auth_disabled;
|
||||
|
||||
if (!authEnabled) {
|
||||
setAuthRoles(AUTH_ROLES);
|
||||
setAuthRoles(AUTH_ADMIN_ROLES);
|
||||
hideAuthGate();
|
||||
updateAuthUI();
|
||||
connect();
|
||||
@@ -5132,10 +5151,15 @@ async function refreshUserManagement() {
|
||||
&& rolesInclude(user.roles, "administrator")
|
||||
&& enabledAdminCount === 1;
|
||||
const administratorInput = roleInputs.find(item => item.value === "administrator")?.input;
|
||||
const guestInput = roleInputs.find(item => item.value === "guest")?.input;
|
||||
if (isOnlyAdmin && administratorInput) {
|
||||
administratorInput.disabled = true;
|
||||
administratorInput.title = "The final administrator cannot be demoted";
|
||||
}
|
||||
if (isOnlyAdmin && guestInput) {
|
||||
guestInput.disabled = true;
|
||||
guestInput.title = "The final administrator cannot become a Guest";
|
||||
}
|
||||
if (isOnlyAdmin) {
|
||||
enabled.disabled = true;
|
||||
enabled.title = "The final enabled administrator cannot be disabled";
|
||||
|
||||
+23
-1
@@ -47,14 +47,16 @@ try {
|
||||
createRoles: [...document.querySelectorAll("#user-create-roles input")].map((input) => input.value),
|
||||
adminEnabledLocked: admin?.querySelector('input[type="checkbox"]')?.disabled,
|
||||
adminRoleLocked: role(admin, "administrator")?.disabled,
|
||||
adminGuestLocked: role(admin, "guest")?.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.deepEqual(state.createRoles, ["guest", ...ALL_ROLES]);
|
||||
assert.equal(state.adminEnabledLocked, true);
|
||||
assert.equal(state.adminRoleLocked, true);
|
||||
assert.equal(state.adminGuestLocked, true);
|
||||
assert.equal(state.adminRemoveLocked, true);
|
||||
assert.equal(state.listenerEnabled, false);
|
||||
assert.equal(state.listenerRead, true);
|
||||
@@ -63,3 +65,23 @@ try {
|
||||
await browser.close();
|
||||
await fixture.close();
|
||||
}
|
||||
|
||||
const guestFixture = await startWebFixture({
|
||||
authSession: {
|
||||
authenticated: true,
|
||||
username: "guest",
|
||||
roles: ["guest"],
|
||||
auth_disabled: false,
|
||||
},
|
||||
});
|
||||
const guestBrowser = await startBrowser(chromium);
|
||||
try {
|
||||
await guestBrowser.page.goto(`${guestFixture.origin}/settings`, { waitUntil: "domcontentloaded" });
|
||||
await guestBrowser.page.locator("#tab-settings").waitFor({ state: "visible" });
|
||||
assert.equal(await guestBrowser.page.locator("#settings-account-tab").isVisible(), false);
|
||||
assert.equal(await guestBrowser.page.locator("#settings-users-tab").isVisible(), false);
|
||||
assert.deepEqual(guestBrowser.runtimeErrors, []);
|
||||
} finally {
|
||||
await guestBrowser.browser.close();
|
||||
await guestFixture.close();
|
||||
}
|
||||
|
||||
@@ -15,9 +15,12 @@ function loadAuth(fetch) {
|
||||
return context.AuthApi;
|
||||
}
|
||||
|
||||
test("role policy is centralized and preserves the Control-to-Read implication", () => {
|
||||
test("role policy centralizes Guest and implied read access", () => {
|
||||
const auth = loadAuth(async () => { throw new Error("unused"); });
|
||||
assert.deepEqual(Array.from(auth.AUTH_ROLES), ["read", "control", "write", "administrator"]);
|
||||
assert.deepEqual(Array.from(auth.AUTH_ROLES), ["guest", "read", "control", "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(["control"], "write"), false);
|
||||
assert.equal(auth.hasAuthRole(["administrator"], "write"), true);
|
||||
|
||||
@@ -51,6 +51,7 @@ test("account lifecycle controls include self-service passwords and enable state
|
||||
assert.match(html, /id="account-password-form"/);
|
||||
assert.match(html, /id="user-create-enabled"/);
|
||||
assert.match(app, /changeOwnPassword/);
|
||||
assert.match(app, /hasAccountControls\(authRoles\)/);
|
||||
assert.match(app, /enabledAdminCount/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user