fix(web): guard admin config from non-admin users
This commit is contained in:
parent
2db27ef686
commit
db29a3f6b9
@ -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 () => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user