diff --git a/frontend/src/__tests__/settings-permissions.test.ts b/frontend/src/__tests__/settings-permissions.test.ts index 1b6321f84..13f4333ad 100644 --- a/frontend/src/__tests__/settings-permissions.test.ts +++ b/frontend/src/__tests__/settings-permissions.test.ts @@ -1,10 +1,14 @@ /** - * Settings page permission gating for issue #365. + * Settings page permission gating for issue #365 and issue #870. * * The /admin/general + /admin/purchasing sub-tabs are reachable for * every signed-in role (only /admin/accounts and /admin/users are * navigation.ts-redirected). Render the form read-only for non-admin * sessions: disable every control, hide Save and Reset. + * + * Issue #870: the Purchasing Policies panel (#purchasing-panel) is a + * sibling
outside #global-settings-form. The viewer role must + * see all its inputs as disabled and both Save/Reset buttons hidden. */ import { loadGlobalSettings } from '../settings'; @@ -130,7 +134,30 @@ const setupDom = () => { err.id = 'settings-error'; err.className = 'hidden'; - document.body.append(loading, form, err); + // Purchasing Policies panel: a sibling
outside #global-settings-form. + // Only the grace-period inputs (unique to this panel) and the action buttons + // are included here to avoid duplicate IDs with the form above. In the real + // HTML the term/payment/coverage selects also live in this panel; that does + // not affect the read-only gate logic since applyReadOnlySettings queries + // #purchasing-panel independently of #global-settings-form. + const purchasingPanel = document.createElement('section'); + purchasingPanel.id = 'purchasing-panel'; + for (const id of ['setting-grace-aws', 'setting-grace-azure', 'setting-grace-gcp']) { + const inp = document.createElement('input'); + inp.id = id; + inp.type = 'number'; + inp.value = '7'; + purchasingPanel.appendChild(inp); + } + const savePurchBtn = document.createElement('button'); + savePurchBtn.id = 'save-purchasing-btn'; + savePurchBtn.type = 'button'; + const resetPurchBtn = document.createElement('button'); + resetPurchBtn.id = 'reset-purchasing-btn'; + resetPurchBtn.type = 'button'; + purchasingPanel.append(savePurchBtn, resetPurchBtn); + + document.body.append(loading, form, err, purchasingPanel); }; describe('Settings page permission gating (issue #365)', () => { @@ -187,3 +214,58 @@ describe('Settings page permission gating (issue #365)', () => { expect(emailInput.disabled).toBe(true); }); }); + +describe('Purchasing Policies panel permission gating (issue #870)', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupDom(); + }); + + test('admin: purchasing inputs enabled and Save/Reset buttons visible', async () => { + mockUser('admin'); + await loadGlobalSettings(); + const save = document.getElementById('save-purchasing-btn') as HTMLButtonElement; + const reset = document.getElementById('reset-purchasing-btn') as HTMLButtonElement; + expect(save.hidden).toBe(false); + expect(reset.hidden).toBe(false); + const inputs = document.querySelectorAll( + '#purchasing-panel input, #purchasing-panel select', + ); + expect(inputs.length).toBeGreaterThan(0); + inputs.forEach((el) => expect(el.disabled).toBe(false)); + }); + + test('readonly role: purchasing inputs disabled and Save/Reset buttons hidden', async () => { + mockUser('readonly'); + await loadGlobalSettings(); + const save = document.getElementById('save-purchasing-btn') as HTMLButtonElement; + const reset = document.getElementById('reset-purchasing-btn') as HTMLButtonElement; + expect(save.hidden).toBe(true); + expect(reset.hidden).toBe(true); + const inputs = document.querySelectorAll( + '#purchasing-panel input, #purchasing-panel select', + ); + expect(inputs.length).toBeGreaterThan(0); + inputs.forEach((el) => expect(el.disabled).toBe(true)); + }); + + test('user role: purchasing inputs disabled and Save/Reset buttons hidden', async () => { + mockUser('user'); + await loadGlobalSettings(); + const save = document.getElementById('save-purchasing-btn') as HTMLButtonElement; + const reset = document.getElementById('reset-purchasing-btn') as HTMLButtonElement; + expect(save.hidden).toBe(true); + expect(reset.hidden).toBe(true); + const graceInput = document.getElementById('setting-grace-aws') as HTMLInputElement; + expect(graceInput.disabled).toBe(true); + }); + + test('null user: purchasing inputs disabled and Save/Reset buttons hidden', async () => { + mockUser(null); + await loadGlobalSettings(); + const save = document.getElementById('save-purchasing-btn') as HTMLButtonElement; + expect(save.hidden).toBe(true); + const graceGcp = document.getElementById('setting-grace-gcp') as HTMLInputElement; + expect(graceGcp.disabled).toBe(true); + }); +}); diff --git a/frontend/src/riexchange.ts b/frontend/src/riexchange.ts index 8f51cdfcc..55bd6a31e 100644 --- a/frontend/src/riexchange.ts +++ b/frontend/src/riexchange.ts @@ -22,6 +22,7 @@ import type { import { openModal, closeModal } from './modal'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; import { canAccess } from './permissions'; +import { applyReadOnlySettings } from './settings'; import { showToast } from './toast'; import { getCurrentUser } from './state'; @@ -987,6 +988,12 @@ function renderAutomationSettings(container: HTMLElement, config: RIExchangeConf } }); } + + // Issue #870: re-apply the purchasing-panel read-only gate after rendering + // so the dynamically-injected RI Exchange inputs are covered for non-admin + // sessions (loadGlobalSettings runs concurrently and may complete before + // these inputs exist). + applyReadOnlySettings(null); } export async function saveAutomationSettings(): Promise { diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index 8f76f8e8a..1d74ab92d 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -2700,21 +2700,34 @@ async function confirmAndPropagatePayment(select: HTMLSelectElement): Promise outside #global-settings-form. Called from + * riexchange.ts after the RI Exchange Automation form is rendered so + * dynamically-injected inputs are covered too. + * * The form stays VISIBLE so non-admin sessions can still inspect the * configured providers, default term/payment, and grace windows. */ -function applyReadOnlySettings(formEl: HTMLElement | null): void { +export function applyReadOnlySettings(formEl: HTMLElement | null): void { + const purchasingPanel = document.getElementById('purchasing-panel'); + const controlSelector = 'input, select, textarea, button'; + if (canAccess('admin', '*')) { // Admin: ensure controls are enabled in case a prior session // (e.g. role downgraded mid-session) left them disabled. if (formEl) { formEl.querySelectorAll( - 'input, select, textarea, button', + controlSelector, ).forEach((el) => { el.disabled = false; }); } - document.querySelectorAll('#save-settings-btn, #reset-settings-btn').forEach((el) => { - el.hidden = false; - }); + if (purchasingPanel) { + purchasingPanel.querySelectorAll( + controlSelector, + ).forEach((el) => { el.disabled = false; }); + } + document.querySelectorAll( + '#save-settings-btn, #reset-settings-btn, #save-purchasing-btn, #reset-purchasing-btn', + ).forEach((el) => { el.hidden = false; }); return; } @@ -2724,12 +2737,17 @@ function applyReadOnlySettings(formEl: HTMLElement | null): void { // fires even if a control sneaks past the disable. if (formEl) { formEl.querySelectorAll( - 'input, select, textarea, button', + controlSelector, ).forEach((el) => { el.disabled = true; }); } - document.querySelectorAll('#save-settings-btn, #reset-settings-btn').forEach((el) => { - el.hidden = true; - }); + if (purchasingPanel) { + purchasingPanel.querySelectorAll( + controlSelector, + ).forEach((el) => { el.disabled = true; }); + } + document.querySelectorAll( + '#save-settings-btn, #reset-settings-btn, #save-purchasing-btn, #reset-purchasing-btn', + ).forEach((el) => { el.hidden = true; }); } /**