feat: add GRANITE as fourth workflow type in Ivanti queue
- Add GRANITE to VALID_WORKFLOW_TYPES in backend (no vendor required, same as CARD) - Update vendor validation and error messages across all endpoints (single add, batch, PUT, redirect) - Add GRANITE option to RedirectModal with warm slate color (#A1887F) - Rename QueuePanel CARD section to Inventory, group CARD + GRANITE with sub-divider - Add GRANITE to AddToQueuePopover and SelectionToolbar - Update spec docs (requirements, design, tasks)
This commit is contained in:
@@ -1135,7 +1135,7 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [onCancel]);
|
||||
|
||||
const isCard = queueForm.workflowType === 'CARD';
|
||||
const isCard = queueForm.workflowType === 'CARD' || queueForm.workflowType === 'GRANITE';
|
||||
const canSubmit = isCard || queueForm.vendor.trim().length > 0;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
@@ -1201,9 +1201,10 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '0.375rem' }}>
|
||||
{[
|
||||
{ key: 'FP', col: '#F59E0B', rgb: '245,158,11' },
|
||||
{ key: 'Archer', col: '#0EA5E9', rgb: '14,165,233' },
|
||||
{ key: 'CARD', col: '#10B981', rgb: '16,185,129' },
|
||||
{ key: 'FP', col: '#F59E0B', rgb: '245,158,11' },
|
||||
{ key: 'Archer', col: '#0EA5E9', rgb: '14,165,233' },
|
||||
{ key: 'CARD', col: '#10B981', rgb: '16,185,129' },
|
||||
{ key: 'GRANITE', col: '#A1887F', rgb: '161,136,127' },
|
||||
].map(({ key, col, rgb }) => {
|
||||
const active = queueForm.workflowType === key;
|
||||
return (
|
||||
@@ -1303,10 +1304,164 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
setTimeout(() => setRedirectSuccess(null), 3000);
|
||||
};
|
||||
|
||||
// CARD items are their own top section; everything else groups by vendor
|
||||
// Render a single queue item row
|
||||
const renderQueueItem = (item, { done, selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }) => {
|
||||
const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' }
|
||||
: item.workflow_type === 'Archer' ? { col: '#0EA5E9', rgb: '14,165,233' }
|
||||
: item.workflow_type === 'GRANITE' ? { col: '#A1887F', rgb: '161,136,127' }
|
||||
: { col: '#10B981', rgb: '16,185,129' };
|
||||
const cves = item.cves || [];
|
||||
const cveDisplay = cves.length > 0
|
||||
? cves.slice(0, 3).join(', ') + (cves.length > 3 ? ` +${cves.length - 3}` : '')
|
||||
: '—';
|
||||
const isInventoryItem = item.workflow_type === 'CARD' || item.workflow_type === 'GRANITE';
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: '0.5rem',
|
||||
padding: '0.5rem 0.625rem',
|
||||
marginBottom: '0.25rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: done ? 'rgba(16,185,129,0.04)' : 'rgba(14,165,233,0.04)',
|
||||
border: `1px solid ${done ? 'rgba(16,185,129,0.12)' : 'rgba(14,165,233,0.1)'}`,
|
||||
opacity: done ? 0.55 : 1,
|
||||
transition: 'opacity 0.15s',
|
||||
}}
|
||||
>
|
||||
{/* Selection checkbox — for bulk delete */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(item.id)}
|
||||
onChange={() => toggleSelect(item.id)}
|
||||
style={{ accentColor: '#EF4444', width: '12px', height: '12px', flexShrink: 0, marginTop: '3px', cursor: 'pointer', opacity: selectedIds.has(item.id) ? 1 : 0.35 }}
|
||||
title="Select for deletion"
|
||||
/>
|
||||
|
||||
{/* Complete checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={done}
|
||||
onChange={() => onUpdate(item.id, { status: done ? 'pending' : 'complete' })}
|
||||
style={{ accentColor: '#10B981', width: '14px', height: '14px', flexShrink: 0, marginTop: '2px', cursor: 'pointer' }}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||||
color: done ? '#475569' : '#CBD5E1',
|
||||
textDecoration: done ? 'line-through' : 'none',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}} title={item.finding_id}>
|
||||
{item.finding_id}
|
||||
</div>
|
||||
{isInventoryItem ? (
|
||||
<>
|
||||
{item.hostname && (
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||
color: done ? '#334155' : '#94A3B8',
|
||||
textDecoration: done ? 'line-through' : 'none',
|
||||
marginTop: '2px',
|
||||
}}>
|
||||
{item.hostname}
|
||||
</div>
|
||||
)}
|
||||
{item.ip_address && (
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||
color: done ? '#334155' : '#10B981',
|
||||
textDecoration: done ? 'line-through' : 'none',
|
||||
marginTop: '2px',
|
||||
}}>
|
||||
{item.ip_address}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{cves.length > 0 && (
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.62rem',
|
||||
color: done ? '#334155' : '#64748B',
|
||||
textDecoration: done ? 'line-through' : 'none',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
marginTop: '1px',
|
||||
}} title={cves.join(', ')}>
|
||||
{cveDisplay}
|
||||
</div>
|
||||
)}
|
||||
{item.hostname && (
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.62rem',
|
||||
color: done ? '#334155' : '#94A3B8',
|
||||
textDecoration: done ? 'line-through' : 'none',
|
||||
marginTop: '1px',
|
||||
}}>
|
||||
{item.hostname}
|
||||
</div>
|
||||
)}
|
||||
{item.ip_address && (
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||
color: done ? '#334155' : '#10B981',
|
||||
textDecoration: done ? 'line-through' : 'none',
|
||||
marginTop: '1px',
|
||||
}}>
|
||||
{item.ip_address}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflow type badge */}
|
||||
<span style={{
|
||||
flexShrink: 0,
|
||||
padding: '0.1rem 0.35rem',
|
||||
borderRadius: '0.2rem',
|
||||
background: `rgba(${wfColor.rgb},0.12)`,
|
||||
border: `1px solid rgba(${wfColor.rgb},0.3)`,
|
||||
color: wfColor.col,
|
||||
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '700',
|
||||
}}>
|
||||
{item.workflow_type}
|
||||
</span>
|
||||
|
||||
{/* Redirect button — completed items only */}
|
||||
{canWrite && done && (
|
||||
<button
|
||||
onClick={() => setRedirectItem(item)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: '1px', lineHeight: 1, flexShrink: 0 }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = '#0EA5E9'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = '#334155'}
|
||||
title="Redirect to another workflow"
|
||||
>
|
||||
<CornerUpRight style={{ width: '13px', height: '13px' }} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={() => onDelete(item.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: '1px', lineHeight: 1, flexShrink: 0 }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = '#EF4444'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = '#334155'}
|
||||
title="Remove from queue"
|
||||
>
|
||||
<Trash2 style={{ width: '13px', height: '13px' }} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Inventory items (CARD + GRANITE) are their own top section; everything else groups by vendor
|
||||
const grouped = useMemo(() => {
|
||||
const cardItems = items.filter((i) => i.workflow_type === 'CARD');
|
||||
const otherItems = items.filter((i) => i.workflow_type !== 'CARD');
|
||||
const inventoryItems = items.filter((i) => i.workflow_type === 'CARD' || i.workflow_type === 'GRANITE');
|
||||
const cardItems = inventoryItems.filter((i) => i.workflow_type === 'CARD');
|
||||
const graniteItems = inventoryItems.filter((i) => i.workflow_type === 'GRANITE');
|
||||
const otherItems = items.filter((i) => i.workflow_type !== 'CARD' && i.workflow_type !== 'GRANITE');
|
||||
|
||||
const map = {};
|
||||
otherItems.forEach((item) => {
|
||||
@@ -1315,11 +1470,11 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
map[v].push(item);
|
||||
});
|
||||
const vendorGroups = Object.keys(map).sort().map((vendor) => ({
|
||||
key: vendor, label: vendor, items: map[vendor], isCard: false,
|
||||
key: vendor, label: vendor, items: map[vendor], isInventory: false,
|
||||
}));
|
||||
|
||||
return cardItems.length > 0
|
||||
? [{ key: '__CARD__', label: 'CARD', items: cardItems, isCard: true }, ...vendorGroups]
|
||||
return inventoryItems.length > 0
|
||||
? [{ key: '__INVENTORY__', label: 'Inventory', cardItems, graniteItems, items: inventoryItems, isInventory: true }, ...vendorGroups]
|
||||
: vendorGroups;
|
||||
}, [items]);
|
||||
|
||||
@@ -1393,15 +1548,15 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
Check a row in the findings table to add it.
|
||||
</span>
|
||||
</div>
|
||||
) : grouped.map(({ key, label, items: groupItems, isCard }) => (
|
||||
) : grouped.map(({ key, label, items: groupItems, isInventory, cardItems, graniteItems }) => (
|
||||
<div key={key} style={{ marginBottom: '1.25rem' }}>
|
||||
{/* Group header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '0.3rem 0', marginBottom: '0.375rem',
|
||||
borderBottom: `1px solid ${isCard ? 'rgba(16,185,129,0.2)' : 'rgba(255,255,255,0.06)'}`,
|
||||
borderBottom: `1px solid ${isInventory ? 'rgba(16,185,129,0.2)' : 'rgba(255,255,255,0.06)'}`,
|
||||
}}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: isCard ? '#10B981' : '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: isInventory ? '#10B981' : '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
|
||||
@@ -1409,157 +1564,22 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
{groupItems.map((item) => {
|
||||
const done = item.status === 'complete';
|
||||
const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' }
|
||||
: item.workflow_type === 'Archer' ? { col: '#0EA5E9', rgb: '14,165,233' }
|
||||
: { col: '#10B981', rgb: '16,185,129' };
|
||||
const cves = item.cves || [];
|
||||
const cveDisplay = cves.length > 0
|
||||
? cves.slice(0, 3).join(', ') + (cves.length > 3 ? ` +${cves.length - 3}` : '')
|
||||
: '—';
|
||||
const isCardItem = item.workflow_type === 'CARD';
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: '0.5rem',
|
||||
padding: '0.5rem 0.625rem',
|
||||
marginBottom: '0.25rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: done ? 'rgba(16,185,129,0.04)' : 'rgba(14,165,233,0.04)',
|
||||
border: `1px solid ${done ? 'rgba(16,185,129,0.12)' : 'rgba(14,165,233,0.1)'}`,
|
||||
opacity: done ? 0.55 : 1,
|
||||
transition: 'opacity 0.15s',
|
||||
}}
|
||||
>
|
||||
{/* Selection checkbox — for bulk delete */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(item.id)}
|
||||
onChange={() => toggleSelect(item.id)}
|
||||
style={{ accentColor: '#EF4444', width: '12px', height: '12px', flexShrink: 0, marginTop: '3px', cursor: 'pointer', opacity: selectedIds.has(item.id) ? 1 : 0.35 }}
|
||||
title="Select for deletion"
|
||||
/>
|
||||
|
||||
{/* Complete checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={done}
|
||||
onChange={() => onUpdate(item.id, { status: done ? 'pending' : 'complete' })}
|
||||
style={{ accentColor: '#10B981', width: '14px', height: '14px', flexShrink: 0, marginTop: '2px', cursor: 'pointer' }}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||||
color: done ? '#475569' : '#CBD5E1',
|
||||
textDecoration: done ? 'line-through' : 'none',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}} title={item.finding_id}>
|
||||
{item.finding_id}
|
||||
</div>
|
||||
{isCardItem ? (
|
||||
<>
|
||||
{item.hostname && (
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||
color: done ? '#334155' : '#94A3B8',
|
||||
textDecoration: done ? 'line-through' : 'none',
|
||||
marginTop: '2px',
|
||||
}}>
|
||||
{item.hostname}
|
||||
</div>
|
||||
)}
|
||||
{item.ip_address && (
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||
color: done ? '#334155' : '#10B981',
|
||||
textDecoration: done ? 'line-through' : 'none',
|
||||
marginTop: '2px',
|
||||
}}>
|
||||
{item.ip_address}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{cves.length > 0 && (
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.62rem',
|
||||
color: done ? '#334155' : '#64748B',
|
||||
textDecoration: done ? 'line-through' : 'none',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
marginTop: '1px',
|
||||
}} title={cves.join(', ')}>
|
||||
{cveDisplay}
|
||||
</div>
|
||||
)}
|
||||
{item.hostname && (
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.62rem',
|
||||
color: done ? '#334155' : '#94A3B8',
|
||||
textDecoration: done ? 'line-through' : 'none',
|
||||
marginTop: '1px',
|
||||
}}>
|
||||
{item.hostname}
|
||||
</div>
|
||||
)}
|
||||
{item.ip_address && (
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||
color: done ? '#334155' : '#10B981',
|
||||
textDecoration: done ? 'line-through' : 'none',
|
||||
marginTop: '1px',
|
||||
}}>
|
||||
{item.ip_address}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflow type badge */}
|
||||
<span style={{
|
||||
flexShrink: 0,
|
||||
padding: '0.1rem 0.35rem',
|
||||
borderRadius: '0.2rem',
|
||||
background: `rgba(${wfColor.rgb},0.12)`,
|
||||
border: `1px solid rgba(${wfColor.rgb},0.3)`,
|
||||
color: wfColor.col,
|
||||
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '700',
|
||||
}}>
|
||||
{item.workflow_type}
|
||||
</span>
|
||||
|
||||
{/* Redirect button — completed items only */}
|
||||
{canWrite && done && (
|
||||
<button
|
||||
onClick={() => setRedirectItem(item)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: '1px', lineHeight: 1, flexShrink: 0 }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = '#0EA5E9'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = '#334155'}
|
||||
title="Redirect to another workflow"
|
||||
>
|
||||
<CornerUpRight style={{ width: '13px', height: '13px' }} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={() => onDelete(item.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: '1px', lineHeight: 1, flexShrink: 0 }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = '#EF4444'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = '#334155'}
|
||||
title="Remove from queue"
|
||||
>
|
||||
<Trash2 style={{ width: '13px', height: '13px' }} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Items — Inventory section renders CARD then GRANITE with optional sub-divider */}
|
||||
{isInventory ? (
|
||||
<>
|
||||
{cardItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))}
|
||||
{cardItems.length > 0 && graniteItems.length > 0 && (
|
||||
<div style={{
|
||||
height: '1px',
|
||||
background: 'rgba(161,136,127,0.18)',
|
||||
margin: '0.5rem 0.625rem',
|
||||
}} />
|
||||
)}
|
||||
{graniteItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))}
|
||||
</>
|
||||
) : (
|
||||
groupItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -2803,7 +2823,7 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
|
||||
// SelectionToolbar — batch action bar for multi-selected findings
|
||||
// ---------------------------------------------------------------------------
|
||||
function SelectionToolbar({ count, workflowType, vendor, submitting, error, onWorkflowChange, onVendorChange, onSubmit, onClear }) {
|
||||
const isCard = workflowType === 'CARD';
|
||||
const isCard = workflowType === 'CARD' || workflowType === 'GRANITE';
|
||||
const canSubmit = !submitting && (isCard || vendor.trim().length > 0);
|
||||
|
||||
return (
|
||||
@@ -2838,6 +2858,7 @@ function SelectionToolbar({ count, workflowType, vendor, submitting, error, onWo
|
||||
{ type: 'FP', color: '#F59E0B', rgb: '245,158,11' },
|
||||
{ type: 'Archer', color: '#0EA5E9', rgb: '14,165,233' },
|
||||
{ type: 'CARD', color: '#10B981', rgb: '16,185,129' },
|
||||
{ type: 'GRANITE', color: '#A1887F', rgb: '161,136,127' },
|
||||
].map(({ type, color, rgb }) => {
|
||||
const active = workflowType === type;
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user