fix(web): guard admin config from non-admin users

This commit is contained in:
root 2026-05-05 21:38:56 +00:00
parent 2db27ef686
commit db29a3f6b9
6 changed files with 67 additions and 4 deletions

View File

@ -4,6 +4,9 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { AdminTab } from './AdminTab'; import { AdminTab } from './AdminTab';
const { fetchDynamicConfigItemsMock } = vi.hoisted(() => ({
fetchDynamicConfigItemsMock: vi.fn()
}));
const { authState, socketMock } = vi.hoisted(() => ({ const { authState, socketMock } = vi.hoisted(() => ({
authState: { authState: {
@ -20,6 +23,11 @@ vi.mock('../components/AuthContext', () => ({
useAuth: () => authState useAuth: () => authState
})); }));
vi.mock('../lib/dynamicConfigApi', () => ({
fetchDynamicConfigItems: fetchDynamicConfigItemsMock,
upsertDynamicConfigItems: vi.fn()
}));
vi.mock('../hooks/useWebSocket', () => ({ vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: () => ({ useWebSocket: () => ({
socket: socketMock, socket: socketMock,
@ -40,6 +48,7 @@ describe('AdminTab coverage', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
authState.profile.role = 'admin'; authState.profile.role = 'admin';
fetchDynamicConfigItemsMock.mockResolvedValue([]);
}); });
@ -47,6 +56,7 @@ describe('AdminTab coverage', () => {
authState.profile.role = 'user'; authState.profile.role = 'user';
render(<AdminTab botState={DUMMY_BOT_STATE} socket={socketMock as any} />); render(<AdminTab botState={DUMMY_BOT_STATE} socket={socketMock as any} />);
expect(screen.getByText(/Access Denied/i)).toBeInTheDocument(); expect(screen.getByText(/Access Denied/i)).toBeInTheDocument();
expect(fetchDynamicConfigItemsMock).not.toHaveBeenCalled();
}); });
it('listens for debug logs and slices them', async () => { it('listens for debug logs and slices them', async () => {

View File

@ -227,6 +227,8 @@ export const AdminTab = ({ botState, socket }: AdminTabProps) => {
}; };
React.useEffect(() => { React.useEffect(() => {
if (profile?.role !== 'admin') return;
const fetchConfig = async () => { const fetchConfig = async () => {
try { try {
const apiUrl = tradingRuntime.tradingApiUrl; const apiUrl = tradingRuntime.tradingApiUrl;
@ -266,7 +268,7 @@ export const AdminTab = ({ botState, socket }: AdminTabProps) => {
fetchConfig(); fetchConfig();
fetchDbSyncSettings(); fetchDbSyncSettings();
}, []); }, [profile?.role]);
const handleUpdateDbSync = async () => { const handleUpdateDbSync = async () => {
setIsDbSyncLoading(true); setIsDbSyncLoading(true);

View File

@ -5,13 +5,21 @@ import userEvent from '@testing-library/user-event';
import { ConfigTab } from './ConfigTab'; import { ConfigTab } from './ConfigTab';
const { const {
authState,
fetchDynamicConfigItemsMock, fetchDynamicConfigItemsMock,
upsertDynamicConfigItemsMock upsertDynamicConfigItemsMock
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
authState: {
profile: { role: 'admin' } as any
},
fetchDynamicConfigItemsMock: vi.fn(), fetchDynamicConfigItemsMock: vi.fn(),
upsertDynamicConfigItemsMock: vi.fn() upsertDynamicConfigItemsMock: vi.fn()
})); }));
vi.mock('../components/AuthContext', () => ({
useAuth: () => authState
}));
vi.mock('../lib/dynamicConfigApi', () => ({ vi.mock('../lib/dynamicConfigApi', () => ({
fetchDynamicConfigItems: fetchDynamicConfigItemsMock, fetchDynamicConfigItems: fetchDynamicConfigItemsMock,
upsertDynamicConfigItems: upsertDynamicConfigItemsMock upsertDynamicConfigItems: upsertDynamicConfigItemsMock
@ -19,6 +27,7 @@ vi.mock('../lib/dynamicConfigApi', () => ({
describe('ConfigTab DOM behavior', () => { describe('ConfigTab DOM behavior', () => {
beforeEach(() => { beforeEach(() => {
authState.profile.role = 'admin';
fetchDynamicConfigItemsMock.mockReset(); fetchDynamicConfigItemsMock.mockReset();
upsertDynamicConfigItemsMock.mockReset(); upsertDynamicConfigItemsMock.mockReset();
fetchDynamicConfigItemsMock.mockResolvedValue([ fetchDynamicConfigItemsMock.mockResolvedValue([
@ -94,4 +103,13 @@ describe('ConfigTab DOM behavior', () => {
expect(screen.getByText(/Sync failed: write failed/)).toBeInTheDocument(); expect(screen.getByText(/Sync failed: write failed/)).toBeInTheDocument();
}); });
}, 20000); }, 20000);
it('denies access to non-admin users without fetching dynamic config', () => {
authState.profile.role = 'user';
render(<ConfigTab />);
expect(screen.getByText(/Access Denied/i)).toBeInTheDocument();
expect(fetchDynamicConfigItemsMock).not.toHaveBeenCalled();
});
}); });

View File

@ -2,6 +2,7 @@ import React from 'react';
import { AlertCircle, CheckCircle2, LayoutDashboard, Info, Zap, Key, ShieldAlert } from 'lucide-react'; import { AlertCircle, CheckCircle2, LayoutDashboard, Info, Zap, Key, ShieldAlert } from 'lucide-react';
import { clearBacktestRuntimeFlagCache } from '../backtest/flags'; import { clearBacktestRuntimeFlagCache } from '../backtest/flags';
import { fetchDynamicConfigItems, upsertDynamicConfigItems } from '../lib/dynamicConfigApi'; import { fetchDynamicConfigItems, upsertDynamicConfigItems } from '../lib/dynamicConfigApi';
import { useAuth } from '../components/AuthContext';
import { BACKTEST_FLAG_KEYS } from '../../../shared/feature-flags.js'; import { BACKTEST_FLAG_KEYS } from '../../../shared/feature-flags.js';
interface ConfigItem { interface ConfigItem {
@ -49,6 +50,8 @@ export function buildConfigUpsertPayload(configs: ConfigItem[], timestampIso?: s
} }
export const ConfigTab = () => { export const ConfigTab = () => {
const { profile } = useAuth();
const isAdmin = profile?.role === 'admin';
const [configs, setConfigs] = React.useState<ConfigItem[]>([]); const [configs, setConfigs] = React.useState<ConfigItem[]>([]);
const [originalConfigs, setOriginalConfigs] = React.useState<ConfigItem[]>([]); const [originalConfigs, setOriginalConfigs] = React.useState<ConfigItem[]>([]);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
@ -78,8 +81,12 @@ export const ConfigTab = () => {
}; };
React.useEffect(() => { React.useEffect(() => {
if (!isAdmin) {
setLoading(false);
return;
}
fetchConfigs(); fetchConfigs();
}, []); }, [isAdmin]);
const handleChange = (key: string, value: string) => { const handleChange = (key: string, value: string) => {
setConfigs(prev => prev.map(item => item.key === key ? { ...item, value } : item)); setConfigs(prev => prev.map(item => item.key === key ? { ...item, value } : item));
@ -133,6 +140,20 @@ export const ConfigTab = () => {
const hasChanges = hasConfigChanges(configs, originalConfigs); const hasChanges = hasConfigChanges(configs, originalConfigs);
if (!isAdmin) {
return (
<div className="bg-[#14151a] border border-red-500/20 p-8 rounded-3xl">
<div className="max-w-xl">
<p className="text-[11px] font-black uppercase tracking-[0.3em] text-red-400">Restricted</p>
<h2 className="text-2xl font-black text-white mt-3">Access Denied</h2>
<p className="text-sm text-zinc-400 mt-3">
Global bot configuration is limited to administrator accounts.
</p>
</div>
</div>
);
}
if (loading) { if (loading) {
return ( return (
<div className="flex flex-col items-center justify-center h-64 text-gray-500 animate-pulse"> <div className="flex flex-col items-center justify-center h-64 text-gray-500 animate-pulse">

View File

@ -60,4 +60,16 @@ describe('SettingsView legacy surface contrast', () => {
await user.click(screen.getByRole('button', { name: 'Admin Panel' })); await user.click(screen.getByRole('button', { name: 'Admin Panel' }));
expect(screen.getByText('Admin panel content')).toBeInTheDocument(); expect(screen.getByText('Admin panel content')).toBeInTheDocument();
}); });
it('hides admin-only sections for non-admin users', () => {
render(
<AppContext.Provider value={{ ...appContext, isAdmin: false, profile: { role: 'user' } as any }}>
<SettingsView />
</AppContext.Provider>,
);
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();
});
}); });

View File

@ -10,7 +10,7 @@ export function SettingsView() {
const { botState, isAdmin, socket } = useAppContext(); const { botState, isAdmin, socket } = useAppContext();
const sections: SettingsSection[] = [ const sections: SettingsSection[] = [
'Account', 'Account',
'Bot Config', ...(isAdmin ? ['Bot Config' as SettingsSection] : []),
...(isAdmin ? ['Admin Panel' as SettingsSection] : []), ...(isAdmin ? ['Admin Panel' as SettingsSection] : []),
]; ];
const [section, setSection] = useState<SettingsSection>('Account'); const [section, setSection] = useState<SettingsSection>('Account');
@ -57,7 +57,7 @@ export function SettingsView() {
}} }}
> >
{section === 'Account' && <SettingsTab botState={botState} />} {section === 'Account' && <SettingsTab botState={botState} />}
{section === 'Bot Config' && <ConfigTab />} {section === 'Bot Config' && isAdmin && <ConfigTab />}
{section === 'Admin Panel' && isAdmin && <AdminTab botState={botState} socket={socket} />} {section === 'Admin Panel' && isAdmin && <AdminTab botState={botState} socket={socket} />}
</div> </div>
</div> </div>