Add Archer Template Library for risk acceptance form reuse
Adds a template management system to the Ivanti Queue's Archer Risk Acceptance workflow. Templates store static form content (Environment Overview, Segmentation, Mitigating Controls, etc.) organized by Vendor > Platform > Model hierarchy. Features: - Full CRUD API at /api/archer-templates with search, filter, clone, and hierarchy navigation endpoints - Template Manager page (nav: Template Mgr) with grouped list view, create/edit/clone/delete modals, role-based access - TemplateSelector component integrated into Ivanti Todo Queue for Archer workflow items with per-section copy-to-clipboard buttons and Copy All functionality - Database migration with case-insensitive uniqueness enforcement - Audit logging for all template mutations New files: - backend/migrations/add_archer_templates_table.js - backend/routes/archerTemplates.js - frontend/src/components/pages/ArcherTemplatePage.js - frontend/src/components/TemplateSelector.js - frontend/src/components/TemplateFormModal.js - frontend/src/components/DeleteConfirmModal.js
This commit is contained in:
271
frontend/src/components/DeleteConfirmModal.js
Normal file
271
frontend/src/components/DeleteConfirmModal.js
Normal file
@@ -0,0 +1,271 @@
|
||||
// DeleteConfirmModal.js
|
||||
// Confirmation dialog for deleting Archer templates.
|
||||
// Identifies the template by vendor/platform/model before deletion.
|
||||
// On confirm: calls DELETE API, invokes onConfirm callback, closes.
|
||||
// On cancel: dismisses dialog, leaves template unchanged.
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
/**
|
||||
* DeleteConfirmModal — confirmation dialog for deleting an Archer template.
|
||||
*
|
||||
* Props:
|
||||
* template {object|null} The template to delete (contains id, vendor, platform, model).
|
||||
* When null/undefined, modal is hidden.
|
||||
* onConfirm {function} Callback after successful delete (refresh list).
|
||||
* onCancel {function} Callback to close without deleting.
|
||||
*/
|
||||
export default function DeleteConfirmModal({ template, onConfirm, onCancel }) {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const cancelRef = useRef(null);
|
||||
|
||||
// Focus cancel button on open and handle Escape key
|
||||
useEffect(() => {
|
||||
if (!template) return;
|
||||
|
||||
const timer = setTimeout(() => cancelRef.current?.focus(), 50);
|
||||
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape' && !deleting) onCancel?.();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [template, deleting, onCancel]);
|
||||
|
||||
// Reset state when template changes (new modal open)
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
setDeleting(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [template]);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!template) return;
|
||||
setDeleting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/archer-templates/${template.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Delete failed (${res.status})`);
|
||||
}
|
||||
|
||||
onConfirm?.();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setDeleting(false);
|
||||
}
|
||||
}, [template, onConfirm]);
|
||||
|
||||
if (!template) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-confirm-title"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 70,
|
||||
background: 'rgba(10, 14, 39, 0.95)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !deleting) onCancel?.();
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.7), 0 0 30px rgba(239,68,68,0.06)',
|
||||
width: '100%',
|
||||
maxWidth: '440px',
|
||||
padding: '1.75rem 2rem',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.625rem',
|
||||
marginBottom: '1rem',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '0.5rem',
|
||||
background: 'rgba(239, 68, 68, 0.10)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<AlertTriangle style={{ width: '16px', height: '16px', color: '#EF4444' }} />
|
||||
</div>
|
||||
<div
|
||||
id="delete-confirm-title"
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.95rem',
|
||||
fontWeight: '700',
|
||||
color: '#EF4444',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
Delete Template
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{
|
||||
fontSize: '0.82rem',
|
||||
color: '#CBD5E1',
|
||||
lineHeight: '1.6',
|
||||
marginBottom: '1.25rem',
|
||||
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||
}}>
|
||||
<p style={{ margin: '0 0 0.75rem 0' }}>
|
||||
Are you sure you want to delete this template? This action cannot be undone.
|
||||
</p>
|
||||
<div style={{
|
||||
background: 'rgba(239, 68, 68, 0.06)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.15)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
||||
<span style={{ color: '#94A3B8', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Vendor
|
||||
</span>
|
||||
<span style={{ color: '#e0e0e0', fontSize: '0.82rem', fontWeight: 600 }}>
|
||||
{template.vendor}
|
||||
</span>
|
||||
<span style={{ color: '#94A3B8', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: '0.3rem' }}>
|
||||
Platform
|
||||
</span>
|
||||
<span style={{ color: '#e0e0e0', fontSize: '0.82rem', fontWeight: 600 }}>
|
||||
{template.platform}
|
||||
</span>
|
||||
<span style={{ color: '#94A3B8', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: '0.3rem' }}>
|
||||
Model
|
||||
</span>
|
||||
<span style={{ color: '#e0e0e0', fontSize: '0.82rem', fontWeight: 600 }}>
|
||||
{template.model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '0.6rem 0.75rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.25)',
|
||||
color: '#FCA5A5',
|
||||
fontSize: '0.78rem',
|
||||
marginBottom: '1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
}}>
|
||||
<AlertTriangle style={{ width: '12px', height: '12px', flexShrink: 0 }} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<button
|
||||
ref={cancelRef}
|
||||
onClick={onCancel}
|
||||
disabled={deleting}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '0.625rem',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(100,116,139,0.4)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#94A3B8',
|
||||
cursor: deleting ? 'not-allowed' : 'pointer',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.78rem',
|
||||
opacity: deleting ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
if (!deleting) {
|
||||
e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)';
|
||||
e.currentTarget.style.color = '#CBD5E1';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)';
|
||||
e.currentTarget.style.color = '#94A3B8';
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={deleting}
|
||||
style={{
|
||||
flex: 1.5,
|
||||
padding: '0.625rem',
|
||||
background: 'rgba(239, 68, 68, 0.10)',
|
||||
border: '1px solid #EF4444',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#EF4444',
|
||||
cursor: deleting ? 'not-allowed' : 'pointer',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.4rem',
|
||||
opacity: deleting ? 0.7 : 1,
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
if (!deleting) {
|
||||
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.18)';
|
||||
e.currentTarget.style.boxShadow = '0 0 20px rgba(239,68,68,0.15)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.10)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<Trash2 style={{ width: '13px', height: '13px' }} />
|
||||
{deleting ? 'Deleting...' : 'Delete Template'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user