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:
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight, FileSpreadsheet } from 'lucide-react';
|
||||
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight, FileSpreadsheet, FileText } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import ConsolidationModal from '../ConsolidationModal';
|
||||
import LoaderModal from '../LoaderModal';
|
||||
import TemplateSelector from '../TemplateSelector';
|
||||
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
|
||||
import { groupQueueItems } from '../../utils/queueGrouping';
|
||||
import { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject } from './JiraPage';
|
||||
@@ -311,6 +312,9 @@ export default function IvantiTodoQueuePage() {
|
||||
// Collapse state for grouped sections (Requirement 2.2, 2.7)
|
||||
const [collapsedSections, setCollapsedSections] = useState({});
|
||||
|
||||
// Archer Template Selector panel — tracks which item ID has the panel expanded (Requirement 5.1)
|
||||
const [templatePanelOpenId, setTemplatePanelOpenId] = useState(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data fetching
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -384,6 +388,13 @@ export default function IvantiTodoQueuePage() {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toggle Archer Template Selector panel (Requirement 5.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
const toggleTemplatePanel = useCallback((itemId) => {
|
||||
setTemplatePanelOpenId((prev) => (prev === itemId ? null : itemId));
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection mode toggle (Requirement 1.1, 1.5)
|
||||
// When deactivated, clear all selections
|
||||
@@ -727,151 +738,202 @@ export default function IvantiTodoQueuePage() {
|
||||
const cveDisplay = cves.length > 0
|
||||
? cves.slice(0, 2).join(', ') + (cves.length > 2 ? ` +${cves.length - 2}` : '')
|
||||
: '';
|
||||
const isArcherItem = item.workflow_type === 'Archer';
|
||||
const isTemplatePanelOpen = templatePanelOpenId === item.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
style={isSelected ? STYLES.queueItemSelected : STYLES.queueItem}
|
||||
onClick={selectionMode ? () => toggleItemSelection(item.id) : undefined}
|
||||
role={selectionMode ? 'button' : undefined}
|
||||
tabIndex={selectionMode ? 0 : undefined}
|
||||
onKeyDown={selectionMode ? (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleItemSelection(item.id); } } : undefined}
|
||||
>
|
||||
{/* Selection checkbox (Requirement 1.2) */}
|
||||
{selectionMode && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => { e.stopPropagation(); toggleItemSelection(item.id); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={STYLES.checkbox}
|
||||
aria-label={`Select ${item.finding_title || item.finding_id}`}
|
||||
/>
|
||||
)}
|
||||
<React.Fragment key={item.id}>
|
||||
<div
|
||||
style={isSelected ? STYLES.queueItemSelected : STYLES.queueItem}
|
||||
onClick={selectionMode ? () => toggleItemSelection(item.id) : undefined}
|
||||
role={selectionMode ? 'button' : undefined}
|
||||
tabIndex={selectionMode ? 0 : undefined}
|
||||
onKeyDown={selectionMode ? (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleItemSelection(item.id); } } : undefined}
|
||||
>
|
||||
{/* Selection checkbox (Requirement 1.2) */}
|
||||
{selectionMode && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => { e.stopPropagation(); toggleItemSelection(item.id); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={STYLES.checkbox}
|
||||
aria-label={`Select ${item.finding_title || item.finding_id}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Finding info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: '#CBD5E1',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}} title={item.finding_title || item.finding_id}>
|
||||
{item.finding_title || item.finding_id}
|
||||
</div>
|
||||
{cveDisplay && (
|
||||
{/* Finding info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.65rem',
|
||||
color: '#64748B',
|
||||
marginTop: '2px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: '#CBD5E1',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}} title={cves.join(', ')}>
|
||||
{cveDisplay}
|
||||
}} title={item.finding_title || item.finding_id}>
|
||||
{item.finding_title || item.finding_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{cveDisplay && (
|
||||
<div style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.65rem',
|
||||
color: '#64748B',
|
||||
marginTop: '2px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}} title={cves.join(', ')}>
|
||||
{cveDisplay}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ticket link badge (Requirements 6.3, 6.4) */}
|
||||
{ticketLinks[item.id] && (
|
||||
<a
|
||||
href={ticketLinks[item.id].jira_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
{/* Archer Template toggle button (Requirement 5.1) */}
|
||||
{isArcherItem && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleTemplatePanel(item.id); }}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.3rem',
|
||||
padding: '0.2rem 0.5rem',
|
||||
borderRadius: '4px',
|
||||
border: isTemplatePanelOpen
|
||||
? '1px solid rgba(0, 212, 255, 0.5)'
|
||||
: '1px solid rgba(0, 212, 255, 0.2)',
|
||||
background: isTemplatePanelOpen
|
||||
? 'rgba(0, 212, 255, 0.15)'
|
||||
: 'rgba(0, 212, 255, 0.05)',
|
||||
color: isTemplatePanelOpen ? '#00d4ff' : '#7DD3FC',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.62rem',
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
title={isTemplatePanelOpen ? 'Hide template selector' : 'Show template selector'}
|
||||
aria-expanded={isTemplatePanelOpen}
|
||||
aria-label="Toggle template selector"
|
||||
>
|
||||
<FileText style={{ width: '11px', height: '11px' }} />
|
||||
{isTemplatePanelOpen ? 'Hide' : 'Template'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Ticket link badge (Requirements 6.3, 6.4) */}
|
||||
{ticketLinks[item.id] && (
|
||||
<a
|
||||
href={ticketLinks[item.id].jira_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.6rem',
|
||||
fontWeight: 700,
|
||||
color: '#6EE7B7',
|
||||
background: 'rgba(16, 185, 129, 0.1)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.3)',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.5rem',
|
||||
textDecoration: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
title={`Open ${ticketLinks[item.id].ticket_key} in Jira`}
|
||||
>
|
||||
{ticketLinks[item.id].ticket_key} ↗
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Workflow type badge */}
|
||||
<div style={{
|
||||
width: '80px',
|
||||
textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.6rem',
|
||||
fontWeight: 700,
|
||||
color: '#6EE7B7',
|
||||
background: 'rgba(16, 185, 129, 0.1)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.3)',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.5rem',
|
||||
textDecoration: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
title={`Open ${ticketLinks[item.id].ticket_key} in Jira`}
|
||||
>
|
||||
{ticketLinks[item.id].ticket_key} ↗
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Workflow type badge */}
|
||||
<div style={{
|
||||
width: '80px',
|
||||
textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.6rem',
|
||||
fontWeight: 700,
|
||||
color: wfColor.col,
|
||||
background: `rgba(${wfColor.rgb}, 0.1)`,
|
||||
border: `1px solid rgba(${wfColor.rgb}, 0.3)`,
|
||||
borderRadius: '4px',
|
||||
padding: '0.15rem 0.4rem',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
{item.workflow_type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Vendor */}
|
||||
<div style={{
|
||||
width: '120px',
|
||||
flexShrink: 0,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.68rem',
|
||||
color: '#94A3B8',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}} title={item.vendor}>
|
||||
{item.vendor || '—'}
|
||||
</div>
|
||||
|
||||
{/* Hostname / IP */}
|
||||
<div style={{
|
||||
width: '120px',
|
||||
flexShrink: 0,
|
||||
minWidth: 0,
|
||||
}}>
|
||||
{item.hostname && (
|
||||
<div style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.65rem',
|
||||
color: '#94A3B8',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}} title={item.hostname}>
|
||||
{item.hostname}
|
||||
</div>
|
||||
)}
|
||||
{item.ip_address && (
|
||||
<div style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.62rem',
|
||||
color: '#10B981',
|
||||
marginTop: item.hostname ? '1px' : 0,
|
||||
color: wfColor.col,
|
||||
background: `rgba(${wfColor.rgb}, 0.1)`,
|
||||
border: `1px solid rgba(${wfColor.rgb}, 0.3)`,
|
||||
borderRadius: '4px',
|
||||
padding: '0.15rem 0.4rem',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
{item.ip_address}
|
||||
</div>
|
||||
)}
|
||||
{item.workflow_type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Vendor */}
|
||||
<div style={{
|
||||
width: '120px',
|
||||
flexShrink: 0,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.68rem',
|
||||
color: '#94A3B8',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}} title={item.vendor}>
|
||||
{item.vendor || '—'}
|
||||
</div>
|
||||
|
||||
{/* Hostname / IP */}
|
||||
<div style={{
|
||||
width: '120px',
|
||||
flexShrink: 0,
|
||||
minWidth: 0,
|
||||
}}>
|
||||
{item.hostname && (
|
||||
<div style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.65rem',
|
||||
color: '#94A3B8',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}} title={item.hostname}>
|
||||
{item.hostname}
|
||||
</div>
|
||||
)}
|
||||
{item.ip_address && (
|
||||
<div style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.62rem',
|
||||
color: '#10B981',
|
||||
marginTop: item.hostname ? '1px' : 0,
|
||||
}}>
|
||||
{item.ip_address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Archer Template Selector expandable panel (Requirement 5.1) */}
|
||||
{isArcherItem && isTemplatePanelOpen && (
|
||||
<div style={{
|
||||
marginBottom: '0.5rem',
|
||||
marginLeft: selectionMode ? '1.625rem' : '0',
|
||||
padding: '0.75rem',
|
||||
background: 'linear-gradient(135deg, rgba(22, 33, 62, 0.6), rgba(15, 23, 42, 0.8))',
|
||||
border: '1px solid rgba(0, 212, 255, 0.15)',
|
||||
borderTop: 'none',
|
||||
borderRadius: '0 0 8px 8px',
|
||||
}}>
|
||||
<TemplateSelector />
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user