Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 84 additions & 2 deletions frontend/src/__tests__/settings-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -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 <section> outside #global-settings-form. The viewer role must
* see all its inputs as disabled and both Save/Reset buttons hidden.
*/
import { loadGlobalSettings } from '../settings';

Expand Down Expand Up @@ -130,7 +134,30 @@ const setupDom = () => {
err.id = 'settings-error';
err.className = 'hidden';

document.body.append(loading, form, err);
// Purchasing Policies panel: a sibling <section> 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)', () => {
Expand Down Expand Up @@ -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<HTMLInputElement | HTMLSelectElement>(
'#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<HTMLInputElement | HTMLSelectElement>(
'#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);
});
});
7 changes: 7 additions & 0 deletions frontend/src/riexchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<void> {
Expand Down
36 changes: 27 additions & 9 deletions frontend/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2700,21 +2700,34 @@ async function confirmAndPropagatePayment(select: HTMLSelectElement): Promise<vo
* even start editing, and hide Save / Reset / Delete-override CTAs.
* Backend remains authoritative; this is a UX gate.
*
* Issue #870: also covers the Purchasing Policies panel (#purchasing-panel),
* which is a sibling <section> 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<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>(
'input, select, textarea, button',
controlSelector,
).forEach((el) => { el.disabled = false; });
}
document.querySelectorAll<HTMLButtonElement>('#save-settings-btn, #reset-settings-btn').forEach((el) => {
el.hidden = false;
});
if (purchasingPanel) {
purchasingPanel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>(
controlSelector,
).forEach((el) => { el.disabled = false; });
}
document.querySelectorAll<HTMLButtonElement>(
'#save-settings-btn, #reset-settings-btn, #save-purchasing-btn, #reset-purchasing-btn',
).forEach((el) => { el.hidden = false; });
return;
}

Expand All @@ -2724,12 +2737,17 @@ function applyReadOnlySettings(formEl: HTMLElement | null): void {
// fires even if a control sneaks past the disable.
if (formEl) {
formEl.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>(
'input, select, textarea, button',
controlSelector,
).forEach((el) => { el.disabled = true; });
}
document.querySelectorAll<HTMLButtonElement>('#save-settings-btn, #reset-settings-btn').forEach((el) => {
el.hidden = true;
});
if (purchasingPanel) {
purchasingPanel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>(
controlSelector,
).forEach((el) => { el.disabled = true; });
}
document.querySelectorAll<HTMLButtonElement>(
'#save-settings-btn, #reset-settings-btn, #save-purchasing-btn, #reset-purchasing-btn',
).forEach((el) => { el.hidden = true; });
}

/**
Expand Down
Loading