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 { 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(<AdminTab botState={DUMMY_BOT_STATE} socket={socketMock as any} />);
expect(screen.getByText(/Access Denied/i)).toBeInTheDocument();
expect(fetchDynamicConfigItemsMock).not.toHaveBeenCalled();
});
it('listens for debug logs and slices them', async () => {

View File

@ -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);

View File

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