From 49adc1431ff6a7f4c813f36472d8fea0015a3dbf Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:47:38 +0200 Subject: [PATCH 01/21] fix(frontend): reject past start date in Add Purchases form The server refuses non-future dates; mirror the check inline so the error shows before the round trip. Closes #50 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/plans-range-validation.test.ts | 19 +++++++++++++++++++ frontend/src/plans.ts | 7 +++++++ 2 files changed, 26 insertions(+) diff --git a/frontend/src/__tests__/plans-range-validation.test.ts b/frontend/src/__tests__/plans-range-validation.test.ts index 86eabc7d8..cdf54d3e0 100644 --- a/frontend/src/__tests__/plans-range-validation.test.ts +++ b/frontend/src/__tests__/plans-range-validation.test.ts @@ -674,6 +674,25 @@ describe('Add Purchases modal: start-date picker enforces future-only dates (QA }); }); +describe('Add Purchases rejects a past start date client-side (#50)', () => { + beforeEach(async () => { + jest.clearAllMocks(); + document.body.replaceChildren(); + await openAddPurchasesModal('plan-past', 'Past plan', 3); + }); + + it('blocks a date before today without calling the API and shows an inline error', async () => { + const date = document.getElementById('add-purchases-start-date') as HTMLInputElement; + date.value = '2020-01-01'; + document.getElementById('add-purchases-form')?.dispatchEvent(new Event('submit', { cancelable: true })); + await new Promise(resolve => setTimeout(resolve, 20)); + expect(api.createPlannedPurchases).not.toHaveBeenCalled(); + const err = document.getElementById('add-purchases-error'); + expect(err?.classList.contains('hidden')).toBe(false); + expect(err?.textContent).toMatch(/today or later/i); + }); +}); + describe('Add Purchases configured notice lead time', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index 7145826f5..74c22865d 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -2358,6 +2358,13 @@ async function handleAddPurchases(e: Event): Promise { } const count = rawCount; const startDate = (document.getElementById('add-purchases-start-date') as HTMLInputElement).value; + if (startDate < toLocalDateInputValue(new Date())) { + if (errorDiv) { + errorDiv.textContent = 'Start date must be today or later'; + errorDiv.classList.remove('hidden'); + } + return; + } await api.createPlannedPurchases(planId, count, startDate); From 15f87bde01f6ea89d48e179cc0eafb6c352fd1b6 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:49:04 +0200 Subject: [PATCH 02/21] fix(frontend): show stored zero max amount and refuse invalid caps A stored max_amount of 0 rendered blank (|| vs ??), and the form saved 0, negative or unparsable amounts without the cap. Show the stored value and refuse anything not greater than 0 on save; blank stays no limit (owner decision 2026-10-16). Closes #133 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/groups.test.ts | 30 ++++++++++++++++++++++++--- frontend/src/groups/groupModals.ts | 22 +++++++++----------- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/frontend/src/__tests__/groups.test.ts b/frontend/src/__tests__/groups.test.ts index 0ebe7f9b2..c5d267afb 100644 --- a/frontend/src/__tests__/groups.test.ts +++ b/frontend/src/__tests__/groups.test.ts @@ -1348,8 +1348,32 @@ describe('11-M3 max_amount validation in collectPermissions', () => { const event = new Event('submit', { cancelable: true }); await groupModals.saveGroup(event); - const call = (api.createGroup as jest.Mock).mock.calls[0][0] as { permissions: Array<{ constraints?: { max_amount?: number } }> }; - // Negative value must be silently skipped. - expect(call.permissions[0]?.constraints?.max_amount).toBeUndefined(); + // Negative value must be refused, not silently dropped (#133). + expect(api.createGroup).not.toHaveBeenCalled(); + expect(document.querySelector('.toast-error')?.textContent).toContain('greater than 0'); + }); + + it.each(['0', '-100', '1e999'])('refuses max_amount %s instead of saving without the cap (#133)', async raw => { + document.body.innerHTML = ` + + + +
+
+ + + +
+
+ `; + await groupModals.saveGroup(new Event('submit', { cancelable: true })); + expect(api.createGroup).not.toHaveBeenCalled(); + expect(document.querySelector('.toast-error')?.textContent).toContain('greater than 0'); + }); + + it('shows a stored max_amount of 0 in the edit form (#133)', () => { + document.body.innerHTML = '
'; + groupModals.addPermission({ action: 'execute', resource: 'purchases', constraints: { max_amount: 0 } }); + expect((document.querySelector('.perm-max-amount') as HTMLInputElement).value).toBe('0'); }); }); diff --git a/frontend/src/groups/groupModals.ts b/frontend/src/groups/groupModals.ts index 192744ab3..74204c179 100644 --- a/frontend/src/groups/groupModals.ts +++ b/frontend/src/groups/groupModals.ts @@ -282,7 +282,7 @@ export function addPermission(permission?: Permission): void { @@ -397,6 +397,14 @@ function unrepresentablePermissionErrors(): string[] { errors.push(`${label} has a "${dimension}" value with no usable entries`); } } + + const rawAmount = (item.querySelector('.perm-max-amount') as HTMLInputElement | null)?.value ?? ''; + if (rawAmount.trim() !== '') { + const amount = Number(rawAmount); + if (!Number.isFinite(amount) || amount <= 0) { + errors.push(`${label} has a Max Amount that must be a number greater than 0 (leave blank for no limit)`); + } + } }); return errors; } @@ -463,17 +471,7 @@ function collectPermissions(): Permission[] { if (providersList.length > 0) permission.constraints.providers = providersList; if (servicesList.length > 0) permission.constraints.services = servicesList; if (regionsList.length > 0) permission.constraints.regions = regionsList; - if (maxAmount) { - const parsed = parseFloat(maxAmount); - // Reject non-finite or negative values (feedback_nullable_not_zero). - // A malformed entry is silently skipped so the rest of the constraints - // still reach the API; the input's type="number" min="0" already - // prevents browser submission of non-numeric values, but the JS - // path must guard too. - if (Number.isFinite(parsed) && parsed >= 0) { - permission.constraints.max_amount = parsed; - } - } + if ((maxAmount ?? '').trim() !== '') permission.constraints.max_amount = Number(maxAmount); // Nothing survived: the permission carries no restriction, so send no // constraints object rather than an empty one. if (Object.keys(permission.constraints).length === 0) { From 72176edecfab982634150e7da3644614cbe73070 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:50:23 +0200 Subject: [PATCH 03/21] fix(frontend): show the account id once in active commitments Skip the (account_id) suffix when the account name already contains the id. Closes #179 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/inventory.test.ts | 11 +++++++++++ frontend/src/inventory.ts | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/inventory.test.ts b/frontend/src/__tests__/inventory.test.ts index 010177181..d8feb5bba 100644 --- a/frontend/src/__tests__/inventory.test.ts +++ b/frontend/src/__tests__/inventory.test.ts @@ -383,6 +383,17 @@ describe('loadActiveCommitments — fetch + render flow', () => { expect(api.listActiveCommitments).toHaveBeenCalledTimes(2); }); + test('does not repeat the account id when the name already contains it (#179)', async () => { + (api.listActiveCommitments as jest.Mock).mockResolvedValue([ + makeCommitment({ account_name: 'CUDly host (acct-1234)', account_id: 'acct-1234' }), + ]); + + await loadActiveCommitments(); + + const accountCell = document.querySelector('#active-commitments-list tbody tr td:nth-child(2)'); + expect(accountCell?.textContent).toBe('CUDly host (acct-1234)'); + }); + test('falls back to monospaced account_id when account_name is missing', async () => { (api.listActiveCommitments as jest.Mock).mockResolvedValue([ makeCommitment({ account_name: undefined, account_id: 'acc-no-name' }), diff --git a/frontend/src/inventory.ts b/frontend/src/inventory.ts index 81d80de63..43926d1c2 100644 --- a/frontend/src/inventory.ts +++ b/frontend/src/inventory.ts @@ -322,7 +322,9 @@ function mountInventoryAmortizeCheckbox(): void { function buildAccountCell(c: api.InventoryCommitment): HTMLTableCellElement { const td = document.createElement('td'); - if (c.account_name) { + if (c.account_name && c.account_name.includes(c.account_id)) { + td.textContent = c.account_name; + } else if (c.account_name) { td.appendChild(document.createTextNode(c.account_name + ' ')); const id = document.createElement('span'); id.className = 'monospace'; From 8efab3553d18587e18abd3b88ed2c8c40f21541d Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:50:44 +0200 Subject: [PATCH 04/21] fix(frontend): add accessible names to planned purchase row actions The glyph-only run, pause, resume, edit and cancel buttons only had a title; add matching aria-labels. The RI Exchange settings button already has visible text. Closes #184 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/plans-permissions.test.ts | 3 +++ frontend/src/plans.ts | 10 +++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/__tests__/plans-permissions.test.ts b/frontend/src/__tests__/plans-permissions.test.ts index 383bf3699..53a4d835a 100644 --- a/frontend/src/__tests__/plans-permissions.test.ts +++ b/frontend/src/__tests__/plans-permissions.test.ts @@ -132,6 +132,9 @@ describe('Plans page permission gating (issue #365)', () => { expect(html).toContain('data-action="pause"'); expect(html).toContain('data-action="edit"'); expect(html).toContain('data-action="disable"'); + const buttons = Array.from(pp.querySelectorAll('td.actions button')); + expect(buttons.length).toBeGreaterThanOrEqual(4); + for (const b of buttons) expect(b.getAttribute('aria-label')).toBeTruthy(); }); }); diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index 74c22865d..cd04399bc 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -755,11 +755,11 @@ function renderPlannedPurchaseRow(purchase: PlannedPurchase): string { ${savingsCell} ${escapeHtml(purchase.status)} - ${canRunPurchase ? `` : ''} - ${canPauseOrResumePurchase && isPending ? `` : ''} - ${canPauseOrResumePurchase && isPaused ? `` : ''} - ${canEditPlan ? `` : ''} - ${canDisablePlan ? `` : ''} + ${canRunPurchase ? `` : ''} + ${canPauseOrResumePurchase && isPending ? `` : ''} + ${canPauseOrResumePurchase && isPaused ? `` : ''} + ${canEditPlan ? `` : ''} + ${canDisablePlan ? `` : ''} `; From 2016c920104153b5a8f03284594477f3724af556 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:52:27 +0200 Subject: [PATCH 05/21] fix(frontend): make the profile entry point a button The signed-in email was a span with a click listener, unreachable by keyboard or screen reader. Use a button reset to the same look; the placeholder aria-label is dropped once the email is shown so the name matches the visible text. Closes #715 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/html.test.ts | 6 ++++++ frontend/src/auth.ts | 1 + frontend/src/index.html | 2 +- frontend/src/styles/layout.css | 9 +++++++++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/html.test.ts b/frontend/src/__tests__/html.test.ts index 8be1d4622..183d0fe67 100644 --- a/frontend/src/__tests__/html.test.ts +++ b/frontend/src/__tests__/html.test.ts @@ -783,5 +783,11 @@ describe('HTML Structure', () => { // Link target must be the AWS console role-creation wizard. expect(link?.getAttribute('href')).toBe('https://console.aws.amazon.com/iam/home#/roles$new'); }); + + test('profile entry point is a keyboard-focusable button (#715)', () => { + const el = document.getElementById('user-email-display'); + expect(el?.tagName.toLowerCase()).toBe('button'); + expect(el?.getAttribute('type')).toBe('button'); + }); }); }); diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts index 64ad596e9..6258dd421 100644 --- a/frontend/src/auth.ts +++ b/frontend/src/auth.ts @@ -907,6 +907,7 @@ export function updateUserUI(): void { // Update the user email display with click-to-edit functionality if (userEmailEl) { userEmailEl.textContent = currentUser.email; + userEmailEl.removeAttribute('aria-label'); userEmailEl.title = 'Click to edit your profile'; userEmailEl.classList.add('cursor-pointer'); // Replace element to avoid duplicate listeners on repeated calls diff --git a/frontend/src/index.html b/frontend/src/index.html index 5d0c7b317..837e5c67d 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -41,7 +41,7 @@

