feat: add return classification for archive chart, CARD API integration, compliance charts, systemd services

This commit is contained in:
root
2026-05-01 17:15:41 +00:00
parent 8df961cce8
commit 15abf8bae4
21 changed files with 3639 additions and 210 deletions

View File

@@ -1,6 +1,13 @@
// ⚠️ CONVENTION: This component uses Tailwind utility classes (e.g. bg-white, rounded-lg, hover:bg-gray-50)
// instead of inline styles or App.css global classes. This is the legacy modal kept for UserMenu quick-access;
// the themed replacement lives in AdminPage.js.
// ⚠️ CONVENTION: This component uses inline styles matching the dark "tactical intelligence"
// design system (DESIGN_SYSTEM.md). Colors use the --intel-* and --text-* palette.
//
// ⚠️ CONVENTION: This file is INCOMPLETE — the exported functional component (UserManagement)
// was removed during the style refactor. Only style constants remain. The file must include:
// - A default-exported functional component using hooks (useState, useEffect)
// - Data fetching via fetch() with credentials: 'include' and relative API paths
// - Loading and error state handling in the rendered output
// - JSX that uses the imported lucide-react icons (X, Plus, Edit2, Trash2, etc.)
// - The ConfirmModal integration for delete/group-change confirmations
import React, { useState, useEffect } from 'react';
import { X, Plus, Edit2, Trash2, Loader, AlertCircle, CheckCircle, User, Mail, Shield } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
@@ -18,12 +25,150 @@ const GROUP_LABELS = {
};
const GROUP_BADGE_STYLES = {
Admin: { backgroundColor: '#FEE2E2', color: '#991B1B' },
Standard_User: { backgroundColor: '#DBEAFE', color: '#1E40AF' },
Leadership: { backgroundColor: '#F3E8FF', color: '#6B21A8' },
Read_Only: { backgroundColor: '#F3F4F6', color: '#374151' }
Admin: { backgroundColor: 'rgba(239, 68, 68, 0.25)', color: '#FCA5A5', border: '1px solid rgba(239, 68, 68, 0.4)' },
Standard_User: { backgroundColor: 'rgba(14, 165, 233, 0.25)', color: '#7DD3FC', border: '1px solid rgba(14, 165, 233, 0.4)' },
Leadership: { backgroundColor: 'rgba(168, 85, 247, 0.25)', color: '#C4B5FD', border: '1px solid rgba(168, 85, 247, 0.4)' },
Read_Only: { backgroundColor: 'rgba(148, 163, 184, 0.2)', color: '#CBD5E1', border: '1px solid rgba(148, 163, 184, 0.3)' }
};
/* ── Shared style constants ── */
const styles = {
overlay: {
position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50, padding: '1rem',
},
modal: {
background: 'linear-gradient(135deg, #1E293B 0%, #0F172A 100%)',
borderRadius: '0.75rem', border: '1.5px solid rgba(14,165,233,0.3)',
boxShadow: '0 8px 24px rgba(0,0,0,0.6), 0 0 28px rgba(14,165,233,0.08)',
maxWidth: '56rem', width: '100%', maxHeight: '90vh', overflow: 'hidden',
display: 'flex', flexDirection: 'column', color: '#F8FAFC',
},
header: {
padding: '1.5rem', borderBottom: '1px solid rgba(14,165,233,0.2)',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
},
title: {
fontSize: '1.5rem', fontWeight: 700, color: '#F8FAFC', margin: 0,
fontFamily: "'JetBrains Mono', monospace",
},
subtitle: { color: '#94A3B8', fontSize: '0.875rem', margin: '0.25rem 0 0' },
closeBtn: {
background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer', padding: '0.5rem',
borderRadius: '0.375rem', transition: 'color 0.2s',
},
body: { padding: '1.5rem', overflowY: 'auto', flex: 1 },
addBtn: {
marginBottom: '1.5rem', padding: '0.5rem 1rem',
background: 'linear-gradient(135deg, rgba(14,165,233,0.15), rgba(14,165,233,0.1))',
border: '1px solid #0EA5E9', borderRadius: '0.5rem', color: '#38BDF8',
cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.5rem',
fontSize: '0.875rem', fontWeight: 500, transition: 'all 0.2s',
textShadow: '0 0 6px rgba(14,165,233,0.2)',
},
formCard: {
marginBottom: '1.5rem', padding: '1.5rem',
background: 'linear-gradient(135deg, rgba(15,23,42,0.95), rgba(30,41,59,0.9))',
borderRadius: '0.5rem', border: '1px solid rgba(14,165,233,0.25)',
},
formTitle: {
fontSize: '1.125rem', fontWeight: 600, color: '#0EA5E9', margin: '0 0 1rem',
fontFamily: "'JetBrains Mono', monospace",
},
label: {
display: 'block', fontSize: '0.75rem', fontWeight: 500, color: '#CBD5E1',
marginBottom: '0.375rem', textTransform: 'uppercase', letterSpacing: '0.5px',
},
inputWrap: { position: 'relative' },
inputIcon: {
position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)',
color: '#94A3B8', width: '1.125rem', height: '1.125rem', pointerEvents: 'none',
},
input: {
width: '100%', padding: '0.5rem 0.75rem 0.5rem 2.5rem',
background: 'rgba(30,41,59,0.6)', border: '1px solid rgba(14,165,233,0.25)',
borderRadius: '0.5rem', color: '#F8FAFC', fontSize: '0.875rem',
fontFamily: "'JetBrains Mono', monospace", outline: 'none', transition: 'border-color 0.2s',
boxSizing: 'border-box',
},
inputNoIcon: {
width: '100%', padding: '0.5rem 0.75rem',
background: 'rgba(30,41,59,0.6)', border: '1px solid rgba(14,165,233,0.25)',
borderRadius: '0.5rem', color: '#F8FAFC', fontSize: '0.875rem',
fontFamily: "'JetBrains Mono', monospace", outline: 'none', transition: 'border-color 0.2s',
boxSizing: 'border-box',
},
select: {
width: '100%', padding: '0.5rem 0.75rem 0.5rem 2.5rem',
background: 'rgba(30,41,59,0.6)', border: '1px solid rgba(14,165,233,0.25)',
borderRadius: '0.5rem', color: '#F8FAFC', fontSize: '0.875rem',
fontFamily: "'JetBrains Mono', monospace", outline: 'none', cursor: 'pointer',
appearance: 'none', boxSizing: 'border-box',
},
primaryBtn: {
padding: '0.5rem 1rem',
background: 'linear-gradient(135deg, rgba(14,165,233,0.15), rgba(14,165,233,0.1))',
border: '1px solid #0EA5E9', borderRadius: '0.5rem', color: '#38BDF8',
cursor: 'pointer', fontSize: '0.875rem', fontWeight: 500, transition: 'all 0.2s',
textShadow: '0 0 6px rgba(14,165,233,0.2)',
},
cancelBtn: {
padding: '0.5rem 1rem',
background: 'rgba(51,65,85,0.5)', border: '1px solid rgba(148,163,184,0.3)',
borderRadius: '0.5rem', color: '#CBD5E1', cursor: 'pointer',
fontSize: '0.875rem', fontWeight: 500, transition: 'all 0.2s',
},
alertError: {
marginBottom: '1rem', padding: '0.75rem',
background: 'rgba(239,68,68,0.15)', border: '1px solid rgba(239,68,68,0.3)',
borderRadius: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem',
},
alertSuccess: {
marginBottom: '1rem', padding: '0.75rem',
background: 'rgba(16,185,129,0.15)', border: '1px solid rgba(16,185,129,0.3)',
borderRadius: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem',
},
th: {
textAlign: 'left', padding: '0.75rem 1rem', fontSize: '0.75rem', fontWeight: 600,
color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.5px',
borderBottom: '1px solid rgba(14,165,233,0.2)',
},
thRight: {
textAlign: 'right', padding: '0.75rem 1rem', fontSize: '0.75rem', fontWeight: 600,
color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.5px',
borderBottom: '1px solid rgba(14,165,233,0.2)',
},
td: { padding: '0.75rem 1rem', borderBottom: '1px solid rgba(51,65,85,0.5)' },
tdRight: { padding: '0.75rem 1rem', borderBottom: '1px solid rgba(51,65,85,0.5)', textAlign: 'right' },
username: { fontWeight: 500, color: '#F8FAFC', fontSize: '0.875rem' },
email: { fontSize: '0.8rem', color: '#94A3B8' },
lastLogin: { fontSize: '0.8rem', color: '#94A3B8' },
badge: {
padding: '0.25rem 0.625rem', borderRadius: '0.375rem',
fontSize: '0.7rem', fontWeight: 600, display: 'inline-block',
fontFamily: "'JetBrains Mono', monospace", letterSpacing: '0.3px',
},
statusActive: {
padding: '0.2rem 0.5rem', borderRadius: '0.25rem', fontSize: '0.7rem', fontWeight: 600,
background: 'rgba(16,185,129,0.2)', color: '#6EE7B7', border: '1px solid rgba(16,185,129,0.3)',
cursor: 'pointer', transition: 'opacity 0.2s',
},
statusInactive: {
padding: '0.2rem 0.5rem', borderRadius: '0.25rem', fontSize: '0.7rem', fontWeight: 600,
background: 'rgba(239,68,68,0.2)', color: '#FCA5A5', border: '1px solid rgba(239,68,68,0.3)',
cursor: 'pointer', transition: 'opacity 0.2s',
},
actionBtn: {
background: 'none', border: 'none', padding: '0.375rem', borderRadius: '0.375rem',
cursor: 'pointer', color: '#94A3B8', transition: 'all 0.2s',
},
deleteBtn: {
background: 'none', border: 'none', padding: '0.375rem', borderRadius: '0.375rem',
cursor: 'pointer', color: '#EF4444', transition: 'all 0.2s',
},
};
export default function UserManagement({ onClose }) {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState([]);
@@ -106,7 +251,6 @@ export default function UserManagement({ onClose }) {
const handleSubmit = (e) => {
e.preventDefault();
// If editing and group changed, show confirmation modal
if (editingUser && formData.group !== editingUser.group) {
let message = `Are you sure you want to change ${editingUser.username}'s group from ${editingUser.group} to ${formData.group}?`;
if (editingUser.group === 'Admin' && formData.group !== 'Admin') {
@@ -189,29 +333,29 @@ export default function UserManagement({ onClose }) {
}
};
// Check if group dropdown should be disabled for self-demotion prevention
const isGroupDropdownDisabled = (targetUser) => {
if (!targetUser || !currentUser) return false;
return targetUser.id === currentUser.id && currentUser.group === 'Admin';
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-6 border-b border-gray-200 flex justify-between items-center">
<div style={styles.overlay}>
<div style={styles.modal}>
{/* Header */}
<div style={styles.header}>
<div>
<h2 className="text-2xl font-bold text-gray-900">User Management</h2>
<p className="text-gray-600">Manage user accounts and permissions</p>
<h2 style={styles.title}>User Management</h2>
<p style={styles.subtitle}>Manage user accounts and permissions</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 p-2"
>
<X className="w-6 h-6" />
<button onClick={onClose} style={styles.closeBtn}
onMouseEnter={e => e.currentTarget.style.color = '#F8FAFC'}
onMouseLeave={e => e.currentTarget.style.color = '#94A3B8'}>
<X style={{ width: '1.5rem', height: '1.5rem' }} />
</button>
</div>
<div className="p-6 overflow-y-auto flex-1">
{/* Body */}
<div style={styles.body}>
{!showAddUser && (
<button
onClick={() => {
@@ -221,69 +365,80 @@ export default function UserManagement({ onClose }) {
setFormError('');
setFormSuccess('');
}}
className="mb-6 px-4 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors flex items-center gap-2 shadow-md"
style={styles.addBtn}
onMouseEnter={e => {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14,165,233,0.25), rgba(14,165,233,0.2))';
e.currentTarget.style.boxShadow = '0 0 20px rgba(14,165,233,0.25)';
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14,165,233,0.15), rgba(14,165,233,0.1))';
e.currentTarget.style.boxShadow = 'none';
}}
>
<Plus className="w-5 h-5" />
<Plus style={{ width: '1.125rem', height: '1.125rem' }} />
Add User
</button>
)}
{/* Add / Edit Form */}
{showAddUser && (
<div className="mb-6 p-6 bg-gray-50 rounded-lg border border-gray-200">
<h3 className="text-lg font-semibold mb-4">
<div style={styles.formCard}>
<h3 style={styles.formTitle}>
{editingUser ? 'Edit User' : 'Add New User'}
</h3>
{formError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-600" />
<span className="text-sm text-red-700">{formError}</span>
<div style={styles.alertError}>
<AlertCircle style={{ width: '1.125rem', height: '1.125rem', color: '#FCA5A5', flexShrink: 0 }} />
<span style={{ fontSize: '0.8rem', color: '#FCA5A5' }}>{formError}</span>
</div>
)}
{formSuccess && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<span className="text-sm text-green-700">{formSuccess}</span>
<div style={styles.alertSuccess}>
<CheckCircle style={{ width: '1.125rem', height: '1.125rem', color: '#6EE7B7', flexShrink: 0 }} />
<span style={{ fontSize: '0.8rem', color: '#6EE7B7' }}>{formSuccess}</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<form onSubmit={handleSubmit}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Username *
</label>
<div className="relative">
<User className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<label style={styles.label}>Username *</label>
<div style={styles.inputWrap}>
<User style={styles.inputIcon} />
<input
type="text"
required
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
style={styles.input}
placeholder="Enter username"
onFocus={e => e.target.style.borderColor = '#0EA5E9'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.25)'}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<div className="relative">
<Mail className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<label style={styles.label}>Email *</label>
<div style={styles.inputWrap}>
<Mail style={styles.inputIcon} />
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
style={styles.input}
placeholder="user@example.com"
onFocus={e => e.target.style.borderColor = '#0EA5E9'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.25)'}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label style={styles.label}>
Password {editingUser ? '(leave blank to keep current)' : '*'}
</label>
<input
@@ -291,49 +446,58 @@ export default function UserManagement({ onClose }) {
required={!editingUser}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
style={styles.inputNoIcon}
onFocus={e => e.target.style.borderColor = '#0EA5E9'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.25)'}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Group *
</label>
<div className="relative">
<Shield className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<label style={styles.label}>Group *</label>
<div style={styles.inputWrap}>
<Shield style={styles.inputIcon} />
<select
value={formData.group}
onChange={(e) => setFormData({ ...formData, group: e.target.value })}
disabled={isGroupDropdownDisabled(editingUser)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
style={{
...styles.select,
opacity: isGroupDropdownDisabled(editingUser) ? 0.5 : 1,
cursor: isGroupDropdownDisabled(editingUser) ? 'not-allowed' : 'pointer',
}}
title={isGroupDropdownDisabled(editingUser) ? 'Cannot change your own Admin group' : ''}
>
{VALID_GROUPS.map((g) => (
<option key={g} value={g}>{GROUP_LABELS[g]}</option>
<option key={g} value={g} style={{ background: '#1E293B', color: '#F8FAFC' }}>
{GROUP_LABELS[g]}
</option>
))}
</select>
{isGroupDropdownDisabled(editingUser) && (
<p className="text-xs text-amber-600 mt-1">You cannot change your own Admin group.</p>
<p style={{ fontSize: '0.7rem', color: '#F59E0B', marginTop: '0.375rem' }}>
You cannot change your own Admin group.
</p>
)}
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
className="px-4 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors"
>
<div style={{ display: 'flex', gap: '0.75rem', paddingTop: '1rem' }}>
<button type="submit" style={styles.primaryBtn}
onMouseEnter={e => {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14,165,233,0.25), rgba(14,165,233,0.2))';
e.currentTarget.style.boxShadow = '0 0 20px rgba(14,165,233,0.25)';
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14,165,233,0.15), rgba(14,165,233,0.1))';
e.currentTarget.style.boxShadow = 'none';
}}>
{editingUser ? 'Update User' : 'Create User'}
</button>
<button
type="button"
onClick={() => {
setShowAddUser(false);
setEditingUser(null);
}}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
<button type="button" style={styles.cancelBtn}
onClick={() => { setShowAddUser(false); setEditingUser(null); }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(51,65,85,0.8)'}
onMouseLeave={e => e.currentTarget.style.background = 'rgba(51,65,85,0.5)'}>
Cancel
</button>
</div>
@@ -341,87 +505,92 @@ export default function UserManagement({ onClose }) {
</div>
)}
{/* User Table */}
{loading ? (
<div className="text-center py-12">
<Loader className="w-8 h-8 text-[#0476D9] mx-auto animate-spin" />
<p className="text-gray-600 mt-2">Loading users...</p>
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
<Loader style={{ width: '2rem', height: '2rem', color: '#0EA5E9', margin: '0 auto', animation: 'spin 1s linear infinite' }} />
<p style={{ color: '#94A3B8', marginTop: '0.5rem', fontSize: '0.875rem' }}>Loading users...</p>
</div>
) : error ? (
<div className="text-center py-12">
<AlertCircle className="w-8 h-8 text-red-500 mx-auto" />
<p className="text-red-600 mt-2">{error}</p>
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
<AlertCircle style={{ width: '2rem', height: '2rem', color: '#EF4444', margin: '0 auto' }} />
<p style={{ color: '#FCA5A5', marginTop: '0.5rem', fontSize: '0.875rem' }}>{error}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">User</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Group</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Status</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Last Login</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-700">Actions</th>
<tr>
<th style={styles.th}>User</th>
<th style={styles.th}>Group</th>
<th style={styles.th}>Status</th>
<th style={styles.th}>Last Login</th>
<th style={styles.thRight}>Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-3 px-4">
<tr key={user.id}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(14,165,233,0.05)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<td style={styles.td}>
<div>
<p className="font-medium text-gray-900">{user.username}</p>
<p className="text-sm text-gray-500">{user.email}</p>
<p style={styles.username}>{user.username}</p>
<p style={styles.email}>{user.email}</p>
</div>
</td>
<td className="py-3 px-4">
<span
style={{
...GROUP_BADGE_STYLES[user.group] || GROUP_BADGE_STYLES.Read_Only,
padding: '2px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500',
display: 'inline-block'
}}
>
<td style={styles.td}>
<span style={{
...styles.badge,
...(GROUP_BADGE_STYLES[user.group] || GROUP_BADGE_STYLES.Read_Only),
}}>
{user.group ? user.group.replace('_', ' ') : 'Read Only'}
</span>
</td>
<td className="py-3 px-4">
<td style={styles.td}>
<button
onClick={() => handleToggleActive(user)}
disabled={user.id === currentUser.id}
className={`px-2 py-1 rounded text-xs font-medium ${
user.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
} ${user.id === currentUser.id ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:opacity-80'}`}
style={{
...(user.is_active ? styles.statusActive : styles.statusInactive),
opacity: user.id === currentUser.id ? 0.5 : 1,
cursor: user.id === currentUser.id ? 'not-allowed' : 'pointer',
}}
>
{user.is_active ? 'Active' : 'Inactive'}
</button>
</td>
<td className="py-3 px-4 text-sm text-gray-500">
{user.last_login
? new Date(user.last_login).toLocaleString()
: 'Never'}
<td style={styles.td}>
<span style={styles.lastLogin}>
{user.last_login
? new Date(user.last_login).toLocaleString()
: 'Never'}
</span>
</td>
<td className="py-3 px-4">
<div className="flex justify-end gap-2">
<td style={styles.tdRight}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.25rem' }}>
<button
onClick={() => handleEdit(user)}
className="p-2 text-gray-600 hover:bg-gray-100 rounded"
style={styles.actionBtn}
title="Edit user"
onMouseEnter={e => { e.currentTarget.style.color = '#0EA5E9'; e.currentTarget.style.background = 'rgba(14,165,233,0.1)'; }}
onMouseLeave={e => { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.background = 'none'; }}
>
<Edit2 className="w-4 h-4" />
<Edit2 style={{ width: '1rem', height: '1rem' }} />
</button>
<button
onClick={() => handleDelete(user.id)}
disabled={user.id === currentUser.id}
className={`p-2 text-red-600 hover:bg-red-50 rounded ${
user.id === currentUser.id ? 'opacity-50 cursor-not-allowed' : ''
}`}
style={{
...styles.deleteBtn,
opacity: user.id === currentUser.id ? 0.3 : 1,
cursor: user.id === currentUser.id ? 'not-allowed' : 'pointer',
}}
title="Delete user"
onMouseEnter={e => { if (user.id !== currentUser.id) { e.currentTarget.style.background = 'rgba(239,68,68,0.1)'; } }}
onMouseLeave={e => { e.currentTarget.style.background = 'none'; }}
>
<Trash2 className="w-4 h-4" />
<Trash2 style={{ width: '1rem', height: '1rem' }} />
</button>
</div>
</td>
@@ -446,4 +615,4 @@ export default function UserManagement({ onClose }) {
/>
</div>
);
}
}

View File

@@ -102,6 +102,13 @@ const CLASSIFICATION_LABELS = {
decommissioned: 'decommissioned',
};
const RETURN_CLASSIFICATION_LABELS = {
bu_reassignment: 'BU reassigned back',
severity_drift: 'severity re-escalated',
closed_on_platform: 'reopened on platform',
decommissioned: 're-provisioned',
};
// ---------------------------------------------------------------------------
// Build the summary text from anomaly data
// ---------------------------------------------------------------------------
@@ -220,6 +227,20 @@ export default function AnomalyBanner() {
<span style={DETAIL_COUNT}>{anomaly.returned_count}</span>
</div>
)}
{anomaly.returned_count > 0 && anomaly.return_classification && (
<>
{Object.entries(RETURN_CLASSIFICATION_LABELS).map(([key, label]) => {
const val = (anomaly.return_classification || {})[key] || 0;
if (val === 0) return null;
return (
<div key={`ret-${key}`} style={{ ...DETAIL_ROW, paddingLeft: '0.75rem', fontSize: '0.6rem', color: '#94A3B8' }}>
<span> {label}</span>
<span style={{ fontWeight: '600', color: '#14B8A6' }}>{val}</span>
</div>
);
})}
</>
)}
</div>
)}
</div>

