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', () => {
Admin content
+ 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
-
+
-
+
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