// 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 (
{/* Header */}
Archer Template Library
{/* Toolbar */}
{totalCount} template{totalCount !== 1 ? 's' : ''}
{canWrite() && ( )}
{/* Error banner */} {error && (
{error}
)} {/* Loading state */} {loading && (
Loading templates...
)} {/* Empty state */} {!loading && !error && templates.length === 0 && (
No templates found
{canWrite() && (
Click "Create Template" to add your first template.
)}
)} {/* Template list grouped by vendor > platform */} {!loading && grouped.map(({ vendor, platforms }) => (
{/* Vendor header — collapsible */}
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] ? : } {vendor} {platforms.reduce((sum, p) => sum + p.templates.length, 0)} template{platforms.reduce((sum, p) => sum + p.templates.length, 0) !== 1 ? 's' : ''}
{/* Platform subgroups */} {expandedVendors[vendor] && platforms.map(({ platform, templates: platTemplates }) => (
{platform}
{platTemplates.map(template => (
{ e.currentTarget.style.background = 'rgba(0, 212, 255, 0.04)'; }} onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }} > toggleView(template.id)} title="View template sections" > {viewExpandedId === template.id ? : } {template.model} {canWrite() && (
)}
{/* Expandable view panel */} {viewExpandedId === template.id && (
{/* Copy All button */}
{/* Section blocks */} {SECTIONS.map(section => { const content = template[section.key]; const isEmpty = !content || !content.trim(); const isCopied = copiedSections[section.key]; return (
{section.label}
{isEmpty ? (
No content stored
) : (
{content}
)}
); })}
)}
))}
))}
))}
{/* Template form modal (create/edit/clone) */} {modalState.open && ( setModalState({ open: false, mode: 'create', template: null })} onSuccess={fetchTemplates} /> )} {/* Delete confirmation modal */} { setDeleteTarget(null); fetchTemplates(); }} onCancel={() => setDeleteTarget(null)} />
); }