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 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 () => {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user