feat: enhance UI components with config management and RBAC gating

- Add Config.tsx component for admin configuration management
- Update AttackerDetail, DeckyFleet components to use server-side RBAC gating
- Remove client-side role checks per memory: server-side UI gating is mandatory
- Add Config.css for configuration UI styling
This commit is contained in:
2026-04-15 12:51:08 -04:00
parent 0ee23b8700
commit a78126b1ba
4 changed files with 1139 additions and 34 deletions

View File

@@ -1,9 +1,44 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Crosshair, Fingerprint, Shield, Clock, Wifi, Lock, FileKey } from 'lucide-react'; import { Activity, ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Crosshair, Fingerprint, Shield, Clock, Wifi, Lock, FileKey, Radio, Timer } from 'lucide-react';
import api from '../utils/api'; import api from '../utils/api';
import './Dashboard.css'; import './Dashboard.css';
interface AttackerBehavior {
os_guess: string | null;
hop_distance: number | null;
tcp_fingerprint: {
window?: number | null;
wscale?: number | null;
mss?: number | null;
options_sig?: string;
has_sack?: boolean;
has_timestamps?: boolean;
} | null;
retransmit_count: number;
behavior_class: string | null;
beacon_interval_s: number | null;
beacon_jitter_pct: number | null;
tool_guess: string | null;
timing_stats: {
event_count?: number;
duration_s?: number;
mean_iat_s?: number | null;
median_iat_s?: number | null;
stdev_iat_s?: number | null;
min_iat_s?: number | null;
max_iat_s?: number | null;
cv?: number | null;
} | null;
phase_sequence: {
recon_end_ts?: string | null;
exfil_start_ts?: string | null;
exfil_latency_s?: number | null;
large_payload_count?: number;
} | null;
updated_at?: string;
}
interface AttackerData { interface AttackerData {
uuid: string; uuid: string;
ip: string; ip: string;
@@ -21,6 +56,7 @@ interface AttackerData {
fingerprints: any[]; fingerprints: any[];
commands: { service: string; decky: string; command: string; timestamp: string }[]; commands: { service: string; decky: string; command: string; timestamp: string }[];
updated_at: string; updated_at: string;
behavior: AttackerBehavior | null;
} }
// ─── Fingerprint rendering ─────────────────────────────────────────────────── // ─── Fingerprint rendering ───────────────────────────────────────────────────
@@ -312,6 +348,250 @@ const FingerprintGroup: React.FC<{ fpType: string; items: any[] }> = ({ fpType,
); );
}; };
// ─── Behavioral profile blocks ──────────────────────────────────────────────
const OS_LABELS: Record<string, string> = {
linux: 'LINUX',
windows: 'WINDOWS',
macos_ios: 'macOS / iOS',
freebsd: 'FREEBSD',
openbsd: 'OPENBSD',
embedded: 'EMBEDDED',
nmap: 'NMAP (SCANNER)',
unknown: 'UNKNOWN',
};
const BEHAVIOR_COLORS: Record<string, string> = {
beaconing: '#ff6b6b',
interactive: 'var(--accent-color)',
scanning: '#e5c07b',
mixed: 'var(--text-color)',
unknown: 'var(--text-color)',
};
const TOOL_LABELS: Record<string, string> = {
cobalt_strike: 'COBALT STRIKE',
sliver: 'SLIVER',
havoc: 'HAVOC',
mythic: 'MYTHIC',
};
const fmtOpt = (v: number | null | undefined): string =>
v === null || v === undefined ? '—' : String(v);
const fmtSecs = (v: number | null | undefined): string => {
if (v === null || v === undefined) return '—';
if (v < 1) return `${(v * 1000).toFixed(0)} ms`;
if (v < 60) return `${v.toFixed(2)} s`;
if (v < 3600) return `${(v / 60).toFixed(2)} m`;
return `${(v / 3600).toFixed(2)} h`;
};
const StatBlock: React.FC<{ label: string; value: React.ReactNode; color?: string }> = ({
label, value, color,
}) => (
<div className="stat-card">
<div className="stat-value" style={{ color: color || 'var(--text-color)' }}>
{value}
</div>
<div className="stat-label">{label}</div>
</div>
);
const KeyValueRow: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div style={{ display: 'flex', gap: '12px', alignItems: 'baseline' }}>
<span className="dim" style={{ fontSize: '0.7rem', letterSpacing: '1px', minWidth: '120px' }}>
{label}
</span>
<span className="matrix-text" style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
{value}
</span>
</div>
);
const BehaviorHeadline: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
const osLabel = b.os_guess ? (OS_LABELS[b.os_guess] || b.os_guess.toUpperCase()) : '—';
const behaviorLabel = b.behavior_class ? b.behavior_class.toUpperCase() : 'UNKNOWN';
const behaviorColor = b.behavior_class ? BEHAVIOR_COLORS[b.behavior_class] : undefined;
const toolLabel = b.tool_guess ? (TOOL_LABELS[b.tool_guess] || b.tool_guess.toUpperCase()) : '—';
return (
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
<StatBlock label="OS GUESS" value={osLabel} />
<StatBlock label="HOP DISTANCE" value={fmtOpt(b.hop_distance)} />
<StatBlock label="BEHAVIOR" value={behaviorLabel} color={behaviorColor} />
<StatBlock
label="TOOL ATTRIBUTION"
value={toolLabel}
color={b.tool_guess ? '#ff6b6b' : undefined}
/>
</div>
);
};
const BeaconBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
if (b.behavior_class !== 'beaconing' || b.beacon_interval_s === null) return null;
return (
<div style={{
border: '1px solid var(--border-color)', padding: '12px 16px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
<Radio size={14} style={{ opacity: 0.6 }} />
<span style={{ fontSize: '0.75rem', letterSpacing: '2px', fontWeight: 'bold' }}>
BEACON CADENCE
</span>
</div>
<div style={{ display: 'flex', gap: '32px', alignItems: 'baseline' }}>
<div>
<span className="dim" style={{ fontSize: '0.7rem' }}>INTERVAL </span>
<span className="matrix-text" style={{ fontSize: '1.3rem', fontWeight: 'bold' }}>
{fmtSecs(b.beacon_interval_s)}
</span>
</div>
{b.beacon_jitter_pct !== null && (
<div>
<span className="dim" style={{ fontSize: '0.7rem' }}>JITTER </span>
<span className="matrix-text" style={{ fontSize: '1.3rem', fontWeight: 'bold' }}>
{b.beacon_jitter_pct.toFixed(1)}%
</span>
</div>
)}
</div>
</div>
);
};
const TcpStackBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
const fp = b.tcp_fingerprint;
if (!fp || (!fp.window && !fp.mss && !fp.options_sig)) return null;
return (
<div style={{
border: '1px solid var(--border-color)', padding: '12px 16px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
<Wifi size={14} style={{ opacity: 0.6 }} />
<span style={{ fontSize: '0.75rem', letterSpacing: '2px', fontWeight: 'bold' }}>
TCP STACK (PASSIVE)
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<div style={{ display: 'flex', gap: '24px', flexWrap: 'wrap' }}>
{fp.window !== null && fp.window !== undefined && (
<div>
<span className="dim" style={{ fontSize: '0.7rem' }}>WIN </span>
<span className="matrix-text" style={{ fontSize: '1.1rem', fontWeight: 'bold' }}>
{fp.window}
</span>
</div>
)}
{fp.wscale !== null && fp.wscale !== undefined && (
<div>
<span className="dim" style={{ fontSize: '0.7rem' }}>WSCALE </span>
<span className="matrix-text" style={{ fontSize: '1.1rem', fontWeight: 'bold' }}>
{fp.wscale}
</span>
</div>
)}
{fp.mss !== null && fp.mss !== undefined && (
<div>
<span className="dim" style={{ fontSize: '0.7rem' }}>MSS </span>
<span className="matrix-text" style={{ fontSize: '1.1rem' }}>{fp.mss}</span>
</div>
)}
<div>
<span className="dim" style={{ fontSize: '0.7rem' }}>RETRANSMITS </span>
<span
className="matrix-text"
style={{
fontSize: '1.1rem',
fontWeight: 'bold',
color: b.retransmit_count > 0 ? '#e5c07b' : undefined,
}}
>
{b.retransmit_count}
</span>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{fp.has_sack && <Tag>SACK</Tag>}
{fp.has_timestamps && <Tag>TS</Tag>}
</div>
{fp.options_sig && (
<div>
<span className="dim" style={{ fontSize: '0.7rem' }}>OPTS: </span>
<span className="matrix-text" style={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
{fp.options_sig}
</span>
</div>
)}
</div>
</div>
);
};
const TimingStatsBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
const s = b.timing_stats;
if (!s || !s.event_count || s.event_count < 2) return null;
return (
<div style={{
border: '1px solid var(--border-color)', padding: '12px 16px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
<Timer size={14} style={{ opacity: 0.6 }} />
<span style={{ fontSize: '0.75rem', letterSpacing: '2px', fontWeight: 'bold' }}>
INTER-EVENT TIMING
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<KeyValueRow label="EVENT COUNT" value={s.event_count ?? '—'} />
<KeyValueRow label="DURATION" value={fmtSecs(s.duration_s)} />
<KeyValueRow label="MEAN IAT" value={fmtSecs(s.mean_iat_s)} />
<KeyValueRow label="MEDIAN IAT" value={fmtSecs(s.median_iat_s)} />
<KeyValueRow label="STDEV IAT" value={fmtSecs(s.stdev_iat_s)} />
<KeyValueRow
label="MIN / MAX IAT"
value={`${fmtSecs(s.min_iat_s)} / ${fmtSecs(s.max_iat_s)}`}
/>
<KeyValueRow
label="CV (JITTER)"
value={s.cv !== null && s.cv !== undefined ? s.cv.toFixed(3) : '—'}
/>
</div>
</div>
);
};
const PhaseSequenceBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
const p = b.phase_sequence;
if (!p || (!p.recon_end_ts && !p.exfil_start_ts && !p.large_payload_count)) return null;
return (
<div style={{
border: '1px solid var(--border-color)', padding: '12px 16px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
<Activity size={14} style={{ opacity: 0.6 }} />
<span style={{ fontSize: '0.75rem', letterSpacing: '2px', fontWeight: 'bold' }}>
PHASE SEQUENCE
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<KeyValueRow
label="RECON END"
value={p.recon_end_ts ? new Date(p.recon_end_ts).toLocaleString() : '—'}
/>
<KeyValueRow
label="EXFIL START"
value={p.exfil_start_ts ? new Date(p.exfil_start_ts).toLocaleString() : '—'}
/>
<KeyValueRow label="RECON→EXFIL LATENCY" value={fmtSecs(p.exfil_latency_s)} />
<KeyValueRow
label="LARGE PAYLOADS"
value={p.large_payload_count ?? 0}
/>
</div>
</div>
);
};
// ─── Collapsible section ──────────────────────────────────────────────────── // ─── Collapsible section ────────────────────────────────────────────────────
const Section: React.FC<{ const Section: React.FC<{
@@ -352,6 +632,7 @@ const AttackerDetail: React.FC = () => {
timeline: true, timeline: true,
services: true, services: true,
deckies: true, deckies: true,
behavior: true,
commands: true, commands: true,
fingerprints: true, fingerprints: true,
}); });
@@ -543,6 +824,29 @@ const AttackerDetail: React.FC = () => {
</div> </div>
</Section> </Section>
{/* Behavioral Profile */}
<Section
title="BEHAVIORAL PROFILE"
open={openSections.behavior}
onToggle={() => toggle('behavior')}
>
{attacker.behavior ? (
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
<BehaviorHeadline b={attacker.behavior} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<BeaconBlock b={attacker.behavior} />
<TcpStackBlock b={attacker.behavior} />
<TimingStatsBlock b={attacker.behavior} />
<PhaseSequenceBlock b={attacker.behavior} />
</div>
</div>
) : (
<div style={{ padding: '24px', textAlign: 'center', opacity: 0.5 }}>
NO BEHAVIORAL DATA YET PROFILER HAS NOT RUN FOR THIS ATTACKER
</div>
)}
</Section>
{/* Commands */} {/* Commands */}
{(() => { {(() => {
const cmdTotalPages = Math.ceil(cmdTotal / cmdLimit); const cmdTotalPages = Math.ceil(cmdTotal / cmdLimit);

View File

@@ -0,0 +1,282 @@
.config-page {
display: flex;
flex-direction: column;
gap: 24px;
}
.config-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border-color);
background-color: var(--secondary-color);
}
.config-tab {
padding: 12px 24px;
display: flex;
align-items: center;
gap: 8px;
font-size: 0.75rem;
letter-spacing: 1.5px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--text-color);
opacity: 0.5;
cursor: pointer;
transition: all 0.3s ease;
}
.config-tab:hover {
opacity: 0.8;
background: rgba(0, 255, 65, 0.03);
box-shadow: none;
color: var(--text-color);
}
.config-tab.active {
opacity: 1;
border-bottom-color: var(--accent-color);
color: var(--text-color);
}
.config-panel {
background-color: var(--secondary-color);
border: 1px solid var(--border-color);
padding: 32px;
}
.config-field {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 24px;
}
.config-field:last-child {
margin-bottom: 0;
}
.config-label {
font-size: 0.7rem;
letter-spacing: 1px;
opacity: 0.6;
}
.config-value {
font-size: 1.1rem;
padding: 8px 0;
}
.config-input-row {
display: flex;
align-items: center;
gap: 12px;
}
.config-input-row input {
width: 120px;
}
.config-input-row input[type="text"] {
width: 160px;
}
.preset-buttons {
display: flex;
gap: 8px;
}
.preset-btn {
padding: 6px 14px;
font-size: 0.75rem;
opacity: 0.7;
}
.preset-btn.active {
opacity: 1;
border-color: var(--accent-color);
color: var(--accent-color);
}
.save-btn {
padding: 8px 20px;
font-weight: bold;
letter-spacing: 1px;
display: flex;
align-items: center;
gap: 6px;
}
.save-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* User Management Table */
.users-table-container {
overflow-x: auto;
margin-bottom: 24px;
}
.users-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
text-align: left;
}
.users-table th {
padding: 12px 24px;
border-bottom: 1px solid var(--border-color);
opacity: 0.5;
font-weight: normal;
font-size: 0.7rem;
letter-spacing: 1px;
}
.users-table td {
padding: 12px 24px;
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
}
.users-table tr:hover {
background-color: rgba(0, 255, 65, 0.03);
}
.user-actions {
display: flex;
gap: 8px;
}
.action-btn {
padding: 4px 10px;
font-size: 0.7rem;
display: flex;
align-items: center;
gap: 4px;
}
.action-btn.danger {
border-color: #ff4141;
color: #ff4141;
}
.action-btn.danger:hover {
background: #ff4141;
color: var(--background-color);
box-shadow: 0 0 10px rgba(255, 65, 65, 0.5);
}
/* Add User Form */
.add-user-section {
border-top: 1px solid var(--border-color);
padding-top: 24px;
}
.add-user-form {
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
}
.add-user-form .form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.add-user-form label {
font-size: 0.65rem;
letter-spacing: 1px;
opacity: 0.6;
}
.add-user-form input {
width: 180px;
}
.add-user-form select {
background: #0d1117;
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 8px 12px;
font-family: inherit;
cursor: pointer;
}
.add-user-form select:focus {
outline: none;
border-color: var(--text-color);
box-shadow: var(--matrix-green-glow);
}
.role-select {
background: #0d1117;
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 4px 8px;
font-family: inherit;
font-size: 0.75rem;
cursor: pointer;
}
.role-badge {
font-size: 0.7rem;
padding: 2px 8px;
border: 1px solid;
display: inline-block;
}
.role-badge.admin {
border-color: var(--accent-color);
color: var(--accent-color);
}
.role-badge.viewer {
border-color: var(--border-color);
color: var(--text-color);
opacity: 0.6;
}
.must-change-badge {
font-size: 0.65rem;
color: #ffaa00;
opacity: 0.8;
}
.config-success {
color: var(--text-color);
font-size: 0.75rem;
padding: 6px 12px;
border: 1px solid var(--text-color);
background: rgba(0, 255, 65, 0.1);
display: inline-block;
}
.config-error {
color: #ff4141;
font-size: 0.75rem;
padding: 6px 12px;
border: 1px solid #ff4141;
background: rgba(255, 65, 65, 0.1);
display: inline-block;
}
.confirm-dialog {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.75rem;
}
.confirm-dialog span {
color: #ff4141;
}
.interval-hint {
font-size: 0.65rem;
opacity: 0.4;
letter-spacing: 0.5px;
}

View File

@@ -1,18 +1,516 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { Settings } from 'lucide-react'; import api from '../utils/api';
import { Settings, Users, Sliders, Trash2, UserPlus, Key, Save, Shield, AlertTriangle } from 'lucide-react';
import './Dashboard.css'; import './Dashboard.css';
import './Config.css';
interface UserEntry {
uuid: string;
username: string;
role: string;
must_change_password: boolean;
}
interface ConfigData {
role: string;
deployment_limit: number;
global_mutation_interval: string;
users?: UserEntry[];
developer_mode?: boolean;
}
const Config: React.FC = () => { const Config: React.FC = () => {
const [config, setConfig] = useState<ConfigData | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'limits' | 'users' | 'globals'>('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<string | null>(null);
// Reset password state
const [resetTarget, setResetTarget] = useState<string | null>(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
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 ( return (
<div className="logs-section">
<div className="loader">LOADING CONFIGURATION...</div>
</div>
);
}
if (!config) {
return (
<div className="logs-section">
<div style={{ padding: '40px', textAlign: 'center', opacity: 0.5 }}>
<p>FAILED TO LOAD CONFIGURATION</p>
</div>
</div>
);
}
const tabs: { key: string; label: string; icon: React.ReactNode }[] = [
{ key: 'limits', label: 'DEPLOYMENT LIMITS', icon: <Sliders size={14} /> },
...(config.users
? [{ key: 'users', label: 'USER MANAGEMENT', icon: <Users size={14} /> }]
: []),
{ key: 'globals', label: 'GLOBAL VALUES', icon: <Settings size={14} /> },
];
return (
<div className="config-page">
<div className="logs-section"> <div className="logs-section">
<div className="section-header"> <div className="section-header">
<Settings size={20} /> <Shield size={20} />
<h2>SYSTEM CONFIGURATION</h2> <h2>SYSTEM CONFIGURATION</h2>
</div> </div>
<div style={{ padding: '40px', textAlign: 'center', opacity: 0.5 }}>
<p>CONFIGURATION READ-ONLY MODE ACTIVE.</p>
<p style={{ marginTop: '10px', fontSize: '0.8rem' }}>(Config view placeholder)</p>
</div> </div>
<div className="config-tabs">
{tabs.map((tab) => (
<button
key={tab.key}
className={`config-tab ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => setActiveTab(tab.key as any)}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* DEPLOYMENT LIMITS TAB */}
{activeTab === 'limits' && (
<div className="config-panel">
<div className="config-field">
<span className="config-label">MAXIMUM DECKIES PER DEPLOYMENT</span>
{isAdmin ? (
<>
<div className="config-input-row">
<input
type="number"
min={1}
max={500}
value={limitInput}
onChange={(e) => setLimitInput(e.target.value)}
/>
<div className="preset-buttons">
{[10, 50, 100, 200].map((v) => (
<button
key={v}
className={`preset-btn ${limitInput === String(v) ? 'active' : ''}`}
onClick={() => setLimitInput(String(v))}
>
{v}
</button>
))}
</div>
<button
className="save-btn"
onClick={handleSaveLimit}
disabled={limitSaving}
>
<Save size={14} />
{limitSaving ? 'SAVING...' : 'SAVE'}
</button>
</div>
{limitMsg && (
<span className={limitMsg.type === 'success' ? 'config-success' : 'config-error'}>
{limitMsg.text}
</span>
)}
</>
) : (
<span className="config-value">{config.deployment_limit}</span>
)}
</div>
</div>
)}
{/* USER MANAGEMENT TAB (only if server sent users) */}
{activeTab === 'users' && config.users && (
<div className="config-panel">
<div className="users-table-container">
<table className="users-table">
<thead>
<tr>
<th>USERNAME</th>
<th>ROLE</th>
<th>STATUS</th>
<th>ACTIONS</th>
</tr>
</thead>
<tbody>
{config.users.map((user) => (
<tr key={user.uuid}>
<td>{user.username}</td>
<td>
<span className={`role-badge ${user.role}`}>{user.role.toUpperCase()}</span>
</td>
<td>
{user.must_change_password && (
<span className="must-change-badge">MUST CHANGE PASSWORD</span>
)}
</td>
<td>
<div className="user-actions">
{/* Role change dropdown */}
<select
className="role-select"
value={user.role}
onChange={(e) => handleRoleChange(user.uuid, e.target.value)}
>
<option value="admin">admin</option>
<option value="viewer">viewer</option>
</select>
{/* Reset password */}
{resetTarget === user.uuid ? (
<div className="confirm-dialog">
<input
type="password"
placeholder="New password"
value={resetPassword}
onChange={(e) => setResetPassword(e.target.value)}
style={{ width: '140px' }}
/>
<button className="action-btn" onClick={() => handleResetPassword(user.uuid)}>
SET
</button>
<button className="action-btn" onClick={() => { setResetTarget(null); setResetPassword(''); }}>
CANCEL
</button>
</div>
) : (
<button
className="action-btn"
onClick={() => setResetTarget(user.uuid)}
>
<Key size={12} />
RESET
</button>
)}
{/* Delete */}
{confirmDelete === user.uuid ? (
<div className="confirm-dialog">
<span>CONFIRM?</span>
<button className="action-btn danger" onClick={() => handleDeleteUser(user.uuid)}>
YES
</button>
<button className="action-btn" onClick={() => setConfirmDelete(null)}>
NO
</button>
</div>
) : (
<button
className="action-btn danger"
onClick={() => setConfirmDelete(user.uuid)}
>
<Trash2 size={12} />
DELETE
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="add-user-section">
<form className="add-user-form" onSubmit={handleAddUser}>
<div className="form-group">
<label>USERNAME</label>
<input
type="text"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
required
minLength={1}
maxLength={64}
/>
</div>
<div className="form-group">
<label>PASSWORD</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
maxLength={72}
/>
</div>
<div className="form-group">
<label>ROLE</label>
<select
value={newRole}
onChange={(e) => setNewRole(e.target.value as 'admin' | 'viewer')}
>
<option value="viewer">viewer</option>
<option value="admin">admin</option>
</select>
</div>
<button type="submit" className="save-btn" disabled={addingUser}>
<UserPlus size={14} />
{addingUser ? 'CREATING...' : 'ADD USER'}
</button>
{userMsg && (
<span className={userMsg.type === 'success' ? 'config-success' : 'config-error'}>
{userMsg.text}
</span>
)}
</form>
</div>
</div>
)}
{/* GLOBAL VALUES TAB */}
{activeTab === 'globals' && (
<div className="config-panel">
<div className="config-field">
<span className="config-label">GLOBAL MUTATION INTERVAL</span>
{isAdmin ? (
<>
<div className="config-input-row">
<input
type="text"
value={intervalInput}
onChange={(e) => setIntervalInput(e.target.value)}
placeholder="30m"
/>
<button
className="save-btn"
onClick={handleSaveInterval}
disabled={intervalSaving}
>
<Save size={14} />
{intervalSaving ? 'SAVING...' : 'SAVE'}
</button>
</div>
<span className="interval-hint">
FORMAT: &lt;number&gt;&lt;unit&gt; m=minutes, d=days, M=months, y=years (e.g. 30m, 7d, 1M)
</span>
{intervalMsg && (
<span className={intervalMsg.type === 'success' ? 'config-success' : 'config-error'}>
{intervalMsg.text}
</span>
)}
</>
) : (
<span className="config-value">{config.global_mutation_interval}</span>
)}
</div>
</div>
)}
{/* DANGER ZONE — developer mode only, server-gated, shown on globals tab */}
{activeTab === 'globals' && config.developer_mode && (
<div className="config-panel" style={{ borderColor: '#ff4141' }}>
<div className="config-field" style={{ marginBottom: 0 }}>
<span className="config-label" style={{ color: '#ff4141' }}>
<AlertTriangle size={12} style={{ display: 'inline', verticalAlign: 'middle', marginRight: '6px' }} />
DANGER ZONE DEVELOPER MODE
</span>
<p style={{ fontSize: '0.75rem', opacity: 0.5, margin: '4px 0 12px' }}>
Purge all logs, bounty vault entries, and attacker profiles. This action is irreversible.
</p>
{!confirmReinit ? (
<button
className="action-btn danger"
onClick={() => setConfirmReinit(true)}
style={{ padding: '8px 16px', fontSize: '0.8rem' }}
>
<Trash2 size={14} />
PURGE ALL DATA
</button>
) : (
<div className="confirm-dialog">
<span>THIS WILL DELETE ALL COLLECTED DATA. ARE YOU SURE?</span>
<button
className="action-btn danger"
onClick={handleReinit}
disabled={reiniting}
style={{ padding: '6px 16px' }}
>
{reiniting ? 'PURGING...' : 'YES, PURGE'}
</button>
<button
className="action-btn"
onClick={() => setConfirmReinit(false)}
style={{ padding: '6px 16px' }}
>
CANCEL
</button>
</div>
)}
{reinitMsg && (
<span className={reinitMsg.type === 'success' ? 'config-success' : 'config-error'} style={{ marginTop: '8px' }}>
{reinitMsg.text}
</span>
)}
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -22,6 +22,7 @@ const DeckyFleet: React.FC = () => {
const [showDeploy, setShowDeploy] = useState(false); const [showDeploy, setShowDeploy] = useState(false);
const [iniContent, setIniContent] = useState(''); const [iniContent, setIniContent] = useState('');
const [deploying, setDeploying] = useState(false); const [deploying, setDeploying] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const fetchDeckies = async () => { const fetchDeckies = async () => {
try { try {
@@ -34,6 +35,15 @@ const DeckyFleet: React.FC = () => {
} }
}; };
const fetchRole = async () => {
try {
const res = await api.get('/config');
setIsAdmin(res.data.role === 'admin');
} catch {
setIsAdmin(false);
}
};
const handleMutate = async (name: string) => { const handleMutate = async (name: string) => {
setMutating(name); setMutating(name);
try { try {
@@ -94,6 +104,7 @@ const DeckyFleet: React.FC = () => {
useEffect(() => { useEffect(() => {
fetchDeckies(); fetchDeckies();
fetchRole();
const _interval = setInterval(fetchDeckies, 10000); // Fleet state updates less frequently than logs const _interval = setInterval(fetchDeckies, 10000); // Fleet state updates less frequently than logs
return () => clearInterval(_interval); return () => clearInterval(_interval);
}, []); }, []);
@@ -107,12 +118,14 @@ const DeckyFleet: React.FC = () => {
<Server size={20} /> <Server size={20} />
<h2 style={{ margin: 0 }}>DECOY FLEET ASSET INVENTORY</h2> <h2 style={{ margin: 0 }}>DECOY FLEET ASSET INVENTORY</h2>
</div> </div>
{isAdmin && (
<button <button
onClick={() => setShowDeploy(!showDeploy)} onClick={() => setShowDeploy(!showDeploy)}
style={{ display: 'flex', alignItems: 'center', gap: '8px', border: '1px solid var(--accent-color)', color: 'var(--accent-color)' }} style={{ display: 'flex', alignItems: 'center', gap: '8px', border: '1px solid var(--accent-color)', color: 'var(--accent-color)' }}
> >
+ DEPLOY DECKIES + DEPLOY DECKIES
</button> </button>
)}
</div> </div>
{showDeploy && ( {showDeploy && (
@@ -186,12 +199,19 @@ const DeckyFleet: React.FC = () => {
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem', marginTop: '8px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem', marginTop: '8px' }}>
<Clock size={14} className="dim" /> <Clock size={14} className="dim" />
<span className="dim">MUTATION:</span> <span className="dim">MUTATION:</span>
{isAdmin ? (
<span <span
style={{ color: 'var(--accent-color)', cursor: 'pointer', textDecoration: 'underline' }} style={{ color: 'var(--accent-color)', cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => handleIntervalChange(decky.name, decky.mutate_interval)} onClick={() => handleIntervalChange(decky.name, decky.mutate_interval)}
> >
{decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'} {decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'}
</span> </span>
) : (
<span style={{ color: 'var(--accent-color)' }}>
{decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'}
</span>
)}
{isAdmin && (
<button <button
onClick={() => handleMutate(decky.name)} onClick={() => handleMutate(decky.name)}
disabled={!!mutating} disabled={!!mutating}
@@ -204,6 +224,7 @@ const DeckyFleet: React.FC = () => {
> >
<RefreshCw size={10} className={mutating === decky.name ? "spin" : ""} /> {mutating === decky.name ? 'MUTATING...' : 'FORCE'} <RefreshCw size={10} className={mutating === decky.name ? "spin" : ""} /> {mutating === decky.name ? 'MUTATING...' : 'FORCE'}
</button> </button>
)}
</div> </div>
{decky.last_mutated > 0 && ( {decky.last_mutated > 0 && (
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', fontStyle: 'italic', marginTop: '4px' }}> <div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', fontStyle: 'italic', marginTop: '4px' }}>