CUDly

- + API Docs diff --git a/frontend/src/styles/layout.css b/frontend/src/styles/layout.css index 9b4491af8..55708f7ff 100644 --- a/frontend/src/styles/layout.css +++ b/frontend/src/styles/layout.css @@ -227,6 +227,15 @@ header h1 { opacity: 1; } +#user-email-display { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: inherit; +} + #logout-btn { padding: var(--cudly-sp-2) var(--cudly-sp-3); border-radius: var(--cudly-r-sm); From 6b96751227740d259c8ea0bd24d83910e36060dc Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:53:32 +0200 Subject: [PATCH 06/21] fix(frontend): highlight the nav from the URL before data loads init() now marks the sidebar item for the current path active before awaiting the user and permission fetches, instead of leaving the static Home highlight until switchTab ran. Extract setActiveNav from switchTab for this. Closes #187 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/app.test.ts | 12 ++++++++++++ frontend/src/app.ts | 6 +++++- frontend/src/navigation.ts | 15 ++++++++++----- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/frontend/src/__tests__/app.test.ts b/frontend/src/__tests__/app.test.ts index 731f4eafd..b20799121 100644 --- a/frontend/src/__tests__/app.test.ts +++ b/frontend/src/__tests__/app.test.ts @@ -35,6 +35,7 @@ jest.mock('../dashboard', () => ({ jest.mock('../navigation', () => ({ switchTab: jest.fn(), + setActiveNav: jest.fn(), applyTabFromPath: jest.fn().mockReturnValue('dashboard'), initRouter: jest.fn(), switchSettingsSubTab: jest.fn(), @@ -113,6 +114,17 @@ describe('App Module', () => { expect(auth.updateUserUI).toHaveBeenCalled(); }); + test('highlights the nav from the URL before any async fetch resolves (#187)', async () => { + (api.isAuthenticated as jest.Mock).mockReturnValue(true); + (api.getCurrentUser as jest.Mock).mockReturnValue(new Promise(() => {})); + (navigation.applyTabFromPath as jest.Mock).mockReturnValue('opportunities'); + + void init(); + await Promise.resolve(); + + expect(navigation.setActiveNav).toHaveBeenCalledWith('opportunities'); + }); + test('seeds the URL from canonicalTabPath before routing', async () => { (api.isAuthenticated as jest.Mock).mockReturnValue(true); (api.getCurrentUser as jest.Mock).mockResolvedValue({ id: 'user-1', email: 'test@example.com' }); diff --git a/frontend/src/app.ts b/frontend/src/app.ts index 0c8a39ce8..86f5b3659 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -7,7 +7,7 @@ import * as state from './state'; import { showLoginModal, showAdminSetupModal, showResetPasswordModal, updateUserUI } from './auth'; import { loadDashboard, setupDashboardHandlers } from './dashboard'; import { setupRecommendationsHandlers, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, getFanOutBuckets, clearFanOutBuckets, getExecuteMode, clearExecuteMode, fanOutBucketAccountLabel, getAccountName, type FanOutBucket } from './recommendations'; -import { switchTab, applyTabFromPath, initRouter, switchSettingsSubTab, canonicalTabPath } from './navigation'; +import { switchTab, setActiveNav, applyTabFromPath, initRouter, switchSettingsSubTab, canonicalTabPath } from './navigation'; import { savePlan, setupPlanHandlers, closePlanModal, openNewPlanModal, closePurchaseModal } from './plans'; import { saveGlobalSettings, setupSettingsHandlers, resetSettings } from './settings'; import { setupUserHandlers } from './users'; @@ -75,6 +75,10 @@ export async function init(): Promise { return; } + // The server-rendered markup marks Home active; correct it from the URL + // now instead of after the async fetches below. + setActiveNav(applyTabFromPath()); + if (!api.isAuthenticated()) { try { const publicInfo = await api.getPublicInfo(); diff --git a/frontend/src/navigation.ts b/frontend/src/navigation.ts index abc0408dc..dc71108a7 100644 --- a/frontend/src/navigation.ts +++ b/frontend/src/navigation.ts @@ -134,6 +134,15 @@ function confirmLeaveSettings(): Promise { }); } +/** Mark the sidebar item for tabName active, without loading the tab. */ +export function setActiveNav(tabName: string): void { + document.querySelectorAll('.tab-btn').forEach(btn => { + const isActive = btn.dataset['tab'] === tabName; + btn.classList.toggle('active', isActive); + btn.setAttribute('aria-selected', isActive ? 'true' : 'false'); + }); +} + export function switchTab(tabName: string, opts: SwitchTabOptions = {}): void { if (!isKnownKey(TABS, tabName)) tabName = 'home'; @@ -151,11 +160,7 @@ export function switchTab(tabName: string, opts: SwitchTabOptions = {}): void { return; } - document.querySelectorAll('.tab-btn').forEach(btn => { - const isActive = btn.dataset['tab'] === tabName; - btn.classList.toggle('active', isActive); - btn.setAttribute('aria-selected', isActive ? 'true' : 'false'); - }); + setActiveNav(tabName); document.querySelectorAll('.tab-content').forEach(content => { content.classList.toggle('active', content.id === `${tabName}-tab`); From e63c4e8dea379a99e54818ef9ea42e9d7d7d44c6 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 18:54:09 +0200 Subject: [PATCH 07/21] fix(frontend): focus a rendered element when opening the mobile drawer openDrawer picked the desktop-only sidebar toggle, which is display:none below the breakpoint, so focus stayed on the hamburger. Skip the toggle so the first nav link takes focus. Closes #216 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/mobile-nav.test.ts | 5 ++--- frontend/src/app.ts | 5 +++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/__tests__/mobile-nav.test.ts b/frontend/src/__tests__/mobile-nav.test.ts index 481894e78..3a920597a 100644 --- a/frontend/src/__tests__/mobile-nav.test.ts +++ b/frontend/src/__tests__/mobile-nav.test.ts @@ -27,6 +27,7 @@ function buildDOM(): void {