View File

@@ -1,7 +1,7 @@
// ComplianceChartsPanel.js
// Tier-1 time-based compliance charts using Recharts.
// Charts rendered: Active Findings Over Time, Change per Cycle,
// Team Health, MTTR by Team, Persistent Findings, Archer Pipeline.
// Team Health, Aging Findings Distribution, Resolution Rate, Archer Pipeline.
import React, { useState, useEffect, useMemo } from 'react';
import {
@@ -208,47 +208,68 @@ function TeamTrendChart({ data }) {
}
// ---------------------------------------------------------------------------
// Chart 4 — MTTR by Team (horizontal bar)
// Chart 4 — Aging Findings Distribution (vertical stacked bar by age bucket)
// ---------------------------------------------------------------------------
function MttrChart({ data }) {
if (data.length === 0) return <NoData msg="No resolved findings yet — MTTR will appear after items are remediated" />;
function AgingChart({ data }) {
if (data.length === 0) return <NoData />;
const teamKeys = Object.keys(TEAM_COLORS);
return (
<ResponsiveContainer width="100%" height={Math.max(160, data.length * 44 + 40)}>
<BarChart data={data} layout="vertical" margin={{ top: 4, right: 48, bottom: 0, left: 4 }}>
<ResponsiveContainer width="100%" height={210}>
<BarChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis type="number" tick={AXIS_STYLE} unit=" d" />
<YAxis type="category" dataKey="team" tick={AXIS_STYLE} width={86} />
<XAxis dataKey="bucket" tick={AXIS_STYLE} />
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
<Tooltip content={<DarkTooltip />} />
<Bar dataKey="avg_days" name="Avg Days" fill={TEAL} fillOpacity={0.8} radius={[0, 3, 3, 0]}
label={{ position: 'right', style: { ...AXIS_STYLE, fill: '#64748B' }, formatter: v => `${v}d` }}
/>
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
{teamKeys.map((team, i) => (
<Bar
key={team}
dataKey={team}
name={team}
stackId="aging"
fill={TEAM_COLORS[team]}
fillOpacity={0.85}
radius={i === teamKeys.length - 1 ? [2, 2, 0, 0] : [0, 0, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Chart 5 — Most Persistent Findings (horizontal bar by seen_count)
// Chart 5 — Resolution Rate per Cycle (line chart, % of findings resolved)
// ---------------------------------------------------------------------------
function RecurringChart({ data }) {
if (data.length === 0) return <NoData />;
const top10 = data.slice(0, 10).map(r => ({
...r,
label: r.metric_id.length > 18 ? r.metric_id.slice(0, 18) + '…' : r.metric_id,
}));
function ResolutionRateChart({ data }) {
const rateData = useMemo(() => {
return data
.filter(t => t.total_active > 0 || t.resolved_count > 0)
.map(t => {
const pool = t.total_active + t.resolved_count;
return {
date: t.date,
rate: pool > 0 ? Math.round((t.resolved_count / pool) * 1000) / 10 : 0,
resolved: t.resolved_count,
active: t.total_active,
};
});
}, [data]);
if (rateData.length < 2) return <NoData />;
return (
<ResponsiveContainer width="100%" height={Math.max(160, top10.length * 28 + 40)}>
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 48, bottom: 0, left: 4 }}>
<ResponsiveContainer width="100%" height={210}>
<LineChart data={rateData} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis type="number" tick={AXIS_STYLE} unit=" cycles" allowDecimals={false} />
<YAxis type="category" dataKey="label" tick={AXIS_STYLE} width={110} />
<Tooltip content={<DarkTooltip />} formatter={(val, name, props) => [
`${val} cycles (${props.payload.host_count} host${props.payload.host_count !== 1 ? 's' : ''})`, props.payload.team
]} />
<Bar dataKey="seen_count" name="Cycles Seen" fill="#F59E0B" fillOpacity={0.85} radius={[0, 3, 3, 0]}
label={{ position: 'right', style: { ...AXIS_STYLE, fill: '#64748B' }, formatter: v => `${v}×` }}
<XAxis dataKey="date" tick={AXIS_STYLE} />
<YAxis tick={AXIS_STYLE} unit="%" domain={[0, 'auto']} />
<Tooltip content={<DarkTooltip />} />
<Line
type="monotone" dataKey="rate" name="Resolution %"
stroke="#10B981" strokeWidth={2}
dot={{ r: 4, fill: '#10B981', strokeWidth: 0 }}
activeDot={{ r: 6 }}
/>
</BarChart>
</LineChart>
</ResponsiveContainer>
);
}
@@ -286,8 +307,7 @@ export default function ComplianceChartsPanel() {
const [collapsed, setCollapsed] = useState(false);
const [loading, setLoading] = useState(true);
const [trends, setTrends] = useState([]);
const [mttr, setMttr] = useState([]);
const [recurring, setRecurring] = useState([]);
const [aging, setAging] = useState([]);
const [archerRaw, setArcherRaw] = useState([]);
useEffect(() => {
@@ -295,16 +315,14 @@ export default function ComplianceChartsPanel() {
const load = async () => {
setLoading(true);
try {
const [tRes, mRes, rRes, aRes] = await Promise.all([
const [tRes, mRes, aRes] = await Promise.all([
fetch(`${API_BASE}/compliance/trends`, { credentials: 'include' }),
fetch(`${API_BASE}/compliance/mttr`, { credentials: 'include' }),
fetch(`${API_BASE}/compliance/top-recurring`, { credentials: 'include' }),
fetch(`${API_BASE}/archer-tickets/status-trend`, { credentials: 'include' }),
]);
if (cancelled) return;
if (tRes.ok) { const d = await tRes.json(); setTrends(d.trends || []); }
if (mRes.ok) { const d = await mRes.json(); setMttr(d.mttr || []); }
if (rRes.ok) { const d = await rRes.json(); setRecurring(d.items || []); }
if (mRes.ok) { const d = await mRes.json(); setAging(d.aging || []); }
if (aRes.ok) { const d = await aRes.json(); setArcherRaw(d.statusTrend || []); }
} catch { /* silent — charts will show no-data state */ }
finally { if (!cancelled) setLoading(false); }
@@ -393,20 +411,20 @@ export default function ComplianceChartsPanel() {
<TeamTrendChart data={formattedTrends} />
</ChartCard>
{/* 4. MTTR per team */}
{/* 4. Aging Findings Distribution */}
<ChartCard
title="Mean Time to Resolution"
subtitle="Average calendar days between first-seen and resolved, by team"
title="Aging Findings Distribution"
subtitle="Active findings by age bucket — stacked by team"
>
<MttrChart data={mttr} />
<AgingChart data={aging} />
</ChartCard>
{/* 5. Most persistent / recurring findings */}
{/* 5. Resolution Rate per Cycle */}
<ChartCard
title="Most Persistent Findings"
subtitle="Active items with the highest recurrence count (top 10)"
title="Resolution Rate"
subtitle="Percentage of known findings resolved each cycle — higher is better"
>
<RecurringChart data={recurring} />
<ResolutionRateChart data={formattedTrends} />
</ChartCard>
{/* 6. Archer ticket pipeline */}

View File

@@ -76,6 +76,7 @@ function ArchiveTooltip({ active, payload, label }) {
// Parse classification if present
const dataPoint = payload[0]?.payload;
const classification = dataPoint?.classification;
const returnClassification = dataPoint?.return_classification;
return (
<div style={{
@@ -133,6 +134,37 @@ function ArchiveTooltip({ active, payload, label }) {
)}
</div>
)}
{returnClassification && returned > 0 && (returnClassification.bu_reassignment > 0 || returnClassification.severity_drift > 0 || returnClassification.closed_on_platform > 0 || returnClassification.decommissioned > 0) && (
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', marginTop: '0.35rem', paddingTop: '0.3rem' }}>
<div style={{ color: '#475569', fontSize: '0.58rem', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '0.2rem' }}>
Returned because
</div>
{returnClassification.bu_reassignment > 0 && (
<div style={{ color: '#FB923C', fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<span style={{ opacity: 0.8 }}>BU reassigned back</span>
<span>{returnClassification.bu_reassignment}</span>
</div>
)}
{returnClassification.severity_drift > 0 && (
<div style={{ color: '#A78BFA', fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<span style={{ opacity: 0.8 }}>Severity re-escalated</span>
<span>{returnClassification.severity_drift}</span>
</div>
)}
{returnClassification.closed_on_platform > 0 && (
<div style={{ color: SKY, fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<span style={{ opacity: 0.8 }}>Reopened on platform</span>
<span>{returnClassification.closed_on_platform}</span>
</div>
)}
{returnClassification.decommissioned > 0 && (
<div style={{ color: '#94A3B8', fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<span style={{ opacity: 0.8 }}>Re-provisioned</span>
<span>{returnClassification.decommissioned}</span>
</div>
)}
</div>
)}
</div>
);
}
@@ -219,10 +251,11 @@ export default function IvantiCountsChart() {
archived: anomaly.newly_archived_count || 0,
returned: anomaly.returned_count || 0,
classification: anomaly.classification || {},
return_classification: anomaly.return_classification || {},
is_significant: anomaly.is_significant,
};
}
return { date: point.date, archived: 0, returned: 0, classification: {}, is_significant: false };
return { date: point.date, archived: 0, returned: 0, classification: {}, return_classification: {}, is_significant: false };
});
}, [anomalies, chartData]);

View File

@@ -1518,7 +1518,7 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd
// ---------------------------------------------------------------------------
// QueuePanel — fixed slide-out panel showing the user's Ivanti queue
// ---------------------------------------------------------------------------
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, onRedirectComplete, canWrite, fpSubmissions, onEditSubmission }) {
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, onRedirectComplete, canWrite, fpSubmissions, onEditSubmission, cardConfigured, cardTeams, onQueueRefresh }) {
const pendingCount = items.filter((i) => i.status === 'pending').length;
const completedCount = items.filter((i) => i.status === 'complete').length;
@@ -1526,6 +1526,24 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
const [redirectItem, setRedirectItem] = useState(null);
const [redirectSuccess, setRedirectSuccess] = useState(null);
// CARD action state — tracks which item has an active action form
const [cardAction, setCardAction] = useState(null); // { itemId, type: 'confirm'|'decline'|'redirect' }
const [cardFormTeam, setCardFormTeam] = useState('');
const [cardFormComment, setCardFormComment] = useState('');
const [cardFormFromTeam, setCardFormFromTeam] = useState('');
const [cardFormToTeam, setCardFormToTeam] = useState('');
const [cardActionLoading, setCardActionLoading] = useState(false);
const [cardActionError, setCardActionError] = useState(null);
// CARD Asset Search state
const [assetSearchOpen, setAssetSearchOpen] = useState(false);
const [assetSearchTeam, setAssetSearchTeam] = useState('');
const [assetSearchDisposition, setAssetSearchDisposition] = useState('confirmed');
const [assetSearchResults, setAssetSearchResults] = useState(null);
const [assetSearchLoading, setAssetSearchLoading] = useState(false);
const [assetSearchError, setAssetSearchError] = useState(null);
const [assetSearchPage, setAssetSearchPage] = useState(1);
// Drop any selected IDs that no longer exist in items
useEffect(() => {
setSelectedIds((prev) => {
@@ -1556,6 +1574,110 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
setTimeout(() => setRedirectSuccess(null), 3000);
};
// CARD action handlers
const openCardAction = (itemId, type) => {
setCardAction({ itemId, type });
setCardFormTeam('');
setCardFormComment('');
setCardFormFromTeam('');
setCardFormToTeam('');
setCardActionError(null);
};
const closeCardAction = () => {
setCardAction(null);
setCardFormTeam('');
setCardFormComment('');
setCardFormFromTeam('');
setCardFormToTeam('');
setCardActionError(null);
setCardActionLoading(false);
};
const handleCardConfirmDecline = async (item, actionType) => {
if (!cardFormTeam) return;
setCardActionLoading(true);
setCardActionError(null);
try {
const res = await fetch(`${API_BASE}/card/queue/${item.id}/${actionType}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
teamName: cardFormTeam,
assetId: item.ip_address,
comment: cardFormComment || '',
}),
});
const data = await res.json();
if (!res.ok) {
setCardActionError(data.error || `${actionType} failed.`);
setCardActionLoading(false);
return;
}
// Update local state to complete without full refresh
onUpdate(item.id, { status: 'complete' });
closeCardAction();
} catch (err) {
setCardActionError(err.message || 'Network error.');
setCardActionLoading(false);
}
};
const handleCardRedirect = async (item) => {
if (!cardFormFromTeam || !cardFormToTeam) return;
setCardActionLoading(true);
setCardActionError(null);
try {
const res = await fetch(`${API_BASE}/card/queue/${item.id}/redirect`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fromTeam: cardFormFromTeam,
toTeam: cardFormToTeam,
assetId: item.ip_address,
}),
});
const data = await res.json();
if (!res.ok) {
setCardActionError(data.error || 'Redirect failed.');
setCardActionLoading(false);
return;
}
onUpdate(item.id, { status: 'complete' });
closeCardAction();
} catch (err) {
setCardActionError(err.message || 'Network error.');
setCardActionLoading(false);
}
};
// CARD Asset Search handler
const handleAssetSearch = async (page = 1) => {
if (!assetSearchTeam || !assetSearchDisposition) return;
setAssetSearchLoading(true);
setAssetSearchError(null);
setAssetSearchPage(page);
try {
const res = await fetch(
`${API_BASE}/card/teams/${encodeURIComponent(assetSearchTeam)}/assets?disposition=${encodeURIComponent(assetSearchDisposition)}&page_size=50&page=${page}`,
{ credentials: 'include' }
);
const data = await res.json();
if (!res.ok) {
setAssetSearchError(data.error || 'Search failed.');
setAssetSearchLoading(false);
return;
}
setAssetSearchResults(data);
} catch (err) {
setAssetSearchError(err.message || 'Network error.');
} finally {
setAssetSearchLoading(false);
}
};
// Render a single queue item row
const renderQueueItem = (item, { done, selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }) => {
const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' }
@@ -1681,6 +1803,66 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
{item.workflow_type}
</span>
{/* CARD action buttons — pending CARD items only */}
{item.workflow_type === 'CARD' && item.status === 'pending' && canWrite && (
<div style={{ display: 'flex', gap: '0.25rem', flexShrink: 0 }}>
<button
onClick={() => openCardAction(item.id, 'confirm')}
disabled={!cardConfigured || cardActionLoading}
title={!cardConfigured ? 'CARD integration not configured' : 'Confirm asset'}
style={{
background: cardConfigured ? 'rgba(16,185,129,0.1)' : 'transparent',
border: `1px solid ${cardConfigured ? 'rgba(16,185,129,0.3)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.2rem',
padding: '0.15rem 0.3rem',
fontFamily: 'monospace', fontSize: '0.55rem', fontWeight: '700',
color: cardConfigured ? '#10B981' : '#334155',
cursor: cardConfigured ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.04em',
transition: 'all 0.12s',
}}
>
Confirm
</button>
<button
onClick={() => openCardAction(item.id, 'decline')}
disabled={!cardConfigured || cardActionLoading}
title={!cardConfigured ? 'CARD integration not configured' : 'Decline asset'}
style={{
background: cardConfigured ? 'rgba(239,68,68,0.1)' : 'transparent',
border: `1px solid ${cardConfigured ? 'rgba(239,68,68,0.3)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.2rem',
padding: '0.15rem 0.3rem',
fontFamily: 'monospace', fontSize: '0.55rem', fontWeight: '700',
color: cardConfigured ? '#EF4444' : '#334155',
cursor: cardConfigured ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.04em',
transition: 'all 0.12s',
}}
>
Decline
</button>
<button
onClick={() => openCardAction(item.id, 'redirect')}
disabled={!cardConfigured || cardActionLoading}
title={!cardConfigured ? 'CARD integration not configured' : 'Redirect asset'}
style={{
background: cardConfigured ? 'rgba(14,165,233,0.1)' : 'transparent',
border: `1px solid ${cardConfigured ? 'rgba(14,165,233,0.3)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.2rem',
padding: '0.15rem 0.3rem',
fontFamily: 'monospace', fontSize: '0.55rem', fontWeight: '700',
color: cardConfigured ? '#0EA5E9' : '#334155',
cursor: cardConfigured ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.04em',
transition: 'all 0.12s',
}}
>
Redirect
</button>
</div>
)}
{/* Redirect button — completed items only */}
{canWrite && done && (
<button
@@ -1708,6 +1890,228 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
);
};
// Render CARD action inline form below a queue item
const renderCardActionForm = (item) => {
if (!cardAction || cardAction.itemId !== item.id) return null;
const { type } = cardAction;
if (type === 'confirm' || type === 'decline') {
const accentColor = type === 'confirm' ? '#10B981' : '#EF4444';
const accentRgb = type === 'confirm' ? '16,185,129' : '239,68,68';
const canSubmit = !cardActionLoading && cardFormTeam.length > 0;
return (
<div style={{
padding: '0.5rem 0.625rem',
marginBottom: '0.25rem',
borderRadius: '0.375rem',
background: `rgba(${accentRgb},0.04)`,
border: `1px solid rgba(${accentRgb},0.15)`,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', marginBottom: '0.375rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '700', color: accentColor, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
{type === 'confirm' ? 'Confirm Asset' : 'Decline Asset'}
</span>
</div>
<select
value={cardFormTeam}
onChange={(e) => setCardFormTeam(e.target.value)}
disabled={cardActionLoading}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15, 23, 42, 0.6)',
border: `1px solid rgba(${accentRgb}, 0.25)`,
borderRadius: '0.25rem',
padding: '0.35rem 0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
color: '#E2E8F0',
outline: 'none',
marginBottom: '0.375rem',
}}
>
<option value="">Select team</option>
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<input
type="text"
value={cardFormComment}
onChange={(e) => setCardFormComment(e.target.value)}
disabled={cardActionLoading}
placeholder="Comment (optional)"
maxLength={500}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15, 23, 42, 0.6)',
border: `1px solid rgba(${accentRgb}, 0.15)`,
borderRadius: '0.25rem',
padding: '0.35rem 0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
color: '#E2E8F0',
outline: 'none',
marginBottom: '0.375rem',
}}
/>
{cardActionError && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.3rem 0.5rem',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.25)',
borderRadius: '0.25rem',
marginBottom: '0.375rem',
}}>
<AlertCircle style={{ width: '12px', height: '12px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#FCA5A5' }}>{cardActionError}</span>
</div>
)}
<div style={{ display: 'flex', gap: '0.375rem' }}>
<button
onClick={() => handleCardConfirmDecline(item, type)}
disabled={!canSubmit}
style={{
flex: 1, padding: '0.3rem',
background: canSubmit ? `rgba(${accentRgb},0.12)` : 'transparent',
border: `1px solid ${canSubmit ? `rgba(${accentRgb},0.35)` : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.25rem',
color: canSubmit ? accentColor : '#334155',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
cursor: canSubmit ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.04em',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem',
}}
>
{cardActionLoading && <Loader style={{ width: '10px', height: '10px', animation: 'spin 1s linear infinite' }} />}
{cardActionLoading ? 'Submitting…' : type === 'confirm' ? 'Confirm' : 'Decline'}
</button>
<button
onClick={closeCardAction}
disabled={cardActionLoading}
style={{
padding: '0.3rem 0.5rem',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '0.25rem',
color: '#64748B',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
cursor: 'pointer',
textTransform: 'uppercase', letterSpacing: '0.04em',
}}
>
Cancel
</button>
</div>
</div>
);
}
if (type === 'redirect') {
const canSubmit = !cardActionLoading && cardFormFromTeam.length > 0 && cardFormToTeam.length > 0;
return (
<div style={{
padding: '0.5rem 0.625rem',
marginBottom: '0.25rem',
borderRadius: '0.375rem',
background: 'rgba(14,165,233,0.04)',
border: '1px solid rgba(14,165,233,0.15)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', marginBottom: '0.375rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
Redirect Asset
</span>
</div>
<select
value={cardFormFromTeam}
onChange={(e) => setCardFormFromTeam(e.target.value)}
disabled={cardActionLoading}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '0.25rem',
padding: '0.35rem 0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
color: '#E2E8F0',
outline: 'none',
marginBottom: '0.375rem',
}}
>
<option value="">From team</option>
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<select
value={cardFormToTeam}
onChange={(e) => setCardFormToTeam(e.target.value)}
disabled={cardActionLoading}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '0.25rem',
padding: '0.35rem 0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
color: '#E2E8F0',
outline: 'none',
marginBottom: '0.375rem',
}}
>
<option value="">To team</option>
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
</select>
{cardActionError && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.3rem 0.5rem',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.25)',
borderRadius: '0.25rem',
marginBottom: '0.375rem',
}}>
<AlertCircle style={{ width: '12px', height: '12px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#FCA5A5' }}>{cardActionError}</span>
</div>
)}
<div style={{ display: 'flex', gap: '0.375rem' }}>
<button
onClick={() => handleCardRedirect(item)}
disabled={!canSubmit}
style={{
flex: 1, padding: '0.3rem',
background: canSubmit ? 'rgba(14,165,233,0.12)' : 'transparent',
border: `1px solid ${canSubmit ? 'rgba(14,165,233,0.35)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.25rem',
color: canSubmit ? '#0EA5E9' : '#334155',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
cursor: canSubmit ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.04em',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem',
}}
>
{cardActionLoading && <Loader style={{ width: '10px', height: '10px', animation: 'spin 1s linear infinite' }} />}
{cardActionLoading ? 'Redirecting…' : 'Redirect'}
</button>
<button
onClick={closeCardAction}
disabled={cardActionLoading}
style={{
padding: '0.3rem 0.5rem',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '0.25rem',
color: '#64748B',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
cursor: 'pointer',
textTransform: 'uppercase', letterSpacing: '0.04em',
}}
>
Cancel
</button>
</div>
</div>
);
}
return null;
};
// Inventory items (CARD + GRANITE) are their own top section; everything else groups by vendor
const grouped = useMemo(() => {
const inventoryItems = items.filter((i) => i.workflow_type === 'CARD' || i.workflow_type === 'GRANITE');
@@ -1819,7 +2223,12 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
{/* Items — Inventory section renders CARD then GRANITE with optional sub-divider */}
{isInventory ? (
<>
{cardItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))}
{cardItems.map((item) => (
<React.Fragment key={item.id}>
{renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite })}
{renderCardActionForm(item)}
</React.Fragment>
))}
{cardItems.length > 0 && graniteItems.length > 0 && (
<div style={{
height: '1px',
@@ -1836,6 +2245,211 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
))}
</div>
{/* CARD Asset Search section */}
{cardConfigured && (
<div style={{ padding: '0 1.25rem 0.75rem' }}>
<div
onClick={() => setAssetSearchOpen(!assetSearchOpen)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.3rem 0', marginBottom: '0.375rem',
borderBottom: '1px solid rgba(14,165,233,0.2)',
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
<Database style={{ width: '12px', height: '12px', color: '#0EA5E9' }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
CARD Asset Search
</span>
</div>
{assetSearchOpen
? <ChevronUp style={{ width: '14px', height: '14px', color: '#475569' }} />
: <ChevronDown style={{ width: '14px', height: '14px', color: '#475569' }} />
}
</div>
{assetSearchOpen && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
<select
value={assetSearchTeam}
onChange={(e) => { setAssetSearchTeam(e.target.value); setAssetSearchResults(null); setAssetSearchError(null); }}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '0.25rem',
padding: '0.35rem 0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
color: '#E2E8F0',
outline: 'none',
}}
>
<option value="">Select team</option>
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<select
value={assetSearchDisposition}
onChange={(e) => { setAssetSearchDisposition(e.target.value); setAssetSearchResults(null); setAssetSearchError(null); }}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '0.25rem',
padding: '0.35rem 0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
color: '#E2E8F0',
outline: 'none',
}}
>
<option value="confirmed">Confirmed</option>
<option value="unconfirmed">Unconfirmed</option>
<option value="declined">Declined</option>
<option value="candidate">Candidate</option>
</select>
<button
onClick={() => handleAssetSearch(1)}
disabled={!assetSearchTeam || assetSearchLoading}
style={{
padding: '0.35rem',
background: assetSearchTeam ? 'rgba(14,165,233,0.12)' : 'transparent',
border: `1px solid ${assetSearchTeam ? 'rgba(14,165,233,0.35)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.25rem',
color: assetSearchTeam ? '#0EA5E9' : '#334155',
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
cursor: assetSearchTeam ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.05em',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.375rem',
}}
>
{assetSearchLoading
? <Loader style={{ width: '12px', height: '12px', animation: 'spin 1s linear infinite' }} />
: <Search style={{ width: '12px', height: '12px' }} />
}
{assetSearchLoading ? 'Searching…' : 'Search'}
</button>
{/* Error */}
{assetSearchError && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.3rem 0.5rem',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.25)',
borderRadius: '0.25rem',
}}>
<AlertCircle style={{ width: '12px', height: '12px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#FCA5A5' }}>{assetSearchError}</span>
</div>
)}
{/* Results */}
{assetSearchResults && (
<div>
<div style={{
fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600',
color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em',
marginBottom: '0.25rem',
}}>
{assetSearchResults.total != null ? `${assetSearchResults.total} asset${assetSearchResults.total !== 1 ? 's' : ''} found` : 'Results'}
</div>
{/* Results table */}
{Array.isArray(assetSearchResults.assets) && assetSearchResults.assets.length > 0 ? (
<div style={{
maxHeight: '200px', overflowY: 'auto',
border: '1px solid rgba(14,165,233,0.12)',
borderRadius: '0.25rem',
}}>
<table style={{
width: '100%', borderCollapse: 'collapse',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.62rem',
}}>
<thead>
<tr style={{ background: 'rgba(14,165,233,0.06)' }}>
<th style={{ padding: '0.3rem 0.5rem', textAlign: 'left', color: '#94A3B8', fontWeight: '700', textTransform: 'uppercase', letterSpacing: '0.06em', borderBottom: '1px solid rgba(14,165,233,0.12)' }}>
Asset ID
</th>
{assetSearchResults.assets[0] && Object.keys(assetSearchResults.assets[0]).filter(k => k !== 'asset_id' && k !== '_id').slice(0, 3).map(k => (
<th key={k} style={{ padding: '0.3rem 0.5rem', textAlign: 'left', color: '#94A3B8', fontWeight: '700', textTransform: 'uppercase', letterSpacing: '0.06em', borderBottom: '1px solid rgba(14,165,233,0.12)' }}>
{k.replace(/_/g, ' ')}
</th>
))}
</tr>
</thead>
<tbody>
{assetSearchResults.assets.map((asset, idx) => {
const extraKeys = Object.keys(asset).filter(k => k !== 'asset_id' && k !== '_id').slice(0, 3);
return (
<tr key={asset.asset_id || asset._id || idx} style={{ borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
<td style={{ padding: '0.25rem 0.5rem', color: '#CBD5E1', fontWeight: '600' }}>
{asset.asset_id || asset._id || '—'}
</td>
{extraKeys.map(k => (
<td key={k} style={{ padding: '0.25rem 0.5rem', color: '#94A3B8' }}>
{typeof asset[k] === 'object' ? JSON.stringify(asset[k]) : String(asset[k] ?? '—')}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', padding: '0.5rem 0' }}>
No assets found.
</div>
)}
{/* Pagination */}
{assetSearchResults.total != null && assetSearchResults.total > (assetSearchResults.page_size || 50) && (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
marginTop: '0.375rem',
}}>
<button
onClick={() => handleAssetSearch(assetSearchPage - 1)}
disabled={assetSearchPage <= 1 || assetSearchLoading}
style={{
padding: '0.2rem 0.4rem',
background: assetSearchPage > 1 ? 'rgba(14,165,233,0.08)' : 'transparent',
border: `1px solid ${assetSearchPage > 1 ? 'rgba(14,165,233,0.25)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.2rem',
color: assetSearchPage > 1 ? '#0EA5E9' : '#334155',
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
cursor: assetSearchPage > 1 ? 'pointer' : 'not-allowed',
}}
>
Prev
</button>
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#64748B' }}>
Page {assetSearchPage} of {Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50))}
</span>
<button
onClick={() => handleAssetSearch(assetSearchPage + 1)}
disabled={assetSearchPage >= Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) || assetSearchLoading}
style={{
padding: '0.2rem 0.4rem',
background: assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? 'rgba(14,165,233,0.08)' : 'transparent',
border: `1px solid ${assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? 'rgba(14,165,233,0.25)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.2rem',
color: assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? '#0EA5E9' : '#334155',
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
cursor: assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? 'pointer' : 'not-allowed',
}}
>
Next
</button>
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Submissions section */}
{fpSubmissions && fpSubmissions.length > 0 && (
<div style={{ padding: '0 1.25rem 0.75rem' }}>
@@ -4344,6 +4958,10 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const [atlasMetricsLoading, setAtlasMetricsLoading] = useState(false);
const [atlasMetricsError, setAtlasMetricsError] = useState(null);
// CARD API state — session-level caching for teams list
const [cardConfigured, setCardConfigured] = useState(false);
const [cardTeams, setCardTeams] = useState([]);
const updateColumns = useCallback((newOrder) => {
setColumnOrder(newOrder);
saveColumnOrder(newOrder);
@@ -4481,6 +5099,31 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
}
}, []);
// CARD API — fetch status and teams (session-level caching)
const cardTeamsFetchedRef = useRef(false);
const fetchCardStatus = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/card/status`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setCardConfigured(data.configured === true);
if (data.configured && !cardTeamsFetchedRef.current) {
cardTeamsFetchedRef.current = true;
const teamsRes = await fetch(`${API_BASE}/card/teams`, { credentials: 'include' });
if (teamsRes.ok) {
const teamsData = await teamsRes.json();
const teams = Array.isArray(teamsData)
? teamsData.map(t => t.card_team_name || t._id).filter(Boolean).sort()
: [];
setCardTeams(teams);
}
}
}
} catch (err) {
console.error('[card-api] Failed to fetch CARD status:', err.message);
}
}, []);
const fetchFindings = async () => {
setLoading(true);
try {
@@ -4523,6 +5166,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
fetchFpSubmissions();
fetchAtlasStatus();
fetchAtlasMetrics();
fetchCardStatus();
}, []); // eslint-disable-line
// Set/clear a single column filter
@@ -5658,6 +6302,9 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
canWrite={canWrite}
fpSubmissions={fpSubmissions}
onEditSubmission={handleEditSubmission}
cardConfigured={cardConfigured}
cardTeams={cardTeams}
onQueueRefresh={fetchQueue}
/>
<FpWorkflowModal
open={fpModalOpen}