Files
cve-dashboard/frontend/src/components/pages/ArcherTemplatePage.js
Jordan Ramos 50f14c14d2 Add inline view panel to Template Manager with copy buttons
Click the model name or eye icon to expand a read-only view of all
template sections with per-section copy-to-clipboard and Copy All.
2026-06-03 11:04:25 -06:00

570 lines
21 KiB
JavaScript

// ArcherTemplatePage.js
// Full-page Template Manager — browse, create, edit, clone, and delete
// Archer Risk Acceptance templates organized by Vendor > Platform > Model.
// Write operations require editor/admin role (Standard_User or Admin group).
import React, { useState, useEffect, useCallback } from 'react';
import {
FileText, Plus, Edit, Copy, Trash2, ChevronDown, ChevronRight,
Loader, AlertCircle, RefreshCw, Eye, EyeOff, Clipboard, Check,
} from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import TemplateFormModal from '../TemplateFormModal';
import DeleteConfirmModal from '../DeleteConfirmModal';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// Section field mapping — ordered: static first, then semi-static
const SECTIONS = [
{ key: 'environment_overview', label: 'Environment Overview' },
{ key: 'segmentation', label: 'Segmentation' },
{ key: 'mitigating_controls', label: 'Mitigating Controls' },
{ key: 'additional_info', label: 'Additional Info/Background' },
{ key: 'charter_network_banner', label: 'Charter Network Banner' },
{ key: 'data_classification', label: 'Data Classification' },
{ key: 'charter_network', label: 'Charter Network' },
{ key: 'additional_access_list', label: 'Additional Access List' },
];
// ---------------------------------------------------------------------------
// Styles — dark theme tactical intelligence aesthetic
// ---------------------------------------------------------------------------
const STYLES = {
page: {
minHeight: '60vh',
},
card: {
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(15, 23, 42, 0.98))',
border: '1px solid rgba(0, 212, 255, 0.15)',
borderRadius: '12px',
padding: '1.5rem',
marginBottom: '1rem',
},
header: {
fontFamily: 'monospace',
fontSize: '0.7rem',
fontWeight: 700,
color: '#00d4ff',
textTransform: 'uppercase',
letterSpacing: '0.15em',
marginBottom: '1rem',
},
toolbar: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '1rem',
flexWrap: 'wrap',
gap: '0.5rem',
},
btn: {
padding: '0.5rem 1rem',
borderRadius: '8px',
border: '1px solid rgba(0, 212, 255, 0.3)',
background: 'rgba(0, 212, 255, 0.1)',
color: '#7DD3FC',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
transition: 'all 0.2s',
},
btnDanger: {
padding: '0.4rem 0.75rem',
borderRadius: '6px',
border: '1px solid rgba(239, 68, 68, 0.3)',
background: 'rgba(239, 68, 68, 0.1)',
color: '#FCA5A5',
cursor: 'pointer',
fontSize: '0.75rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.3rem',
transition: 'all 0.2s',
},
btnSmall: {
padding: '0.35rem 0.65rem',
borderRadius: '6px',
border: '1px solid rgba(0, 212, 255, 0.25)',
background: 'rgba(0, 212, 255, 0.08)',
color: '#7DD3FC',
cursor: 'pointer',
fontSize: '0.75rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.3rem',
transition: 'all 0.2s',
},
vendorGroup: {
marginBottom: '0.75rem',
},
vendorHeader: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.6rem 0.75rem',
background: 'rgba(0, 212, 255, 0.05)',
border: '1px solid rgba(0, 212, 255, 0.12)',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.15s',
},
vendorLabel: {
fontSize: '0.85rem',
fontWeight: 700,
color: '#e0e0e0',
},
vendorCount: {
fontSize: '0.7rem',
color: '#64748B',
marginLeft: 'auto',
},
platformSubgroup: {
marginLeft: '1.25rem',
marginTop: '0.5rem',
paddingLeft: '0.75rem',
borderLeft: '2px solid rgba(0, 212, 255, 0.1)',
},
platformLabel: {
fontSize: '0.78rem',
fontWeight: 600,
color: '#94A3B8',
marginBottom: '0.35rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
},
templateRow: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.45rem 0.6rem',
borderRadius: '6px',
marginBottom: '0.25rem',
transition: 'background 0.15s',
},
templateModel: {
fontSize: '0.82rem',
color: '#e0e0e0',
fontWeight: 500,
},
templateActions: {
display: 'flex',
alignItems: 'center',
gap: '0.35rem',
},
emptyState: {
textAlign: 'center',
padding: '3rem 1rem',
color: '#64748B',
fontSize: '0.9rem',
},
errorBanner: {
padding: '0.75rem 1rem',
borderRadius: '8px',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.25)',
color: '#FCA5A5',
fontSize: '0.82rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
marginBottom: '1rem',
},
loadingState: {
textAlign: 'center',
padding: '3rem 1rem',
color: '#64748B',
fontSize: '0.85rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.75rem',
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Group templates by vendor, then by platform within each vendor.
* Returns: [ { vendor, platforms: [ { platform, templates: [...] } ] } ]
*/
function groupTemplates(templates) {
const vendorMap = {};
for (const t of templates) {
if (!vendorMap[t.vendor]) vendorMap[t.vendor] = {};
if (!vendorMap[t.vendor][t.platform]) vendorMap[t.vendor][t.platform] = [];
vendorMap[t.vendor][t.platform].push(t);
}
const vendors = Object.keys(vendorMap).sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: 'base' })
);
return vendors.map(vendor => {
const platforms = Object.keys(vendorMap[vendor]).sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: 'base' })
);
return {
vendor,
platforms: platforms.map(platform => ({
platform,
templates: vendorMap[vendor][platform].sort((a, b) =>
a.model.localeCompare(b.model, undefined, { sensitivity: 'base' })
),
})),
};
});
}
// ---------------------------------------------------------------------------
// ArcherTemplatePage
// ---------------------------------------------------------------------------
export default function ArcherTemplatePage() {
const { canWrite } = useAuth();
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [expandedVendors, setExpandedVendors] = useState({});
// Modal state for create/edit/clone
const [modalState, setModalState] = useState({ open: false, mode: 'create', template: null });
const [deleteTarget, setDeleteTarget] = useState(null);
// View panel state — which template ID is expanded for viewing
const [viewExpandedId, setViewExpandedId] = useState(null);
// Copy state for view panel
const [copiedSections, setCopiedSections] = useState({});
const [copyAllCopied, setCopyAllCopied] = useState(false);
// -------------------------------------------------------------------------
// Fetch templates
// -------------------------------------------------------------------------
const fetchTemplates = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/archer-templates`, {
credentials: 'include',
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Failed to fetch templates (${res.status})`);
}
const data = await res.json();
setTemplates(data);
// Expand all vendors by default on initial load
const expanded = {};
const grouped = groupTemplates(data);
for (const g of grouped) {
expanded[g.vendor] = true;
}
setExpandedVendors(expanded);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchTemplates();
}, [fetchTemplates]);
// -------------------------------------------------------------------------
// Vendor toggle
// -------------------------------------------------------------------------
const toggleVendor = (vendor) => {
setExpandedVendors(prev => ({ ...prev, [vendor]: !prev[vendor] }));
};
// -------------------------------------------------------------------------
// View panel toggle and copy handlers
// -------------------------------------------------------------------------
const toggleView = (templateId) => {
setViewExpandedId(prev => prev === templateId ? null : templateId);
setCopiedSections({});
setCopyAllCopied(false);
};
const handleCopySection = async (sectionKey, content) => {
if (!content) return;
try {
await navigator.clipboard.writeText(content);
setCopiedSections(prev => ({ ...prev, [sectionKey]: true }));
setTimeout(() => {
setCopiedSections(prev => ({ ...prev, [sectionKey]: false }));
}, 2000);
} catch (_err) { /* clipboard failed */ }
};
const handleCopyAll = async (template) => {
const parts = [];
for (const section of SECTIONS) {
const content = template[section.key];
if (content && content.trim()) {
parts.push(`${section.label}\n${content}`);
}
}
try {
await navigator.clipboard.writeText(parts.join('\n\n'));
setCopyAllCopied(true);
setTimeout(() => setCopyAllCopied(false), 2000);
} catch (_err) { /* clipboard failed */ }
};
// -------------------------------------------------------------------------
// Grouped data
// -------------------------------------------------------------------------
const grouped = groupTemplates(templates);
const totalCount = templates.length;
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
return (
<div style={STYLES.page}>
<div style={STYLES.card}>
{/* Header */}
<div style={STYLES.header}>
<FileText size={12} style={{ display: 'inline', verticalAlign: 'middle', marginRight: '0.4rem' }} />
Archer Template Library
</div>
{/* Toolbar */}
<div style={STYLES.toolbar}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ color: '#94A3B8', fontSize: '0.8rem' }}>
{totalCount} template{totalCount !== 1 ? 's' : ''}
</span>
<button
style={STYLES.btn}
onClick={fetchTemplates}
title="Refresh"
>
<RefreshCw size={13} />
</button>
</div>
{canWrite() && (
<button
style={STYLES.btn}
onClick={() => setModalState({ open: true, mode: 'create', template: null })}
>
<Plus size={14} />
Create Template
</button>
)}
</div>
{/* Error banner */}
{error && (
<div style={STYLES.errorBanner}>
<AlertCircle size={14} />
{error}
</div>
)}
{/* Loading state */}
{loading && (
<div style={STYLES.loadingState}>
<Loader size={24} style={{ animation: 'spin 1s linear infinite' }} />
<span>Loading templates...</span>
</div>
)}
{/* Empty state */}
{!loading && !error && templates.length === 0 && (
<div style={STYLES.emptyState}>
<FileText size={32} style={{ marginBottom: '0.75rem', opacity: 0.4 }} />
<div>No templates found</div>
{canWrite() && (
<div style={{ marginTop: '0.5rem', fontSize: '0.8rem', color: '#475569' }}>
Click "Create Template" to add your first template.
</div>
)}
</div>
)}
{/* Template list grouped by vendor > platform */}
{!loading && grouped.map(({ vendor, platforms }) => (
<div key={vendor} style={STYLES.vendorGroup}>
{/* Vendor header — collapsible */}
<div
style={STYLES.vendorHeader}
onClick={() => toggleVendor(vendor)}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.1)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.05)'; }}
>
{expandedVendors[vendor]
? <ChevronDown size={14} style={{ color: '#00d4ff' }} />
: <ChevronRight size={14} style={{ color: '#64748B' }} />
}
<span style={STYLES.vendorLabel}>{vendor}</span>
<span style={STYLES.vendorCount}>
{platforms.reduce((sum, p) => sum + p.templates.length, 0)} template{platforms.reduce((sum, p) => sum + p.templates.length, 0) !== 1 ? 's' : ''}
</span>
</div>
{/* Platform subgroups */}
{expandedVendors[vendor] && platforms.map(({ platform, templates: platTemplates }) => (
<div key={platform} style={STYLES.platformSubgroup}>
<div style={STYLES.platformLabel}>{platform}</div>
{platTemplates.map(template => (
<div key={template.id}>
<div
style={STYLES.templateRow}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.04)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
>
<span
style={{ ...STYLES.templateModel, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem' }}
onClick={() => toggleView(template.id)}
title="View template sections"
>
{viewExpandedId === template.id
? <EyeOff size={13} style={{ color: '#00d4ff' }} />
: <Eye size={13} style={{ color: '#64748B' }} />
}
{template.model}
</span>
{canWrite() && (
<div style={STYLES.templateActions}>
<button
style={STYLES.btnSmall}
onClick={() => setModalState({ open: true, mode: 'edit', template })}
title="Edit template"
>
<Edit size={12} />
</button>
<button
style={STYLES.btnSmall}
onClick={() => setModalState({ open: true, mode: 'clone', template })}
title="Clone template"
>
<Copy size={12} />
</button>
<button
style={STYLES.btnDanger}
onClick={() => setDeleteTarget(template)}
title="Delete template"
>
<Trash2 size={12} />
</button>
</div>
)}
</div>
{/* Expandable view panel */}
{viewExpandedId === template.id && (
<div style={{
margin: '0.25rem 0 0.75rem 1.5rem',
padding: '0.75rem 1rem',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(0, 212, 255, 0.12)',
borderRadius: '8px',
}}>
{/* Copy All button */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '0.5rem' }}>
<button
onClick={() => handleCopyAll(template)}
style={{
padding: '0.3rem 0.6rem',
borderRadius: '5px',
border: copyAllCopied ? '1px solid rgba(34, 197, 94, 0.4)' : '1px solid rgba(0, 212, 255, 0.3)',
background: copyAllCopied ? 'rgba(34, 197, 94, 0.12)' : 'rgba(0, 212, 255, 0.08)',
color: copyAllCopied ? '#22c55e' : '#00d4ff',
fontSize: '0.7rem',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '0.3rem',
}}
>
{copyAllCopied ? <><Check size={11} /> Copied!</> : <><Clipboard size={11} /> Copy All</>}
</button>
</div>
{/* Section blocks */}
{SECTIONS.map(section => {
const content = template[section.key];
const isEmpty = !content || !content.trim();
const isCopied = copiedSections[section.key];
return (
<div key={section.key} style={{
marginBottom: '0.5rem',
padding: '0.5rem 0.6rem',
background: 'rgba(30, 41, 59, 0.5)',
border: '1px solid rgba(100, 116, 139, 0.12)',
borderRadius: '6px',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.25rem' }}>
<span style={{ fontSize: '0.72rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{section.label}
</span>
<button
onClick={() => handleCopySection(section.key, content)}
disabled={isEmpty}
style={{
padding: '0.2rem 0.4rem',
borderRadius: '4px',
border: isCopied ? '1px solid rgba(34, 197, 94, 0.3)' : '1px solid rgba(100, 116, 139, 0.25)',
background: isCopied ? 'rgba(34, 197, 94, 0.1)' : 'rgba(100, 116, 139, 0.1)',
color: isCopied ? '#22c55e' : '#94a3b8',
fontSize: '0.65rem',
cursor: isEmpty ? 'not-allowed' : 'pointer',
opacity: isEmpty ? 0.4 : 1,
display: 'flex',
alignItems: 'center',
gap: '0.2rem',
}}
>
{isCopied ? <><Check size={9} /> Copied!</> : <><Clipboard size={9} /> Copy</>}
</button>
</div>
{isEmpty ? (
<div style={{ fontSize: '0.78rem', color: '#475569', fontStyle: 'italic' }}>No content stored</div>
) : (
<div style={{ fontSize: '0.78rem', color: '#e0e0e0', lineHeight: 1.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: '150px', overflowY: 'auto' }}>
{content}
</div>
)}
</div>
);
})}
</div>
)}
</div>
))}
</div>
))}
</div>
))}
</div>
{/* Template form modal (create/edit/clone) */}
{modalState.open && (
<TemplateFormModal
mode={modalState.mode}
template={modalState.template}
onClose={() => setModalState({ open: false, mode: 'create', template: null })}
onSuccess={fetchTemplates}
/>
)}
{/* Delete confirmation modal */}
<DeleteConfirmModal
template={deleteTarget}
onConfirm={() => {
setDeleteTarget(null);
fetchTemplates();
}}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}