// TemplateFormModal.js // Modal for creating, editing, and cloning Archer Risk Acceptance templates. // Supports three modes: // - create: all fields empty // - edit: pre-populated from existing template // - clone: sections pre-populated from source, hierarchy fields empty import React, { useState, useEffect, useRef } from 'react'; import { X, Save, AlertCircle, Loader } from 'lucide-react'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; // --------------------------------------------------------------------------- // Section definitions — ordered as 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 // --------------------------------------------------------------------------- const STYLES = { backdrop: { position: 'fixed', inset: 0, zIndex: 70, background: 'rgba(10, 14, 39, 0.95)', backdropFilter: 'blur(8px)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: '2rem 1rem', overflowY: 'auto', }, modal: { background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)', border: '1px solid rgba(0, 212, 255, 0.2)', borderRadius: '12px', boxShadow: '0 20px 60px rgba(0,0,0,0.7), 0 0 30px rgba(0,212,255,0.08)', width: '100%', maxWidth: '700px', padding: '1.75rem 2rem', marginTop: '1rem', marginBottom: '2rem', }, header: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.25rem', }, title: { fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: 700, color: '#00d4ff', textTransform: 'uppercase', letterSpacing: '0.12em', }, closeBtn: { background: 'transparent', border: 'none', color: '#64748B', cursor: 'pointer', padding: '0.25rem', borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', }, fieldGroup: { marginBottom: '1rem', }, label: { display: 'block', fontSize: '0.75rem', fontWeight: 600, color: '#94A3B8', marginBottom: '0.3rem', textTransform: 'uppercase', letterSpacing: '0.05em', }, input: { width: '100%', padding: '0.55rem 0.75rem', borderRadius: '6px', border: '1px solid rgba(0, 212, 255, 0.2)', background: 'rgba(15, 23, 42, 0.8)', color: '#e0e0e0', fontSize: '0.85rem', fontFamily: 'inherit', outline: 'none', transition: 'border-color 0.2s', boxSizing: 'border-box', }, inputError: { borderColor: '#ef4444', }, textarea: { width: '100%', padding: '0.55rem 0.75rem', borderRadius: '6px', border: '1px solid rgba(0, 212, 255, 0.15)', background: 'rgba(15, 23, 42, 0.8)', color: '#e0e0e0', fontSize: '0.82rem', fontFamily: 'inherit', outline: 'none', resize: 'vertical', minHeight: '80px', transition: 'border-color 0.2s', boxSizing: 'border-box', }, errorText: { fontSize: '0.72rem', color: '#ef4444', marginTop: '0.2rem', }, errorBanner: { padding: '0.65rem 0.85rem', borderRadius: '8px', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.25)', color: '#FCA5A5', fontSize: '0.8rem', display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '1rem', }, sectionDivider: { margin: '1.25rem 0 0.75rem', padding: '0.4rem 0', borderTop: '1px solid rgba(0, 212, 255, 0.08)', fontSize: '0.7rem', fontWeight: 700, color: '#00d4ff', textTransform: 'uppercase', letterSpacing: '0.12em', }, footer: { display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1.5rem', paddingTop: '1rem', borderTop: '1px solid rgba(0, 212, 255, 0.08)', }, cancelBtn: { padding: '0.55rem 1.1rem', borderRadius: '6px', border: '1px solid rgba(100,116,139,0.4)', background: 'transparent', color: '#94A3B8', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600, transition: 'all 0.2s', }, submitBtn: { padding: '0.55rem 1.25rem', borderRadius: '6px', border: '1px solid rgba(0, 212, 255, 0.4)', background: 'rgba(0, 212, 255, 0.12)', color: '#7DD3FC', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '0.4rem', transition: 'all 0.2s', }, submitBtnDisabled: { opacity: 0.5, cursor: 'not-allowed', }, charCount: { fontSize: '0.65rem', color: '#475569', textAlign: 'right', marginTop: '0.15rem', }, }; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- /** * TemplateFormModal * * Props: * mode {'create'|'edit'|'clone'} Determines form behavior * template {object|null} Source template (for edit/clone) * onClose {function} Callback to close the modal * onSuccess {function} Callback after successful save (refreshes list) */ export default function TemplateFormModal({ mode = 'create', template = null, onClose, onSuccess }) { // Form state const [vendor, setVendor] = useState(''); const [platform, setPlatform] = useState(''); const [model, setModel] = useState(''); const [sections, setSections] = useState(() => { const initial = {}; for (const s of SECTIONS) { initial[s.key] = ''; } return initial; }); // Validation and submission state const [fieldErrors, setFieldErrors] = useState({}); const [apiError, setApiError] = useState(null); const [submitting, setSubmitting] = useState(false); const vendorRef = useRef(null); // ------------------------------------------------------------------------- // Initialize form based on mode // ------------------------------------------------------------------------- useEffect(() => { if (mode === 'edit' && template) { setVendor(template.vendor || ''); setPlatform(template.platform || ''); setModel(template.model || ''); const sectionValues = {}; for (const s of SECTIONS) { sectionValues[s.key] = template[s.key] || ''; } setSections(sectionValues); } else if (mode === 'clone' && template) { // Clone: copy sections, leave hierarchy empty setVendor(''); setPlatform(''); setModel(''); const sectionValues = {}; for (const s of SECTIONS) { sectionValues[s.key] = template[s.key] || ''; } setSections(sectionValues); } // create mode: all fields already empty (initial state) }, [mode, template]); // Focus the vendor input on mount useEffect(() => { const timer = setTimeout(() => vendorRef.current?.focus(), 80); return () => clearTimeout(timer); }, []); // Handle Escape key useEffect(() => { const handleKey = (e) => { if (e.key === 'Escape') onClose?.(); }; document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey); }, [onClose]); // ------------------------------------------------------------------------- // Validation // ------------------------------------------------------------------------- function validate() { const errors = {}; if (!vendor.trim()) errors.vendor = 'Vendor is required'; else if (vendor.trim().length > 100) errors.vendor = 'Vendor must be 100 characters or fewer'; if (!platform.trim()) errors.platform = 'Platform is required'; else if (platform.trim().length > 100) errors.platform = 'Platform must be 100 characters or fewer'; if (!model.trim()) errors.model = 'Model is required'; else if (model.trim().length > 100) errors.model = 'Model must be 100 characters or fewer'; setFieldErrors(errors); return Object.keys(errors).length === 0; } // ------------------------------------------------------------------------- // Submit // ------------------------------------------------------------------------- async function handleSubmit(e) { e.preventDefault(); setApiError(null); if (!validate()) return; setSubmitting(true); try { const body = { vendor: vendor.trim(), platform: platform.trim(), model: model.trim(), }; // Include section fields for (const s of SECTIONS) { body[s.key] = sections[s.key]; } let url; let method; if (mode === 'edit' && template) { // PUT to update url = `${API_BASE}/archer-templates/${template.id}`; method = 'PUT'; } else if (mode === 'clone' && template) { // POST to clone endpoint url = `${API_BASE}/archer-templates/${template.id}/clone`; method = 'POST'; // Clone endpoint only needs vendor, platform, model delete body.environment_overview; delete body.segmentation; delete body.mitigating_controls; delete body.additional_info; delete body.charter_network_banner; delete body.data_classification; delete body.charter_network; delete body.additional_access_list; } else { // POST to create url = `${API_BASE}/archer-templates`; method = 'POST'; } const res = await fetch(url, { method, credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { const data = await res.json().catch(() => ({})); if (res.status === 409) { setApiError(data.error || 'A template with this vendor/platform/model combination already exists'); } else { setApiError(data.error || `Request failed (${res.status})`); } return; } // Success — close and refresh onSuccess?.(); onClose?.(); } catch (err) { setApiError(err.message || 'Network error — please try again'); } finally { setSubmitting(false); } } // ------------------------------------------------------------------------- // Section change handler // ------------------------------------------------------------------------- function handleSectionChange(key, value) { setSections(prev => ({ ...prev, [key]: value })); } // ------------------------------------------------------------------------- // Title based on mode // ------------------------------------------------------------------------- const titles = { create: 'Create Template', edit: 'Edit Template', clone: 'Clone Template', }; // ------------------------------------------------------------------------- // Render // ------------------------------------------------------------------------- return (
{ if (e.target === e.currentTarget) onClose?.(); }} >
{/* Header */}
{titles[mode] || 'Template'}
{/* API error banner */} {apiError && (
{apiError}
)}
{/* Hierarchy fields */}
{/* Vendor */}
{ setVendor(e.target.value); if (fieldErrors.vendor) setFieldErrors(prev => ({ ...prev, vendor: undefined })); }} style={{ ...STYLES.input, ...(fieldErrors.vendor ? STYLES.inputError : {}) }} placeholder="e.g. Harmonic" /> {fieldErrors.vendor &&
{fieldErrors.vendor}
}
{/* Platform */}
{ setPlatform(e.target.value); if (fieldErrors.platform) setFieldErrors(prev => ({ ...prev, platform: undefined })); }} style={{ ...STYLES.input, ...(fieldErrors.platform ? STYLES.inputError : {}) }} placeholder="e.g. vCMTS" /> {fieldErrors.platform &&
{fieldErrors.platform}
}
{/* Model */}
{ setModel(e.target.value); if (fieldErrors.model) setFieldErrors(prev => ({ ...prev, model: undefined })); }} style={{ ...STYLES.input, ...(fieldErrors.model ? STYLES.inputError : {}) }} placeholder="e.g. 3.29.1" /> {fieldErrors.model &&
{fieldErrors.model}
}
{/* Section textareas */}
Template Sections
{SECTIONS.map((section) => (