fix(reporting): smart-flip queue popover + add CARD workflow type

Popover now flips above the row when it would overflow the bottom of the
viewport, and clamps horizontally to stay within the window.

Adds CARD as a third workflow type (for out-of-team asset disposition in
CARD) alongside FP and Archer. CARD is styled in green (#10B981) across
the popover toggle and queue panel badge.

DB: new migration (add_card_workflow_type.js) recreates ivanti_todo_queue
with an updated CHECK constraint to allow 'CARD'; run manually on dev.
App-level validation in the route is updated to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 14:46:59 -06:00
parent 887d11610e
commit 4d472b0aef
4 changed files with 104 additions and 14 deletions

View File

@@ -0,0 +1,78 @@
// Migration: Add CARD to workflow_type CHECK constraint on ivanti_todo_queue
// SQLite cannot ALTER a CHECK constraint, so this recreates the table.
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const dbPath = path.join(__dirname, '..', 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Starting add_card_workflow_type migration...');
db.serialize(() => {
db.run('PRAGMA foreign_keys = OFF', (err) => {
if (err) console.error('PRAGMA error:', err);
});
db.run('BEGIN TRANSACTION', (err) => {
if (err) { console.error('BEGIN error:', err); return; }
});
db.run(`
CREATE TABLE ivanti_todo_queue_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
finding_id TEXT NOT NULL,
finding_title TEXT,
cves_json TEXT,
vendor TEXT NOT NULL,
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD')),
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`, (err) => {
if (err) console.error('Error creating new table:', err);
else console.log('✓ ivanti_todo_queue_new created');
});
db.run(
'INSERT INTO ivanti_todo_queue_new SELECT * FROM ivanti_todo_queue',
(err) => {
if (err) console.error('Error copying data:', err);
else console.log('✓ Data copied');
}
);
db.run('DROP TABLE ivanti_todo_queue', (err) => {
if (err) console.error('Error dropping old table:', err);
else console.log('✓ Old table dropped');
});
db.run(
'ALTER TABLE ivanti_todo_queue_new RENAME TO ivanti_todo_queue',
(err) => {
if (err) console.error('Error renaming table:', err);
else console.log('✓ Table renamed');
}
);
db.run(
'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)',
(err) => {
if (err) console.error('Error creating index:', err);
else console.log('✓ Index recreated');
}
);
db.run('COMMIT', (err) => {
if (err) console.error('COMMIT error:', err);
else console.log('✓ Transaction committed');
});
db.run('PRAGMA foreign_keys = ON', () => {});
});
db.close(() => {
console.log('Migration complete!');
});

View File

@@ -1,7 +1,7 @@
// routes/ivantiTodoQueue.js // routes/ivantiTodoQueue.js
const express = require('express'); const express = require('express');
const VALID_WORKFLOW_TYPES = ['FP', 'Archer']; const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD'];
const VALID_STATUSES = ['pending', 'complete']; const VALID_STATUSES = ['pending', 'complete'];
function isValidVendor(vendor) { function isValidVendor(vendor) {

View File

@@ -139,7 +139,7 @@ const db = new sqlite3.Database('./cve_database.db', (err) => {
finding_title TEXT, finding_title TEXT,
cves_json TEXT, cves_json TEXT,
vendor TEXT NOT NULL, vendor TEXT NOT NULL,
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer')), workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD')),
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')), status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,

View File

@@ -1084,7 +1084,14 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd
useEffect(() => { useEffect(() => {
if (!anchorRect) return; if (!anchorRect) return;
setPos({ top: anchorRect.bottom + 6, left: anchorRect.left }); const PANEL_W = 260;
const PANEL_H = 360; // conservative estimate (3 workflow buttons)
const spaceBelow = window.innerHeight - anchorRect.bottom - 6;
const top = spaceBelow >= PANEL_H
? anchorRect.bottom + 6
: Math.max(8, anchorRect.top - PANEL_H - 6);
const left = Math.min(anchorRect.left, window.innerWidth - PANEL_W - 8);
setPos({ top, left });
setTimeout(() => inputRef.current?.focus(), 0); setTimeout(() => inputRef.current?.focus(), 0);
}, [anchorRect]); }, [anchorRect]);
@@ -1156,16 +1163,19 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd
Workflow Type Workflow Type
</span> </span>
<div style={{ display: 'flex', gap: '0.375rem' }}> <div style={{ display: 'flex', gap: '0.375rem' }}>
{['FP', 'Archer'].map((wt) => { {[
const active = queueForm.workflowType === wt; { key: 'FP', col: '#F59E0B', rgb: '245,158,11' },
const col = wt === 'FP' ? '#F59E0B' : '#0EA5E9'; { key: 'Archer', col: '#0EA5E9', rgb: '14,165,233' },
{ key: 'CARD', col: '#10B981', rgb: '16,185,129' },
].map(({ key, col, rgb }) => {
const active = queueForm.workflowType === key;
return ( return (
<button <button
key={wt} key={key}
onClick={() => setQueueForm((f) => ({ ...f, workflowType: wt }))} onClick={() => setQueueForm((f) => ({ ...f, workflowType: key }))}
style={{ style={{
flex: 1, padding: '0.3rem', flex: 1, padding: '0.3rem',
background: active ? `rgba(${wt === 'FP' ? '245,158,11' : '14,165,233'},0.15)` : 'transparent', background: active ? `rgba(${rgb},0.15)` : 'transparent',
border: `1px solid ${active ? col : 'rgba(255,255,255,0.1)'}`, border: `1px solid ${active ? col : 'rgba(255,255,255,0.1)'}`,
borderRadius: '0.25rem', borderRadius: '0.25rem',
color: active ? col : '#475569', color: active ? col : '#475569',
@@ -1173,7 +1183,7 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd
cursor: 'pointer', transition: 'all 0.12s', cursor: 'pointer', transition: 'all 0.12s',
}} }}
> >
{wt} {key}
</button> </button>
); );
})} })}
@@ -1322,7 +1332,9 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onClearCompleted
{/* Items */} {/* Items */}
{vendorItems.map((item) => { {vendorItems.map((item) => {
const done = item.status === 'complete'; const done = item.status === 'complete';
const isFP = item.workflow_type === 'FP'; 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 cves = item.cves || [];
const cveDisplay = cves.length > 0 const cveDisplay = cves.length > 0
? cves.slice(0, 3).join(', ') + (cves.length > 3 ? ` +${cves.length - 3}` : '') ? cves.slice(0, 3).join(', ') + (cves.length > 3 ? ` +${cves.length - 3}` : '')
@@ -1377,9 +1389,9 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onClearCompleted
flexShrink: 0, flexShrink: 0,
padding: '0.1rem 0.35rem', padding: '0.1rem 0.35rem',
borderRadius: '0.2rem', borderRadius: '0.2rem',
background: isFP ? 'rgba(245,158,11,0.12)' : 'rgba(14,165,233,0.12)', background: `rgba(${wfColor.rgb},0.12)`,
border: `1px solid ${isFP ? 'rgba(245,158,11,0.3)' : 'rgba(14,165,233,0.3)'}`, border: `1px solid rgba(${wfColor.rgb},0.3)`,
color: isFP ? '#F59E0B' : '#0EA5E9', color: wfColor.col,
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '700', fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '700',
}}> }}>
{item.workflow_type} {item.workflow_type}