diff --git a/web/src/tabs/AdminTab.dom.test.tsx b/web/src/tabs/AdminTab.dom.test.tsx index a53326f..82ca634 100644 --- a/web/src/tabs/AdminTab.dom.test.tsx +++ b/web/src/tabs/AdminTab.dom.test.tsx @@ -4,6 +4,9 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { AdminTab } from './AdminTab'; +const { fetchDynamicConfigItemsMock } = vi.hoisted(() => ({ + fetchDynamicConfigItemsMock: vi.fn() +})); const { authState, socketMock } = vi.hoisted(() => ({ authState: { @@ -20,6 +23,11 @@ vi.mock('../components/AuthContext', () => ({ useAuth: () => authState })); +vi.mock('../lib/dynamicConfigApi', () => ({ + fetchDynamicConfigItems: fetchDynamicConfigItemsMock, + upsertDynamicConfigItems: vi.fn() +})); + vi.mock('../hooks/useWebSocket', () => ({ useWebSocket: () => ({ socket: socketMock, @@ -40,6 +48,7 @@ describe('AdminTab coverage', () => { beforeEach(() => { vi.clearAllMocks(); authState.profile.role = 'admin'; + fetchDynamicConfigItemsMock.mockResolvedValue([]); }); @@ -47,6 +56,7 @@ describe('AdminTab coverage', () => { authState.profile.role = 'user'; render(); expect(screen.getByText(/Access Denied/i)).toBeInTheDocument(); + expect(fetchDynamicConfigItemsMock).not.toHaveBeenCalled(); }); it('listens for debug logs and slices them', async () => { diff --git a/web/src/tabs/AdminTab.tsx b/web/src/tabs/AdminTab.tsx index c5ab87c..ee84c40 100644 --- a/web/src/tabs/AdminTab.tsx +++ b/web/src/tabs/AdminTab.tsx @@ -227,6 +227,8 @@ export const AdminTab = ({ botState, socket }: AdminTabProps) => { }; React.useEffect(() => { + if (profile?.role !== 'admin') return; + const fetchConfig = async () => { try { const apiUrl = tradingRuntime.tradingApiUrl; @@ -266,7 +268,7 @@ export const AdminTab = ({ botState, socket }: AdminTabProps) => { fetchConfig(); fetchDbSyncSettings(); - }, []); + }, [profile?.role]); const handleUpdateDbSync = async () => { setIsDbSyncLoading(true); diff --git a/web/src/tabs/ConfigTab.dom.test.tsx b/web/src/tabs/ConfigTab.dom.test.tsx index 8d3fd4d..1e6e4d6 100644 --- a/web/src/tabs/ConfigTab.dom.test.tsx +++ b/web/src/tabs/ConfigTab.dom.test.tsx @@ -5,13 +5,21 @@ import userEvent from '@testing-library/user-event'; import { ConfigTab } from './ConfigTab'; const { + authState, fetchDynamicConfigItemsMock, upsertDynamicConfigItemsMock } = vi.hoisted(() => ({ + authState: { + profile: { role: 'admin' } as any + }, fetchDynamicConfigItemsMock: vi.fn(), upsertDynamicConfigItemsMock: vi.fn() })); +vi.mock('../components/AuthContext', () => ({ + useAuth: () => authState +})); + vi.mock('../lib/dynamicConfigApi', () => ({ fetchDynamicConfigItems: fetchDynamicConfigItemsMock, upsertDynamicConfigItems: upsertDynamicConfigItemsMock @@ -19,6 +27,7 @@ vi.mock('../lib/dynamicConfigApi', () => ({ describe('ConfigTab DOM behavior', () => { beforeEach(() => { + authState.profile.role = 'admin'; fetchDynamicConfigItemsMock.mockReset(); upsertDynamicConfigItemsMock.mockReset(); fetchDynamicConfigItemsMock.mockResolvedValue([ @@ -94,4 +103,13 @@ describe('ConfigTab DOM behavior', () => { expect(screen.getByText(/Sync failed: write failed/)).toBeInTheDocument(); }); }, 20000); + + it('denies access to non-admin users without fetching dynamic config', () => { + authState.profile.role = 'user'; + + render(); + + expect(screen.getByText(/Access Denied/i)).toBeInTheDocument(); + expect(fetchDynamicConfigItemsMock).not.toHaveBeenCalled(); + }); }); diff --git a/web/src/tabs/ConfigTab.tsx b/web/src/tabs/ConfigTab.tsx index 6abd85c..339845a 100644 --- a/web/src/tabs/ConfigTab.tsx +++ b/web/src/tabs/ConfigTab.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { AlertCircle, CheckCircle2, LayoutDashboard, Info, Zap, Key, ShieldAlert } from 'lucide-react'; import { clearBacktestRuntimeFlagCache } from '../backtest/flags'; import { fetchDynamicConfigItems, upsertDynamicConfigItems } from '../lib/dynamicConfigApi'; +import { useAuth } from '../components/AuthContext'; import { BACKTEST_FLAG_KEYS } from '../../../shared/feature-flags.js'; interface ConfigItem { @@ -49,6 +50,8 @@ export function buildConfigUpsertPayload(configs: ConfigItem[], timestampIso?: s } export const ConfigTab = () => { + const { profile } = useAuth(); + const isAdmin = profile?.role === 'admin'; const [configs, setConfigs] = React.useState([]); const [originalConfigs, setOriginalConfigs] = React.useState([]); const [loading, setLoading] = React.useState(true); @@ -78,8 +81,12 @@ export const ConfigTab = () => { }; React.useEffect(() => { + if (!isAdmin) { + setLoading(false); + return; + } fetchConfigs(); - }, []); + }, [isAdmin]); const handleChange = (key: string, value: string) => { setConfigs(prev => prev.map(item => item.key === key ? { ...item, value } : item)); @@ -133,6 +140,20 @@ export const ConfigTab = () => { const hasChanges = hasConfigChanges(configs, originalConfigs); + if (!isAdmin) { + return ( +
+
+

Restricted

+

Access Denied

+

+ Global bot configuration is limited to administrator accounts. +

+
+
+ ); + } + if (loading) { return (
diff --git a/web/src/views/SettingsView.dom.test.tsx b/web/src/views/SettingsView.dom.test.tsx index 923c33a..b449406 100644 --- a/web/src/views/SettingsView.dom.test.tsx +++ b/web/src/views/SettingsView.dom.test.tsx @@ -60,4 +60,16 @@ describe('SettingsView legacy surface contrast', () => { await user.click(screen.getByRole('button', { name: 'Admin Panel' })); expect(screen.getByText('Admin panel content')).toBeInTheDocument(); }); + + it('hides admin-only sections for non-admin users', () => { + render( + + + , + ); + + expect(screen.getByRole('button', { name: 'Account' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Bot Config' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Admin Panel' })).not.toBeInTheDocument(); + }); }); diff --git a/web/src/views/SettingsView.tsx b/web/src/views/SettingsView.tsx index ef6e500..f524077 100644 --- a/web/src/views/SettingsView.tsx +++ b/web/src/views/SettingsView.tsx @@ -10,7 +10,7 @@ export function SettingsView() { const { botState, isAdmin, socket } = useAppContext(); const sections: SettingsSection[] = [ 'Account', - 'Bot Config', + ...(isAdmin ? ['Bot Config' as SettingsSection] : []), ...(isAdmin ? ['Admin Panel' as SettingsSection] : []), ]; const [section, setSection] = useState('Account'); @@ -57,7 +57,7 @@ export function SettingsView() { }} > {section === 'Account' && } - {section === 'Bot Config' && } + {section === 'Bot Config' && isAdmin && } {section === 'Admin Panel' && isAdmin && }