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
59 changes: 59 additions & 0 deletions frontend/src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,8 @@ describe('Auth Module', () => {
<button id="logout-btn">Logout</button>
</div>
<div class="admin-only hidden">Admin content</div>
<a class="requires-purchases" id="purchases-tab-btn">Purchases</a>
<a class="requires-purchases" id="inventory-tab-btn">Inventory &amp; Coverage</a>
`;
});

Expand Down Expand Up @@ -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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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',
Expand Down
56 changes: 56 additions & 0 deletions frontend/src/__tests__/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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', () => {
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -934,6 +934,15 @@ export function updateUserUI(): void {
document.querySelectorAll<HTMLElement>('.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<HTMLElement>('.requires-purchases').forEach(el => {
el.classList.toggle('visible', canViewPurchases);
});
} else {
// Hide user info when not logged in
if (userInfoEl) {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,11 @@ <h1>CUDly</h1>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sidebar-icon"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M9 12l2 2 4-4"/></svg>
<span class="sidebar-label">Plans</span>
</a>
<a class="tab-btn" id="purchases-tab-btn" href="/purchases" data-tab="purchases" role="tab" aria-selected="false" aria-controls="purchases-tab" aria-label="Purchases">
<a class="tab-btn requires-purchases" id="purchases-tab-btn" href="/purchases" data-tab="purchases" role="tab" aria-selected="false" aria-controls="purchases-tab" aria-label="Purchases">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sidebar-icon"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/></svg>
<span class="sidebar-label">Purchases</span>
</a>
<a class="tab-btn" id="inventory-tab-btn" href="/inventory" data-tab="inventory" role="tab" aria-selected="false" aria-controls="inventory-tab" aria-label="Inventory &amp; Coverage">
<a class="tab-btn requires-purchases" id="inventory-tab-btn" href="/inventory" data-tab="inventory" role="tab" aria-selected="false" aria-controls="inventory-tab" aria-label="Inventory &amp; Coverage">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sidebar-icon"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27,6.96 12,12.01 20.73,6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
<span class="sidebar-label">Inventory &amp; Coverage</span>
</a>
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
DEFAULT_INVENTORY_SUB_SECTION,
} from './inventory';
import { isAdmin } from './auth';
import { canAccess } from './permissions';

interface TabMeta {
title: string;
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/styles/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading