Files
cve-dashboard/frontend/src/components/TemplateFormModal.js

523 lines
16 KiB
JavaScript
Raw Normal View History

// 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'; // ⚠️ CONVENTION: Prefer relative API paths (e.g. '/api') over absolute URL fallback
// ---------------------------------------------------------------------------
// 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 (
<div
role="dialog"
aria-modal="true"
aria-labelledby="template-form-modal-title"
style={STYLES.backdrop}
>
<div style={STYLES.modal}>
{/* Header */}
<div style={STYLES.header}>
<span id="template-form-modal-title" style={STYLES.title}>
{titles[mode] || 'Template'}
</span>
<button
style={STYLES.closeBtn}
onClick={onClose}
title="Close"
aria-label="Close modal"
>
<X size={18} />
</button>
</div>
{/* API error banner */}
{apiError && (
<div style={STYLES.errorBanner}>
<AlertCircle size={14} />
{apiError}
</div>
)}
<form onSubmit={handleSubmit} noValidate>
{/* Hierarchy fields */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.75rem' }}>
{/* Vendor */}
<div style={STYLES.fieldGroup}>
<label style={STYLES.label} htmlFor="tmpl-vendor">Vendor *</label>
<input
ref={vendorRef}
id="tmpl-vendor"
type="text"
maxLength={100}
value={vendor}
onChange={(e) => {
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 && <div style={STYLES.errorText}>{fieldErrors.vendor}</div>}
</div>
{/* Platform */}
<div style={STYLES.fieldGroup}>
<label style={STYLES.label} htmlFor="tmpl-platform">Platform *</label>
<input
id="tmpl-platform"
type="text"
maxLength={100}
value={platform}
onChange={(e) => {
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 && <div style={STYLES.errorText}>{fieldErrors.platform}</div>}
</div>
{/* Model */}
<div style={STYLES.fieldGroup}>
<label style={STYLES.label} htmlFor="tmpl-model">Model *</label>
<input
id="tmpl-model"
type="text"
maxLength={100}
value={model}
onChange={(e) => {
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 && <div style={STYLES.errorText}>{fieldErrors.model}</div>}
</div>
</div>
{/* Section textareas */}
<div style={STYLES.sectionDivider}>Template Sections</div>
{SECTIONS.map((section) => (
<div key={section.key} style={STYLES.fieldGroup}>
<label style={STYLES.label} htmlFor={`tmpl-${section.key}`}>
{section.label}
</label>
<textarea
id={`tmpl-${section.key}`}
value={sections[section.key]}
onChange={(e) => handleSectionChange(section.key, e.target.value)}
maxLength={10000}
style={STYLES.textarea}
placeholder={`Enter ${section.label.toLowerCase()} content...`}
/>
{sections[section.key].length > 9500 && (
<div style={STYLES.charCount}>
{sections[section.key].length}/10,000
</div>
)}
</div>
))}
{/* Footer actions */}
<div style={STYLES.footer}>
<button
type="button"
style={STYLES.cancelBtn}
onClick={onClose}
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
style={{
...STYLES.submitBtn,
...(submitting ? STYLES.submitBtnDisabled : {}),
}}
>
{submitting ? <Loader size={13} /> : <Save size={13} />}
{submitting ? 'Saving...' : (mode === 'edit' ? 'Update Template' : 'Save Template')}
</button>
</div>
</form>
</div>
</div>
);
}