diff --git a/decnet_web/src/components/Config.tsx b/decnet_web/src/components/Config.tsx index 1e8bf94e..78158bf4 100644 --- a/decnet_web/src/components/Config.tsx +++ b/decnet_web/src/components/Config.tsx @@ -1,206 +1,37 @@ import React, { useEffect, useState } from 'react'; -import api from '../utils/api'; -import { Settings, Users, Sliders, Trash2, UserPlus, Key, Save, Shield, AlertTriangle, Palette, Activity } from '../icons'; +import { + Settings, Users, Sliders, Shield, Palette, Activity, +} from '../icons'; import { useToast } from './Toasts/useToast'; import RuleStateControls from './RuleStateControls'; import './Dashboard.css'; import './Config.css'; - -import type { ConfigData, ConfigTab } from './Config/types'; +import type { ConfigTab } from './Config/types'; +import { useConfig } from './Config/useConfig'; import { WorkersPanel } from './Config/WorkersPanel'; +import { LimitsTab } from './Config/tabs/LimitsTab'; +import { UsersTab } from './Config/tabs/UsersTab'; +import { GlobalsTab } from './Config/tabs/GlobalsTab'; +import { AppearanceTab } from './Config/tabs/AppearanceTab'; const Config: React.FC = () => { - const [config, setConfig] = useState(null); - const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState('limits'); - const [accent, setAccent] = useState<'matrix' | 'violet'>(() => { - try { - const raw = localStorage.getItem('decnet_tweaks'); - if (raw) { - const parsed = JSON.parse(raw); - if (parsed?.accent === 'violet') return 'violet'; - } - } catch { /* noop */ } - return 'matrix'; - }); + const { + config, loading, isAdmin, + setDeploymentLimit, setGlobalMutationInterval, + addUser, deleteUser, setUserRole, resetUserPassword, + reinit, + } = useConfig(); const { push: pushToast } = useToast(); - const handleAccentChange = (value: 'matrix' | 'violet') => { - setAccent(value); - let existing: Record = {}; - try { - const raw = localStorage.getItem('decnet_tweaks'); - if (raw) existing = JSON.parse(raw) ?? {}; - } catch { existing = {}; } - localStorage.setItem('decnet_tweaks', JSON.stringify({ ...existing, accent: value })); - document.documentElement.setAttribute('data-accent', value); - pushToast({ text: `ACCENT · ${value.toUpperCase()}`, icon: 'check-circle', tone: 'violet' }); - }; + const [activeTab, setActiveTab] = useState('limits'); - // Deployment limit state - const [limitInput, setLimitInput] = useState(''); - const [limitSaving, setLimitSaving] = useState(false); - const [limitMsg, setLimitMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - - // Global mutation interval state - const [intervalInput, setIntervalInput] = useState(''); - const [intervalSaving, setIntervalSaving] = useState(false); - const [intervalMsg, setIntervalMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - - // Add user form state - const [newUsername, setNewUsername] = useState(''); - const [newPassword, setNewPassword] = useState(''); - const [newRole, setNewRole] = useState<'admin' | 'viewer'>('viewer'); - const [addingUser, setAddingUser] = useState(false); - const [userMsg, setUserMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - - // Confirm delete state - const [confirmDelete, setConfirmDelete] = useState(null); - - // Reset password state - const [resetTarget, setResetTarget] = useState(null); - const [resetPassword, setResetPassword] = useState(''); - - // Reinit state - const [confirmReinit, setConfirmReinit] = useState(false); - const [reiniting, setReiniting] = useState(false); - const [reinitMsg, setReinitMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - - const isAdmin = config?.role === 'admin'; - - const fetchConfig = async () => { - try { - const res = await api.get('/config'); - setConfig(res.data); - setLimitInput(String(res.data.deployment_limit)); - setIntervalInput(res.data.global_mutation_interval); - } catch (err) { - console.error('Failed to fetch config', err); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchConfig(); - }, []); - - // If server didn't send users, force tab away from users + // If server didn't send users, force tab away from users. useEffect(() => { if (config && !config.users && activeTab === 'users') { setActiveTab('limits'); } }, [config, activeTab]); - const handleSaveLimit = async () => { - const val = parseInt(limitInput); - if (isNaN(val) || val < 1 || val > 500) { - setLimitMsg({ type: 'error', text: 'VALUE MUST BE 1-500' }); - return; - } - setLimitSaving(true); - setLimitMsg(null); - try { - await api.put('/config/deployment-limit', { deployment_limit: val }); - setLimitMsg({ type: 'success', text: 'DEPLOYMENT LIMIT UPDATED' }); - fetchConfig(); - } catch (err: any) { - setLimitMsg({ type: 'error', text: err.response?.data?.detail || 'UPDATE FAILED' }); - } finally { - setLimitSaving(false); - } - }; - - const handleSaveInterval = async () => { - if (!/^[1-9]\d*[mdMyY]$/.test(intervalInput)) { - setIntervalMsg({ type: 'error', text: 'INVALID FORMAT (e.g. 30m, 1d, 6M)' }); - return; - } - setIntervalSaving(true); - setIntervalMsg(null); - try { - await api.put('/config/global-mutation-interval', { global_mutation_interval: intervalInput }); - setIntervalMsg({ type: 'success', text: 'MUTATION INTERVAL UPDATED' }); - fetchConfig(); - } catch (err: any) { - setIntervalMsg({ type: 'error', text: err.response?.data?.detail || 'UPDATE FAILED' }); - } finally { - setIntervalSaving(false); - } - }; - - const handleAddUser = async (e: React.FormEvent) => { - e.preventDefault(); - if (!newUsername.trim() || !newPassword.trim()) return; - setAddingUser(true); - setUserMsg(null); - try { - await api.post('/config/users', { - username: newUsername.trim(), - password: newPassword, - role: newRole, - }); - setNewUsername(''); - setNewPassword(''); - setNewRole('viewer'); - setUserMsg({ type: 'success', text: 'USER CREATED' }); - fetchConfig(); - } catch (err: any) { - setUserMsg({ type: 'error', text: err.response?.data?.detail || 'CREATE FAILED' }); - } finally { - setAddingUser(false); - } - }; - - const handleDeleteUser = async (uuid: string) => { - try { - await api.delete(`/config/users/${uuid}`); - setConfirmDelete(null); - fetchConfig(); - } catch (err: any) { - alert(err.response?.data?.detail || 'Delete failed'); - } - }; - - const handleRoleChange = async (uuid: string, role: string) => { - try { - await api.put(`/config/users/${uuid}/role`, { role }); - fetchConfig(); - } catch (err: any) { - alert(err.response?.data?.detail || 'Role update failed'); - } - }; - - const handleResetPassword = async (uuid: string) => { - if (!resetPassword.trim() || resetPassword.length < 8) { - alert('Password must be at least 8 characters'); - return; - } - try { - await api.put(`/config/users/${uuid}/reset-password`, { new_password: resetPassword }); - setResetTarget(null); - setResetPassword(''); - fetchConfig(); - } catch (err: any) { - alert(err.response?.data?.detail || 'Password reset failed'); - } - }; - - const handleReinit = async () => { - setReiniting(true); - setReinitMsg(null); - try { - const res = await api.delete('/config/reinit'); - const d = res.data.deleted; - setReinitMsg({ type: 'success', text: `PURGED: ${d.logs} logs, ${d.bounties} bounties, ${d.attackers} attacker profiles` }); - setConfirmReinit(false); - } catch (err: any) { - setReinitMsg({ type: 'error', text: err.response?.data?.detail || 'REINIT FAILED' }); - } finally { - setReiniting(false); - } - }; - if (loading) { return (
@@ -219,15 +50,19 @@ const Config: React.FC = () => { ); } - const tabs: { key: string; label: string; icon: React.ReactNode }[] = [ + const tabs: { key: ConfigTab; label: string; icon: React.ReactNode }[] = [ { key: 'limits', label: 'DEPLOYMENT LIMITS', icon: }, ...(config.users - ? [{ key: 'users', label: 'USER MANAGEMENT', icon: }] + ? [{ key: 'users' as const, label: 'USER MANAGEMENT', icon: }] : []), { key: 'globals', label: 'GLOBAL VALUES', icon: }, { key: 'appearance', label: 'APPEARANCE', icon: }, - ...(isAdmin ? [{ key: 'workers', label: 'WORKERS', icon: }] : []), - ...(isAdmin ? [{ key: 'ttp', label: 'TTP RULES', icon: }] : []), + ...(isAdmin + ? [{ key: 'workers' as const, label: 'WORKERS', icon: }] + : []), + ...(isAdmin + ? [{ key: 'ttp' as const, label: 'TTP RULES', icon: }] + : []), ]; return ( @@ -244,7 +79,7 @@ const Config: React.FC = () => {
- {/* DEPLOYMENT LIMITS TAB */} {activeTab === 'limits' && ( -
-
- MAXIMUM DECKIES PER DEPLOYMENT - {isAdmin ? ( - <> -
- setLimitInput(e.target.value)} - /> -
- {[10, 50, 100, 200].map((v) => ( - - ))} -
- -
- {limitMsg && ( - - {limitMsg.text} - - )} - - ) : ( - {config.deployment_limit} - )} -
-
+ )} - {/* USER MANAGEMENT TAB (only if server sent users) */} {activeTab === 'users' && config.users && ( -
-
- - - - - - - - - - - {config.users.map((user) => ( - - - - - - - ))} - -
USERNAMEROLESTATUSACTIONS
{user.username} - {user.role.toUpperCase()} - - {user.must_change_password && ( - MUST CHANGE PASSWORD - )} - -
- {/* Role change dropdown */} - - - {/* Reset password */} - {resetTarget === user.uuid ? ( -
- setResetPassword(e.target.value)} - style={{ width: '140px' }} - /> - - -
- ) : ( - - )} - - {/* Delete */} - {confirmDelete === user.uuid ? ( -
- CONFIRM? - - -
- ) : ( - - )} -
-
-
- -
-
-
- - setNewUsername(e.target.value)} - required - minLength={1} - maxLength={64} - /> -
-
- - setNewPassword(e.target.value)} - required - minLength={8} - maxLength={72} - /> -
-
- - -
- - {userMsg && ( - - {userMsg.text} - - )} -
-
-
+ )} - {/* GLOBAL VALUES TAB */} {activeTab === 'globals' && ( -
-
- GLOBAL MUTATION INTERVAL - {isAdmin ? ( - <> -
- setIntervalInput(e.target.value)} - placeholder="30m" - /> - -
- - FORMAT: <number><unit> — m=minutes, d=days, M=months, y=years (e.g. 30m, 7d, 1M) - - {intervalMsg && ( - - {intervalMsg.text} - - )} - - ) : ( - {config.global_mutation_interval} - )} -
-
+ )} - {/* WORKERS TAB (admin only, server-gated too) */} + {activeTab === 'appearance' && } + {activeTab === 'workers' && isAdmin && ( )} - {/* TTP RULES TAB — admin only. RuleStateControls also self-gates - on /config?.role so a state leak can't render it. */} - {activeTab === 'ttp' && isAdmin && ( - - )} - - {/* APPEARANCE TAB */} - {activeTab === 'appearance' && ( -
-
- ACCENT COLOR -

- Swaps the UI accent (nav bars, hover glows, chip borders) between matrix-green and electric-violet. Persists per-browser. -

-
- {(['matrix', 'violet'] as const).map((value) => ( - - ))} -
-
-
- )} - - {/* DANGER ZONE — developer mode only, server-gated, shown on globals tab */} - {activeTab === 'globals' && config.developer_mode && ( -
-
- - - DANGER ZONE — DEVELOPER MODE - -

- Purge all logs, bounty vault entries, and attacker profiles. This action is irreversible. -

- {!confirmReinit ? ( - - ) : ( -
- THIS WILL DELETE ALL COLLECTED DATA. ARE YOU SURE? - - -
- )} - {reinitMsg && ( - - {reinitMsg.text} - - )} -
-
- )} + {/* RuleStateControls also self-gates on /config?.role so a state + leak can't render it. */} + {activeTab === 'ttp' && isAdmin && } ); }; - export default Config; diff --git a/decnet_web/vite.config.ts b/decnet_web/vite.config.ts index 618a28d7..e97b4405 100644 --- a/decnet_web/vite.config.ts +++ b/decnet_web/vite.config.ts @@ -15,14 +15,14 @@ export default defineConfig({ include: ['src/**/*.{ts,tsx}'], exclude: ['src/**/*.d.ts', 'src/test/**', 'src/main.tsx'], // Baseline floors. Each refactor PR raises these; never lower. - // Phase 3 (CanaryTokens split): page shell down from 1,334 to 210 - // LOC; hook + 3 modals + 3 list views + ui/types/helpers, 33 new - // tests. Suite: 28 files, 131 tests, 14.51% lines / 11.43% branches. + // Phase 4 (Config split): page shell down from 989 to 131 LOC; + // hook + WorkersPanel + 4 tab files, 25 new tests. Suite: + // 34 files, 156 tests, 17.73% lines / 13.85% branches. thresholds: { - lines: 14, - functions: 13, - branches: 11, - statements: 13, + lines: 17, + functions: 15, + branches: 13, + statements: 16, }, }, },