diff --git a/frontend/src/__tests__/auth.test.ts b/frontend/src/__tests__/auth.test.ts index 33bfa71cc..102f935a1 100644 --- a/frontend/src/__tests__/auth.test.ts +++ b/frontend/src/__tests__/auth.test.ts @@ -398,6 +398,8 @@ describe('Auth Module', () => { + Purchases + Inventory & Coverage `; }); @@ -466,6 +468,63 @@ describe('Auth Module', () => { }); }); + // issue #1000: requires-purchases nav gating + test('shows requires-purchases nav elements for a user with view:purchases', () => { + (state.getCurrentUser as jest.Mock).mockReturnValue({ + id: 'user-1', + email: 'user@example.com', + groups: [], + effectivePermissions: [{ action: 'view', resource: 'purchases' }], + }); + + updateUserUI(); + + const els = document.querySelectorAll('.requires-purchases'); + expect(els.length).toBeGreaterThan(0); + els.forEach(el => { + expect(el.classList.contains('visible')).toBe(true); + }); + }); + + test('hides requires-purchases nav elements for a read-only user without view:purchases', () => { + (state.getCurrentUser as jest.Mock).mockReturnValue({ + id: 'readonly-1', + email: 'readonly@example.com', + groups: [], + // READONLY_PERMS does not include view:purchases + effectivePermissions: [ + { action: 'view', resource: 'recommendations' }, + { action: 'view', resource: 'plans' }, + { action: 'view', resource: 'history' }, + ], + }); + + updateUserUI(); + + const els = document.querySelectorAll('.requires-purchases'); + expect(els.length).toBeGreaterThan(0); + els.forEach(el => { + expect(el.classList.contains('visible')).toBe(false); + }); + }); + + test('shows requires-purchases nav elements for an admin (admin:* covers view:purchases)', () => { + (state.getCurrentUser as jest.Mock).mockReturnValue({ + id: 'admin-1', + email: 'admin@example.com', + groups: [ADMINISTRATORS_GROUP_ID], + effectivePermissions: [{ action: 'admin', resource: '*' }], + }); + + updateUserUI(); + + const els = document.querySelectorAll('.requires-purchases'); + expect(els.length).toBeGreaterThan(0); + els.forEach(el => { + expect(el.classList.contains('visible')).toBe(true); + }); + }); + test('makes user email clickable', () => { (state.getCurrentUser as jest.Mock).mockReturnValue({ id: 'user-1', diff --git a/frontend/src/__tests__/navigation.test.ts b/frontend/src/__tests__/navigation.test.ts index 72eadf170..afa3f58da 100644 --- a/frontend/src/__tests__/navigation.test.ts +++ b/frontend/src/__tests__/navigation.test.ts @@ -33,6 +33,10 @@ jest.mock('../riexchange', () => ({ jest.mock('../auth', () => ({ isAdmin: jest.fn().mockReturnValue(true), })); +// Default: all users can view purchases. Individual tests override this. +jest.mock('../permissions', () => ({ + canAccess: jest.fn().mockReturnValue(true), +})); // Mock inventory so navigation tests stay focused on routing/history and // don't pull in the real fetch/render machinery. switchInventorySubSection // must still resolve+return the sub-section (default-first) because @@ -55,6 +59,8 @@ import { loadPlans } from '../plans'; import { initHistoryDateRange, loadHistory } from '../history'; import { loadGlobalSettings } from '../settings'; import { loadAutomationSettings } from '../riexchange'; +import { canAccess } from '../permissions'; +import { loadInventory } from '../inventory'; describe('Navigation Module', () => { beforeEach(() => { @@ -253,6 +259,56 @@ describe('Navigation Module', () => { const dashboardBtn = document.querySelector('[data-tab="home"]'); expect(dashboardBtn?.classList.contains('active')).toBe(true); }); + + // issue #1000: users without view:purchases must not trigger API calls + // and must see the no-access placeholder instead of the real page. + describe('view:purchases gate', () => { + beforeEach(() => { + // Default mock returns true; override to false for these tests. + (canAccess as jest.Mock).mockReturnValue(false); + }); + + afterEach(() => { + // Restore default so other tests are unaffected. + (canAccess as jest.Mock).mockReturnValue(true); + }); + + test('purchases tab: does not fire loadHistory or initHistoryDateRange when user lacks view:purchases', () => { + switchTab('purchases'); + expect(initHistoryDateRange).not.toHaveBeenCalled(); + expect(loadHistory).not.toHaveBeenCalled(); + }); + + test('purchases tab: renders no-access placeholder when user lacks view:purchases', () => { + switchTab('purchases'); + const container = document.getElementById('purchases-tab'); + expect(container?.textContent).toContain('You do not have access to this page'); + }); + + test('inventory tab: does not fire loadInventory when user lacks view:purchases', () => { + switchTab('inventory'); + expect(loadInventory).not.toHaveBeenCalled(); + }); + + test('inventory tab: renders no-access placeholder when user lacks view:purchases', () => { + switchTab('inventory'); + const container = document.getElementById('inventory-tab'); + expect(container?.textContent).toContain('You do not have access to this page'); + }); + + test('purchases tab: fires normally when user has view:purchases', () => { + (canAccess as jest.Mock).mockReturnValue(true); + switchTab('purchases'); + expect(initHistoryDateRange).toHaveBeenCalled(); + expect(loadHistory).toHaveBeenCalled(); + }); + + test('inventory tab: fires normally when user has view:purchases', () => { + (canAccess as jest.Mock).mockReturnValue(true); + switchTab('inventory'); + expect(loadInventory).toHaveBeenCalled(); + }); + }); }); describe('switchSettingsSubTab', () => { diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts index 7b67d3d9a..c9ce44408 100644 --- a/frontend/src/auth.ts +++ b/frontend/src/auth.ts @@ -6,7 +6,7 @@ import * as api from './api'; import * as state from './state'; import { escapeHtml } from './utils'; import { openModal, closeModal } from './modal'; -import { isAdmin as permissionsIsAdmin } from './permissions'; +import { isAdmin as permissionsIsAdmin, canAccess } from './permissions'; // Login rate limiting let lastLoginAttempt = 0; @@ -934,6 +934,15 @@ export function updateUserUI(): void { document.querySelectorAll('.admin-only').forEach(el => { el.classList.toggle('visible', adminOnly); }); + + // Gate nav entries and page containers that require view:purchases. + // Mirrors the admin-only pattern: the CSS class hides by default; + // .visible makes the element visible. Direct URL navigation into a + // gated page is handled in navigation.ts switchTab(). + const canViewPurchases = canAccess('view', 'purchases'); + document.querySelectorAll('.requires-purchases').forEach(el => { + el.classList.toggle('visible', canViewPurchases); + }); } else { // Hide user info when not logged in if (userInfoEl) { diff --git a/frontend/src/index.html b/frontend/src/index.html index c3a899706..456239f98 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -61,11 +61,11 @@

CUDly

Plans - + Purchases - + Inventory & Coverage diff --git a/frontend/src/navigation.ts b/frontend/src/navigation.ts index 505dfce90..af668c21b 100644 --- a/frontend/src/navigation.ts +++ b/frontend/src/navigation.ts @@ -18,6 +18,7 @@ import { DEFAULT_INVENTORY_SUB_SECTION, } from './inventory'; import { isAdmin } from './auth'; +import { canAccess } from './permissions'; interface TabMeta { title: string; @@ -70,6 +71,22 @@ interface SwitchTabOptions { skipDirtyGuard?: boolean; } +/** + * Render a friendly no-access placeholder inside a tab container. + * Called when the user navigates to a tab they lack the required permission + * for (issue #1000). Replaces any prior content so no stale API error + * banners are left behind. + */ +function renderNoAccess(tabId: string): void { + const container = document.getElementById(tabId); + if (!container) return; + const p = document.createElement('p'); + p.className = 'empty-state'; + p.textContent = + 'You do not have access to this page. Contact your administrator if you need access.'; + container.replaceChildren(p); +} + /** * Switch between tabs */ @@ -108,6 +125,10 @@ export function switchTab(tabName: string, opts: SwitchTabOptions = {}): void { void loadPlans(); break; case 'purchases': + if (!canAccess('view', 'purchases')) { + renderNoAccess(`${tabName}-tab`); + break; + } initHistoryDateRange(); void loadSavingsHistory(); // Auto-load history so the Approval queue card and the Purchase @@ -121,6 +142,10 @@ export function switchTab(tabName: string, opts: SwitchTabOptions = {}): void { switchSettingsSubTab(getSettingsSubTabFromPath(), { push: false }); break; case 'inventory': + if (!canAccess('view', 'purchases')) { + renderNoAccess(`${tabName}-tab`); + break; + } loadInventory(getInventorySubTabFromPath()); break; } diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 46f244ee7..8a8f4d33b 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -375,6 +375,20 @@ body { display: revert; } +/* + * Nav entries and page containers that require the view:purchases permission + * (issue #1000). Hidden by default; toggled visible by updateUserUI() in + * auth.ts when canAccess('view', 'purchases') returns true. Uses the same + * display:revert trick as .admin-only so sidebar flex layout is preserved. + */ +.requires-purchases { + display: none; +} + +.requires-purchases.visible { + display: revert; +} + /* Honour the user's OS-level reduced-motion preference (issue #340). * Transitions and CSS animations get effectively disabled — duration * collapses to 0.01ms so animation-end events still fire (some JS