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:
@@ -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);
|
||||||
|
|||||||
282
decnet_web/src/components/Config.css
Normal file
282
decnet_web/src/components/Config.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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: <number><unit> — 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user